Paper Context is a retrieval-first system for born-digital research PDFs. The MVP runtime is implemented end to end: upload and replacement flows, queued ingestion, revision-aware normalization, retrieval indexing in Postgres + pgvector, document inspection endpoints, and MCP retrieval tools.
The codebase is optimized for explicit retrieval behavior and provenance, not for generic chat demos or framework abstraction. The key boundary is simple: ingestion, normalization, indexing, and retrieval are deterministic service-owned logic; downstream agents consume that surface through HTTP and MCP.
Implemented now:
- FastAPI endpoints for upload, replacement, document reads, ingest status, health, and readiness
- A PGMQ-backed worker that advances jobs through
queued,parsing,normalizing,enriching_metadata,chunking,indexing, andreadyorfailed - Docling-first parsing with
pdfplumberfallback when structure is degraded - Revision-aware storage of documents, artifacts, sections, passages, tables, references, ingest jobs, and retrieval assets
- Retrieval over passages and tables with sparse search, dense search, fusion, reranking, bounded parent expansion, and context-pack assembly
- Mounted FastMCP tools for
search_documents,search_passages,search_tables,get_document_outline,get_table,get_passage_context, andbuild_context_pack - Compose bring-up for
db,migrate,app, andworker - Readiness reporting with database state, storage checks, queue metrics, and recent operation timings
Still intentionally limited:
- Metadata enrichment is wired as a no-op
NullMetadataEnricherby default - Provider-backed retrieval is optional; without API keys the runtime uses deterministic embeddings and a heuristic reranker
- The project is still a self-hosted MVP, not a polished multi-tenant product or answer-generation app
- Python
3.14as pinned in.python-version uvfor dependency management and local commands- Docker only when you want the repo-managed Postgres service, full Compose bring-up, or the Postgres-backed integration lane
Local development uses four processes:
db: Postgres withpgvectorandpgmqmigrate: one-shot Alembic runnerapp: FastAPI service, which mounts the MCP Streamable HTTP app at/mcpworker: background ingestion and indexing loop
Production Compose is intentionally different:
docker-compose.prod.ymlrunsmigrate,app, andworker- it expects an external Postgres on
dokploy-network - when
PAPER_CONTEXT_ENVIRONMENT=production, database settings must include secure SSL mode, an application name, and non-null timeout and pool settings
The core data model is revision-aware:
documentsis the stable paper identity- each upload or replacement creates a new
document_revisionsrow documents.active_revision_idpoints reads and retrieval at the current live revision- older revisions are retained instead of being destructively overwritten
That matters for replacement behavior. POST /documents/{document_id}/replace does not mutate canonical rows in place; it stages a new revision, enqueues a new ingest job, and only promotes that revision when indexing completes successfully.
- Ingest born-digital PDFs and preserve page provenance
- Normalize sections, passages, tables, references, and artifacts into Postgres
- Build passage and table retrieval assets tied to
retrieval_index_runs - Serve operational HTTP routes for upload, status, and document inspection
- Serve agent-facing MCP tools for document search and retrieval
- OCR or scanned-PDF ingestion
- answer synthesis or chat orchestration
- multi-tenant SaaS concerns
- LlamaIndex or PydanticAI as part of the retrieval core
Install dependencies and prepare local config:
cp .env.example .env
uv sync --extra devStart Postgres and run migrations:
docker compose up --build -d db
docker compose run --rm migrateRun the app and worker from the host:
uv run paper-context serve
uv run paper-context workerOr bring up the full stack in Compose:
docker compose up --build -dUseful defaults:
- host database URL:
postgresql+psycopg://paper_context:paper_context@localhost:5433/paper_context - default artifact root on host:
./var/artifacts - Compose artifact mount:
/var/lib/paper-context/artifacts - default upload limit:
25 MiB
Upload a PDF:
curl -X POST http://127.0.0.1:8000/documents \
-F "file=@/absolute/path/to/paper.pdf" \
-F "title=Optional Paper Title"Poll the job:
curl http://127.0.0.1:8000/ingest-jobs/<ingest_job_id>Inspect the current active document state:
curl http://127.0.0.1:8000/documents/<document_id>
curl http://127.0.0.1:8000/documents/<document_id>/outline
curl http://127.0.0.1:8000/documents/<document_id>/tablesReplace the source PDF for the same logical document:
curl -X POST http://127.0.0.1:8000/documents/<document_id>/replace \
-F "file=@/absolute/path/to/replacement.pdf" \
-F "title=Optional Replacement Title"Operational checks:
curl http://127.0.0.1:8000/healthz
curl http://127.0.0.1:8000/readyzThe app mounts FastMCP at http://127.0.0.1:8000/mcp using Streamable HTTP.
Current tools:
search_documentssearch_passagessearch_tablesget_document_outlineget_tableget_passage_contextbuild_context_pack
Minimal raw MCP example:
SESSION_ID=$(
curl -si http://127.0.0.1:8000/mcp \
-H 'accept: application/json, text/event-stream' \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "example", "version": "0"}
}
}' | awk '/mcp-session-id:/ {print $2}' | tr -d '\r'
)
curl http://127.0.0.1:8000/mcp \
-H 'accept: application/json, text/event-stream' \
-H 'content-type: application/json' \
-H "mcp-session-id: ${SESSION_ID}" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "build_context_pack",
"arguments": {"query": "attention mechanism"}
}
}'For most downstream consumers, build_context_pack is the best default entrypoint.
Important implemented behavior:
- uploads must be non-empty PDFs; non-PDF files are rejected before staging
- warnings such as
parser_fallback_used,reduced_structure_confidence,metadata_low_confidence, andparent_context_truncatedare part of the public retrieval contract - document reads always resolve against the active revision
- retrieval results include
index_version,retrieval_index_run_id, andparser_source - if a newer job supersedes an older queued or in-flight job for the same document, the older job is failed explicitly as superseded
- replacement retains prior revisions; a failed replacement can leave the previous ready revision active
- parser execution defaults to isolated subprocesses with timeout, memory, and output bounds
The repo exposes a single CLI entrypoint:
uv run paper-context serveuv run paper-context workeruv run paper-context worker --onceuv run paper-context verify-synthetic-job
verify-synthetic-job is a fast queue and worker smoke check. The main product workflow is still real PDF upload plus status polling.
Repo-standard checks:
uv run pre-commit run --all-filesuv run pyrightuv run pytest -m "unit or slice"uv run pytest -m "integration or migration" -n 2 --dist=loadfileuv run pytest -m contractuv run pytest -m "regression and not staging_only"
docs/README.md: documentation overviewdocs/architecture.md: runtime topology and service boundariesdocs/ingestion-and-indexing.md: ingest lifecycle, parser fallback, revision activation, and indexingdocs/data-model.md: canonical schema, revisions, provenance, and retrieval assetsdocs/apis-and-tools.md: HTTP and MCP contractsdocs/retrieval.md: retrieval pipeline, budgets, warnings, and output semanticsdocs/evaluation-and-roadmap.md: what is validated now and what comes nextdocs/test-strategy.md: suite taxonomy and CI lanes
This is still an active project, so interfaces may move as the self-hosted runtime is hardened. Useful feedback is on ingestion correctness, provenance guarantees, retrieval quality, replacement semantics, and contract stability.