Case study

Synapse — an agentic RAG document assistant

Upload a document, ask it questions, and get answers built only from its pages — with a citation on every claim, a critic that checks the draft before you see it, and a trace that shows the whole pipeline's work.

Python · FastAPI · FAISS + BM25 · Ollama / Groq · 31 tests · live

The problem

Ask an LLM about a document it hasn't read and it will answer anyway — fluently, confidently, and sometimes from thin air. Retrieval-augmented generation is the standard fix: find the relevant passages first, then tell the model to answer from those and nothing else. But most RAG demos treat retrieval as a solved step — one vector search, top few results, straight into the prompt — and that's exactly where they quietly fail. Vector search whiffs on exact terms like invoice numbers and names; keyword search misses paraphrases; and whatever slips through, the model happily narrates over the gaps.

Synapse was my attempt to take the retrieval problem seriously and to make every stage of the answer auditable. Retrieval quality, not the model, is where RAG lives or dies — which is why three of the seven backend modules are retrieval stages.

How a question moves through it

Ingestion first: loaders.py reads PDFs page-by-page (keeping page numbers for citations), chunker.py slices the text into ~1600-character chunks along paragraph boundaries with a 200-character overlap so facts straddling a boundary survive intact, and each chunk lands in three places — its text in sqlite, its embedding in a FAISS vector index, and its words in a BM25 keyword index.

When a question arrives at /api/chat, the orchestrator (orchestrator.py, about 140 lines) runs four LLM roles in sequence, streaming progress to the browser as server-sent events:

Every stage is visible in the UI: click "Show reasoning trace" and you get the planner's sub-questions, the exact chunks retrieved for each, the pre-critique draft, and the critic's actual review text. It is not one prompt — it's a plan–retrieve–draft–critique pipeline, and you can watch it think.

The retrieval funnel

stage 1 FAISS vector search + BM25 keyword search, in parallel — 20 candidates each
stage 2 Reciprocal Rank Fusion merges the two lists — top 12 survive
stage 3 listwise LLM rerank, all 12 judged in one call — top 5 become evidence

Each stage exists because the one before it has a known blind spot. Embeddings catch paraphrases ("firing" matches "termination") but fumble exact tokens; BM25 nails exact terms but misses rewordings — so run both. Their scores live on incomparable scales (a cosine of 0.83 versus a BM25 of 14.2 means nothing), so RRF ignores raw scores entirely and scores each chunk by its rank position in each list — a chunk ranked well by both retrievers floats to the top. And rank fusion is still just similarity math: it can rank a lexically-similar but off-topic passage highly. So the last word goes to an LLM that sees all 12 candidates at once and outputs an ordering — one cheap call over short previews, and it can spot what similarity scores can't.

The retrieval funnel. The document is indexed as 1600-character chunks with 200 characters of overlap. Each question fans out to two retrievers in parallel — BM25 keyword search returns 20 candidates and FAISS vector search returns 20. Reciprocal Rank Fusion merges the two lists and 12 survive. A listwise LLM rerank judges all 12 in one call and the top 5 become the evidence the answer is written from. 1600-char chunks · 200 overlap the document BM25 keyword 20 candidates FAISS vector 20 candidates two retrievers Reciprocal Rank Fusion 12 fused listwise LLM rerank 5 the evidence the answer is written from these
Fan out wide, cut hard: 40 candidates in, 5 out. Every width is proportional to its count.

Retrieval quality, not the model, is where RAG lives or dies. If the right passage isn't in the evidence, no prompt can save the answer.

Decisions I'd defend

Honest numbers

whatnumberwhere it lives
retrieval funnel20 → 12 → 5config.py
chunk size / overlap1600 / 200 charschunker.py
planner cap3 sub-questionsconfig.py
session memorylast 6 turnsroutes.py
rate limits20 chats, 10 uploads /hr/IPrate_limit.py
tests31 across 7 filespytest, no model needed
eval dataset2 cases — a stubeval/dataset.json

All 31 tests run instantly with no model installed, because every LLM call is faked — the fallback paths are the thing under test. CI is a single job on Python 3.12 running pytest on every push. And the eval harness deserves a plain sentence: it's real and runnable — an LLM judge scores each answer 1–5 on faithfulness — but the shipped dataset is 2 stub cases, so I don't quote a hit-rate or a faithfulness score, because I haven't earned one yet.

Limitations, stated plainly

What's next

A real eval dataset, so faithfulness and hit-rate become numbers instead of claims — the harness is already built and waiting. Then latency: skip the planner for obviously simple questions and parallelize sub-question retrieval. Then the scaling list in order — Redis for sessions, an HNSW index, an incremental keyword index. Each is a known fix for a limitation I've already written down above.

One note before you click: the live demo runs on Render's free tier, which sleeps when idle — the first load can take a minute to wake — and uploaded documents reset whenever the instance restarts. Bring a small PDF and re-upload if it's gone.

Try it live ↗ Read the code ↗ Back to the site →

David Jeremie Anand · New Delhi · every number on this page comes from config.py, the test suite, or the prompts in the repo — nothing measured is claimed that wasn't run.