← Back to portfolio
Build Notes

Teaching a RAG Assistant to Say “I Don’t Know”

Inside Document Navigator: what it takes to make a local RAG pipeline over PDFs trustworthy — not just fluent — and how I measured whether it actually got there.

Jul 2026 ~6 min read View the repo →

Most RAG demos answer confidently no matter what you ask. That's the easy part — retrieve some chunks, hand them to an LLM, let it generate something plausible. The hard part is getting the system to know when it doesn't have enough evidence, and to say so instead of quietly making something up. That's the problem Document Navigator is built around.

It's a locally-run assistant that answers questions over a PDF corpus, cites every claim as [filename.pdf:page], and — this is the part I actually care about — refuses to answer when the retrieved evidence is too weak to support a claim. No API keys, no cloud calls: FAISS for the vector index, MiniLM for embeddings, Ollama running qwen2.5:7b for generation.

Stack: Python · LangChain · FAISS · sentence-transformers (MiniLM-L6-v2) · Ollama (qwen2.5:7b) · Streamlit

The pipeline

Ingestion loads PDFs page-by-page, splits them into overlapping chunks with source and page metadata attached, embeds them with MiniLM, and persists the index to disk. At query time, retrieval runs top-k similarity search and converts FAISS's L2² distances into interpretable cosine scores — normalizing the embeddings up front means FAISS's inner product mathematically equals cosine similarity, so I get clean [0, 1] scores without a separate normalization pass.

Every retrieval gets logged as a structured trace before generation even runs, so I have a paper trail for any answer the system produces without needing to re-embed anything to debug it later.

The part that actually matters: the refusal gate

Before the LLM is ever called, the top-1 similarity score gets checked against a threshold. Below 0.25, the query is refused outright — no generation, no chance of the model quietly answering from its own training data instead of the corpus. Above that line, the retrieved chunks get assembled into a structured context block and handed to the model with separated system and human messages, treating the retrieved content as untrusted data rather than instructions. That single design choice does double duty: it's a cost optimization (skip the LLM call entirely when there's nothing to answer from) and a safety measure (it's also where prompt-injection attempts from inside the corpus get neutralized, since the LLM never confuses retrieved text for a system instruction).

Did it actually work? The eval harness says mostly yes

I didn't want to just eyeball a few outputs and call it done, so I built a 20-question evaluation set: 15 questions scoring answer quality, 5 adversarial questions designed to trigger the refusal path.

MetricScore
precision@1 (answer cohort)0.867
precision@3 / precision@51.000
citation accuracy0.800
refusal correctness (safety cohort)1.000
false refusal rate (answer cohort)0.000
mean latency4.7s / query

The number I was most nervous about was refusal correctness, since a system that refuses too eagerly is nearly as useless as one that hallucinates. All 5 adversarial rows — 3 out-of-scope questions, 2 prompt-injection attempts — triggered the pre-LLM refusal path correctly, and zero legitimate questions got falsely refused.

Where it actually fell short: of the 4 failing answer-cohort rows, manual review showed only 1 was a genuine quality failure. The other 3 were correct answers penalized by an eval rubric that scores against a single gold key phrase — a real answer that happens to be phrased differently still reads as "wrong" to that scoring method. The eval caught a real limitation, just not the one I expected: the harness itself needs a softer matching rule before the 73% answer pass rate means what it looks like it means.

What I'd tighten next

None of that is a reason not to ship it — it's the difference between a demo and a system with a documented boundary. That boundary is exactly what I'd want to know as the person deciding whether to trust this on real documents.

View the full repo → ← Back to portfolio