AI EngineeringZero to ProductionHome·About·Contact
Project 7 · Design Chapter

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?"

🎯 Intermediate→Advanced📈 high ROI🧾 legal / finance / opsRAG + extraction
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • an Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.
Builds on Project 4Project 4 (Document Intelligence Agent) extracts fields from one document. This project scales that to a searchable knowledge base of documents — extraction feeds the index, and RAG answers questions over the whole set. Read P4 first; this is its corpus-scale sibling.

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 needAgent 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
Problem statement"Our documents hold the answers but nobody can find them. We want to ask questions in plain English and get an answer with the exact source document and page — so we trust it and can verify it — instead of paying people to hunt through folders."

2 · Architecture advanced

INGEST (offline) documentsPDF corpus parse +chunk extractmetadata (P4) embed vector +metadata DB QUERY (online) question retrieve +filter by metadata LLM answer+ cite sources answer +citations
🗺️ How to read this diagram

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 into parse + chunk (turn each doc into clean text, then cut it into bite-size pieces), then extract metadata (pull out vendor, dates, type — the Project 4 trick), then embed (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 question goes into retrieve + 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 and cites 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

RiskControl
🔴 Confident answer with no basis in the documentsGrounded generation only — every claim cites a retrieved chunk; "not found in the documents" is a valid, required answer
🟠 Retrieving from the wrong document / mixing sourcesCarry 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'tFilter retrieval by the user's permissions before the LLM ever sees a chunk (Ch 6)
Citations are the trust mechanismAn answer over legal/financial documents is worthless — dangerous, even — if the user can't verify it. Every sentence the agent asserts must point to a source document and page the user can open. No citation, no claim.

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},
        )
▶ How this works

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.

  1. for doc in corpus: means "do all of the following once for every document in the pile". corpus is just your whole set of documents.
  2. 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.
  3. 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.
  4. index.add(...) files each piece away with three things: its embedding (the meaning-as-numbers), its text, and its metadata — crucially including doc_id and page so 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.

Chunk on structure, not character countBlind fixed-size chunks split clauses and tables mid-thought, wrecking retrieval. Chunk on document structure (sections, clauses, pages) so each chunk is a self-contained idea. This is the single highest-leverage decision in the pipeline — see Ch 3.

5 · Tool surface advanced

CapabilityDoesRisk
search_documentsVector + metadata retrieval over the corpus🟢 read-only
get_documentFetch a full document / page by ID🟢 read-only (permission-checked)
extract_fieldsProject 4 structured extraction on demand🟢 read-only
filter_by_metadataScope search (type, date range, vendor)🟢 deterministic
export_report (optional)Write a summary/answer to a file or ticket🟠 gated — write action

6 · Evaluation advanced

EvalMeasures
Retrieval recall@kDoes the right chunk appear in the top-k? (the ceiling on answer quality)
Answer faithfulnessIs every claim supported by a cited chunk? (LLM-as-judge, Ch 5)
Citation correctnessDo the cited doc + page actually contain the claim?
AbstentionDoes it say "not in the documents" instead of inventing?
Metadata-filter precision"Contracts expiring in Q3" returns exactly those
Test retrieval separately from generationIf answers are wrong, you must know whether retrieval missed the chunk or the LLM ignored it. Measure recall@k on a labeled query set first; only then judge answer faithfulness. Two dials, two evals.

7 · Phased rollout expert

Phase 1 · Search + cite — return relevant passages with citations; a human reads them. Pure retrieval, instantly useful. (Ch 3)
Phase 2 · Grounded answers — synthesize an answer from retrieved chunks, every claim cited, abstaining when unsupported. (Ch 3 + Ch 5)
Phase 3 · Cross-document analytics — "which contracts…?" via metadata filters + extraction; export reports. (Ch 6)
Never — present an uncited claim as fact, or let a user retrieve documents outside their permissions.

Skills & course map expert

SkillLearn it in
Chunking, embedding, retrieval, grounded answersCh 3 · RAG
Metadata extraction from documentsProject 4 + Ch 2
Embeddings & similarityK2 · A7
Hybrid retrieval & re-rankingM3
Faithfulness / citation evalsCh 5
Access control, PII, deployCh 6
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

What you need before you startOnly Python 3.10 or newer. Check with 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

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 — run in your terminal
terminalmkdir -p doc-rag/samples doc-rag/tests
cd doc-rag
# no output — you are now inside doc-rag/
WindowsUse 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).

Step 2 — macOS / Linux
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 $
Keep this terminal openThe (.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.

Step 3 — run in your terminal
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.

Step 4 — create this 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]
▶ How this works

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.

  1. pool = [c for c in self.chunks if not where or all(...)] keeps only chunks that match the where filter (e.g. {"owner": "alice"}). If where is empty, every chunk stays.
  2. q = embed(query) turns the question into a vector, then sorted(pool, key=lambda c: cosine(q, c.embedding), reverse=True) orders the survivors best-match-first.
  3. The final line returns the top k but 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.

▶ How this works

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.

  1. 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.
  2. 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; the if na == 0 or nb == 0 guard just avoids dividing by zero for empty text.
  3. @dataclass class Chunk is a tidy record for one stored piece: its text, which document it came from (doc_id), the page, free-form meta, and its embedding. field(default_factory=dict) just gives each Chunk its own empty dict/list instead of accidentally sharing one.
  4. Store.add embeds a chunk (if it isn't already) and keeps it in a list. Store.search is the heart: it first builds pool by filtering on metadata, then embeds the query, sorts the pool by cosine similarity (highest first), and returns the top k — 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."

Filter before you rankThe metadata filter runs before similarity, so a user scoped to their own docs can never retrieve someone else's — the chunk is removed from the pool before scoring. That's the access-control point, enforced in code.

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.

Step 5 — create this file

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
▶ How this works

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.

  1. 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.
  2. yield block, page hands back one section at a time (a generator), and page += 1 gives each section a rising page number so we always know where it sat in the document.
  3. ingest(store, doc_id, text, meta) loops over those sections and calls store.add(Chunk(...)) for each — stamping every chunk with the same doc_id and meta, plus its own page.
  4. 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:

Step 6 — create this file

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.

Step 7 — create this file

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"}))
▶ How this works

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.

  1. "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.
  2. "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.
  3. The last clause tells it to reply with exactly the ABSTAIN string when the sources don't contain the answer — the same honest refusal the offline path produces, now enforced by instruction.
  4. client.messages.create(...) sends it all: the system rules, and a user message containing the numbered Sources: followed by the Question:. The reply text is read from msg.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.

▶ How this works

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.

  1. _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.
  2. _mock_llm is 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_llm is the same idea with a real Claude call, and a strict system prompt (covered in the next box).
  3. answer(question, store, where) ties it together: it searches, and if not hits it returns the ABSTAIN string immediately — that early return is the safety valve. Otherwise it picks mock vs real based on the USE_REAL_API environment variable and returns both the text and the list of sources (doc_id + page for each hit).
  4. 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:

Step 7 — run it
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']
▶ How this works

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.

  1. 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.
  2. The second question ("capital of France") is not in the documents, so the answer is the exact abstain line — search() found no overlapping chunk and answer() returned early.
  3. The last line, contracts: ['contract_acme.txt'], is find_docs answering 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).

The abstention path is the safety storyThe out-of-corpus question returns the exact abstention string because 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.

Step 8 — create this 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
▶ How this works

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.

  1. _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.
  2. The first three tests check retrieval and filtering: the right chunk comes back; a type filter returns only the policy; and an owner filter never leaks another user's document — that's the access-control test.
  3. test_out_of_corpus_abstains feeds nonsense ("xyzzy quux nothing") and asserts the answer equals ABSTAIN — pinning the honest-refusal behaviour so a future change can't silently break it.
  4. The last three assert every hit carries a string doc_id and int page (so answers are always citable), that find_docs returns 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.

Step 8 — run the tests
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
✅ What each test proves
TestProves
relevant chunk retrievedthe retriever surfaces the right passage
metadata filter scopes"policy only" excludes the contract
permission filter hides ownersa scoped user never sees another's docs
out-of-corpus abstainsno hallucinated answer — returns the abstain string
hits carry doc + pageevery answer is citable to a source
find_docs by metadata"which contracts?" is an exact filter, not a guess
answer is groundedthe 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:

Step 9 — swap in real components

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].
Tests stay offlineLeave the tests on the stub embedder (fast, free, deterministic). Only the running app needs real embeddings + a key. To return to offline mode: unset USE_REAL_API.

Troubleshooting — every error you might hit expert

⚠️ If something doesn't match the expected output
What you seeWhat it means & the fix
python3: command not foundInstall Python from python.org; on Windows use py.
No (.venv) in promptRe-run the Step 2 activate line for your OS.
ModuleNotFoundError: store / answerRun 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 chunkExpected only when a word overlaps by hash collision — enlarge dims in embed() or use real embeddings.
Wrong document retrievedThe 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.

Exercise 1 · Scaffold the vector store and cosine searchBeginner

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 vector
  • cosine(a, b) returns similarity, safe on zero vectors
  • Store.add embeds and stores a chunk; search returns 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.

Exercise 2 · Structure-aware chunking + ingest pipelineIntermediate

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
  • ingest chunks, 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.

Exercise 3 · Metadata filtering for access control (filter before rank)Advanced

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:

  • search filters 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.

Exercise 4 · Grounded answer with citations — and abstain when emptyExpert

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 where filter
  • 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.

Exercise 5 · Retrieval + faithfulness evals with a golden setProfessional

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_k over 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.

Exercise 6 · Add a re-ranking stage and per-tenant cost capsIndustry scenario

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.py answers the ACME question with a [0] citation and abstains on the out-of-corpus one.
  • python -m pytest tests/ -v shows 7 passed.
  • You can explain why filtering happens before ranking (access control).
  • (Optional) Real embeddings + key make retrieval semantic.
📁 Your finished folder
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)
📋 Master rubric — grade your RAG agent
DimensionMeets the barAbove the bar (staff-level)
Retrieval qualityRetrieval 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 filteringRetrieval 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 faithfulnessAnswers 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 precisionCitations 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 disciplineWhen 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 & costThe 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

✓ Knowledge check

Why does this project separate an offline ingest pipeline from an online query pipeline that share one vector+metadata store?

Show answer
Ingest (parse → chunk → extract metadata → embed → index) is expensive prep done once ahead of time; query retrieves and answers on demand. Filling the shared store once and serving from it many times is what makes per-question answering fast — retrieval quality is decided at ingest time.
✓ Knowledge check

Why is every claim required to carry a citation to a source document and page?

Show answer
An answer over legal/financial documents is worthless — even dangerous — if the user can't verify it. Grounded generation with per-claim citations (and "not found in the documents" as a valid answer) is the trust mechanism: no citation, no claim.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in