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?"
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
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
"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.
1 · Discovery — what questions do people actually ask? advanced
| 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
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
| 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
Retrieval quality is decided at ingest time. Chunking and metadata make or break the whole system.
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
| 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
| 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
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
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"}))
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. client.messages.create(...)sends it all: thesystemrules, and a user message containing the numberedSources:followed by theQuestion:. The reply text is read frommsg.content[0].text.
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 ---
# 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
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?