A multi-strategy retrieval-augmented generation pipeline over a curated corpus of AI/ML research papers. Queries are classified as simple or compound, decomposed into sub-queries, retrieved via hybrid search, re-ranked by a cross-encoder, and answered with cited sources.
Last updated 2026-07-02 · GitHub
A curated 1,000-paper set spanning 20 AI/ML topics. This replaced an earlier 3-tier keyword/category scrape of ~480 papers whose keyword search was badly polluted (e.g. "retrieval augmented generation" pulling in augmented-reality papers).
Selection produces a JSON manifest of chosen papers with arXiv metadata, touching no PDFs and spending no money. ~304 hand-picked anchors and curated papers (verified against arXiv titles) carry the corpus quality. The remaining ~696 slots are filled by per-topic keyword search with quality gates: CS/ML categories only, a title blocklist filtering applied niches (clinical, finance, agriculture, etc.), and a reviewed exclude set of specific off-topic strays. All arXiv responses are cached, so re-running while tuning filters is instant.
paper_exists_in_db() checks ChromaDB by
paper_id. Idempotent and safe to re-run after interruption.cl100k_base (tiktoken), matching the embedding model's encoding.et al.,
Fig., i.e., e.g.), single initials
(A. Vaswani), and numbered references (Table 3.).text-embedding-3-small, 1536-dim vectors, cosine space. Batched 100 per request.
Texts over 8000 tokens truncated before sending.paper_id,
title, authors, published, categories,
chunk_index, chunk_type, field).A multi-strategy RAG pipeline where each feature solves a specific failure mode of the previous iteration:
Base: embed → retrieve (cosine top-k) → generate + Chunking: sentence-boundary splitting, abstract indexing + Hybrid: BM25 keyword search + RRF fusion + Rerank: FlashRank cross-encoder (local ONNX) + Agent: LangGraph (decompose → retrieve → grade → retry → synthesize)
Pure semantic search conflates different uses of the same word ("coverage" in RAG systems vs. facility location vs. conformal prediction). Adding BM25 keyword search fixes this — BM25 weights rare terms via IDF, making it naturally discriminative on proper nouns and coined terms.
Reciprocal Rank Fusion (RRF) merges the ranked lists with k=60,
deduplicates by chunk_id. A chunk ranked #1 by BM25 and #3 semantically scores higher
than one ranked #1 semantically only — RRF rewards agreement between signals. Returns top 24
candidates.
RRF is a ranking fusion, not a relevance judgment. The 24 candidates are reranked
by a FlashRank cross-encoder (ms-marco-TinyBERT-L-2-v2, ~4MB ONNX model, 2-layer) that reads
query and chunk text together, producing a true relevance score.
top_n=8),
then each survivor must clear a relevance bar — so the result is at most 8 chunks, often fewer.RRF_TRUST_CUTOFF, both searches agreed) needs only RELEVANCE_THRESHOLD = 0.5;
one that barely made the pool (rank 11–24) must clear the stricter
STRICT_RELEVANCE_THRESHOLD = 0.8. A high score with no upstream support is treated as a
likely keyword coincidence. (TinyBERT scores cluster 0.94–0.99, so the base floor is deliberately low.)TRUSTED_TIERS) before the top-8 cut, so the small model can't drop a
known-canonical source. The floor only ever raises a weak score to the base bar — never lowers a real one.FALLBACK_MIN_SCORE) — otherwise the
sub-query legitimately returns nothing rather than force a low-confidence citation.Reranking was first moved from a hosted Jina API to local FlashRank to drop per-call cost and rate-limit handling — the agent reranks once per sub-query per retrieval pass, so compound queries were generating many billed API calls. The 12-layer MiniLM model then became the dominant latency cost (~7s/call, ~67% of total request time on the CPU-only Space), so it was switched to the 2-layer TinyBERT — ~20× faster, trading some ranking precision, which the synthesizer absorbs by answering from partial context.
Single-pass retrieval favors the dominant paper on compound queries. The agent decomposes queries into sub-queries, retrieves independently for each, grades coverage, and retries with reformulated queries when context is insufficient.
AgentState is a TypedDict with 10 fields. Two use the
Annotated[list, add] reducer, which concatenates onto the existing list instead of
overwriting. On accumulated_context — without it, each retrieval pass would overwrite the
previous context rather than accumulate it, silently breaking multi-pass retrieval (duplicates are still
removed inside retriever_node, since the reducer merges but can't dedupe). The second,
all_sub_queries, is the full history of every sub-query searched across all passes, since
the plain sub_queries field is overwritten by the reformulator on each retry.
| Node | Purpose | LLM Call |
|---|---|---|
planner_node |
Classify query as simple/compound, generate sub-queries | GPT-4o-mini + QueryPlan |
retriever_node |
Run full retrieval pipeline per sub-query, deduplicate | None |
grader_node |
Evaluate context sufficiency, identify missing elements | GPT-4o-mini + InformationCheck |
reformulator_node |
Generate new sub-queries targeting gaps | GPT-4o-mini + QueryPlan |
synthesizer_node |
Build final answer from accumulated context, extract citations. Answers from partial context while staying grounded — unrelated queries still get "I don't have enough information" | gpt-5.4-nano |
START → planner → retriever → grader → [route] → synthesizer → END
│
context_sufficient=False
AND retry_count < 2
│
reformulator → retriever (loop)
Max 1 retry (2 total retrieval passes). The retriever_node receives
ChromaDB and BM25 via a factory closure (make_retriever_node(collection, bm25_index)),
keeping non-serializable infrastructure out of the graph state.
A compound query thatonce took ~60s because every sub-query's work (embedding, ChromaDB queries, BM25, rerank) ran one after another. Three changes cut that specific to ~15s with no reranker model change:
Swapping the larger MiniLM model back in under the new design was tested as a follow-up: it doubled latency and did not fix the residual miss noted under Known Limitations, so it was not adopted.
FastAPI serves the same LangGraph agent as the CLI over HTTP. The compiled agent
is initialized once at startup via FastAPI's @asynccontextmanager lifespan pattern and
shared across all requests.
Building the BM25 index from scratch at server boot originally exceeded Render's
free-tier memory limits. A separate offline script (build_bm25_cache.py) pre-builds and
pickles the index; the server loads the serialized file on startup instead of reconstructing it.
The app now runs on Hugging Face's 16GB CPU tier, but the AOT pattern is kept for fast startup.
| Endpoint | Purpose |
|---|---|
POST /query |
Run a full agent query. Returns answer, sub-queries, retry count, retrieved chunks with scores, and citations. |
GET /papers |
Returns the full corpus as a browsable list: paper ID, title, authors, field, year, and arXiv URL. Loaded once from the manifest at startup. |
GET /health |
Returns {"status": "ok"}. Used for health checks. |
Docker container on Hugging Face Spaces (free CPU tier, 16GB RAM). Uvicorn serves
the ASGI app on port 7860. CORS middleware with allow_origins=["*"].
text-embedding-3-small (1536-dim)ms-marco-TinyBERT-L-2-v2, ~4MB local ONNX)rank-bm25 BM25Okapifitz)tiktoken (cl100k_base)openai>=1.30.0 # Embeddings + GPT-4o-mini + gpt-5.4-nano chromadb>=0.5.0 # Vector store PyMuPDF>=1.24.0 # PDF parsing feedparser>=6.0.0 # arXiv Atom feed requests>=2.31.0 # arXiv PDF downloads python-dotenv>=1.0.0 # .env loading tiktoken>=0.7.0 # Token counting rank-bm25>=0.2.2 # BM25Okapi implementation flashrank>=0.2.10 # Local cross-encoder reranker langgraph>=0.2.0 # Stateful agent graph fastapi>=0.115.0 # HTTP API framework uvicorn>=0.34.0 # ASGI server
async def query() calls the
synchronous agent.invoke() directly (no run_in_threadpool), so it blocks the
event loop for a whole request. Retrieval is parallel within one request, but two overlapping HTTP
requests are handled one after the other./tmp by default, so an ephemeral host that resets between runs re-downloads it each cold
start. A persistent cache directory or bundling the model would remove that cost.allow_origins=["*"] is fine for development but should
be scoped for production.Each iteration fixed a specific failure mode of the previous one.
Embed → cosine top-k → generate. Get a working end-to-end pipeline.
Fixed incoherent chunks from mid-sentence splits. Added sentence-boundary chunking and separate abstract indexing.
Fixed vocabulary mismatch — cosine conflates different uses of the same word. Added BM25 keyword search + Reciprocal Rank Fusion.
RRF is a ranking fusion, not a relevance judgment. Added a FlashRank cross-encoder that reads query and chunk text together for true relevance scoring.
Single-pass retrieval favors the dominant paper on compound queries. Added query decomposition, context grading, and a retry loop.
Removed hosted API dependency (cost, rate limits). Then switched from 12-layer MiniLM to 2-layer TinyBERT, ~20× faster on CPU, trading some ranking precision. Net effect on test compound query: ~80s → ~36s.
Lowered MAX_RETRIEVAL_PASSES and relaxed grader so a well-covered first pass is accepted. Cuts latency with no quality loss.
Now answers from partial context (noting gaps) instead of refusing when coverage is incomplete. Removed false "I don't have enough information" on answerable queries.
Render's 512MB free tier was too small once a local reranker model shared the process. Moved to a Hugging Face Space with 16GB free CPU.
Replaced the polluted 3-tier keyword/category scrape with a two-phase manifest pipeline. 20 topics, ~30% hand-picked canonical papers verified against arXiv titles, the rest relevance-filled behind quality gates. Selection now decoupled from ingestion. 20,845 chunks.
TinyBERT scores cluster 0.94-0.99, so the tight cutoff was arbitrary. Lowered to a safety floor; top 8 candidates now passed through instead of 3-5.
Synthesizer switched from GPT-4o-mini to gpt-5.4-nano, top_n raised from 5 to 8, prompt rewritten. Tuned via a 2x2 A/B test. Planner, grader, and reformulator remain on GPT-4o-mini.
Batched sub-query embeddings, parallelized per-sub-query retrieval, an RRF-rank-aware tiered threshold with gated fallback, an anchor/curated score floor, and a wider candidate pool. Took an example compound query from ~36s → ~15s and removed two reproduced off-topic citations, with no reranker model change.