AI EngineeringZero to ProductionHome·About·What’s new·Contact
Part II · Chapter 3

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.

⏱️ ~2 hours🧪 5 labs🎯 Intermediate📦 numpy, rank-bm25
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the code below):Either way, keep the key in a .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.

plain LLM · answers from memory question LLM confident guess ✗ no access to your docs RAG · look it up first, then answer question retrieve docs LLM grounded, cited ✓ RAG changes the input, not the model. A plain LLM answers a question it never saw the source for and confidently guesses; RAG inserts a "fetch your documents first" step so the same model answers from facts it can cite.
🗺️ How to read this diagram

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 docs box 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.

ApproachBest whenTrade-off
RAGKnowledge changes often; needs citations; large corpusRetrieval quality is the bottleneck
Long context (paste it all)Small, stable doc set that fits the windowExpensive per call; doesn't scale to millions of docs
Fine-tuningTeaching style/behavior, not factsCostly to update; can't cite; facts go stale
The truth about "hallucination" in RAGMost wrong answers in a RAG system are retrieval failures, not generation failures — the model answered correctly from the wrong (or missing) context. That's why we test retrieval separately and first.

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

OFFLINE — build the index once Documents Chunk +metadata Embed Vector store ONLINE — per user question Question Retrievevec + BM25 Re-rank Buildcontext LLM+ citations Answer We build each box ourselves in Labs 3.1 – 3.5
🗺️ How to read this diagram

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 Question comes in and flows left to right through Retrieve → 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 store down to Retrieve shows 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.

Sample corpusCreate a folder 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.

too big idea · idea · ideablurry average too small how… …but one fact split across a cut just right one coherent idea+ small overlap semantic chunk + carried overlap keeps a boundary fact whole chunk N: …the how overlap chunk N+1: but only if the overlapping region survives in both chunks Chunk size is a trade-off, and overlap is the safety net. Too big blurs several ideas into one weak match; too small severs a single fact; a semantic chunk with a carried overlap keeps each fact both sharp and whole.
🗺️ How to read this diagram

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 overlap region (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.

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.
Lab 3.1
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])
▶ How this works

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.

  1. chunk_text first splits the document on blank lines into paragraphs (paras). It then walks paragraph by paragraph, adding each to a buffer buf.
  2. 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.
  3. Each chunk is stored as a small dictionary with text, its source filename, and a unique id like faq.md#0. That metadata is what lets you cite sources later.
  4. load_corpus loops over every file in the docs/ 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.

Chunking principles
  • 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.

one embed() model → coordinates "reset my password" "update login credentials" "cancel my subscription" embed() password login creds subscription near = similar meaning Meaning becomes location. A single 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.
🗺️ How to read this diagram

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.

Embedding model noteUse a dedicated embeddings model/endpoint (e.g. from your provider or an open one like 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.
Lab 3.2
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]
▶ How this works

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.

  1. 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.
  2. normalize_embeddings=True scales 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.
  3. VectorStore.add(chunks) embeds all chunk texts at once and keeps the resulting matrix self.vecs (N chunks × d numbers each) alongside the chunks themselves.
  4. search(query, k) embeds the query, then self.vecs @ q computes 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.

This IS a vector databaseA production vector DB (Pinecone, Weaviate, pgvector, FAISS…) adds persistence, scale, filtering, and approximate-nearest-neighbor speed. But the core operation is exactly what you just wrote: cosine similarity between a query vector and stored vectors. Now you know what they do under the hood.

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.

embed the query, then find the chunks whose vectors point the same way query chunk A ✓ chunk B small angle = high cosine = more similar score = cos(θ) = q·c (normalized) A · 0.91 C · 0.78 B · 0.55 top-k (heap, D4) → context Retrieval = nearest vectors by cosine similarity. The query becomes a vector; each chunk's score is the cosine of the angle between them (a dot product on normalized vectors). Take the top-k with a heap (D4) — that's the context you feed the model.
🗺️ How to read this diagram

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 query arrow 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 A hugs 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.

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

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.

  1. It builds a VectorStore, fills it with load_corpus() (your chunked docs), and then calls store.search(...) with a plain-English question and k=3 (return the 3 closest chunks).
  2. 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.
  3. 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.

Test retrieval alone, firstBefore writing a single line of generation code, run 10 real questions through 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.

retrieved chunks chunk chunk chunk numbered context + rules [1] … [2] … [3] "answer only fromcontext; cite [n]" LLM answer [1] "don't know" two prompt rules = the whole anti-hallucination recipe answer only from contextconstrains to facts admit when it's missinghonest escape hatch Grounding lives in the prompt, not the code. Numbering the chunks lets the model cite [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.
🗺️ How to read this diagram

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.

Lab 3.4

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

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.

  1. The SYSTEM prompt 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.
  2. answer() first calls store.search(question, k) to get the top chunks (the retrieve step).
  3. It then builds context by numbering each chunk [1] (source: …) and joining them (the augment step) — the numbers are what the model cites.
  4. 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.

The two rules that prevent hallucination
  1. "Answer only from context" — constrains the model to supplied facts.
  2. "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.

vector · meaning BM25 · exact terms RRF fuse by rank, not score top 10–30wide + cheap re-rank → 3–5 the funnel: over-fetch wide, then narrow precisely hybrid recall (wide) re-ranked precision (narrow) Hybrid + re-rank is a funnel. Vector and BM25 catch each other's blind spots; RRF fuses them by rank position (no score tuning); you over-fetch wide and cheap, then re-rank down to the precise best few — usually the single biggest quality jump in RAG.
🗺️ How to read this diagram

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: vector is great at meaning, BM25 is great at exact terms (codes, product names).
  • Both feed the amber RRF fuse box, 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–5 box 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.

Lab 3.5
  1. Add BM25 keyword search.
    hybrid.pyfrom 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)
  2. 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]
  3. 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.pyimport 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)]
▶ How this works

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.

  1. 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.
  2. 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.
  3. 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 to keep. This precise final cut is the highest-leverage lever in RAG.
  4. 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.

The retrieval funnelOver-fetch cheaply (hybrid, top-10–30) → re-rank precisely (top-3–5) → generate. Cheap-and-wide then expensive-and-narrow gives you both recall and precision without blowing the token budget.

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:

wrong answer — where? retrieval miss · not in top-k generation miss · chunk was there Recall@k Context prec. Groundedness Answer rel. → fix chunking / embedding → fix the prompt Split the failure to find the fix. A wrong answer is either a retrieval miss (the chunk never reached the top-k) or a generation miss (the chunk was present, the model still answered badly) — measure each side separately so the metric points you straight at the culprit.
🗺️ How to read this diagram

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?) and Context 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?) and Answer 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.

StageMetricQuestion it answers
RetrievalRecall@kDid the answer-bearing chunk make the top-k?
RetrievalContext precisionHow much of what we retrieved was actually relevant?
GenerationGroundednessIs every claim supported by the retrieved context?
GenerationAnswer relevanceDid 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.

the pipeline · symptom shows at the end, cause hides upstream chunking embedding retrieval generation 👁 symptom you see true cause usually here "it makes things up" → trace back → the chunk was never retrieved test retrieval alone first to tell the two apart The symptom lies about its cause. A wrong answer surfaces at the generation box, so that's where people tune — but the answer-bearing chunk was usually lost upstream in chunking or retrieval. Trace the symptom back rather than turning the nearest knob.
🗺️ How to read this diagram

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.

SymptomReal causeFix
"It makes things up"Answer chunk never retrievedFix chunking/hybrid search; test retrieval alone
Answers are vagueChunks too large, signal dilutedSmaller chunks + re-ranking
Misses exact IDs/codesPure vector searchAdd BM25 (hybrid)
Leaks other tenants' dataNo metadata filterFilter by ACL/tenant before semantic search
Query & docs embedded differentlyTwo embedding modelsOne 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 tenant value
  • 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.

Exercise 1 · Cosine similarity on normalized vectorsBeginner

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
Exercise 2 · Chunk with overlap + carry metadataIntermediate

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 unique id
  • Consecutive chunks share overlap trailing 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])
Exercise 3 · A tiny VectorStore with top-k searchAdvanced

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 once
  • search() returns k chunks 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"])
Exercise 4 · Fuse two rankings with Reciprocal Rank FusionExpert

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 included

The 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.

Exercise 5 · Measure Recall@k and enforce a tenant filterProfessional

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

Exercise 6 · Onboard the RAG assistant to a new customer, groundedlyIndustry scenario

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.
🏗️ Toward the capstoneRAG is how the AI DevOps Engineer gets onboarded into any company. The generic agent knows Kubernetes; RAG over this company's runbooks, conventions, and past PRs is what makes it know their stack. The 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 →
☁️ Managed RAG on AWSYou built this pipeline by hand to own the tradeoffs. When you want AWS to manage the embeddings, vector store, and retrieval, a Bedrock Knowledge Base does exactly this behind two API calls. See W4 · Knowledge Bases (managed RAG) →

Knowledge check check yourself

✓ Knowledge check

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
It means the model usually answered correctly but from the wrong or missing context, so you must test retrieval separately and first — run real questions through search() and confirm the answer-bearing chunk lands in the top-k before writing any generation code. No clever prompt can rescue context that was never retrieved.
✓ Knowledge check

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.

Show answer
Vector search captures meaning but misses exact terms like error codes, product names, or acronyms; BM25 nails exact terms but misses paraphrase. Fusing both (e.g. via Reciprocal Rank Fusion) then re-ranking the top candidates gives both recall and precision — you over-fetch cheaply and wide, then narrow precisely, so you catch exact matches and semantic matches without wasting tokens on junk.
© 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