Module 16: Retrieval-augmented generation 45 min9 min quick readLLMs
The RAG pipeline
Build retrieval-augmented generation end to end (ingest, chunk, embed, index, retrieve, rerank, construct context, generate with citations) and diagnose its failures.
Before this: From text to vectors, Word embeddings, Adapting LLMs
Visual explainer: How retrieval-augmented generation finds its evidence, a 7-minute scroll-through where the lab moves as you read. Or press Watch on the lab below.
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. RAG is an open-book exam. Before answering, the system looks up the most relevant pages and puts them in front of the model, with instructions to answer only from those pages and cite them. If the lookup fails, even a brilliant model answers from the wrong pages.
You will learn to
- Describe every stage of a RAG pipeline and its main failure mode.
- Choose chunk size and overlap, and explain the trade-off.
- Compare sparse (BM25/TF-IDF), dense (embedding), and hybrid retrieval, plus metadata filtering and reranking.
- Construct a grounded prompt with citations and an "I don't know" path.
- Diagnose whether a bad answer is a retrieval or a generation failure.
Why it matters. RAG is the most common architecture for putting LLMs on private, changing data: support bots, internal search, documentation assistants. Most RAG quality problems are retrieval problems, which are fixable with search engineering and measurement.
1. Intuition
- Ingest: load documents (PDFs, HTML, tickets), extract clean text, keep metadata (source, date, access permissions).
- Chunk: split documents into retrievable pieces. Too small and a chunk lacks the context to answer; too large and the relevant sentence is diluted (and costs more tokens).
- Embed and index: turn each chunk into a vector (dense retrieval) and/or a term index (sparse retrieval), stored with metadata.
- Retrieve: turn the user's query into the same representation and fetch the top- chunks, optionally filtered by metadata (only this user's documents, only this product).
- Rerank: a slower, more accurate model (often a cross-encoder) rescores the candidates.
- Construct context: place the best chunks in the prompt with source labels.
- Generate: instruct the model to answer only from the provided context, cite sources, and say when the answer isn't there.
- Evaluate: measure retrieval and answers separately (next lesson).
2. Visualization
RAG pipeline lab
Four synthetic policy documents. Chunk them, retrieve for a question, optionally rerank, and see whether the evidence reaches the model.
Question: Are shipping costs refunded if my item was broken on arrival?
Query terms (common words dropped):shippingcostsrefundeditembrokenarrival
- #1refundscosine 0.408in context [1]contains the evidence
they have not been accessed. Shipping fees are not refundable unless the item arrived damaged.
- #2shippingcosine 0.347in context [2]
Shipping. Standard shipping takes 3 to 7 business days. Express shipping takes 1 to 2 business days and costs 15
- #3shippingcosine 0.112in context [3]
business days and costs 15 dollars. International orders may take up to 21 days and can incur customs fees paid
Try it (predict first, then check):
- Pick the Retrieval miss question. Predict whether a better prompt could fix the answer, then turn on semantic matching instead.
- Shrink chunks to 8 words. Predict what happens to evidence recall, then raise the chunk size and compare.
- Set k = 1 and try each question in turn. Which still get their evidence into the context, and does the reranker change any of them?
- Keep the default question and move the chunk size from 20 to 30 words. Predict where the evidence ranks, then check the worked example below to see why it moves.
3. The math
Symbols
| Symbol | Meaning |
|---|---|
| query embedding | |
| embedding of chunk | |
| retrieval score for chunk | |
| number of chunks retrieved | |
| weight between sparse and dense scores in hybrid search |
Dense retrieval
Hybrid scoring (one common form)
where are normalized scores. Reciprocal rank fusion, which adds from each retriever, is a popular score-free alternative.
Chunking arithmetic
With chunk size words and overlap , consecutive chunks start every words. A document of words produces about chunks. Overlap reduces the chance that an answer sentence is cut in half, at the cost of more chunks.
Worked example: one query, step by step
Collection: four short synthetic policy documents. Query: "Are shipping costs refunded if my item was broken on arrival?" Scores use the course's TF-IDF from text to vectors: raw counts times , common words removed, cosine similarity. These are the numbers the lab shows.
With 20-word chunks and 5-word overlap (10 chunks):
| Rank | Source | TF-IDF cosine | Contains the evidence? |
|---|---|---|---|
| 1 | refunds | 0.408 | yes: "Shipping fees are not refundable unless the item arrived damaged." |
| 2 | shipping | 0.347 | no |
| 3 | shipping | 0.112 | no |
Now change only the chunk size, to 30 words (7 chunks):
| Rank | Source | TF-IDF cosine | Contains the evidence? |
|---|---|---|---|
| 1 | shipping | 0.303 | no |
| 2 | refunds | 0.283 | yes |
Same documents, same question, and the evidence drops from rank 1 to rank 2. The bigger refunds chunk also carries sentences about digital downloads and payment methods, which dilute its match, while the shipping chunk says "shipping" three times. With the model still sees the evidence; with it would not, and it would likely answer from shipping times. A reranker that reads the query and chunk together moves the refunds chunk back to the top. Chunk size is a retrieval hyperparameter: tune it against a labeled set, not by eye.
If you reproduce this with scikit-learn's TfidfVectorizer, the numbers differ, because its default smooths IDF to ; with 20-word chunks it happens to rank the shipping chunk first (0.437 vs 0.425). Either way the point stands: lexical scores are fragile, so measure retrieval.
Worked example: a retrieval miss
Query: "Can I get reimbursed for a game I never opened?" The answer exists ("Digital downloads are refundable only if they have not been accessed"), but the query shares no words with it: reimbursed versus refundable, game versus digital downloads, never opened versus not accessed. Every TF-IDF score is 0. A dense embedding retriever would likely match the meaning; a hybrid retriever gets the best of both. Without a fix, the generator either says "I don't know" (good) or invents an answer (bad).
4. Implementation
The full script is code/16-rag/rag_scratch.py (NumPy only, fully offline). It uses the lab's four synthetic documents, stop words, and scoring, so it prints the worked example's numbers. Two parts are deliberately fake so nothing needs a download: query rewriting uses a hand-written synonym map instead of an LLM, and the reranker is a scoring rule instead of a trained cross-encoder. The pipeline around them is the real shape.
Build it from scratch
Step 1: chunk. Fixed windows are the baseline: words, starting every words. Sentence chunking packs whole sentences up to a word budget, so an answer sentence is never cut in half. Each chunk keeps its source, which you need for citations and filters later.
def fixed_chunks(size, overlap):
out, step = [], max(1, size - overlap)
for doc, text in DOCS.items():
w = text.split()
out += [(doc, " ".join(w[i:i + size])) for i in range(0, max(1, len(w) - overlap), step)]
return out # [(source, text)]
def sentence_chunks(max_words):
out = []
for doc, text in DOCS.items():
cur = []
for sent in re.split(r"(?<=\.)\s+", text): # never cut a sentence in half
if cur and len(" ".join(cur + [sent]).split()) > max_words:
out.append((doc, " ".join(cur))); cur = []
cur.append(sent)
out.append((doc, " ".join(cur)))
return outPredict: the refunds document has 45 words. With and , how many fixed chunks does it produce? (Use the chunking formula in section 3.)
Step 2: index and retrieve. Every chunk becomes a TF-IDF vector (raw count times ), stacked into a matrix M of shape [n_chunks, |vocab|]. Retrieval is one matrix-vector product and a sort by cosine. A dense retriever swaps vec for an embedding model; the rest is unchanged.
def retrieve(chunks, index, query_terms, k):
vec, M = index
q = vec(query_terms)
s = M @ q / (np.linalg.norm(M, axis=1) * np.linalg.norm(q) + 1e-12)
order = sorted((i for i in range(len(chunks)) if s[i] > 0), key=lambda i: (-s[i], i))
return [(i, float(s[i])) for i in order[:k]]Predict: the miss query ("reimbursed ... game ... never opened") shares no terms with any chunk. What does retrieve return for it?
Step 3: rewrite the query. When the user's words differ from the document's words, add the document's words. Production systems ask an LLM for paraphrases (multi-query), for a hypothetical answer to embed instead of the question (HyDE), or for sub-questions (decomposition). Here a synonym map plays the LLM.
def rewrite(query):
return [x for t in terms(query) for x in [t] + SYNONYMS.get(t, [])] # an LLM rewriter in productionPredict: rewriting adds "refund" to any query containing "back". What could go wrong for "How do I go back to the previous page?"
Step 4: rerank. A first-stage retriever scores the query and each chunk separately. A reranker reads them together and can reward query words that appear close together in the chunk. A real cross-encoder is a transformer over the pair [query] [SEP] [chunk]; this rule (coverage of query terms times their density) is the same rule the lab's reranker uses.
def rerank_score(chunk_text, query):
toks = terms(chunk_text)
q = set(rewrite(query))
pos = [i for i, t in enumerate(toks) if t in q]
if not pos:
return 0.0
coverage = len({toks[i] for i in pos}) / max(1, len(terms(query)))
span = pos[-1] - pos[0] + 1 if len(pos) > 1 else len(toks)
return round(coverage * (1 + len(pos) / span), 4)Predict: a cross-encoder is far more accurate than a bi-encoder. Why not use it for first-stage retrieval over a million chunks?
Step 5: build a grounded prompt and check citations. Number the sources, tell the model to answer only from them and to say when they don't contain the answer. After generation, check that every cited number points at a real source; a citation to [4] when you sent two sources is a hallucinated citation.
def build_prompt(question, chunks, hits):
sources = "\n".join(f"[{n}] ({chunks[i][0]}) {chunks[i][1]}" for n, (i, _) in enumerate(hits, 1))
return ("Answer using ONLY the sources below. Cite sources like [1]. "
"If the sources do not contain the answer, say you don't know.\n\n"
f"Sources:\n{sources}\n\nQuestion: {question}")
def check_citations(answer, n_sources):
cited = {int(c) for c in re.findall(r"\[(\d+)\]", answer)}
return {"cited": sorted(cited), "invalid": sorted(c for c in cited if not 1 <= c <= n_sources), "uncited": not cited}Predict: the citation check passes. Does that prove the answer is supported by source [1]?
Use the library
The same pipeline with a real embedding model:
# Index (once): chunk, embed, store with metadata
from sentence_transformers import SentenceTransformer # pip install sentence-transformers
import numpy as np
encoder = SentenceTransformer("all-MiniLM-L6-v2")
chunks = [c for doc in docs for c in chunk(doc.text, size=200, overlap=40)]
C = encoder.encode([c.text for c in chunks], normalize_embeddings=True) # [n_chunks, d]
# Query time: retrieve, (rerank), build a grounded prompt
def answer(question, k=5):
q = encoder.encode([question], normalize_embeddings=True)[0]
top = np.argsort(-(C @ q))[:k] # cosine = dot for normalized vectors
context = "\n\n".join(f"[{i}] ({chunks[i].source}) {chunks[i].text}" for i in top)
prompt = (
"Answer using ONLY the sources below. Cite sources like [3]. "
"If the sources do not contain the answer, say you don't know.\n\n"
f"Sources:\n{context}\n\nQuestion: {question}"
)
return llm(prompt), topWhat production adds: the in-memory array becomes a vector database or a search engine with vector support (vector search), with metadata filters for permissions and freshness. Step 4 becomes a trained cross-encoder (sentence_transformers.CrossEncoder or a hosted rerank API) over the top 20 to 100 candidates. Step 3 becomes an LLM call, and step 1 becomes a splitter that respects headings, paragraphs, and code blocks.
Run it
uv run code/16-rag/rag_scratch.pyKey lines of output (synthetic documents):
10 chunks of 20 words: [('refunds', 0.408, True), ('shipping', 0.347, False), ('shipping', 0.112, False)]
7 chunks of 30 words: [('shipping', 0.303, False), ('refunds', 0.283, True)]
30-word chunks after rerank: [('refunds', 1.0476), ('shipping', 0.4167)]
terms: ['get', 'reimbursed', 'game', 'never', 'opened'] -> hits: []
-> top hit: [('refunds', 0.517, True)]
fixed 20w/5 overlap : 10 chunks, recall@1 0.4, with rewrite 1.0
sentences <= 20w : 11 chunks, recall@1 0.6, with rewrite 1.0
citation check on 'Yes, if it arrived damaged [1][4].': {'cited': [1, 4], 'invalid': [4], 'uncited': False}The first two lines are the worked example's tables. The reranker puts the refunds chunk back on top at 30 words, and rewriting fixes the miss. Read the recall lines with care: five questions means each one moves recall by 0.2, and the synonym map was written for these exact questions, so "1.0 with rewrite" shows the mechanism, not a result to expect on real data. The ranking metrics and the chunk-size figure are in code/16-rag/rag_eval.py.
5. Engineering
Retrieval first. Measure retrieval recall@k on a labeled set of (question, relevant chunk) pairs before tuning prompts. If the evidence isn't retrieved, no prompt can fix the answer.
Chunking. Split on structure (headings, paragraphs) rather than fixed word counts where possible; keep titles and section headers with chunks; use overlap; tune size on your retrieval metrics.
Hybrid search and reranking. Sparse retrieval handles exact terms, IDs, and rare words; dense handles paraphrases. Hybrid is a strong default. Rerank the top 20 to 100 candidates with a cross-encoder, accepting the extra latency.
Query rewriting. Multi-query (several paraphrases, results merged), HyDE (embed a model-written hypothetical answer instead of the question), and decomposition (split a two-part question into two searches) all fix vocabulary mismatch and multi-hop questions, at the cost of an extra LLM call before retrieval. Rewrites can also drift from what the user asked, so keep the original query in the mix and measure recall with and without the rewriter.
Metadata filtering and permissions. Filter by access rights before retrieval results reach the model. Never rely on the model to withhold documents the user shouldn't see.
Freshness and versioning. Re-index on document changes; version the index together with the embedding model; record which index version answered each request.
RAG versus fine-tuning. RAG changes the runtime context, so updating knowledge means re-indexing, and answers can cite sources. Fine-tuning changes weights and is the right tool for behavior and format, not for facts that change.
Watch outFailure modes. Parsing failures (tables and PDFs turned into garbage); chunks that split the answer; vocabulary mismatch for sparse retrieval and exact-term misses for dense retrieval; stale indexes; too much context, which dilutes attention and costs money; the model ignoring context and answering from memory; retrieved documents containing prompt injections (see security).
Common mistakes
- Judging a RAG system by how fluent its answers sound.
- Using different embedding models (or versions) for documents and queries.
- Skipping the "I don't know" instruction and the evaluation that rewards it.
- Retrieving across all users' documents and filtering afterwards.
6. Knowledge check
Quiz: The RAG pipeline
A 900-word document is chunked with size 300 and overlap 0. How many chunks?
Practice exercise. A 1,000-word document is split into chunks of 200 words with 50 words of overlap. How many chunks result, and how many words are stored in total?
Solution
Chunks start every words: at 0, 150, 300, 450, 600, 750 (the last covers 750 to 950), and 900 (covers 900 to 1000). That is chunks. Stored words: six full chunks of 200 plus a final chunk of 100 = 1,300 words, 30% more than the original because of overlap.
Implementation challenge. Extend the script: add a dense retriever (any sentence-embedding model), combine it with TF-IDF using reciprocal rank fusion, and report evidence recall@3 for sparse, dense, and hybrid on 20 questions you write about the documents, including paraphrased ones.
Summary
- RAG retrieves relevant chunks at query time and asks the model to answer from them with citations.
- Chunk size and overlap trade answer completeness against context length and precision.
- Sparse, dense, and hybrid retrieval have complementary failure modes; reranking improves precision.
- Most failures are retrieval failures: measure recall@k first.
- RAG changes context, not weights, which makes it the right tool for private and changing knowledge.
Next: Vector search
Related: From text to vectors · Adapting LLMs · Model card: embedding model · Model card: reranker
Go deeper, code-first (AI Engineering from Scratch): Text Summarization · Question Answering Systems · Relation Extraction & Knowledge Graph Construction · Document and Diagram Understanding · ColPali and Vision-Native Document RAG · Multimodal RAG and Cross-Modal Retrieval
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): RAG (Retrieval-Augmented Generation), Advanced RAG (Chunking, Reranking, Hybrid Search), Chunking Strategies for RAG, Chunking Strategies, Compared, Cross-Encoder Reranker, Query Rewriting: HyDE, Multi-Query, and Decomposition.
Interview angle
Your RAG system gives a wrong answer even though the right document is in the corpus. How do you debug it?
Split the failure into retrieval versus generation by checking whether the evidence chunk reached the prompt. Log the retrieved chunk IDs for the query and look for the gold chunk. If it is missing, it's a retrieval failure: check that the document parsed cleanly (tables and PDFs often turn to garbage), that chunking didn't split the answer across boundaries, that a metadata or permission filter didn't exclude it, that the index isn't stale, and that queries and documents use the same embedding model version. Vocabulary mismatch, like "reimbursed" versus "refundable", calls for dense or hybrid retrieval; exact IDs call for sparse. If it ranked just below , raise or add a reranker. If the chunk was in the prompt, it's a generation failure: the model answered from memory, was distracted by too much context, or lost a mid-context chunk. Tighten grounding instructions and require citations. Then add the case to the evaluation set.
How do you choose chunk size and overlap?
Chunk size trades completeness against precision and cost, so tune it on retrieval metrics rather than picking a number. Chunks that are too small lose the context needed to answer and split evidence sentences: in the course's synthetic test, evidence recall@3 was 0.4 with 8-word chunks and 1.0 with 50-word chunks. Chunks that are too large dilute the relevant sentence's embedding with unrelated text, lower ranking precision, and send more tokens per retrieved chunk, which costs money, adds latency, and distracts the model. Overlap reduces the chance that an answer straddles a boundary, at the cost of more chunks and storage. A 1,000-word document with 200-word chunks and 50-word overlap gives 7 chunks and stores 1,300 words, 30% more. In practice, split on structure (headings, paragraphs) first, keep the section title with each chunk, start around a few hundred tokens, and compare candidates on recall@k with a labeled question set.
Sparse, dense, or hybrid retrieval, and where does a reranker fit?
Hybrid retrieval plus a reranker is the strong default, because sparse and dense retrieval fail differently. Sparse retrieval (BM25, TF-IDF) matches exact terms, so it is excellent for product codes, error messages, names, and rare words, but scores zero on paraphrases: "reimbursed for a game I never opened" shares no words with "digital downloads are refundable if not accessed." Dense retrieval embeds meaning, so it catches paraphrases but can miss exact identifiers and rare jargon. Hybrid combines them with a weighted sum of normalized scores or with reciprocal rank fusion, which adds from each list and needs no score calibration. Both retrievers are bi-encoders: query and document are encoded separately, so documents are precomputed and search is fast. A cross-encoder reranker reads query and chunk together, which is far more accurate but needs one model call per pair, so apply it to only the top 20 to 100 candidates.
Design retrieval for 10 million chunks across many customer tenants. How big is the index, and how do you enforce permissions?
Raw vectors for 10M chunks at 768 dimensions in float32 take GB; fp16 halves that to 15.4 GB and int8 to 7.7 GB. ANN structures such as HNSW add graph overhead, and product quantization can compress further at some recall cost. That fits on one large node or a few shards; plan for replicas to meet throughput. Store text, source, tenant ID, access groups, document version, and embedding model version alongside each vector. Enforce permissions as a filter inside the retrieval query, either by partitioning by tenant (a separate index or namespace each, the simplest strong isolation) or by filtered ANN search on tenant and access-group metadata. Never retrieve globally and filter afterwards, and never ask the model to withhold documents. Add a sparse index for exact-term queries, re-index incrementally on document changes, and version the index together with the embedding model.