Document Intelligence with RAG
Beyond one-document extraction (Project 4): this agent ingests a whole corpus — thousands of contracts, policies, or reports — and answers grounded questions across all of them with citations. It marries structured extraction with retrieval, so users can both "pull the fields from this invoice" and "which of our 4,000 contracts auto-renew in Q3?"
USE_REAL_API=1) you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the answer code):- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
What this project teaches you to design
- An ingestion pipeline: parse → chunk → extract metadata → embed → index.
- Retrieval that respects document boundaries and metadata filters.
- Grounded answers with per-claim citations back to the source document + page.
- Evals for retrieval quality and answer faithfulness on a document corpus.
The brief advanced
You have already lived the failure this project fixes. Someone asks "did we ever agree to a right-to-audit clause with this vendor?" and the honest answer is a shrug and forty minutes of Ctrl-F across a shared drive. The information exists — it is just scattered across ten thousand PDFs that no one can query. Multiply that by every question every week and you are burning expensive human hours re-reading documents nobody remembers signing.
The instinct is "just make the file names searchable" or "throw it all into keyword search." That is exactly where naive attempts die: keyword search matches words, not meaning, so a query for "termination" misses the clause that says "either party may end this agreement." The brief here is therefore sharper than "make it searchable" — it is let people ask questions in plain English and get an answer they can trust and verify. That last word carries the weight: an answer with no source is worse than no answer at all in a legal or financial setting, because it looks authoritative while being unaccountable. Read the whole project through that lens — every design choice below exists to make answers both found and provable.
"We have twenty years of documents and no way to ask them anything." The knowledge is trapped in PDFs on a share drive. People re-read the same contracts, miss renewal dates, and can't answer "what did we agree with this vendor?" without an afternoon of digging. A document-intelligence-with-RAG agent turns that pile into something you can query in plain English — with citations you can trust.
Start at the question box at the top, then follow the two arrows down — the left path is the naive approach, the right path is what this project builds.
- The top box is one plain-English question asked of the same document pile.
- The left grey arrow goes to the red box: keyword search looks for the literal word "terminate", so a clause phrased as "either party may end this agreement" is never matched — the answer exists but is invisible.
- The right blue arrow goes to the green box: meaning-based retrieval matches the intent, finds the same clause, and flows down to a final green box — an answer paired with a citation the user can open and verify.
In short: the brief isn't "make it searchable" — it's "let people ask in plain English and get an answer they can trust and verify", which is why meaning-based retrieval with citations is the target, not keyword search.
1 · Discovery — what questions do people actually ask? advanced
The tempting mistake is to build the impressive thing before you know what people need. A team hears "RAG over our contracts" and immediately reaches for "the agent reads the contract and tells you whether the indemnity clause is favourable." That is the one request you should refuse — legal judgment is exactly what a machine should not own, and shipping it invites a confidently wrong opinion that a human then rubber-stamps.
Discovery is the cheap step that saves the expensive rebuild: before designing anything, you sort the real requests by who should be accountable for the answer. Think of it like triage in an ER — not everything that walks in gets the same treatment. Some questions are pure retrieval ("find the clause about X across all contracts"), where the agent is astonishingly useful and low-risk because it only surfaces text a human already trusts. Others are metadata questions ("which contracts auto-renew in Q3?") that are really filters, not reading. And a thin, dangerous slice asks for interpretation or risk judgment. The table below is that triage: the leverage column is really a risk-and-fit column, and the single most valuable line in it is the low-leverage one — because deciding what not to automate is what keeps the whole system trustworthy.
Read it top to bottom as a ladder of decreasing fit: the widest, greenest band at the top is where the agent helps most, and each band down is riskier and narrower.
- The green top band is pure retrieval and field extraction — "find the clause", "extract these fields". The agent only surfaces text a human already trusts, so it is high-fit and low-risk.
- The blue middle band is metadata questions ("which documents expire in Q3?") — really filters, not reading. Useful, medium fit.
- The red bottom band is legal interpretation and risk judgment. It sits below the dashed line: the machine surfaces the source, but a human owns the decision — this is the line you deliberately refuse to automate.
In short: the most valuable output of discovery is knowing what to leave below the line — refusing to automate judgment is exactly what keeps the whole system trustworthy.
| User need | Agent leverage |
|---|---|
| "Find the clause about X across all contracts" | ⭐⭐⭐ high — retrieval over the whole corpus |
| "Extract these fields from this specific document" | ⭐⭐⭐ high — Project 4 extraction, reused |
| "Which documents mention / expire / breach…?" | ⭐⭐ medium — metadata filter + retrieval |
| Legal interpretation / risk judgment | ⭐ low — surface the source, let a human decide |
2 · Architecture advanced
The architecture question people get wrong is "why are there two pipelines instead of one?" The obvious design — read the documents when the user asks — feels simpler, and it works right up until the moment a real user waits thirty seconds while you parse, chunk, and embed four thousand PDFs to answer one question. You cannot afford to do the expensive work per query.
So the shape of every serious RAG system is a split: an offline ingest pipeline that does the slow, one-time prep, and an online query pipeline that does the fast, per-question work — connected by a shared vector-plus-metadata store. The kitchen analogy is exact: ingest is the prep the kitchen does before service (chop, marinate, portion), and query is plating a dish to order. You would never make a diner wait while you butcher the meat. The store in the middle is the pantry both sides share — fill it once, serve from it many times. The deeper lesson hidden in this split is that retrieval quality is decided at ingest time, not at query time: how you chunk and what metadata you attach is fixed before a single question is ever asked, which is why the ingest side gets its own careful section (§4) below. Read the diagram as two rows — top runs once, bottom runs every time — and note that the language model appears in exactly one box, because a RAG system is mostly plumbing with a small, well-fenced use of the LLM at the end.
This one picture is the whole project on a page. Read it as two separate pipelines stacked top and bottom — the top runs once, ahead of time; the bottom runs every time a user asks a question.
- Top row — INGEST (offline). Follow the arrows left to right: your
documents(a pile of PDFs) go intoparse + chunk(turn each doc into clean text, then cut it into bite-size pieces), thenextract metadata(pull out vendor, dates, type — the Project 4 trick), thenembed(turn each piece into a list of numbers that captures its meaning), and finally into the vector + metadata DB where everything is stored. This is prep work you do before anyone asks anything. - The curved arrow dropping from the database down to the query row is the hand-off: the online side reads from the same store the offline side filled.
- Bottom row — QUERY (online). A user's
questiongoes intoretrieve + filter by metadata(find the most relevant stored pieces, narrowed by rules like type or who's allowed to see them), then into the purple LLM answer box (the model writes a reply using only those pieces andcites its sources), producing answer + citations. - The colours are hints: amber marks the extract step borrowed from Project 4; green marks the trustworthy end-points (the stored data and the cited answer); the purple box is the only place the language model is used.
In short: Ingest is the kitchen prep done in advance; query is plating a dish to order. The DB in the middle is the pantry both sides share — fill it once, serve from it many times.
Two pipelines. Ingest (offline): parse each document, chunk it, extract structured metadata with the Project 4 pattern (vendor, dates, type), embed the chunks, and store both vectors and metadata. Query (online): retrieve relevant chunks — optionally filtered by metadata — and have the LLM answer grounded in them, citing the source document and page for every claim.
3 · Risk & safety model advanced
Here is the failure that gets a document-RAG system pulled from production. A user asks whether a contract permits early termination; the agent answers "Yes, with 30 days' notice" — fluent, confident, and completely invented, because the retrieval missed and the model filled the gap from its training data. Nobody checks, because the answer sounded like it came from the documents. That is not a hypothetical; it is the default behaviour of a language model asked a question it can't ground, and over legal or financial text it is the difference between a helpful tool and a liability.
So the safety model here is not a list of nice-to-haves — it is the load-bearing wall. Think of each control below as answering a specific way the system can betray a user's trust: a claim with no basis (fix: cite every claim or refuse), a claim citing the wrong document (fix: carry doc ID + page on every chunk), a stale answer from an out-of-date index (fix: re-index and show an "as-of" date), and — the one that ends careers — a user seeing a document they were never allowed to see (fix: filter by permission before the model sees a single chunk). The through-line is that trust must be built into the retrieval and generation path, not bolted on as a disclaimer. The one rule that subsumes the rest: no citation, no claim — and "not found in the documents" is always a valid, and often the correct, answer.
Read it as four rows, left to right: each red box on the left is a concrete way the system can betray a user, and the green box it points to is the control that removes that failure.
- The red left column lists the four failures in rising severity: a claim with no basis, a claim citing the wrong document, a stale answer from an out-of-date index, and — the worst — a user seeing a document they were never allowed to see.
- The green right column is the paired fix for each: cite-or-refuse, carry
doc_id+ page on every chunk, re-index with an "as-of" date, and filter by permission before the model ever sees a chunk. - The bottom row is flagged as the one that "ends careers" — note its control fires before retrieval, not after, because a leak can't be un-shown.
In short: the safety model is load-bearing, not decorative — each control exists because there is a specific way the system would otherwise betray the user's trust.
| Risk | Control |
|---|---|
| 🔴 Confident answer with no basis in the documents | Grounded generation only — every claim cites a retrieved chunk; "not found in the documents" is a valid, required answer |
| 🟠 Retrieving from the wrong document / mixing sources | Carry document ID + page on every chunk; show citations; metadata filters scope the search |
| 🟠 Stale index (document updated, index not) | Re-index on change; store document version; show "as of" date |
| 🔴 Access control — user sees a document they shouldn't | Filter retrieval by the user's permissions before the LLM ever sees a chunk (Ch 6) |
4 · The ingestion pipeline — where quality is won advanced
Almost everyone who ships a disappointing RAG system tries to fix it at the wrong end. The answers come back vague or wrong, so they swap the model, tune the prompt, crank up top-k — and nothing improves, because the damage was done long before the query ran. The chunk they needed was never a clean, retrievable unit in the first place. The most common culprit is blind fixed-size chunking: cut the document every 500 characters and you routinely slice a clause in half, or glue the end of one section to the start of the next, so the vector that should mean "termination clause" now means "half a termination clause plus a payment schedule header."
That is why this section carries the "where quality is won" title. Retrieval can only ever return chunks you created at ingest time; if the boundaries are wrong, no amount of clever querying recovers the lost meaning — garbage in, garbage retrieved. The fix is to chunk on the document's own structure (sections, clauses, pages) so each chunk is a self-contained thought, and to attach rich metadata — vendor, type, dates, and crucially the source doc ID and page — to every chunk as it goes in. The metadata is what later makes both permission filtering and citations possible: a chunk that doesn't know where it came from can never be cited or access-controlled. The sketch below shows the four jobs every document runs through — parse, extract metadata, chunk, index — and is a shape, not runnable code, so read it for the flow rather than the API.
Read the top row left to right — that is the order every document passes through — then drop to the bottom pair, which zooms in on the one step that decides retrieval quality.
- The top four boxes are the ingest jobs in sequence: parse (PDF into text), the amber extract-metadata box (pull vendor, type, dates — the Project 4 trick), chunk on structure, and the green index box that stores each piece with its
doc_idandpage. The blue arrows are the hand-off from one job to the next. - The bottom-left green box zooms into job 3 done well: cutting on the document's own structure keeps a whole termination clause together as one retrievable thought.
- The bottom-right red box is the payoff-by-contrast: a blind 500-character cut slices the clause in half and glues on an unrelated header, so the chunk's meaning — and any vector built from it — is corrupted before a query ever runs.
In short: the pipeline is parse → extract → chunk → index, and the green-vs-red pair shows why chunking on structure (not character count) is the single highest-leverage decision.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def chunk_by_section(*a, **k): # demo stub
return _Any()
corpus = [
{"id": "x1", "text": "demo one", "name": "api-7f9c", "status": "Running"},
{"id": "x2", "text": "demo two", "name": "worker-2d", "status": "CrashLoopBackOff"},
]
def embed(*a, **k): # demo stub
return _Any()
def extract_metadata(*a, **k): # demo stub
return _Any()
class _index_t:
add = 'demo'
def add(self, *a, **k): return 'demo'
def __getattr__(self, k): return 'demo'
index = _index_t()
def parse(*a, **k): # demo stub
return _Any()ingest.py (shape)for doc in corpus:
text = parse(doc) # native text / OCR / vision (P4)
meta = extract_metadata(text) # vendor, type, dates — Project 4 schema
for chunk in chunk_by_section(text): # respect headings, not blind 500-char cuts
index.add(
embedding=embed(chunk.text),
text=chunk.text,
metadata={**meta, "doc_id": doc.id, "page": chunk.page},
)
This is a sketch, not runnable code — it shows the shape of an ingestion pipeline so the real files later make sense. Read it as the four jobs every document has to go through before it can be searched: read it, understand it, cut it up, and file it away.
for doc in corpus:means "do all of the following once for every document in the pile".corpusis just your whole set of documents.text = parse(doc)turns the raw file (a PDF, a scan) into plain text you can work with.meta = extract_metadata(text)pulls out the structured facts — vendor, type, dates — reusing the Project 4 extraction idea.for chunk in chunk_by_section(text):cuts the document into pieces along its structure (sections, clauses) rather than every 500 characters — so a piece is a whole thought, not a sentence sliced in half.index.add(...)files each piece away with three things: itsembedding(the meaning-as-numbers), itstext, and itsmetadata— crucially includingdoc_idandpageso every piece can be traced back to where it came from.
Try this: Notice {**meta, "doc_id": ..., "page": ...} — the **meta copies in all the extracted fields, then adds the source location on top. That source location is what makes citations possible later.
5 · Tool surface advanced
The instinct is to give the agent one big "answer the question" tool and let it figure everything out. That feels powerful and is actually a trap — a single opaque capability is impossible to permission, impossible to test, and impossible to reason about when it does something wrong. The discipline is the opposite: break the agent's abilities into a small set of named, single-purpose tools, each with a clear job and a clear risk label.
Think of the tool surface as the agent's job description — it lists exactly what the agent is allowed to do and nothing more. Most of what a document-RAG agent needs is read-only: search the corpus, fetch a document, extract fields on demand, scope a query by metadata. Those are green because reading can't corrupt or leak (as long as permission filtering happens first — see §3). The one that earns a warning is anything that writes — export_report to a file or a ticket — because a write action can act on the world, so it is gated rather than granted freely. The reason this earns its own section: the tool surface is your security boundary made concrete. Every capability you add widens what the agent can do and therefore what can go wrong, so the smallest surface that solves the discovered needs (§1) is the right one. Read the table as a contract: capability, what it does, and how much you have to trust it.
Start at the agent box at the top, then follow the solid arrows down to the tools it may call; the dashed arrow marks the one capability that is treated differently.
- The top box is the agent. Its "job description" is exactly the tools below it — nothing else is permitted.
- The four green boxes are read-only (or deterministic) tools: search, fetch a document, extract fields, filter by metadata. Reading can't corrupt or leak — provided permission filtering has already run, which the label under them reminds you happens first (§3).
- The amber box, reached by a dashed arrow, is
export_report— the only tool that writes to the world. It is gated rather than granted freely, because a write action can act beyond just answering.
In short: every tool you add widens what can go wrong, so keep the surface small — mostly read-only, permission-filtered first, and gate the single write.
| Capability | Does | Risk |
|---|---|---|
search_documents | Vector + metadata retrieval over the corpus | 🟢 read-only |
get_document | Fetch a full document / page by ID | 🟢 read-only (permission-checked) |
extract_fields | Project 4 structured extraction on demand | 🟢 read-only |
filter_by_metadata | Scope search (type, date range, vendor) | 🟢 deterministic |
export_report (optional) | Write a summary/answer to a file or ticket | 🟠 gated — write action |
6 · Evaluation advanced
When a RAG answer is wrong, the useless response is "the AI messed up." That tells you nothing you can act on, because there are two completely different failures hiding behind one bad answer: either retrieval never surfaced the right chunk, or retrieval found it and the model ignored or contradicted it. Fix the wrong one and you waste a week — you can't tune generation your way out of a retrieval miss, and you can't chunk your way out of a model that hallucinates over good sources.
So evaluation here is deliberately split into two dials you measure separately, like a doctor checking pulse and blood pressure as distinct readings rather than one vague "health." First, retrieval recall@k: on a labelled set of questions, does the chunk that actually answers each one appear in the top-k? That number is the ceiling on everything downstream — if the right chunk isn't retrieved, the best model on earth cannot answer faithfully. Only once retrieval clears the bar do you measure the second dial: answer faithfulness (does every claim trace to a cited chunk?), citation correctness (do the cited doc and page really contain the claim?), and abstention (does it say "not in the documents" instead of inventing?). The reason to test them in that order is causal: retrieval is upstream of generation, so you diagnose upstream first. The table below is these dials laid out as a checklist — read it as "measure retrieval, then judge the answer."
Follow the blue arrow left to right — that is the order you must run the two evals — and read the red drop-down as what happens when the first dial fails.
- The left blue box is dial one, retrieval recall@k: on a labelled question set, does the chunk that actually answers each question appear in the top-k? This is the upstream measurement.
- The blue arrow is a gate labelled "clears the bar?" — only if retrieval passes do you move to the green box, dial two: the answer-side checks (faithfulness, citation correctness, abstention). The note beneath it says they are only meaningful once the right chunk is actually retrieved.
- The red box hanging below dial one is the payoff idea: retrieval is the ceiling. If it misses, there is no chunk for the model to ground on, so tuning the answer side is wasted effort — you fix the upstream miss first.
In short: measure retrieval recall@k first because it caps everything; judge faithfulness, citations, and abstention only after the right chunk reliably shows up.
| Eval | Measures |
|---|---|
| Retrieval recall@k | Does the right chunk appear in the top-k? (the ceiling on answer quality) |
| Answer faithfulness | Is every claim supported by a cited chunk? (LLM-as-judge, Ch 5) |
| Citation correctness | Do the cited doc + page actually contain the claim? |
| Abstention | Does it say "not in the documents" instead of inventing? |
| Metadata-filter precision | "Contracts expiring in Q3" returns exactly those |
7 · Phased rollout expert
The failure mode of ambitious teams is shipping Phase 3 on day one. They build the full "ask any question, get a synthesized answer, run cross-document analytics, export reports" system, launch it, and then spend months firefighting because the retrieval wasn't trustworthy enough to carry synthesis, and every wrong answer erodes the credibility they needed to keep the project funded. You don't earn trust by promising it; you earn it one verifiable step at a time.
So the rollout is a ladder, and each rung is independently useful and independently trustworthy — you can stop at any rung and still have shipped something valuable. Phase 1 just returns relevant passages with citations and lets a human read them; it is pure retrieval, the lowest-risk thing you can do, and it is instantly useful because "find me the passage" already beats an afternoon of folder-hunting. Phase 2 adds synthesis — the model writes the answer, but every claim is cited and it abstains when unsupported — which you only unlock once Phase 1's retrieval is measurably good (that's what §6's recall@k proved). Phase 3 adds cross-document analytics and report export, the highest-leverage and highest-risk tier. The point of laddering this way is that each rung de-risks the next: you never automate a decision until the layer beneath it has earned the right. Note the final rung is a hard "Never" — some things (uncited claims presented as fact, retrieval past a permission boundary) are not later phases, they are permanent red lines.
Read this bottom to top, like climbing a ladder: the safest, most immediately useful step is at the bottom, and each rung up adds leverage and risk.
- The green bottom rung — Phase 1 is pure retrieval: return the relevant passages with citations and let a human read them. Lowest risk, and useful the day you ship it.
- The blue Phase 2 rung adds synthesis — the model writes the answer, but every claim is cited and it abstains when unsupported. The blue arrows show you only climb here once the rung below is solid; the note ties the unlock to §6's recall@k passing.
- The amber Phase 3 rung is cross-document analytics and report export — the highest leverage and highest risk. Above it sits the red "Never" bar, separated by a dashed line: presenting an uncited claim as fact or retrieving past a permission boundary is not a later phase, it is a permanent red line.
In short: start at Phase 1, and let each rung earn the next; the red bar above the line is something you never build, no matter the phase.
Skills & course map expert
| Skill | Learn it in |
|---|---|
| Chunking, embedding, retrieval, grounded answers | Ch 3 · RAG |
| Metadata extraction from documents | Project 4 + Ch 2 |
| Embeddings & similarity | K2 · A7 |
| Hybrid retrieval & re-ranking | M3 |
| Faithfulness / citation evals | Ch 5 |
| Access control, PII, deploy | Ch 6 |
python3 --version. No accounts or keys needed for the build — the whole pipeline runs on a deterministic local embedder so you can learn the mechanics for free.By the end you will have
- A project folder with a virtual environment and dependencies installed.
- An ingest pipeline that chunks documents, attaches metadata, and embeds them.
- A retriever that filters by metadata/permission before ranking.
- Grounded answers that cite the source document and page — and abstain otherwise.
- Seven passing tests, all with no API key.
How to use this page expert
Do the steps in order, top to bottom. A terminal block means type those commands and press Enter. A file block means create that exact file and paste in the whole contents. Expected output follows each command so you can check yourself. Skip nothing — each step builds on the last.
Step 1 · Create the project folder expert
terminalmkdir -p doc-rag/samples doc-rag/tests
cd doc-rag
# no output — you are now inside doc-rag/
py for python3. If mkdir -p fails, run mkdir doc-rag, then create the samples and tests sub-folders inside it.Step 2 · Virtual environment expert
A virtual environment isolates this project's packages. After activating, your prompt shows (.venv).
terminalpython3 -m venv .venv
source .venv/bin/activate
Step 2 — Windows (PowerShell)
terminalpy -m venv .venv
.venv\Scripts\Activate.ps1
(.venv) /Users/you/doc-rag $
(.venv) only lasts for this window. If you close it, re-run the activate line before continuing.Step 3 · Install dependencies expert
We need pytest for tests and the Anthropic SDK for the optional live mode (Step 9). The core pipeline uses only the Python standard library.
terminalpip install pytest "anthropic>=0.40"
pip freeze > requirements.txt
Successfully installed anthropic-0.69.0 pytest-8.3.4 ...
Step 4 · The store — chunks with metadata + a stub embedder expert
Create store.py. It defines a Chunk, a pure-Python embedder (so similarity works offline), cosine similarity, and a Store that filters by metadata before ranking. Paste the whole file.
doc-rag/store.py
store.py"""In-memory vector store with a deterministic, offline embedder."""
from dataclasses import dataclass, field
import math, re
def embed(text: str, dims: int = 64) -> list[float]:
"""A tiny bag-of-words hashing embedder. Not smart, but deterministic
and offline: same words -> similar vectors. Good enough to LEARN RAG.
In Step 9 you swap this for a real embedding model."""
vec = [0.0] * dims
for word in re.findall(r"[a-z0-9]+", text.lower()):
vec[hash(word) % dims] += 1.0
return vec
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return 0.0 if na == 0 or nb == 0 else dot / (na * nb)
@dataclass
class Chunk:
text: str
doc_id: str
page: int
meta: dict = field(default_factory=dict)
embedding: list[float] = field(default_factory=list)
class Store:
def __init__(self):
self.chunks: list[Chunk] = []
def add(self, chunk: Chunk) -> None:
if not chunk.embedding:
chunk.embedding = embed(chunk.text)
self.chunks.append(chunk)
def search(self, query: str, k: int = 4, where: dict = None) -> list[Chunk]:
"""Filter by metadata FIRST (access control + scoping), THEN rank."""
pool = [c for c in self.chunks
if not where or all(c.meta.get(k2) == v for k2, v in where.items())]
q = embed(query)
ranked = sorted(pool, key=lambda c: cosine(q, c.embedding), reverse=True)
# drop zero-similarity hits so out-of-corpus queries return nothing
return [c for c in ranked[:k] if cosine(q, c.embedding) > 0]
This search method makes one decision that matters for safety: it narrows the candidates by metadata before it ever ranks by similarity. Order is the whole point — a chunk a user isn't allowed to see is removed from the pool before scoring, so it can never surface in results.
pool = [c for c in self.chunks if not where or all(...)]keeps only chunks that match thewherefilter (e.g.{"owner": "alice"}). Ifwhereis empty, every chunk stays.q = embed(query)turns the question into a vector, thensorted(pool, key=lambda c: cosine(q, c.embedding), reverse=True)orders the survivors best-match-first.- The final line returns the top
kbut only those with similarity above 0 — so a query about something not in the corpus returns an empty list instead of the least-bad guess.
Try this: This is the access-control point the risk table warned about, enforced in real code. Compare it to filtering after ranking: if you ranked first, a forbidden document could top the list and only then get hidden — one bug away from a leak.
This file is the engine room of the search: it turns text into numbers, measures how alike two pieces of text are, and stores everything. It runs fully offline so you can learn RAG without paying for anything or setting up an account.
embed(text)turns a string into a list of 64 numbers (a vector). It lower-cases the text, splits it into words, and for each word bumps up one slot in the list. Same words → similar lists. It's deliberately simple — Step 9 swaps it for a real, smarter embedder.cosine(a, b)scores how similar two vectors are, from 0 (nothing in common) to 1 (identical direction). This is the standard way to compare meaning-vectors; theif na == 0 or nb == 0guard just avoids dividing by zero for empty text.@dataclass class Chunkis a tidy record for one stored piece: itstext, which document it came from (doc_id), thepage, free-formmeta, and itsembedding.field(default_factory=dict)just gives each Chunk its own empty dict/list instead of accidentally sharing one.Store.addembeds a chunk (if it isn't already) and keeps it in a list.Store.searchis the heart: it first buildspoolby filtering on metadata, then embeds the query, sorts the pool by cosine similarity (highest first), and returns the topk— dropping any with zero similarity so an off-topic question comes back empty.
What the output means: Nothing prints — this file only defines the tools. Other files import Store, Chunk and embed and put them to work.
Try this: Look at the very last line: it re-checks cosine(q, c.embedding) > 0 so results with no word overlap are thrown away. That one filter is why the out-of-corpus question later can honestly answer "I don't have that."
Step 5 · Ingest — structure-aware chunking expert
Create ingest.py. It splits documents on blank-line sections (not blind character counts) and attaches metadata to every chunk.
doc-rag/ingest.py
ingest.py"""Turn raw document text into chunks with metadata + embeddings."""
from store import Store, Chunk
def chunk_sections(text: str):
"""Yield (section_text, page_number). Splits on blank lines so each
chunk is a self-contained idea, not a mid-sentence cut."""
page = 1
for block in text.split("\n\n"):
block = block.strip()
if block:
yield block, page
page += 1
def ingest(store: Store, doc_id: str, text: str, meta: dict) -> int:
"""Add every section of one document to the store. Returns chunk count."""
n = 0
for section, page in chunk_sections(text):
store.add(Chunk(text=section, doc_id=doc_id, page=page, meta=meta))
n += 1
return n
Now the real ingest file. Its job is to take one document's text and hand the store a series of clean, self-contained pieces — each tagged with a page number so it can be cited later.
chunk_sections(text)splits the document on blank lines (text.split("\n\n")) — a simple stand-in for "cut on structure, not mid-sentence". Each non-empty block becomes one chunk.yield block, pagehands back one section at a time (a generator), andpage += 1gives each section a rising page number so we always know where it sat in the document.ingest(store, doc_id, text, meta)loops over those sections and callsstore.add(Chunk(...))for each — stamping every chunk with the samedoc_idandmeta, plus its ownpage.- It returns
n, the number of chunks added — a small confirmation you can print or assert on.
What the output means: Nothing on its own; called by other code. For a two-section document it adds 2 chunks and returns 2.
Try this: This is where the earlier tip "chunk on structure, not character count" becomes real. Try feeding it text with no blank lines — you'll get one big chunk, and you can feel why good chunk boundaries matter for retrieval.
Step 6 · Sample documents expert
Create two tiny sample files so you have a corpus to query. Create samples/contract_acme.txt:
doc-rag/samples/contract_acme.txt
samples/contract_acme.txtPayment terms: ACME pays Net 30 from the invoice date.
Renewal: this contract auto-renews annually unless cancelled 60 days prior.
doc-rag/samples/policy_refunds.txt
samples/policy_refunds.txtRefund policy: customers may request a refund within 30 days of purchase.
Exceptions: digital goods are non-refundable once downloaded.
Step 7 · The answer engine — grounded, cited, abstaining expert
Create answer.py. It retrieves relevant chunks, builds a numbered context, and (in mock mode) produces a grounded answer with citations — or abstains when nothing is retrieved.
doc-rag/answer.py
The grounded answer step — in Claude or OpenAI
The store, retrieval, context assembly, abstention path, and the offline mock are all vendor-neutral — only _real_llm (and the MODEL id) change. Claude uses client.messages.create(...) → msg.content[0].text; OpenAI uses client.responses.create(...) → resp.output_text. Toggle the tabs.
answer.py"""Grounded question-answering over the store. Mock by default."""
import os
from store import Store
MODEL = "claude-opus-4-8"
ABSTAIN = "I don't have that in the documents."
def _context(hits) -> str:
return "\n".join(
f"[{i}] ({h.doc_id} p{h.page}) {h.text}" for i, h in enumerate(hits))
def _mock_llm(question: str, hits) -> str:
"""Offline stand-in: echoes the top chunk with its citation so you can
see the grounding + citation flow without an API key."""
top = hits[0]
return f"{top.text} [0]"
def _real_llm(question: str, hits) -> str:
import anthropic
client = anthropic.Anthropic()
system = ("Answer ONLY from the numbered sources. Cite the source id in "
"[brackets] after each claim. If the sources do not contain the "
f"answer, reply exactly: {ABSTAIN}")
msg = client.messages.create(
model=MODEL, max_tokens=400, system=system,
messages=[{"role": "user",
"content": f"Sources:\n{_context(hits)}\n\nQuestion: {question}"}])
return msg.content[0].text
def answer(question: str, store: Store, where: dict = None) -> dict:
hits = store.search(question, k=4, where=where)
if not hits:
return {"text": ABSTAIN, "sources": []} # the abstention path
use_real = os.environ.get("USE_REAL_API") == "1"
text = _real_llm(question, hits) if use_real else _mock_llm(question, hits)
return {"text": text, "sources": [(h.doc_id, h.page) for h in hits]}
def find_docs(store: Store, where: dict) -> list[str]:
"""Cross-document analytics: a metadata filter, not a search."""
return sorted({c.doc_id for c in store.chunks
if all(c.meta.get(k) == v for k, v in where.items())})
if __name__ == "__main__":
from ingest import ingest
s = Store()
ingest(s, "contract_acme.txt", open("samples/contract_acme.txt").read(),
{"type": "contract", "vendor": "ACME"})
ingest(s, "policy_refunds.txt", open("samples/policy_refunds.txt").read(),
{"type": "policy"})
print("Q: What are ACME's payment terms?")
print("A:", answer("payment terms ACME Net", s)["text"])
print("Q: What is the capital of France? (out of corpus)")
print("A:", answer("capital of France", s)["text"])
print("contracts:", find_docs(s, {"type": "contract"}))
answer.py"""Grounded question-answering over the store. Mock by default."""
import os
from store import Store
MODEL = "gpt-5.5"
ABSTAIN = "I don't have that in the documents."
def _context(hits) -> str:
return "\n".join(
f"[{i}] ({h.doc_id} p{h.page}) {h.text}" for i, h in enumerate(hits))
def _mock_llm(question: str, hits) -> str:
"""Offline stand-in: echoes the top chunk with its citation so you can
see the grounding + citation flow without an API key."""
top = hits[0]
return f"{top.text} [0]"
def _real_llm(question: str, hits) -> str:
import openai
client = openai.OpenAI()
system = ("Answer ONLY from the numbered sources. Cite the source id in "
"[brackets] after each claim. If the sources do not contain the "
f"answer, reply exactly: {ABSTAIN}")
resp = client.responses.create(
model=MODEL, max_output_tokens=400, instructions=system,
input=f"Sources:\n{_context(hits)}\n\nQuestion: {question}")
return resp.output_text
def answer(question: str, store: Store, where: dict = None) -> dict:
hits = store.search(question, k=4, where=where)
if not hits:
return {"text": ABSTAIN, "sources": []} # the abstention path
use_real = os.environ.get("USE_REAL_API") == "1"
text = _real_llm(question, hits) if use_real else _mock_llm(question, hits)
return {"text": text, "sources": [(h.doc_id, h.page) for h in hits]}
def find_docs(store: Store, where: dict) -> list[str]:
"""Cross-document analytics: a metadata filter, not a search."""
return sorted({c.doc_id for c in store.chunks
if all(c.meta.get(k) == v for k, v in where.items())})
if __name__ == "__main__":
from ingest import ingest
s = Store()
ingest(s, "contract_acme.txt", open("samples/contract_acme.txt").read(),
{"type": "contract", "vendor": "ACME"})
ingest(s, "policy_refunds.txt", open("samples/policy_refunds.txt").read(),
{"type": "policy"})
print("Q: What are ACME's payment terms?")
print("A:", answer("payment terms ACME Net", s)["text"])
print("Q: What is the capital of France? (out of corpus)")
print("A:", answer("capital of France", s)["text"])
print("contracts:", find_docs(s, {"type": "contract"}))
This system prompt is how you keep the model honest in live mode. It's a set of firm instructions the model must obey before it sees the question.
- "Answer ONLY from the numbered sources" forbids the model from using its own background knowledge — it may only use the chunks you retrieved and pasted in.
- "Cite the source id in [brackets] after each claim" forces every statement to point at a specific numbered chunk, so a human can verify it.
- The last clause tells it to reply with exactly the
ABSTAINstring when the sources don't contain the answer — the same honest refusal the offline path produces, now enforced by instruction. - The model call sends it all — the
systemrules plus the numberedSources:andQuestion:. With Claude that'sclient.messages.create(...)and the reply text is read frommsg.content[0].text; with OpenAI it'sclient.responses.create(...)andresp.output_text(toggle the tabs above). Only_real_llmchanges between vendors.
Try this: Grounding lives in two places at once: the prompt tells the model to stay on-source, and retrieval controls which sources it even sees. Weakening either one lets hallucinations back in.
This is the payoff file: given a question, it retrieves the best chunks, then either writes a grounded answer (real mode) or echoes the top chunk (offline mock mode) — and it refuses to answer when retrieval finds nothing. It works with no API key by default.
_context(hits)formats the retrieved chunks into a numbered list like[0] (contract_acme.txt p1) Payment terms.... Those numbers are the citation labels the answer will point back to._mock_llmis the free offline stand-in: it just returns the top chunk's text with[0]tacked on, so you can watch the retrieve→cite flow without paying for a model._real_llmis the same idea with a real Claude call, and a strict system prompt (covered in the next box).answer(question, store, where)ties it together: itsearches, and ifnot hitsit returns theABSTAINstring immediately — that early return is the safety valve. Otherwise it picks mock vs real based on theUSE_REAL_APIenvironment variable and returns both thetextand the list ofsources(doc_id + page for each hit).find_docs(store, where)answers "which documents match these fields?" — pure metadata filtering, no search — which is how you do cross-document questions like "which contracts auto-renew?".
What the output means: Imported and called by the demo block at the bottom and by the tests. On its own it defines the answer/abstain/analytics functions.
Try this: Find the line if not hits: return {"text": ABSTAIN, "sources": []}. That single check is the difference between an honest "I don't know" and a confident made-up answer over a real contract.
Run it — works immediately, no key:
terminalpython answer.py
Q: What are ACME's payment terms?
A: Payment terms: ACME pays Net 30 from the invoice date. [0]
Q: What is the capital of France? (out of corpus)
A: I don't have that in the documents.
contracts: ['contract_acme.txt']
This is the demo actually running — no API key needed. It ingests the two sample files, asks two questions, and lists the contracts. Read the output as proof that the whole pipeline works end to end.
- The first answer repeats ACME's payment sentence with a trailing
[0]— the citation pointing at retrieved chunk 0. The mock model is just echoing the top hit, but the grounding + citation flow is real. - The second question ("capital of France") is not in the documents, so the answer is the exact abstain line —
search()found no overlapping chunk andanswer()returned early. - The last line,
contracts: ['contract_acme.txt'], isfind_docsanswering a cross-document question by metadata filter alone — no search involved.
What the output means: Three things: a cited answer from the corpus, an honest abstention for an out-of-corpus question, and a metadata-filtered document list. That trio is exactly what the tests in Step 8 lock in.
Try this: Add a blank line and a new paragraph to samples/contract_acme.txt, re-run, and the extra section becomes its own citable chunk (p3).
search() dropped zero-similarity hits and answer() returned early. Over real contracts, a confident made-up answer is worse than none — this is the behaviour you test in Step 8.Step 8 · Tests (no key) expert
Create tests/test_docrag.py and paste the whole file.
doc-rag/tests/test_docrag.py
tests/test_docrag.py"""Offline tests — deterministic embedder, no API key."""
from store import Store, Chunk
from ingest import ingest
from answer import answer, find_docs, ABSTAIN
def _store():
s = Store()
ingest(s, "c.txt", "ACME pays Net 30 from invoice date.",
{"type": "contract", "owner": "alice"})
ingest(s, "p.txt", "Refunds allowed within 30 days of purchase.",
{"type": "policy", "owner": "bob"})
return s
def test_relevant_chunk_is_retrieved():
s = _store()
assert answer("payment terms Net invoice", s)["sources"][0][0] == "c.txt"
def test_metadata_filter_scopes_results():
s = _store()
res = answer("refund", s, where={"type": "policy"})
assert all(src[0] == "p.txt" for src in res["sources"])
def test_permission_filter_hides_other_owners():
s = _store()
res = answer("anything", s, where={"owner": "alice"})
assert all(src[0] == "c.txt" for src in res["sources"])
def test_out_of_corpus_abstains():
s = _store()
assert answer("xyzzy quux nothing", s)["text"] == ABSTAIN
def test_every_hit_has_doc_and_page():
s = _store()
for src in answer("payment", s)["sources"]:
assert isinstance(src[0], str) and isinstance(src[1], int)
def test_find_docs_by_metadata():
s = _store()
assert find_docs(s, {"type": "contract"}) == ["c.txt"]
def test_answer_is_grounded_in_a_source():
s = _store()
res = answer("payment terms Net invoice", s)
assert "[0]" in res["text"] # cites a retrieved chunk
These seven tests prove the important behaviours without any API key — they run on the deterministic offline embedder, so they're fast, free, and give the same answer every time. Each test is a tiny, readable statement of one guarantee.
_store()is a shared helper that builds a fresh store with two documents — a contract owned by alice and a policy owned by bob — so every test starts from the same known corpus.- The first three tests check retrieval and filtering: the right chunk comes back; a
typefilter returns only the policy; and anownerfilter never leaks another user's document — that's the access-control test. test_out_of_corpus_abstainsfeeds nonsense ("xyzzy quux nothing") and asserts the answer equalsABSTAIN— pinning the honest-refusal behaviour so a future change can't silently break it.- The last three assert every hit carries a string
doc_idand intpage(so answers are always citable), thatfind_docsreturns exactly the contract, and that the answer text actually contains a[0]citation.
What the output means: Run with python -m pytest tests/ -v you should see all seven lines marked PASSED and 7 passed at the end.
Try this: Break one guarantee on purpose — e.g. delete the > 0 check in store.search — and watch test_out_of_corpus_abstains go red. That's the test doing its job: catching a safety regression.
terminalpython -m pytest tests/ -v
tests/test_docrag.py::test_relevant_chunk_is_retrieved PASSED
tests/test_docrag.py::test_metadata_filter_scopes_results PASSED
tests/test_docrag.py::test_permission_filter_hides_other_owners PASSED
tests/test_docrag.py::test_out_of_corpus_abstains PASSED
tests/test_docrag.py::test_every_hit_has_doc_and_page PASSED
tests/test_docrag.py::test_find_docs_by_metadata PASSED
tests/test_docrag.py::test_answer_is_grounded_in_a_source PASSED
7 passed in 0.06s
| Test | Proves |
|---|---|
| relevant chunk retrieved | the retriever surfaces the right passage |
| metadata filter scopes | "policy only" excludes the contract |
| permission filter hides owners | a scoped user never sees another's docs |
| out-of-corpus abstains | no hallucinated answer — returns the abstain string |
| hits carry doc + page | every answer is citable to a source |
| find_docs by metadata | "which contracts?" is an exact filter, not a guess |
| answer is grounded | the reply cites a retrieved chunk |
Step 9 · Go live: real embeddings + Claude (optional) expert
The stub embedder is enough to learn RAG, but real embeddings understand meaning (synonyms, paraphrase). To go live you change two things — no structural code changes:
1. Replace the body of embed() in store.py with a call to a real embedding model (e.g. an Anthropic or open-source embedder), returning its vector.
2. Set your key and enable the live LLM answer:
terminalexport ANTHROPIC_API_KEY="sk-ant-your-key-here"
export USE_REAL_API=1
python answer.py
Q: What are ACME's payment terms?
A: ACME pays Net 30 from the invoice date [0].
unset USE_REAL_API.Troubleshooting — every error you might hit expert
| What you see | What it means & the fix |
|---|---|
python3: command not found | Install Python from python.org; on Windows use py. |
No (.venv) in prompt | Re-run the Step 2 activate line for your OS. |
ModuleNotFoundError: store / answer | Run pytest from inside doc-rag/, not from tests/. |
FileNotFoundError: samples/... | Run python answer.py from doc-rag/; confirm Step 6 files exist. |
| Out-of-corpus question returns a chunk | Expected only when a word overlaps by hash collision — enlarge dims in embed() or use real embeddings. |
| Wrong document retrieved | The stub embedder is word-overlap only; real embeddings (Step 9) fix semantic matches. |
authentication_error (Step 9) | Key unset/invalid. Re-run the export; check with echo $ANTHROPIC_API_KEY. |
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The whole RAG project stands on a deterministic in-memory store that runs with no API key, so tests are reproducible and you can swap in a real embedding model later without changing the interface.
Your task: Implement embed, cosine, and Store.add/Store.search returning the top-k chunks.
Requirements:
embed(text)is a deterministic hash bag-of-words vectorcosine(a, b)returns similarity, safe on zero vectorsStore.addembeds and stores a chunk;searchreturns the top-k by cosine- The store carries chunk metadata (doc id, page)
- Prove a query returns the most relevant chunk
💡 Hint: Hashing tokens into fixed dimensions gives deterministic embeddings; keep the interface stable so a real embedder drops in later.
Show solution
Offline-runnable core the whole project stands on — no API key:
import math, hashlib
from dataclasses import dataclass, field
def embed(text, dims=64):
v = [0.0] * dims
for tok in text.lower().split():
h = int(hashlib.md5(tok.encode()).hexdigest(), 16)
v[h % dims] += 1.0
return v
def cosine(a, b):
dot = sum(x*y for x, y in zip(a, b))
na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
return dot / (na*nb) if na and nb else 0.0
@dataclass
class Chunk:
text: str; doc_id: str; page: int = 0
meta: dict = field(default_factory=dict)
embedding: list = None
class Store:
def __init__(self): self.chunks = []
def add(self, ch):
ch.embedding = ch.embedding or embed(ch.text)
self.chunks.append(ch)
def search(self, query, k=4):
q = embed(query)
ranked = sorted(self.chunks, key=lambda c: cosine(q, c.embedding), reverse=True)
return ranked[:k]
s = Store()
s.add(Chunk("Refunds are processed within 30 days.", "policy", 1))
s.add(Chunk("The office is closed on public holidays.", "hr", 2))
print(s.search("how long do refunds take?", k=1)[0].text)
# Refunds are processed within 30 days.
Deterministic embeddings make tests reproducible; you swap in a real embedding model later without changing the interface.
Context: Better chunks mean better recall. Splitting on blank lines keeps a clause with its heading, so a retrieved chunk is self-contained enough to cite — unlike a fixed character window.
Your task: Implement chunk_sections(text) yielding (section, page) and ingest(store, doc_id, text, meta) that chunks, embeds and indexes.
Requirements:
- Split on blank lines so sections stay whole (not fixed-size windows)
- Track a page number across the document
ingestchunks, embeds, and indexes each section with its metadata- It returns the count of chunks added
- Demonstrate ingesting a short multi-section doc
💡 Hint: Yield each blank-line-separated block with its page; ingest just loops chunk_sections into store.add.
Show solution
Better chunks = better recall. Sections beat fixed windows:
def chunk_sections(text):
page = 1
for block in text.split("\n\n"):
block = block.strip()
if not block:
continue
if block.startswith("[page]"): # explicit page markers advance page
page += 1; continue
yield block, page
def ingest(store, doc_id, text, meta=None):
n = 0
for section, page in chunk_sections(text):
store.add(Chunk(section, doc_id, page, dict(meta or {})))
n += 1
return n
doc = "Refund policy.\nRefunds within 30 days.\n\n[page]\nShipping.\nGround is 5 days."
print(ingest(s, "policy", doc, {"tenant": "acme"})) # 2 sections indexed
Splitting on blank lines keeps a clause with its heading, so a retrieved chunk is self-contained enough to cite.
Context: Multi-tenant corpora must never leak across tenants. Access control is a pre-filter, not a post-filter — you must never even rank documents the user can't see.
Your task: Add a where filter to search that restricts candidates by metadata before ranking, plus a find_docs helper.
Requirements:
searchfilters candidates by metadata before ranking- A
find_docs(store, where)helper lists matching docs - A query as tenant B never considers tenant A's chunks
- Assert the returned chunks all match the tenant filter
- Runs offline
💡 Hint: Filter the candidate list first, then sort the survivors — filtering before similarity is the difference between a demo and something two customers share.
Show solution
Access control is a pre-filter, not a post-filter — you must never rank documents the user cannot see:
def matches(meta, where):
return all(meta.get(k) == v for k, v in where.items())
def search(store, query, k=4, where=None):
cands = [c for c in store.chunks if not where or matches(c.meta, where)]
q = embed(query)
cands.sort(key=lambda c: cosine(q, c.embedding), reverse=True)
return cands[:k]
def find_docs(store, where):
return sorted({c.doc_id for c in store.chunks if matches(c.meta, where)})
st = Store()
st.add(Chunk("Acme secret roadmap Q3", "road", 1, {"tenant": "acme"}))
st.add(Chunk("Globex secret roadmap Q3", "road", 1, {"tenant": "globex"}))
hits = search(st, "roadmap", where={"tenant": "globex"})
print([h.text for h in hits]) # only Globex — Acme never considered
assert all(h.meta["tenant"] == "globex" for h in hits)
Filtering the candidate set first is the difference between a demo and something you can put in front of two customers on one index.
Context: The answer engine must cite the chunks it used and must refuse when retrieval returns nothing relevant. Abstention is a first-class output — a RAG system that answers when it has nothing is worse than one that says 'I don't know'.
Your task: Build answer(question, store, where) that returns {text, sources}, abstaining on empty/low-signal retrieval.
Requirements:
- Retrieval respects the tenant
wherefilter - Chunks below a minimum similarity are dropped as noise
- On no surviving chunks, return a fixed refusal with empty sources
- A real answer carries the source ids (doc + page) it used
- The grounded-generation call is labelled needs-key; abstention runs offline
💡 Hint: Gate on a minimum similarity: if nothing clears it, short-circuit to the refusal before building any prompt — the eval set must include unanswerable questions.
Show solution
The offline grounding contract — real code swaps the stub for the SDK but the invariant holds:
MIN_SIM = 0.05 # below this, retrieval is noise -> abstain
def answer(question, store, where=None):
q = embed(question)
ranked = [(cosine(q, c.embedding), c) for c in store.chunks
if not where or matches(c.meta, where)]
ranked = [(sim, c) for sim, c in ranked if sim >= MIN_SIM]
ranked.sort(reverse=True, key=lambda t: t[0])
top = [c for _, c in ranked[:3]]
if not top:
return {"text": "I don't have information on that.", "sources": []}
ctx = "\n".join(f"[{c.doc_id} p{c.page}] {c.text}" for c in top)
# --- needs API key: real grounded generation (Claude or OpenAI) ---
# Claude: msg = client.messages.create(model="claude-opus-4-8", max_tokens=400,
# system="Answer ONLY from context. Cite [doc pN]. If absent, say you don't know.",
# messages=[{"role":"user","content":f"{ctx}\n\nQ: {question}"}])
# text = msg.content[0].text
# OpenAI: resp = client.responses.create(model="gpt-5.5", max_output_tokens=400,
# instructions="Answer ONLY from context. Cite [doc pN]. If absent, say you don't know.",
# input=f"{ctx}\n\nQ: {question}")
# text = resp.output_text
text = f"Based on {top[0].doc_id}: {top[0].text}" # offline stand-in
return {"text": text, "sources": [f"{c.doc_id} p{c.page}" for c in top]}
print(answer("refund window?", st, where={"tenant": "acme"})) # abstains: no match
Abstention is a first-class output. A RAG system that answers when it has nothing is worse than one that says "I don't know" — the eval set must include unanswerable questions.
Context: Gate on quality before shipping. Recall@k catches retrieval regressions; the faithfulness scan catches generation drift. Ship only when both clear the bar, and log the numbers to spot slow decay.
Your task: Build recall_at_k against a golden set and a faithfulness check that flags any sentence citing no source, failing CI below the bar.
Requirements:
recall_at_kover a golden set of(question, expected_doc_id)pairs- A faithfulness check flags sentences with no supporting source
- Fail CI (non-zero exit) if recall@k drops below a bar (e.g. 0.80)
- Deterministic — runs in CI with no GPU or key
- Log the numbers each run
💡 Hint: Count a recall hit when the expected doc id is among the retrieved doc ids; the faithfulness scan walks answer sentences against the cited sources.
Show solution
Numbers before ship — deterministic, so it runs in CI without a GPU or key:
GOLD = [("refund window", "policy"), ("shipping time", "policy")]
def recall_at_k(store, gold, k=4):
hits = 0
for q, want in gold:
got = {c.doc_id for c in search(store, q, k=k)}
hits += (want in got)
return hits / len(gold)
def unfaithful_sentences(answer_text, sources):
# every claim sentence should map to at least one cited source id
bad = []
for sent in answer_text.split("."):
sent = sent.strip()
if sent and not any(src.split()[0] in sent or sources for src in sources):
bad.append(sent)
return bad
r = recall_at_k(st, GOLD, k=4)
print(f"recall@4 = {r:.2f}")
BAR = 0.80
raise SystemExit(0 if r >= BAR else 1) # CI gate: block regressions
Recall@k catches retrieval regressions; the faithfulness scan catches generation drift. Ship only when both clear the bar, and log the numbers each run so you can spot slow decay.
Context: Recall@k can be high while the top answer is wrong because the best chunk sits at rank 3. A cheap rerank fixes 'right doc, wrong rank', and a per-tenant budget keeps a shared index cost-safe — the shape most production RAG converges on.
Your task: Add a cross-encoder-style rerank over the top-N candidates and enforce a per-tenant monthly token budget.
Requirements:
- Stage one does cheap vector recall over a wide net (top-N)
- Stage two reranks by a precision signal (e.g. term density)
- The final top-k comes from the reranked order
- A per-tenant budget charges tokens and blocks a tenant over its cap
- Demonstrate both the rerank and a tenant hitting its budget
💡 Hint: Fetch wide then rerank narrow; track per-tenant token usage and raise once a tenant exceeds its monthly cap so one customer can't run up the bill.
Show solution
Two-stage retrieval (cheap recall, then precise rerank) plus a budget guard is the shape most production RAG converges on:
def term_overlap(query, text):
q = set(query.lower().split()); t = set(text.lower().split())
return len(q & t) / (len(q) or 1)
def search_rerank(store, query, k=4, where=None, fetch=20):
# stage 1: cheap vector recall over a wide net
cands = search(store, query, k=fetch, where=where)
# stage 2: precise rerank (real: a cross-encoder; here lexical density)
cands.sort(key=lambda c: term_overlap(query, c.text), reverse=True)
return cands[:k]
class Budget:
def __init__(self, cap_tokens): self.cap = cap_tokens; self.used = {}
def charge(self, tenant, tokens):
used = self.used.get(tenant, 0) + tokens
if used > self.cap:
raise RuntimeError(f"{tenant} over budget ({used} > {self.cap})")
self.used[tenant] = used
b = Budget(cap_tokens=1_000)
b.charge("acme", 400); b.charge("acme", 400)
try: b.charge("acme", 400)
except RuntimeError as e: print("blocked:", e) # acme over budget (1200 > 1000)
Reranking fixes "right doc, wrong rank"; the budget makes the system multi-tenant-safe on cost. Both are the difference between a pilot and a product you can sell to N customers on shared infra.
✓ You are done when…
python answer.pyanswers the ACME question with a[0]citation and abstains on the out-of-corpus one.python -m pytest tests/ -vshows 7 passed.- You can explain why filtering happens before ranking (access control).
- (Optional) Real embeddings + key make retrieval semantic.
doc-rag/
├─ .venv/
├─ requirements.txt
├─ store.py (Chunk, embed, cosine, Store)
├─ ingest.py (structure-aware chunking)
├─ answer.py (grounded, cited, abstaining)
├─ samples/
│ ├─ contract_acme.txt
│ └─ policy_refunds.txt
└─ tests/
└─ test_docrag.py (7 offline tests)
| Dimension | Meets the bar | Above the bar (staff-level) |
|---|---|---|
| Retrieval quality | Retrieval is measured (recall/precision of the right chunks) on a labelled query set; chunking respects document boundaries rather than splitting mid-thought. | Retrieval is evaluated per query type (lookup vs synthesis) and tuned; a low-recall query class is a tracked defect, not a silent gap. |
| Metadata / permission filtering | Retrieval filters by metadata and permission before ranking, so a user can never retrieve a document they aren't allowed to see. | Permission filtering is proven by an adversarial test; a leak across the permission boundary fails CI hard. |
| Answer faithfulness | Answers are grounded in retrieved chunks with per-claim citations to source document + page; the agent abstains when retrieval is empty. | Faithfulness is measured (does the answer stay within what was retrieved?); an answer that drifts beyond its sources is scored as a failure, not a style nit. |
| Citation precision | Citations resolve to the actual document and page a human can open and verify. | Citations are chunk/passage-precise, and a wrong page reference is caught by an eval rather than trusted. |
| Abstention discipline | When the corpus doesn't contain the answer, the agent says so instead of fabricating from parametric memory. | Abstention rate is calibrated against a set of unanswerable questions, so it refuses when it should without becoming uselessly timid. |
| Ingestion robustness & cost | The ingest pipeline (parse→chunk→metadata→embed→index) handles the real corpus; retrieval is bounded (top-k) so answers stay within budget. | Re-ingest is incremental (changed docs only), embedding cost is tracked, and the index can be rebuilt reproducibly. |
Score each row 0 (missing) / 1 (meets) / 2 (above). A passing RAG build is 9+/12 with permission filtering and abstention both at 2 — retrieving a document across a permission boundary, or answering confidently when nothing was retrieved, is an automatic fail: both destroy the trust that a citable corpus is supposed to earn.
Knowledge check check yourself
Why does this project separate an offline ingest pipeline from an online query pipeline that share one vector+metadata store?
Show answer
Why is every claim required to carry a citation to a source document and page?