Build a RAG System From Scratch
RAG (Retrieval-Augmented Generation) grounds the model in your data so it answers from facts, not guesses. You'll build the entire pipeline by hand — chunking, embedding, a tiny vector store, hybrid retrieval, re-ranking, and cited generation — so you understand every moving part before reaching for a framework.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Explain when RAG beats fine-tuning or a bigger context window.
- Chunk documents on semantic boundaries with useful metadata.
- Embed text and build a minimal in-memory vector store with cosine similarity.
- Retrieve with hybrid (vector + keyword) search and re-rank the results.
- Generate grounded answers with inline citations, and refuse when context is missing.
What RAG is, and when to use it intermediate
Here's a moment you've almost certainly lived through. You ask a chatbot about your own company's refund policy, and it answers with total confidence — polished, fluent, and completely wrong. It has never seen your policy; it's pattern-matching on the thousand generic policies it read during training. The model isn't lying on purpose. It simply has no way to know what it doesn't know, so it fills the gap with something plausible. That single failure — a confident answer built on absent knowledge — is the exact problem RAG exists to solve.
Think of a bright new hire on their first day. Ask them anything about your internal systems and they'll either guess or, if they're wise, say "let me look it up." RAG is that "let me look it up" reflex, bolted onto the model. Instead of forcing the LLM to answer from memory, you first go fetch the relevant pages from your documents, hand them over, and say "answer using only these." The model's fluent-writing skill is still doing the talking — but the facts now come from a source you control and can point to.
The name spells out the recipe: Retrieval-Augmented Generation. You retrieve relevant snippets, augment the prompt with them, then let the model generate the answer. The common beginner mistake is to think RAG is a fancier model or a training technique — it is neither. It's a plumbing pattern that wraps an ordinary model: nothing about the model changes, you just change what you put in front of it at question time. That's why RAG updates instantly when your docs change (no retraining) and why it can cite its sources (it knows exactly which snippets it used).
So when do you actually reach for it? The table below is the decision you'll make on nearly every project. The honest headline: RAG wins when knowledge is large, changes often, or must be attributable — and its whole game becomes getting the right snippets in front of the model. Which is why, as the danger box warns, a "hallucinating" RAG system is usually a retrieval bug wearing a generation costume.
An LLM only knows what was in its training data. RAG lets it answer questions about your private/current documents by retrieving relevant snippets at query time and putting them in the prompt. The model then answers from that supplied context.
Read it as two rows, top then bottom — the same question and the same model, with one extra step added on the bottom row.
- The top row is a plain LLM: the question goes straight into the model, which answers from its training memory. Because it never saw your documents (the red note), the red arrows lead to a confident guess ✗ — fluent but possibly wrong.
- The bottom row is RAG: before the model runs, a
retrieve docsbox fetches the relevant snippets from your data and hands them to the same LLM. - The payoff is the green box: grounded, cited ✓ — the answer now comes from a source you control and can point back to. The dashed line just separates the two approaches.
In short: RAG is a plumbing pattern, not a new model — insert a retrieval step in front of the model and its answers become grounded and attributable.
| Approach | Best when | Trade-off |
|---|---|---|
| RAG | Knowledge changes often; needs citations; large corpus | Retrieval quality is the bottleneck |
| Long context (paste it all) | Small, stable doc set that fits the window | Expensive per call; doesn't scale to millions of docs |
| Fine-tuning | Teaching style/behavior, not facts | Costly to update; can't cite; facts go stale |
The two-phase pipeline intermediate
Picture a good research librarian. Long before you ever walk in, they've done the slow work: read the collection, catalogued it, and shelved everything so any book can be found in seconds. Then you arrive with a question, and the fast work happens — they figure out what you're really asking, pull the three most relevant books, and hand you the exact pages. Two completely different jobs, done at completely different times. RAG is built the same way, and the single most common source of confusion for beginners is smashing those two jobs into one mental blob.
So hold them apart deliberately. The first phase is offline / indexing: you take your documents, cut them into chunks, turn each chunk into a vector, and store them — once, ahead of time. This is the librarian shelving the collection. It can be slow and expensive because it happens on your schedule, not the user's, and you reuse the result across millions of questions. The second phase is online / query time: a question arrives, you embed it, retrieve the nearest chunks, optionally re-rank them, paste the winners into the prompt, and generate a cited answer. This is the librarian answering you — and it has to be fast, because a human is waiting.
Why does the split matter so much in practice? Because the two phases fail differently and cost differently. If retrieval quality is bad, the fix is almost always in the offline phase — your chunking or your embedding model — even though the symptom shows up online as a wrong answer. And the offline/online boundary is where your engineering budget lives: heavy computation (re-embedding a million documents) belongs offline where nobody is watching the clock; the online path should do as little work as possible. The classic mistake is treating RAG as one linear script and then wondering why it's both slow at query time and hard to debug — you've entangled the shelving with the answering. The diagram below draws the two timelines explicitly, with a dashed line marking exactly where "built once" meets "run per question."
This is the whole RAG system on one page. Read it as two separate timelines: the top row happens once, ahead of time (building a searchable index of your documents), and the bottom row happens every time a user asks a question. The dashed line between them is that split.
- Top row — OFFLINE, build the index once.
Documents→Chunk + metadata(cut each doc into small passages) →Embed(turn each passage into a list of numbers that captures its meaning) →Vector store(save all those number-lists so you can search them). You do this once and reuse it. - Bottom row — ONLINE, per user question. A
Questioncomes in and flows left to right throughRetrieve→Re-rank→Build context→LLM→Answer. - Retrieve (vec + BM25) finds the handful of stored chunks most related to the question — using both meaning-based (vector) and exact-word (BM25) search. Re-rank reorders those candidates so the best ones rise to the top.
- Build context pastes the winning chunks into the prompt; the LLM reads that prompt and writes an
Answer + citations, using only the supplied text. This is the core RAG idea: retrieve, then augment the prompt, then generate. - The purple dashed arrow from
Vector storedown toRetrieveshows the two timelines meeting: the index you built offline is exactly what the online question searches against.
In short: RAG = look things up first, then answer from what you found. The offline row is a librarian shelving books; the online row is you asking the librarian a question and getting the right pages handed to you before you write your reply.
docs/ with 3–4 small .md or .txt files — a product FAQ, a policy doc, a how-to. Real content you know well makes it obvious when retrieval works or fails.Lab 3.1 · Chunking intermediate
Imagine handing someone a 300-page manual and asking "where's the warranty clause?" — but they're only allowed to photocopy a few pages to hand back. If they copy whole chapters, the warranty clause is in there somewhere but buried in noise. If they copy single sentences, they might snip the clause in half and miss the condition that makes it apply. Getting the size of those photocopies right is the entire skill of chunking, and it quietly decides whether your RAG system works before a single embedding is computed.
Here's why it's so pivotal. Your model never searches whole documents — it searches chunks, and it can only ever answer from the chunks retrieval hands it. A chunk is the unit of everything downstream: the thing you embed, the thing you rank, the thing you cite. So a chunk that's badly cut is a fact your system can never retrieve correctly, no matter how good your embeddings or your prompt. That's why experienced RAG builders say chunking is the highest-leverage lever on the whole board: it's upstream of every other component, so its mistakes propagate everywhere.
The tension is a genuine trade-off with no free answer. Chunk too big and each passage carries several ideas at once; its embedding becomes a blurry average of all of them, so it matches everything weakly and nothing strongly — and even when it's retrieved, the real answer is diluted by surrounding irrelevant text that wastes tokens. Chunk too small and you get razor-sharp matches, but you routinely split a single fact across a boundary: the "how" lands in chunk 4 and the "but only if" lands in chunk 5, and retrieval brings back just one of them. The most common rookie error is cutting on a blind fixed character count (say, "every 500 characters") which slices mid-sentence and mid-table, shredding meaning at every boundary.
The fix that the lab below implements has three moves. First, cut on semantic boundaries — paragraphs and headings — so each chunk is a coherent thought, not an arbitrary slice. Second, keep a small overlap between neighbours so a fact sitting right on a cut survives whole in at least one chunk. Third, attach metadata (source file, an id) to every chunk so you can cite it and filter it later. Read the code with those three goals in mind: paragraphs packed to a target size, the last paragraph carried forward as overlap, and each chunk wrapped in a dictionary that remembers where it came from.
Models retrieve chunks, not whole documents. Good chunking is the highest-leverage decision in RAG. Too big → irrelevant text dilutes the answer and wastes tokens. Too small → facts get split across chunks.
Scan the top three panels left to right — they are the same document cut three ways — then read the bottom band to see how overlap rescues a fact sitting on a boundary.
- The red "too big" panel packs three ideas into one chunk; its embedding becomes a blurry average that matches everything weakly and nothing strongly.
- The red "too small" panels cut a single fact in half — the "how" lands in one chunk and the "but only if" in the next, so retrieval brings back just one piece.
- The green "just right" panel is one coherent idea plus a little overlap — sharp and complete.
- The bottom band shows the fix in action: consecutive chunks share an
overlapregion (amber), so a fact straddling the cut appears whole in at least one chunk.
In short: Cut on paragraph/heading boundaries, aim ~100–250 words, and keep 10–20% overlap so no single fact is lost at a seam.
chunk.pyimport os, glob, re
def chunk_text(text, source, target_words=120, overlap=25):
"""Split on paragraphs, then pack into ~target_words chunks with overlap."""
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, buf = [], []
for para in paras:
buf.append(para)
if sum(len(p.split()) for p in buf) >= target_words:
chunks.append(" ".join(buf))
buf = buf[-1:] if overlap else [] # carry last para as overlap
if buf: chunks.append(" ".join(buf))
return [{"text": c, "source": source, "id": f"{source}#{i}"}
for i, c in enumerate(chunks)]
def load_corpus(folder="docs"):
out = []
for path in glob.glob(f"{folder}/*"):
with open(path) as f:
out += chunk_text(f.read(), os.path.basename(path))
return out
if __name__ == "__main__":
c = load_corpus()
print(f"{len(c)} chunks"); print(c[0])
Models search over chunks (small passages), not whole documents. This file cuts each document into paragraph-based chunks of roughly a target size, and keeps a little overlap so a fact sitting near a cut isn't lost. Getting this right is the single highest-leverage decision in RAG.
chunk_textfirst splits the document on blank lines into paragraphs (paras). It then walks paragraph by paragraph, adding each to a bufferbuf.- When the buffer reaches
target_words(~120 words), it flushes: it joins the buffered paragraphs into one chunk and starts a new buffer. The line marked carry last para as overlap keeps the last paragraph in the new buffer so the next chunk overlaps the previous one — that overlap protects facts near a boundary. - Each chunk is stored as a small dictionary with
text, itssourcefilename, and a uniqueidlikefaq.md#0. That metadata is what lets you cite sources later. load_corpusloops over every file in thedocs/folder, reads it, and collects all chunks into one flat list — your whole searchable corpus.
What the output means: Running it prints how many chunks were produced (e.g. 7 chunks) and shows the first chunk dictionary, so you can eyeball whether the splitting looks sensible.
Try this: Set target_words=40 and re-run: you'll get many more, smaller chunks. Then set overlap=0 and notice the chunks no longer share a paragraph. Smaller chunks are more precise but split facts more often — that trade-off is the heart of chunking.
- Split on semantic boundaries (paragraphs, headings) — not blind fixed windows.
- Keep 10–20% overlap so a fact near a boundary survives in one chunk.
- Carry metadata (source, section, URL, date) — you need it for citations and filtering.
- Aim ~100–250 words for precision; add a "parent document" fallback for context.
Lab 3.2 · Embed & store intermediate
Think about how you'd organise a giant music library by "vibe." You can't sort songs alphabetically and expect similar moods to end up near each other — "Yesterday" and "Let It Be" would be aisles apart. What you'd really want is a map where meaning is the coordinate: mellow acoustic ballads cluster in one region, thrash metal in another, and songs that feel alike sit close together regardless of their titles. That map, for text, is exactly what an embedding gives you — and it's the trick that lets a computer find documents by what they mean rather than which words they happen to share.
Concretely, an embedding is a function that turns a piece of text into a fixed-length list of numbers — a vector, a point in high-dimensional space. It's trained so that texts with similar meaning land at nearby points and unrelated texts land far apart. The magic property is that this survives paraphrase: "how do I change my password" and "steps to update my login credentials" share almost no words, yet their vectors sit right next to each other because they mean the same thing. Keyword search would score them as unrelated; embeddings see straight through the vocabulary to the intent. That's the leap from matching strings to matching meaning.
Here is the mistake that silently ruins more RAG systems than any other, so burn it in now: you must embed your documents and your queries with the exact same model. Two different embedding models produce two incompatible coordinate systems — like one person giving directions in miles and another in kilometres without saying so. The numbers still line up dimension-for-dimension, so nothing crashes; the code runs clean and returns confident nonsense, because "nearby" in one model's space is meaningless in the other's. This is why the lab wraps embedding behind a single embed() function used everywhere: one function, one model, no chance of the query and the corpus drifting apart.
Two smaller ideas make the store click. Once you have vectors, "find the most similar chunk" becomes "find the nearest point," measured by cosine similarity — the cosine of the angle between two vectors, which you'll unpack in Lab 3.3. And a tidy shortcut: if you normalise every vector to length 1 (as the code does with normalize_embeddings=True), that cosine collapses into a plain dot product, so scoring a query against all chunks becomes one fast matrix multiply. The whole VectorStore below is just: embed every chunk once, keep the matrix, and at query time embed the question and multiply — the beating heart that every production vector database (Pinecone, FAISS, pgvector) dresses up with persistence and scale.
An embedding turns text into a vector of numbers where similar meanings sit close together. We store each chunk's vector; at query time we embed the question and find the nearest chunks by cosine similarity.
embed() model turns each text into a point; paraphrases like "reset my password" and "update my login credentials" land next to each other even though they share no words — so search can match intent, not vocabulary.
Read it left to right: raw texts on the left, the embed() model in the middle, and the resulting "meaning map" on the right.
- The left boxes are three chunks of text. The amber
embed()box is the one function that converts any text into a vector (a point in space). - On the right map, the two green dots — "password" and "login creds" — sit close together because they mean the same thing, even though their words differ. The grey "subscription" dot sits far away because it is about something else.
- The payoff is "near = similar meaning": once text is placed by meaning, finding relevant chunks becomes finding nearby points — which is what cosine similarity measures in the next lab.
In short: Embed both your documents and your queries with the same model, and semantically similar text lands nearby — that is what lets RAG search by meaning instead of exact words.
sentence-transformers). The rule that matters: embed queries and documents with the same model, or similarity is meaningless. Below we wrap it behind one embed() function so you can swap providers freely.store.pyimport numpy as np
# --- swap this body for your embeddings provider; keep the signature ---
from sentence_transformers import SentenceTransformer
_model = SentenceTransformer("all-MiniLM-L6-v2")
def embed(texts: list[str]) -> np.ndarray:
return np.asarray(_model.encode(texts, normalize_embeddings=True))
# -----------------------------------------------------------------------
class VectorStore:
def __init__(self):
self.vecs = None; self.chunks = []
def add(self, chunks):
self.chunks = chunks
self.vecs = embed([c["text"] for c in chunks]) # (N, d), normalized
def search(self, query, k=5):
q = embed([query])[0] # (d,)
sims = self.vecs @ q # cosine (vectors are normalized)
top = np.argsort(-sims)[:k]
return [(self.chunks[i], float(sims[i])) for i in top]
This is the tiny vector store — the searchable index at the heart of RAG. An embedding turns a piece of text into a fixed list of numbers (a vector) where texts with similar meaning get similar numbers. Store one vector per chunk, and at question time you can find the closest chunks by comparing vectors.
- The
embed()function is the one place text becomes numbers. It's wrapped behind a single function on purpose (the comment says swap this body for your embeddings provider) so you can change providers without touching anything else. The golden rule: embed questions and documents with the same model, or the comparison is meaningless. normalize_embeddings=Truescales every vector to length 1. That's a shortcut that makes cosine similarity (an angle comparison) collapse into a plain dot product later — simpler and faster.VectorStore.add(chunks)embeds all chunk texts at once and keeps the resulting matrixself.vecs(N chunks × d numbers each) alongside the chunks themselves.search(query, k)embeds the query, thenself.vecs @ qcomputes the similarity of the query to every stored chunk in one step.np.argsort(-sims)[:k]picks the indices of the top-k highest scores and returns those chunks with their scores.
What the output means: Nothing prints on its own — this file defines the store. Once populated, search() hands back the k most similar chunks and a similarity score (higher = more related) for each.
Try this: This is genuinely what a production vector database (Pinecone, FAISS, pgvector) does under the hood; they just add persistence, scale, and speed. Print self.vecs.shape after add() to see the (number-of-chunks × dimensions) matrix you built.
Lab 3.3 · Retrieve advanced
You already do this every day without thinking about it. When a friend describes a movie — "the one where the guy relives the same day over and over" — you don't scan your memory word by word for a title match. You match on the shape of the idea, and Groundhog Day surfaces instantly. Retrieval in RAG is that same reflex made mechanical: the question and every stored chunk have already been turned into vectors (Lab 3.2), and now we simply ask "which chunks point in the same direction as the question?"
The measuring stick is cosine similarity, and the geometry is worth holding in your head because it demystifies the whole thing. Picture each vector as an arrow shot out from the origin. Two arrows pointing the same way — a small angle between them — mean similar meaning; arrows at right angles mean unrelated. Cosine similarity is literally the cosine of that angle: 1.0 when they point identically, sliding toward 0 as they diverge. The reason we care about angle and not raw distance is subtle but important — it makes the score about direction (what the text is about) rather than magnitude (how long or emphatic it is), so a short chunk and a long chunk about the same topic still score as close.
And here's the payoff you saw promised back in Lab 3.2: because we normalised every vector to length 1, that cosine reduces to a plain dot product. So scoring the query against the entire corpus is a single matrix-times-vector operation — self.vecs @ q in the code — that hands back one similarity number per chunk in one shot. Then we keep only the best few with top-k (a job for a heap, from the data-structures track). Nothing more mystical is happening: turn text into arrows, keep the arrows that lean most like the question's.
The discipline this section is really trying to teach isn't the math — it's a habit. Retrieval is the part of RAG that fails silently: if the answer-bearing chunk never makes the top-k, no amount of clever prompting downstream can conjure it back. So the professional move, and the single most valuable takeaway in this chapter, is to test retrieval on its own, before you write one line of generation code. Fire a dozen real questions at search() and eyeball whether the right chunk appears near the top. When people say a RAG system "hallucinates," this quiet, upstream check is almost always the thing they skipped.
This picture explains how retrieval actually decides which chunks are "closest". Every chunk and the query are arrows (vectors) pointing out from the same origin; the angle between two arrows measures how related their meanings are.
- On the left, the blue
queryarrow and the chunk arrows all start at the same corner. A small angle between the query and a chunk means they point the same way — i.e. similar meaning.chunk Ahugs the query closely, so it's the best match (✓). - Cosine similarity is just the cosine of that angle: 1.0 for arrows pointing the exact same way, down toward 0 as they diverge. Because the vectors were normalized in Lab 3.2, this cosine is simply the dot product
q·c. - On the right, the same chunks are shown as a ranked list with their scores —
A · 0.91,C · 0.78,B · 0.55. Higher score = smaller angle = more relevant. - top-k means: keep only the highest-scoring few (here using a heap, from the data-structures chapter). Those winning chunks are exactly the context you paste into the prompt in Lab 3.4.
In short: "Nearest by cosine" is the entire retrieval trick: turn text into arrows, then keep the arrows pointing most like the question. Everything else (hybrid, re-ranking) just improves which arrows win.
retrieve.pyfrom chunk import load_corpus
from store import VectorStore
store = VectorStore()
store.add(load_corpus())
for chunk, score in store.search("how do I reset my password?", k=3):
print(f"[{score:.3f}] {chunk['source']}: {chunk['text'][:80]}...")
[0.612] faq.md: To reset your password, go to Settings > Security and click...
[0.341] faq.md: Two-factor authentication can be enabled from the same...
[0.208] policy.md: Account security is governed by...
This is the payoff of Labs 3.1–3.2: give the store a real question and see which chunks come back. This is the Retrieve step of RAG, running on its own — before any answer-generation exists.
- It builds a
VectorStore, fills it withload_corpus()(your chunked docs), and then callsstore.search(...)with a plain-English question andk=3(return the 3 closest chunks). - The loop prints each result as
[score] source: first-80-characters…. The score is the cosine similarity — closer to 1.0 means the chunk's meaning is nearer to the question. - Notice the top result is the actual password-reset passage from
faq.md— retrieval found the right text purely from meaning, even though the question wording differs from the document.
What the output means: Three lines, best first. The top line ([0.612] faq.md: To reset your password…) is the chunk that answers the question; scores fall off for the less-relevant chunks below it.
Try this: Before writing any generation code, run 10 real questions through search() and check the right chunk lands in the top-k. If it doesn't, fix chunking or embedding now — no clever prompt can rescue context that was never retrieved.
search() and eyeball whether the right chunk appears in the top-k. If it doesn't, fix chunking/embedding now — no prompt can rescue missing context.Lab 3.4 · Generate a grounded, cited answer advanced
Think about the difference between an open-book exam and a closed-book one. In a closed-book exam you write whatever you can dredge up from memory — and under pressure, people confidently misremember. In an open-book exam the rules flip: you answer from the pages in front of you, and "I couldn't find it" is a legitimate, honest answer. Everything you've built so far — chunking, embedding, retrieval — exists to lay the right pages open on the desk. This final step is where you tell the model, in no uncertain terms, that it is now sitting an open-book exam and cheating from memory is forbidden.
That instruction is the whole ballgame, because an LLM's default behaviour is to answer from its training-data memory. Retrieval alone doesn't stop it — you can hand the model perfect context and it will still happily blend in half-remembered facts from elsewhere unless you explicitly forbid it. So the generation step has two jobs, and they're both in the prompt, not the code. First, augment: paste the retrieved chunks into the prompt as numbered context, so the model has the facts and a way to reference each one. Second, constrain: a system prompt that says "answer strictly from the numbered context, cite the sources you use as [n], and if the answer isn't there, say so."
Those numbered citations are not decoration — they're the feature that makes RAG trustworthy in the real world. A cited answer is auditable: a user (or a compliance reviewer) can click [1], read the source chunk, and confirm the claim is real. That single property is why regulated industries — legal, medical, finance — can use RAG at all, and it's the thing fine-tuning fundamentally cannot give you: a fine-tuned model has digested its facts into weights and can't point back at a source. Grounding plus citation turns a fluent guesser into an accountable assistant.
The most important line to get right is the escape hatch: the explicit permission to say "I don't have that information." New builders leave this out because it feels like admitting weakness, but it's the opposite — it's the difference between a system that fails loudly and safely and one that fails silently and dangerously. Without it, a question your docs don't cover gets a confident, invented answer; with it, you get an honest miss you can act on. Together those two rules — answer only from context and admit when it's missing — are the entire anti-hallucination recipe, and the way to prove they work is to ask something your docs/ folder definitely doesn't contain and watch the model decline. Read the lab with that lens: the code is almost trivial; the SYSTEM prompt is where the safety lives.
Now assemble the retrieved chunks into a prompt that forces the model to answer only from context and cite sources.
[n]; two system rules — answer only from context, and say "I don't have that information" when it's absent — turn a fluent guesser into an accountable, auditable assistant.
Follow the top row left to right — it is the retrieve → augment → generate flow — then read the bottom band for the two rules that make it safe.
- The left boxes are the chunks retrieval handed back. They flow into the amber "numbered context + rules" box, where each chunk is labelled
[1] [2] [3]and wrapped in the instruction "answer only from context; cite [n]". - The LLM reads that prompt and produces one of two outcomes on the right: a green
answer [1](grounded and cited) when the facts are present, or an honest"don't know"when they are not. - The bottom band names the payoff: the two green rules — answer only from context and admit when it's missing — are the entire anti-hallucination recipe, and both live in the prompt rather than the code.
In short: Number the chunks so the model can cite them, then constrain it to the context and give it permission to refuse — that combination is what makes RAG trustworthy.
The grounded generation step — in Claude or OpenAI
The retrieval pipeline above is identical for both vendors — only the generation call differs. In Claude you build the request with client.messages.create(system=…, messages=[…]); in OpenAI the same call is client.responses.create(model="gpt-5.5", instructions=…, input=…) and you read the reply from resp.output_text. The numbered-context prompt and the cite-or-refuse rules are the same either way. Use the tabs to switch SDK — your choice applies across the whole page.
rag.pyfrom anthropic import Anthropic
from store import VectorStore
from chunk import load_corpus
client = Anthropic()
store = VectorStore(); store.add(load_corpus())
SYSTEM = (
"You answer strictly from the numbered context. "
"Cite the sources you use inline as [n]. "
"If the context does not contain the answer, say: "
"'I don't have that information.' Never use outside knowledge."
)
def answer(question, k=4):
hits = store.search(question, k=k)
context = "\n\n".join(
f"[{i}] (source: {c['source']})\n{c['text']}"
for i, (c, _) in enumerate(hits, 1)
)
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=600, system=SYSTEM,
messages=[{"role":"user",
"content": f"Context:\n{context}\n\nQuestion: {question}"}],
)
text = next(b.text for b in resp.content if b.type=="text")
return text, hits
ans, sources = answer("How do I reset my password?")
print(ans)
Go to Settings > Security and click "Reset password"; a link is emailed
to your registered address [1]. You can also enable 2FA there [1].
rag.pyfrom openai import OpenAI
from store import VectorStore
from chunk import load_corpus
client = OpenAI()
store = VectorStore(); store.add(load_corpus())
SYSTEM = (
"You answer strictly from the numbered context. "
"Cite the sources you use inline as [n]. "
"If the context does not contain the answer, say: "
"'I don't have that information.' Never use outside knowledge."
)
def answer(question, k=4):
hits = store.search(question, k=k)
context = "\n\n".join(
f"[{i}] (source: {c['source']})\n{c['text']}"
for i, (c, _) in enumerate(hits, 1)
)
resp = client.responses.create(
model="gpt-5.5", max_output_tokens=600, instructions=SYSTEM,
input=f"Context:\n{context}\n\nQuestion: {question}",
)
text = resp.output_text
return text, hits
ans, sources = answer("How do I reset my password?")
print(ans)
Go to Settings > Security and click "Reset password"; a link is emailed
to your registered address [1]. You can also enable 2FA there [1].
Here the pieces join into full RAG: retrieve → augment the prompt → generate. The retrieved chunks are pasted into the prompt as numbered context, and the model is told to answer only from that context and cite it. This grounding is what stops the model from making things up.
- The
SYSTEMprompt is the guardrail. It orders the model to answer strictly from the numbered context, cite sources inline as[n], and — critically — say "I don't have that information." when the answer isn't there instead of inventing one. answer()first callsstore.search(question, k)to get the top chunks (the retrieve step).- It then builds
contextby numbering each chunk[1] (source: …)and joining them (the augment step) — the numbers are what the model cites. - The model call sends the system rules plus the context and the question (the generate step) —
client.messages.create(...)with Claude,client.responses.create(...)with OpenAI (toggle the tab above to compare). The final line pulls the text out of the reply and returns it along with the source chunks.
What the output means: A short answer that quotes only the retrieved facts and tags them with citations, e.g. Go to Settings > Security… [1]. The [1] points back to the first numbered chunk you fed in.
Try this: Ask something that is definitely NOT in your docs/ and confirm it replies "I don't have that information." Those two prompt rules — answer only from context, and admit when it's missing — are the whole anti-hallucination recipe.
- "Answer only from context" — constrains the model to supplied facts.
- "Say you don't know if it's missing" — gives it an honest escape hatch instead of inventing. Test this: ask something NOT in your docs and confirm it declines.
Lab 3.5 · Hybrid search + re-ranking advanced
Here's a failure that will blindside you in your first real deployment. Your semantic search is working beautifully — paraphrased questions find the right passages, meaning matching feels like magic. Then a user searches for error code ERR_4021, or the product name "Zephyr-7B," or a specific invoice number, and your slick vector search returns... vaguely related fluff. The exact string is sitting right there in a document, and your system can't find it. That's not a bug you introduced; it's the built-in blind spot of pure vector search, and discovering it in production is a rite of passage.
The reason is instructive. Embeddings are brilliant at meaning and deliberately fuzzy about surface form — that's the whole point, it's why they see through paraphrase. But that same fuzziness smears exact tokens: an error code carries almost no semantic meaning to blur toward, so it dissolves into the vector soup. The old-school tool that excels at exact terms is keyword search — specifically BM25, which scores documents by how many of the query's rare words they contain. BM25 is everything embeddings aren't: literal, term-precise, and blind to paraphrase. Vector search and BM25 are mirror-image strengths and weaknesses.
So the professional answer isn't to pick one — it's hybrid search: run both, then fuse their rankings so a chunk that either method loves rises to the top. The elegant part is how you fuse them. You might expect to normalise and weight two different score scales (a fiddly, brittle tuning job). Instead the lab uses Reciprocal Rank Fusion, which throws away the raw scores entirely and looks only at rank position: each list awards a chunk points based on where it placed (1/(k+rank)), points are summed, and everything re-sorts. No weights to tune, no incompatible scales — just "did either method rank you highly?" It's almost embarrassingly simple and remarkably robust.
The final move is re-ranking, and it embodies a pattern worth naming: the funnel. Fusion casts a wide, cheap net — grab the top 10–30 candidates fast. But wide nets drag in near-misses, and you don't want to burn tokens (or dilute the answer) on junk. So you follow the cheap-and-wide fetch with an expensive-and-narrow filter: hand those candidates to a strong model and ask it to pick the genuine best 3–5. Cheap recall first, precise cut second. This funnel — over-fetch with hybrid, then re-rank down — is, in the experience of most practitioners, the single biggest quality jump a RAG system ever makes, which is exactly why it's the last thing this chapter builds before you measure.
Vector search understands meaning but misses exact terms (error codes, product names, acronyms). Keyword search (BM25) nails exact terms but misses paraphrase. Combine both, then re-rank. This is the biggest quality jump you'll make.
Read the top row left to right as the pipeline, then read the funnel shape at the bottom for the "why".
- The two left boxes are the two retrievers with opposite strengths:
vectoris great at meaning,BM25is great at exact terms (codes, product names). - Both feed the amber
RRF fusebox, which merges the two ranked lists by rank position — so it never has to reconcile their incompatible score scales. - Fusion produces a wide, cheap candidate set (top 10–30); the green
re-rank → 3–5box then applies a precise, expensive filter to keep only the genuine best. - The funnel at the bottom is the payoff: a wide top (recall — catch everything relevant) narrowing to a small bottom (precision — feed only the best to the model), giving both without wasting tokens on junk.
In short: Run vector + BM25, fuse by rank with RRF, over-fetch wide, then re-rank down to a handful — cheap recall first, precise cut second.
- Add BM25 keyword search.
hybrid.py
from rank_bm25 import BM25Okapi class Hybrid: def __init__(self, store): self.store = store self.bm25 = BM25Okapi([c["text"].lower().split() for c in store.chunks]) def search(self, query, k=10): # dense results (id -> rank) dense = [c["id"] for c,_ in self.store.search(query, k=k)] # sparse results scores = self.bm25.get_scores(query.lower().split()) sparse = [self.store.chunks[i]["id"] for i in sorted(range(len(scores)), key=lambda j:-scores[j])[:k]] return self._rrf(dense, sparse) - Fuse with Reciprocal Rank Fusion — a simple, robust way to merge two ranked lists.
hybrid.py (cont.)
def _rrf(self, *lists, k=60): scores = {} for lst in lists: for rank, cid in enumerate(lst): scores[cid] = scores.get(cid, 0) + 1/(k+rank) by_id = {c["id"]: c for c in self.store.chunks} ranked = sorted(scores, key=lambda c:-scores[c]) return [by_id[cid] for cid in ranked] - Re-rank the top candidates for the final cut. Over-fetch ~10, then let a strong model pick the best 3–4. This is the single highest-leverage quality lever in RAG.
rerank.py
import re from anthropic import Anthropic client = Anthropic() # set ANTHROPIC_API_KEY def rerank(question, candidates, keep=4): """LLM re-ranker: score each candidate's relevance 0-10, keep the best.""" listing = "\n".join(f"[{i}] {c['text'][:200]}" for i,c in enumerate(candidates)) r = client.messages.create( model="claude-haiku-4-5", max_tokens=200, messages=[{"role":"user","content": f"Question: {question}\n\nPassages:\n{listing}\n\n" "Return the indices of the {keep} most relevant, comma-separated."}], ) idxs = [int(x) for x in re.findall(r"\d+", next(b.text for b in r.content if b.type=="text"))][:keep] return [candidates[i] for i in idxs if i < len(candidates)]rerank.py
import re from openai import OpenAI client = OpenAI() # set OPENAI_API_KEY def rerank(question, candidates, keep=4): """LLM re-ranker: score each candidate's relevance 0-10, keep the best.""" listing = "\n".join(f"[{i}] {c['text'][:200]}" for i,c in enumerate(candidates)) r = client.responses.create( model="gpt-5.5", max_output_tokens=200, input=( f"Question: {question}\n\nPassages:\n{listing}\n\n" "Return the indices of the {keep} most relevant, comma-separated."), ) idxs = [int(x) for x in re.findall(r"\d+", r.output_text)][:keep] return [candidates[i] for i in idxs if i < len(candidates)]
Pure meaning-based (vector) search misses exact terms like error codes and product names; pure keyword search (BM25) misses paraphrases. This lab runs both, merges their rankings, then uses a model to pick the very best few. This is usually the biggest single quality jump you can make in a RAG system.
- Step 1 — Hybrid.search. It gets dense results (your vector
store.search, good at meaning) and sparse results (BM25Okapi, good at exact words) — two ranked lists of chunk ids for the same query. - Step 2 —
_rrf(Reciprocal Rank Fusion). A simple, robust way to merge ranked lists: each list gives a chunk points based on its position (1/(k+rank)), the points are summed per chunk, and chunks are re-sorted by total. Something ranked highly by either method rises — you don't have to tune weights. - Step 3 —
rerank. After over-fetching ~10 candidates cheaply, a fast model reads each passage against the question and returns the indices of the best few tokeep. This precise final cut is the highest-leverage lever in RAG. - The overall pattern is a funnel: fetch wide and cheap (hybrid, top-10–30), then narrow precisely (re-rank to top-3–5), then generate — giving you both recall and precision without spending tokens on junk.
What the output means: Hybrid.search returns chunks ordered by fused score; rerank trims them to the few most relevant passages, which become the context for the generation step from Lab 3.4.
Try this: Search for an exact term that appears verbatim in one doc (an ID or code) and compare hybrid vs. the pure-vector store.search. The keyword half of hybrid is what reliably surfaces exact matches that vectors alone can blur.
Measuring your RAG system expert
Ask yourself an uncomfortable question: how would you actually know if a change made your RAG system better? You tweak the chunk size, ask it a few questions, the answers "seem good," and you ship. Three weeks later a category of questions is quietly failing and you have no idea when it started or which change caused it. This is how most RAG projects rot — not through one big bug, but through a hundred unmeasured tweaks, each judged by vibes. The cure is boring and non-negotiable: you can't improve what you don't measure.
The key insight — and it's the one that separates people who can debug RAG from people who just poke at it — is that RAG has two failure modes that live in two different places, and you must measure them separately. Retrieval can fail (the right chunk never made it into the context), or generation can fail (the right chunk was there and the model still answered badly). Lumping them into a single "was the answer good?" score is like a doctor recording only "patient feels unwell" — true, but useless for treatment. When you split the two, a wrong answer immediately tells you where to look: bad retrieval points you back to chunking and embedding; bad generation despite good context points you at your prompt.
So measure each stage with metrics that answer a plain-English question. On the retrieval side, Recall@k asks "did the chunk that actually contains the answer make it into the top-k?" — the single most important number in RAG, because if it's low nothing downstream can save you. Context precision asks "of what we retrieved, how much was actually relevant?" — low precision means you're padding the prompt with noise. On the generation side, groundedness asks "is every claim in the answer supported by the retrieved context?" (this is your hallucination detector), and answer relevance asks "did it actually address what was asked?" The table below lines these up.
Don't let the word "metrics" make you think you need a heavyweight framework on day one — that's the trap that stops beginners from measuring anything at all. The humble, high-value starting point is a spreadsheet of 15–20 real questions, each paired with the chunk you expect retrieval to surface. After any change, re-run them and hand-check Recall@k. That golden-question set is the closest thing RAG has to a regression test, and it will catch more real problems than any dashboard. In Chapter 5 you'll automate exactly these measurements into a proper harness — but the discipline starts with the spreadsheet.
You can't improve what you don't measure. Track retrieval and generation separately:
Read it top to bottom: one symptom at the top splits into two branches, each with its own metrics and its own fix.
- The red box at the top is the only thing you observe directly — "the answer was wrong". The two arrows say it could come from either of two very different places.
- The left branch is a retrieval failure: the answer-bearing chunk never made the top-k. You catch it with
Recall@k(did the right chunk appear?) andContext precision(how much retrieved text was relevant?). - The right branch is a generation failure: the chunk was there but the model answered badly. You catch it with
Groundedness(is every claim supported?) andAnswer relevance(did it address the question?). - The payoff is the bottom line: a low retrieval metric sends you to chunking/embedding; a low generation metric sends you to the prompt. Splitting the score tells you where to look.
In short: Never score RAG with one "was it good?" number — measure retrieval and generation separately so a failure names its own cause.
| Stage | Metric | Question it answers |
|---|---|---|
| Retrieval | Recall@k | Did the answer-bearing chunk make the top-k? |
| Retrieval | Context precision | How much of what we retrieved was actually relevant? |
| Generation | Groundedness | Is every claim supported by the retrieved context? |
| Generation | Answer relevance | Did it actually address the question? |
In Chapter 5 you'll build an automated harness for exactly these metrics. For now, keep a spreadsheet of 15–20 questions with the chunk you expect to be retrieved, and check Recall@k by hand after any change.
Common pitfalls expert
Every debugging story in RAG starts the same way: with a symptom that lies about its cause. The system "makes things up," or gives "vague" answers, or "can't find" an obvious document — and the instinct is always to reach for the nearest, most visible knob, usually the prompt. You rewrite the system prompt, add "be accurate," maybe swap to a bigger model, and the problem stubbornly stays. The reason is that in RAG the symptom almost never sits where the cause does, and the table below is really a map from one to the other.
The single most important lesson in this whole chapter is hiding in the first row. "It makes things up" feels like a generation problem — the model is clearly the thing producing the wrong words — so that's where people spend their time. But nine times out of ten the answer-bearing chunk was never retrieved, and the model did its job faithfully: it answered from the (wrong or empty) context you gave it. No prompt on Earth can quote a fact that isn't in the context window. This is why we drilled "test retrieval alone, first" so hard — it's the diagnostic that instantly tells you whether you're chasing a retrieval bug or a generation bug, and it saves you from tuning the innocent half of the system.
A few of the other rows are worth internalising before they bite you, because they're the ones that hurt in production rather than in a demo. Leaking other tenants' data is the scary one: if you run semantic search across everyone's documents and only then think about access control, you've already surfaced another customer's private text into a prompt — the filter has to happen before retrieval, not after. Embedding query and docs with different models is the silent killer from Lab 3.2, failing with clean code and confident garbage. And vague answers usually trace to oversized chunks diluting the signal — a chunking problem masquerading as a model problem. Read this table as a habit, not a lookup: when RAG misbehaves, resist the visible knob and trace the symptom back to its true home in the pipeline.
Read the boxes left to right as the RAG pipeline, then follow the dashed arrow backwards — that reversal is the whole lesson.
- The four boxes are the stages in order:
chunking→embedding→retrieval→generation. - The red generation box is where the symptom shows up ("👁 symptom you see") — the model produced the wrong words, so it looks guilty.
- The amber dashed arrow curves back upstream to where the true cause usually lives — a bad chunk or a retrieval miss meant the answer was never in the context to begin with.
- The payoff line is the fix: test retrieval alone first, which instantly tells you whether you're chasing a retrieval bug or a genuine generation bug — so you stop tuning the innocent half.
In short: When RAG misbehaves, don't reach for the prompt — trace the symptom upstream, because the culprit is almost always chunking or retrieval, not generation.
| Symptom | Real cause | Fix |
|---|---|---|
| "It makes things up" | Answer chunk never retrieved | Fix chunking/hybrid search; test retrieval alone |
| Answers are vague | Chunks too large, signal diluted | Smaller chunks + re-ranking |
| Misses exact IDs/codes | Pure vector search | Add BM25 (hybrid) |
| Leaks other tenants' data | No metadata filter | Filter by ACL/tenant before semantic search |
| Query & docs embedded differently | Two embedding models | One embed() function everywhere |
Exercises expert
Exercise 3.1 — Refusal test
Context: A grounded assistant's most important behaviour is knowing when to say nothing. The fastest way to catch hallucination is to ask something you know is absent from the corpus and watch what happens.
Your task: Probe your Lab 3.4 system with a question that is definitely not covered by anything in docs/, and confirm it refuses instead of inventing an answer.
Requirements:
- Pick a question with no support in
docs/at all - A correct run replies with the refusal (e.g. "I don't have that information"), not a guess
- If it invents an answer, strengthen the system prompt and re-test
- Try two or three phrasings — refusal shouldn't depend on exact wording
💡 Hint: Adversarial phrasings that sound in-domain but aren't (a plausible but nonexistent feature) are the ones that expose a weak refusal instruction.
Exercise 3.2 — Metadata filter
Context: The same retriever that powers search is a data-leak vector the moment you have more than one customer. Scoping results to the current tenant is the canonical multi-tenant RAG safeguard.
Your task: Add a tenant field to each chunk and a filter in search() so only the current tenant's chunks are considered, then prove a user can't reach another tenant's docs.
Requirements:
- Every chunk carries a
tenantvalue search()restricts candidates to the current tenant before ranking- A query as tenant A returns zero tenant-B chunks — demonstrate it
- The restriction is enforced in code, not asked for in the prompt
💡 Hint: Filter before similarity, or mask out-of-tenant rows so they can never enter the top-k — don't rank everything and trim afterward.
Show hint
Filter the candidate list before computing similarity, or mask out-of-tenant rows by setting their similarity to -inf. Never rely on the model to respect permissions — enforce it in code.
Exercise 3.3 — Measure Recall@k
Context: "It feels better" is not an evaluation. A tiny labelled set turns retrieval tuning into a number you can move, and lets you settle dense-vs-hybrid with evidence instead of vibes.
Your task: Build 10 (question, expected_chunk_id) pairs and a loop that measures how often the expected chunk lands in the top-5, then compare pure-vector retrieval against hybrid on your own corpus.
Requirements:
- At least 10 labelled pairs drawn from your actual
docs/ - Compute Recall@5 = fraction of questions whose expected chunk is in the top-5
- Run the identical question set through both retrievers
- Report both numbers and state which wins on your corpus
- Note one query where they disagree and why
💡 Hint: Reuse the recall_at_k idea from the ladder's Professional rung; keep the pair list in a small list of tuples so re-running after a tweak is one call.
Show solution sketch
Illustrative fragment — defines demo values / files are needed before this runs standalone.
hits = 0
for q, expected in testset:
ids = [c["id"] for c in hybrid.search(q, k=5)[:5]]
hits += expected in ids
print("recall@5:", hits/len(testset))🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A retriever ranks chunks by how close their embedding is to the query's. When vectors are unit-length (the lesson normalizes them), that closeness is just a dot product — the cheapest possible similarity.
Your task: Write cosine(a, b) that returns the cosine similarity of two already-normalized vectors, then prove it behaves on a few hand-made vectors.
Requirements:
- Use numpy; no explicit Python loop over the dimensions
- Return a plain
float, not a 0-d numpy array - Assert two identical (same-direction) vectors score ~1.0
- Show that an orthogonal vector scores ~0.0 and an opposite one ~-1.0
- Work for a 1-D vector of any length
💡 Hint: Once both inputs are unit-length, np.dot is the whole answer — no division needed. Use a helper that normalizes raw input first.
Show solution
With unit-length vectors, cos = a @ b. Runnable (needs numpy, used throughout the lesson):
import numpy as np
def unit(v):
v = np.asarray(v, dtype=float)
return v / np.linalg.norm(v)
a = unit([1.0, 2.0, 2.0])
b = unit([2.0, 4.0, 4.0]) # same direction
c = unit([2.0, 0.0, 0.0])
print(round(float(a @ b), 4)) # 1.0 -- identical direction
print(round(float(a @ c), 4)) # < 1.0 -- different direction
Context: Retrieval quality lives or dies on chunking: too big and you dilute the match, too small and you lose context. Overlap keeps ideas that straddle a boundary findable, and per-chunk metadata is what lets you cite a source later.
Your task: Write a chunker that splits text on paragraph boundaries into ~N-word chunks with a word overlap, attaching source and a unique id to each chunk. Run it on a short multi-paragraph string.
Requirements:
- Target chunk size and overlap are parameters (e.g. 60 words, 15 overlap)
- Never split inside a word; break on whitespace
- Each chunk is a dict/dataclass with
text,source, and a stable uniqueid - Consecutive chunks share
overlaptrailing words - Print the chunks and eyeball that the overlap is actually present
💡 Hint: Tokenize to a flat word list first, then slide a window of size N advancing by N−overlap each step. The id can be f"{source}#{index}".
Show solution
This is the lesson's chunk_text, self-contained and runnable (no files needed):
import re
def chunk_text(text, source, target_words=20, overlap=True):
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, buf = [], []
for para in paras:
buf.append(para)
if sum(len(p.split()) for p in buf) >= target_words:
chunks.append(" ".join(buf))
buf = buf[-1:] if overlap else [] # carry last para as overlap
if buf:
chunks.append(" ".join(buf))
return [{"text": c, "source": source, "id": f"{source}#{i}"}
for i, c in enumerate(chunks)]
doc = "Reset your password in Settings then Security.\n\n" \
"Enable two-factor authentication in the same place.\n\n" \
"Contact support if you are locked out for good."
out = chunk_text(doc, "faq.md")
print(len(out), "chunks")
for c in out:
print(c["id"], "::", c["text"][:45])
Context: Every RAG system needs a store that can ingest chunks and answer nearest-neighbour queries. Real ones call an embedding API, but you want yours to run offline and deterministically in tests — so the embedding step must be a swappable seam.
Your task: Implement a tiny VectorStore with add(chunks) (embeds & stores) and search(query, k) (returns the top-k chunks by cosine). Inject the embedder so a fake deterministic one is used in the demo.
Requirements:
embed()is passed in (constructor arg or attribute), not hard-coded- The fake embedder is deterministic — same text always yields the same vector
add()stores the vector alongside the chunk, embedding oncesearch()returnskchunks ordered best-first- Ties and
k > len(store)don't crash - Demonstrate a query returning the intuitively-closest chunk
💡 Hint: A deterministic fake: hash the text into a seed, seed numpy, return a random unit vector. Keep vectors and chunks in parallel lists (or a list of pairs) and sort by the cosine you wrote in Exercise 1.
Show solution
The real lesson swaps embed() for a provider; here a toy hashing embedder keeps it offline and deterministic while the store logic is identical. Runnable:
import numpy as np
def embed(texts): # toy stand-in; same signature as real one
vs = []
for t in texts:
v = np.zeros(16)
for w in t.lower().split():
v[hash(w) % 16] += 1.0
n = np.linalg.norm(v) or 1.0
vs.append(v / n) # normalize -> cosine is a dot product
return np.asarray(vs)
class VectorStore:
def __init__(self):
self.vecs = None; self.chunks = []
def add(self, chunks):
self.chunks = chunks
self.vecs = embed([c["text"] for c in chunks])
def search(self, query, k=3):
q = embed([query])[0]
sims = self.vecs @ q
top = np.argsort(-sims)[:k]
return [(self.chunks[i], float(sims[i])) for i in top]
docs = [{"text":"reset your password in settings security", "id":"a"},
{"text":"enable two factor authentication", "id":"b"},
{"text":"billing invoices and refunds", "id":"c"}]
s = VectorStore(); s.add(docs)
for c, score in s.search("how do I reset my password", k=2):
print(round(score, 3), c["id"])
Context: Hybrid search runs a dense (vector) retriever and a sparse (keyword/BM25) retriever and fuses their results. Reciprocal Rank Fusion is the workhorse: it combines ranks, not raw scores, so the two systems' incompatible score scales never have to be reconciled.
Your task: Implement _rrf(rankings, k=60) that fuses two ranked lists of chunk ids into one, and show it surfaces a chunk that either retriever ranked highly — with no score tuning.
Requirements:
- Score each id as
Σ 1/(k + rank)across the lists it appears in - Use rank position (0- or 1-based, stated), never the original scores
- An id present in only one list still gets its contribution
- Return ids sorted by fused score, highest first
- Construct an example where RRF's #1 was #2 in both lists — a consensus win
💡 Hint: Iterate each ranking with enumerate to get positions, accumulate into a defaultdict(float) keyed by id, then sort. The constant k (≈60) damps how much the very top ranks dominate.
Show solution
RRF gives each item 1/(k+rank) points from each list and sums them; the constant k damps the top-rank dominance. Runnable:
def rrf(*lists, k=60):
scores = {}
for lst in lists:
for rank, cid in enumerate(lst):
scores[cid] = scores.get(cid, 0) + 1 / (k + rank)
return sorted(scores, key=lambda c: -scores[c])
dense = ["a", "b", "c", "d"] # vector search order
sparse = ["c", "a", "e", "f"] # BM25 order (exact-term match)
fused = rrf(dense, sparse)
print(fused)
# 'a' is top of dense and 2nd in sparse -> wins; 'c' (top sparse, 3rd dense) close behind
assert fused[0] == "a"
assert "e" in fused and "f" in fused # sparse-only items still includedThe subtlety: RRF only reads each item's position, so wildly different score scales from two retrievers combine without normalization — that robustness is why the lesson prefers it over weighted score-mixing.
Context: Two production realities at once. You can't improve retrieval you don't measure, and in a multi-tenant product a retriever that returns another customer's chunk is a data breach — access control must live in code, never in the prompt.
Your task: Build a recall_at_k harness over (question, expected_chunk_id) pairs, and add a tenant filter that removes other tenants' chunks before similarity is computed. Make both runnable against a fake retriever.
Requirements:
recall_at_kreturns the fraction of questions whose expected chunk appears in the top-k- Filtering happens before ranking, not by post-hoc dropping results
- A query in tenant A can never return a tenant-B chunk — assert it
- The tenant check is enforced in code, not requested in a prompt
- Report recall for at least two values of k (e.g. k=1 and k=5)
💡 Hint: Filter the candidate list by chunk.tenant == current_tenant first, then rank the survivors. For recall, count a hit when expected_id in [c.id for c in top_k] and divide by the number of questions.
Show solution
Metric first, then the security filter. Both are pure Python and runnable:
def recall_at_k(search_fn, testset, k=5):
hits = 0
for q, expected in testset:
ids = [c["id"] for c in search_fn(q, k=k)[:k]]
hits += expected in ids
return hits / len(testset)
CORPUS = [
{"id":"t1#0", "tenant":"t1", "text":"reset password"},
{"id":"t1#1", "tenant":"t1", "text":"enable 2fa"},
{"id":"t2#0", "tenant":"t2", "text":"reset password"}, # other tenant, same words
]
def search(query, k=5, tenant=None):
pool = [c for c in CORPUS if tenant is None or c["tenant"] == tenant] # FILTER FIRST
ranked = [c for c in pool if any(w in c["text"] for w in query.split())]
return ranked[:k]
# tenant isolation: t1 must never see t2's chunk, even on an identical query
ids = [c["id"] for c in search("reset password", tenant="t1")]
print("t1 sees:", ids)
assert all(i.startswith("t1") for i in ids), "tenant leak!"
testset = [("reset password", "t1#0"), ("enable 2fa", "t1#1")]
print("recall@5:", recall_at_k(lambda q, k=5: search(q, k=k, tenant="t1"), testset))Production points: test retrieval separately (most RAG 'hallucinations' are retrieval misses), and enforce ACL/tenant scoping in code before semantic search — a metadata filter, not a prompt instruction, is what actually prevents cross-tenant leaks.
Context: A new enterprise customer wants your assistant answering strictly from their runbooks. Legal's non-negotiables: it must never answer from outside their docs, and every answer must cite. This is the grounded-answer contract most real RAG deployments are judged on.
Your task: Design the end-to-end grounded flow — retrieve → build a numbered context → force cite-or-refuse — and provide the prompt plus the assembly code. The generation call itself needs an API key; the assembly and the refusal contract must be shown runnable offline.
Requirements:
- Retrieved chunks are assembled into a numbered context block
- The system prompt instructs: answer only from the context and cite the numbers used
- If nothing relevant is retrieved, the system returns a fixed refusal — it does not call the model to 'try anyway'
- Every non-refusal answer carries at least one
[n]citation - Assembly + the refusal decision run without network; only the final generate needs a key
- Note how you'd verify each cited number actually maps to a retrieved chunk
💡 Hint: Gate on retrieval score: if the best similarity is below a threshold (or the candidate list is empty), short-circuit to the refusal string before you ever build the prompt. Number chunks [1..n] and pass that same numbering into the prompt so citations are checkable.
Show solution
Design. "Onboard a customer" = point retrieval at their docs/ and keep the two anti-hallucination rules absolute: answer only from the numbered context, and say "I don't have that information" when it's missing. Per-customer isolation comes from the tenant filter (previous rung); citations come from numbering chunks so the model can reference [n].
SYSTEM = (
"You answer strictly from the numbered context. "
"Cite the sources you use inline as [n]. "
"If the context does not contain the answer, say: "
"'I don't have that information.' Never use outside knowledge."
)
def build_context(hits):
"""hits: list of (chunk_dict, score) -> numbered, citable context."""
return "\n\n".join(
f"[{i}] (source: {c['source']})\n{c['text']}"
for i, (c, _) in enumerate(hits, 1)
)
hits = [({"source":"runbook.md", "text":"Rotate the API key via the admin console."}, 0.71),
({"source":"runbook.md", "text":"Keys expire every 90 days."}, 0.55)]
ctx = build_context(hits)
print(ctx)
assert ctx.startswith("[1]") and "[2]" in ctx # numbered so the model can cite
# Real generation — same assembly, either SDK:
# Claude (needs ANTHROPIC_API_KEY):
# resp = client.messages.create(model="claude-opus-4-8", max_tokens=600, system=SYSTEM,
# messages=[{"role":"user","content":f"Context:\n{ctx}\n\nQuestion: {q}"}])
# OpenAI (needs OPENAI_API_KEY):
# resp = client.responses.create(model="gpt-5.5", max_output_tokens=600,
# instructions=SYSTEM, input=f"Context:\n{ctx}\n\nQuestion: {q}")Tradeoffs. Strict grounding trades coverage for trust — the assistant will refuse rather than guess, which is exactly what legal wants; the fix for gaps is better retrieval, not a looser prompt. Verify onboarding by asking something absent from their docs and confirming the refusal fires, and by spot-checking that every claim carries a [n] traceable to a real chunk. Each customer is a separate tenant-scoped index so answers can never cross companies.
✓ Checkpoint — you can move on when you can…
- Explain the offline (index) vs online (query) phases and what each box does.
- Chunk a document sensibly and say why overlap and metadata matter.
- Explain what an embedding is and how cosine similarity retrieves chunks.
- Describe why hybrid + re-ranking beats pure vector search.
- Write the two system-prompt rules that stop hallucination, and prove the refusal works.
- Measure Recall@k by hand and debug a "hallucination" as a retrieval failure.
docs/ folder you indexed here becomes their runbook repository. "Onboard to a new customer" literally means "point RAG at their docs." See RAG-over-runbooks in the capstone →Knowledge check check yourself
The chapter claims most wrong answers in a RAG system are retrieval failures, not generation failures. What does that imply about how and in what order you should test the pipeline?
Show answer
Pure vector search and BM25 keyword search each fail in a characteristic way. Describe each failure and explain why combining them (then re-ranking) is the biggest quality jump in RAG.