Debug a broken RAG
A RAG system is returning wrong, hallucinated, and empty answers, and the team says "the model is bad." It usually isn't. Wrong answers hide in one of five layers — chunking, embedding, retrieval, ranking, or generation. This challenge is a systematic diagnosis: for each symptom you'll reproduce the failure in runnable code, root-cause it to a single layer, fix it, and verify — the way a staff engineer debugs a RAG in production.
Learning objectives
- Turn a vague "the RAG is wrong" report into a layer-specific diagnosis.
- Reproduce a failure in a tiny corpus before touching a fix.
- Distinguish a retrieval failure from a ranking failure from a generation failure.
- Root-cause instead of symptom-patch, and verify the fix on the reproduction.
- Test retrieval separately from generation to bisect the pipeline.
The RAG failure decision tree essential
Picture the scene you'll actually walk into. A product manager forwards you three screenshots: one answer is confidently wrong, one is a hallucinated policy that doesn't exist, one just says "I don't have that information" for a question the handbook clearly covers. Slack is on fire, and the loudest voice in the channel says "the model is bad — can we try GPT-5 / a bigger Claude?" You reach for the model dial. Stop. Swapping the model is the most expensive, slowest, least likely fix, and nine times out of ten it changes nothing — because the model was never the broken part.
Here's the mental model that saves you. A RAG answer is the output of a pipeline, not a single black box. Text is chunked, each chunk is embedded, a query retrieves candidates, those candidates are ranked, and only the top few are pasted into a prompt the model then answers. That's five distinct stages — chunking, embedding, retrieval, ranking, generation — and a defect in any one of them surfaces at the very end as "the answer is wrong." The output looks identical whether the true chunk was never retrieved or was retrieved-but-ignored. That's precisely why "the model is bad" is such a seductive misdiagnosis: the symptom is downstream of five possible causes, and the model is only the last one.
Think of it like debugging a leak in a house with water pooling on the ground floor. You don't rip out the ground-floor ceiling first — you start at the roof and work down, because a leak upstream presents as damage downstream. RAG has the same property, and it has a name worth remembering: a fault upstream masquerades as a fault downstream. A chunk that never got retrieved looks exactly like a hallucination. A chunk buried at rank 40 looks exactly like a weak prompt. If you patch the symptom you see (the prompt) while the real leak is upstream (retrieval), you'll burn a week and the bug comes right back.
So the discipline is a tree, and you walk it in pipeline order, top to bottom — the first "no" is your bug, and you stop there:
- Was the right chunk retrieved at all? No → the generator never saw the answer; the fault is retrieval (bad chunking, an embedding mismatch, or vocabulary the query didn't match). §1–2.
- Was it ranked high enough to be read? It came back but sits below fluffier chunks the model never gets to → a ranking problem; add a reranker. §3.
- Is the chunk itself usable? Retrieved and ranked, but the chunk is junk — a fact split across a boundary, a table shredded into nonsense → a chunking problem.
- Did generation actually use it? The correct fact is provably in the prompt, yet the answer contradicts it → only now is it a generation / prompt problem. §4.
The single most common mistake — the one that separates a junior from a staff engineer here — is jumping straight to the last question because the symptom "feels like" a hallucination. Resist it. The whole point of the tree is that you earn the right to blame the prompt only after retrieval and ranking have each passed.
Almost every bad RAG answer maps to exactly one layer. Walk the tree top to bottom — each question isolates the next layer, and the first "no" is your bug. The cardinal rule from Ch 3: most "hallucinations" are retrieval failures — the model answered honestly from the wrong or missing context.
This is the map for the whole challenge: a decision tree that turns a vague "the answer is wrong" into one specific broken layer. Read it left to right — each amber box is a yes/no question, and the first "no" tells you which layer to fix.
- Start at the red Bad answer — the symptom a user reports (wrong, made-up, or empty).
- Right chunk retrieved? If no, the generator never saw the answer — that's a retrieval bug (chunking, embedding, or query mismatch). §1–2.
- Ranked high enough? The chunk came back but sits too low to be used — a ranking bug; add a reranker. §3.
- Chunk itself good? It's retrieved and ranked, but the chunk is junk (a fact split across a boundary) — a chunking bug.
- Generation used it? The right chunk is in the prompt but the answer ignores it — a generation / prompt bug. §4. The green box is the payoff: one pinpointed layer, which you fix and verify.
In short: Diagnose in this order every time. A fault upstream (missing chunk) disguises itself as a fault downstream (looks like a hallucination) — so you check retrieval before you ever touch the prompt.
The branches: not retrieved → chunking / embedding / query mismatch (§1–2). Retrieved but ranked low → add reranking (§3). Chunk retrieved and ranked but the chunk is junk → chunking. Right chunk in context but ignored → generation / prompt (§4). When you can't tell, §5 shows how to test each layer in isolation.
§1 · Symptom: empty results essential
This is the failure that makes users trust you least. A support rep types "how do I reset my password," gets back "I don't have that information," then opens the very handbook page that answers it and screenshots it to your channel with a red circle. Nothing looks more broken than a system that denies knowing something it demonstrably ingested. And the reflex — "our docs must be missing the answer, let's add more content" — is almost always wrong. The content is there. The retriever never handed it over.
Here's the counter-intuitive part worth sitting with: an empty answer is nearly always an empty retrieval. The generator can only speak about chunks it was given; hand it nothing and an honest model correctly refuses. So a blank answer isn't the model failing — it's the model succeeding at the wrong job, because the layer before it returned zero candidates. That reframes the whole investigation: you don't look at the prompt, you look at what came out of the retriever.
Why does a retriever return nothing when the answer is right there? The classic culprit is a vocabulary mismatch combined with an over-strict matching rule. Imagine a retriever that only accepts a chunk if every word of the query appears in it — an AND-match. The user asks about "reset password"; the doc talks about "change credentials." Same meaning, zero shared words, so the AND-match returns an empty list every single time. The words are synonyms to a human and total strangers to a naive lexical filter. This is the gap between how people phrase questions and how documentation phrases answers, and it is the number-one source of phantom "we don't cover this" reports.
The trap now is the lazy fix, and naming it is half the lesson. The obvious patch is to loosen AND into OR — match any query word. But then filler words like "how," "do," and "I" match nearly every chunk, and you've swapped an empty result for a firehose of noise: technically non-empty, practically useless. The real repair has two moves: drop stopwords so junk terms can't dominate, and score by overlap instead of hard-requiring all terms, so a chunk that shares the meaningful words rises even if it doesn't share all of them. That turns a brittle yes/no filter into a ranked list — which is what retrieval should have been all along. (Even this lexical fix can't bridge reset→change; that's the job of the semantic embedding you'll reach for in §2. For now the goal is narrower and vital: prove the failure was retrieval, not the model.)
Report: "It says I don't have that information for questions the docs clearly answer." An empty answer is almost always an empty retrieval — the generator never got a chunk. Reproduce it, then inspect why the retriever returns nothing.
[]; dropping stopwords and scoring by overlap turns the brittle yes/no filter into a ranked list that surfaces the right chunk.
Read each column top to bottom as one query flowing through the retriever: the broken path on the left, the fixed path on the right.
- The left column feeds the raw query into an AND-match: the red boxes show that because no single chunk contains every word, the filter returns an empty list — and an empty retrieval means the generator has nothing to answer from.
- The right column first strips stopwords (
how,do,I,my) so only meaningful terms remain, then scores by overlap instead of hard-requiring all terms — the amber box. - The green box is the payoff: a ranked, non-empty result. The brittle yes/no filter became a ranked list, which is what retrieval should have been from the start.
In short: Requiring all words guarantees zero hits on any vocabulary gap; dropping stopwords and scoring overlap recovers the chunk without matching everything.
s1_empty.py# SYMPTOM 1 — "no results / empty". User asks "How do I reset my password?"
# The corpus DOES contain the answer, but the retriever returns nothing.
CORPUS = [
"To change your account credentials, open Settings then Security.",
"Billing questions are handled by the finance portal.",
"Our office hours are 9am to 5pm on weekdays.",
]
def retrieve_broken(query, k=2):
# BUG: require EVERY query word to appear in the chunk (AND match).
terms = query.lower().split()
hits = [c for c in CORPUS if all(t in c.lower() for t in terms)]
return hits[:k]
print("BROKEN:", retrieve_broken("how do I reset my password"))
# Root cause: the answer chunk shares NO words with the query
# ("reset/password" vs "change/credentials"). AND-matching guarantees zero hits.
# The lazy patch (OR every word) makes "how","do","i" match everything -> noise.
STOP = {"how", "do", "i", "the", "a", "my", "to"}
def retrieve_fixed(query, k=2):
# FIX: drop stopwords, SCORE by term overlap, never hard-require all terms.
terms = [t for t in query.lower().split() if t not in STOP]
scored = []
for c in CORPUS:
cl = c.lower()
score = sum(1 for t in terms if t in cl)
if score:
scored.append((score, c))
scored.sort(key=lambda x: -x[0])
return [c for _, c in scored[:k]]
print("FIXED (lexical):", retrieve_fixed("password reset settings security"))
BROKEN: []
FIXED (lexical): ['To change your account credentials, open Settings then Security.']
This reproduces the most common false alarm in RAG: the system says "I don't have that information" for a question the docs clearly answer. The instinct is to blame the model — but the model never got a chunk. The bug is in the retriever.
retrieve_brokenrequires every query word to appear in a chunk (all(t in c.lower() ...)). The answer chunk says change credentials; the query says reset password — they share no words, so the AND-match returns[]. Empty retrieval → empty answer.- The tempting quick patch — OR the words together — is worse:
how,do,ithen match every chunk, trading empty results for noisy ones. retrieve_fixedis the honest fix: drop stopwords, then score chunks by term overlap instead of hard-requiring all terms. It surfaces the right chunk without matching everything.
What the output means: BROKEN: [] proves the retriever returns nothing; FIXED (lexical): ['To change your account credentials…'] shows the answer chunk recovered once you score instead of AND-match.
Try this: A pure lexical fix still can't bridge reset→change; that's what the semantic embedding in §2 is for. This challenge only proves the failure was retrieval, not generation.
how, do, i match every chunk and you've traded empty results for noisy ones. The root cause is a rigid AND-match plus no synonym bridge; the real fix is scored overlap now and a semantic embedding next (see §2).§2 · Symptom: retrieves the wrong thing intermediate
The empty-result bug at least announces itself. This one is nastier because it's confident. A developer asks "how do I reset the API token," and the system returns a crisp, well-written answer — about resetting a password. Everything downstream worked: a chunk was retrieved, it scored high, generation used it faithfully. The only problem is it was the wrong chunk. Confident-but-wrong is more dangerous than empty, because empty makes users double-check and wrong makes them act.
To see why pure vector search does this, you have to understand what an embedding actually measures. Embeddings map text into a space where closeness means similarity of meaning — and "reset the API token" and "reset your password" are, in meaning-space, near neighbors. Both are about a user, a credential, and the act of resetting it. The embedding faithfully reports "these are very similar," ranks the password chunk at the top, and is right by its own metric — it just answered a slightly different question than the user asked. This is semantic adjacency: two things so close in meaning that the vector geometry can't tell which one the user actually wanted.
The specific weakness underneath is that a single embedding vector blurs rare, exact tokens. Words like api, token, or an error code like ERR-42 carry almost all the discriminating signal in the query — but a dense vector smears them into the overall gist, so a distractor that's topically close can edge out the chunk containing the exact term. Think of it as summarizing a sentence so well that you lose the one word that mattered. This is exactly where the old technology — plain keyword search — wins: a keyword matcher weighted by inverse document frequency (IDF) gives a rare word like token enormous weight precisely because it appears in almost no chunks. The word the embedding threw away is the word IDF prizes.
So the mistake to avoid isn't "using vectors" — vectors are what let paraphrases like reset→change match at all. The mistake is treating dense retrieval as complete. The production answer is neither "vector" nor "keyword" but hybrid: run both, then fuse their rankings (typically with reciprocal rank fusion, RRF) so a chunk that either side ranks highly bubbles up. Semantic search catches the paraphrases; keyword search catches the exact rare terms; fusion gets you the correct chunk in both the "reset password" paraphrase case and the "ERR-42" exact-token case with one mechanism. When someone proposes "let's just switch to keyword search," the right correction is: don't trade one blind spot for another — combine them.
Report: "It confidently answers, but about the wrong topic." The chunk came back with a high score — it's just the wrong chunk. Pure vector search can be fooled by semantic adjacency: a query about an API token lands nearest a password chunk. This is exactly the case where keyword search would have won.
Read it as a map of meaning-space: the two axes are directions of meaning, and every point is a chunk or the query placed by its embedding. What matters is the angle from the origin, because that is what cosine similarity measures.
- The solid blue line is the query "reset the api token." The two dashed lines reach the two candidate chunks; a smaller angle between the query and a chunk means the embedding thinks they are more similar.
- The red dot (password chunk) sits at the smaller angle to the query, so vector search ranks it first — that is the bug, marked "nearest ✗ wrong." The blue dot (API-token chunk) is the real answer but sits at a wider angle, so it loses.
- The green box is the payoff: keyword scoring weighted by IDF gives the rare exact words
apiandtokenhuge weight because they appear in only the correct chunk — recovering what the blurred vector threw away.
In short: the embedding is "right" by its own angle metric yet answers the wrong question; fusing vector recall with keyword IDF (hybrid) is what gets the correct chunk to the top.
s2_wrong.py# SYMPTOM 2 — "retrieves the WRONG thing". Query: "reset the API token".
# The embedding puts the query CLOSEST to a chunk about resetting a *password*
# (semantically adjacent but wrong), beating the chunk that actually documents
# API-token reset. Keyword search, which sees the exact words, fixes it.
import math
# Per-chunk/query embeddings assigned directly so the failure is deterministic
# and inspectable. Axis intuition: [account-security, api-credentials].
Q_VEC = (0.80, 0.60) # "reset the api token"
CORPUS = [ # (text, embedding)
("To reset your password, open Settings > Security.", (0.98, 0.20)), # distractor
("Reset an API token via the developer portal token page.", (0.30, 0.95)), # THE ANSWER
]
def cos(a, b):
dot = sum(x*y for x, y in zip(a, b))
na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
return round(dot/(na*nb), 3)
# BUG: semantic ranking. 'password reset' sits at a SMALLER angle to the query
# than the true 'api token' chunk -> the WRONG chunk is retrieved at rank 1.
sem = sorted(((cos(Q_VEC, v), t) for t, v in CORPUS), reverse=True)
print("SEMANTIC top:", sem[0][0], "->", sem[0][1])
def keyword(query):
terms = query.lower().split()
N = len(CORPUS)
df = {t: sum(1 for txt, _ in CORPUS if t in txt.lower()) for t in terms}
scored = [(round(sum(math.log((N+1)/(df[t]+0.5)) for t in terms if t in txt.lower()), 3), txt)
for txt, _ in CORPUS]
return sorted(scored, reverse=True)
# FIX: keyword search matches the rare exact words 'api'+'token' (df=1), which
# only the correct chunk contains -> it wins. In production you FUSE the two (hybrid).
kw = keyword("reset the api token")
print("KEYWORD top:", kw[0][0], "->", kw[0][1])
SEMANTIC top: 0.904 -> To reset your password, open Settings > Security.
KEYWORD top: 2.262 -> Reset an API token via the developer portal token page.
Here the retriever confidently returns a chunk — the wrong one. This is the classic case where pure vector search loses to keyword search, because two topics that are close in meaning (password reset vs API-token reset) sit near each other in embedding space.
- The embeddings are hand-assigned so the failure is deterministic: the query leans toward account-security, and the password distractor sits at a smaller angle to it than the correct API token chunk.
cos()ranks by that angle, so the semantic top result is the wrong chunk (0.904 > the correct chunk's score).keyword()scores by inverse document frequency: the rare exact wordsapiandtokenappear in only the correct chunk (df=1), so they dominate and the right chunk wins.
What the output means: SEMANTIC top … password (the mistake) vs KEYWORD top … API token (the fix). The exact rare terms are what the embedding couldn't weight.
Try this: The production answer isn't "keyword instead of vector" — it's hybrid: fuse both rankings with RRF (Ch 3 Lab 3.5 / FA6) so paraphrases and exact terms both surface.
The embedding ranks password above API token because the two are close in meaning-space, while the exact rare words api/token only live in the correct chunk. The fix isn't "keyword instead of vector" — it's hybrid: fuse both rankings (RRF, from Ch 3 Lab 3.5 / FA6) so exact terms and paraphrases both surface.
§3 · Symptom: right chunk retrieved but ranked low advanced
This is the symptom that makes engineers doubt their own eyes. You dump the top-10 retrieved chunks to a log, scroll through, and — there it is, the exact chunk that answers the question, sitting at rank 3 or rank 8. The data is clearly present. So why is the final answer still wrong? Because you read all ten; the model only read the top one or two. There is a silent gap between "retrieved" and "used," and it's called ranking.
The key idea is that RAG has two budgets, not one. Retrieval decides what could be relevant and can afford to be generous — pull back 10, 20, 30 candidates. But you can't paste thirty chunks into a prompt: it's slow, it's expensive, and it dilutes the model's attention. So a second stage picks the handful that actually go into the context — usually the top 3 to 5. If the answer-bearing chunk is in the recall set but not in that final handful, it might as well never have been retrieved. It was found and then dropped on the floor.
Here's the distinction that makes this its own layer, separate from §1–2. First-stage retrieval optimizes for recall — "don't miss the answer, cast a wide net." That's why it returns a broad set and why fast, approximate similarity is fine for it. But recall says nothing about order. A generic "security best practices" chunk can score marginally higher on first-stage similarity than the specific "Settings > Security > Reset" chunk, purely because it's broadly on-topic. First-stage scoring is a cheap approximation; it's good at "is this in the neighborhood" and bad at "which of these neighbors is the precise answer." Optimizing recall and optimizing precision are different jobs.
That's what a reranker is for, and why it belongs after retrieval rather than replacing it. A reranker (in production, a cross-encoder that reads the query and each candidate together rather than comparing two pre-computed vectors) rescores the whole candidate set by true query-chunk relevance and reorders it, so the precise answer climbs to rank 1. The pattern to internalize is the retrieval funnel: over-fetch wide and cheap, then rerank precisely down to the few chunks generation will actually read. The common mistake here is misreading the symptom as "retrieval missed it" and responding by fetching even more chunks — but more recall doesn't help if the answer already came back and got buried. More candidates without a reranker just means a longer list the model still won't read past. Skipping the rerank is the single most frequent reason a chunk that was retrieved never gets used.
Report: "When I dump the top-10 I can see the answer in there — but the final answer is still wrong." The answer-bearing chunk is retrieved, just buried below fluffier chunks, and generation only reads the top few. This is a ranking failure — the fix is a reranker over a wide recall set, not more retrieval.
Read it left to right as a funnel: a wide recall set enters, a reranker reorders it, and only a narrow top-k exits into generation.
- The left stack is what first-stage retrieval returns — generous and cheap, optimized for recall. Note the green "the answer" chunk sits at rank 3, below broadly-on-topic fluff.
- The amber reranker in the middle rescores every candidate by reading the query and chunk together, so it judges precise relevance rather than the cheap first-stage approximation.
- The right stack is the narrow top-k that reaches the model — after reranking, the green answer chunk is now rank 1.
- The two lines below the divider are the payoff: without a reranker the model reads only the fluffy rank-1 chunk and answers wrong; with one, the buried answer climbs to the top and the answer is right.
In short: Over-fetch wide (recall), then rerank precisely down to the top 3–5 — fetching more candidates without reranking just makes a longer list the model still won't read past.
s3_rank.py# SYMPTOM 3 — "the right chunk IS retrieved, but ranked too low to be used".
# First-stage retrieval returns a wide set; the answer-bearing chunk sits at rank 3.
# Generation only reads the top-1 chunk, so the answer is wrong. A reranker that
# scores query-chunk relevance promotes the right chunk to rank 1.
CANDIDATES = [ # (first_stage_score, text) -- as retrieval returned them
(0.71, "General security best practices: rotate credentials regularly."),
(0.68, "Passwords must be at least 12 characters with a symbol."),
(0.66, "To reset your password: Settings > Security > Reset, then check email."), # answer
]
QUESTION = "how do I reset my password"
def top1(cands):
return max(cands, key=lambda c: c[0])[1]
# BUG: feed only the rank-1 chunk to the model. It's the vague one, so the model
# can't answer (or invents). The answer chunk WAS retrieved but never used.
print("USED (no rerank):", top1(CANDIDATES))
def rerank(question, cands):
# Cheap cross-encoder stand-in: relevance = overlap of meaningful query terms.
STOP = {"how", "do", "i", "the", "a", "my", "to"}
q = {w for w in question.lower().split() if w not in STOP}
scored = [(sum(t in text.lower() for t in q), text) for _, text in cands]
scored.sort(key=lambda x: -x[0])
return scored
# FIX: rerank the WHOLE candidate set by real query relevance before selecting.
ranked = rerank(QUESTION, CANDIDATES)
print("RERANKED top:", ranked[0][1])
print("USED (rerank):", ranked[0][1])
USED (no rerank): General security best practices: rotate credentials regularly.
RERANKED top: To reset your password: Settings > Security > Reset, then check email.
USED (rerank): To reset your password: Settings > Security > Reset, then check email.
This is the sneakiest symptom: dump the top-10 and you can see the answer in the list — but the final answer is still wrong. The chunk was retrieved; it was just ranked too low for generation to read. That's a ranking failure, not a retrieval one.
CANDIDATEScomes back from first-stage retrieval with the vague "best practices" chunk scored highest and the actual password-reset chunk third.top1()feeds only the rank-1 chunk to the model — so the model gets the fluff and can't answer. The answer chunk was retrieved but never used.rerank()is a cheap stand-in for a cross-encoder: it rescores the whole candidate set by real query relevance (meaningful-term overlap) and promotes the correct chunk to rank 1.
What the output means: USED (no rerank) is the vague chunk; after reranking, USED (rerank) is the real password-reset chunk. Same retrieval, better ordering.
Try this: The pattern is the retrieval funnel: over-fetch wide and cheap (top 10–30), then rerank precisely to the top 3–5 before generation. Skipping the rerank is the most common reason a retrieved answer never gets used.
§4 · Symptom: right chunk in context, answer ignores it professional
Now you've done everything right and it's still wrong — which is the most maddening kind of bug. You paste the literal prompt that was sent to the model into your terminal. The correct fact is right there in the context block: "Refunds are processed within 5 business days." And the model answers "30 days." The retriever found it, the reranker surfaced it, it's provably in the prompt — and the model looked past it. Only after clearing the three upstream layers do you get to say the words "this is a generation problem," and now you've earned them.
Why would a model ignore a fact it was handed? Because an LLM has two sources of knowledge, and left to itself it doesn't know which to trust. There's the context you supply (the retrieved chunk), and there's its parametric memory — everything baked into its weights during training. If the training data said refunds take 30 days and your current policy says 5, a weakly-instructed model will happily answer from its stale prior, because to the model both are just "things it knows." Retrieval put the right answer on the desk; the model read from its own memory instead. The context and the parameters conflict, and nothing told the model the context wins.
That "nothing told it" is the actual bug, and it lives in the prompt. A vague system prompt like "You are a helpful assistant" gives the model no rule for resolving the conflict, so it defaults to its priors — which feel authoritative because they're fluent. The fix is two grounding rules, and they're worth memorizing because they solve a startling fraction of "hallucination" reports: (1) answer only from the supplied context, and (2) if the context doesn't contain the answer, say you don't know. The first pins the model to the retrieved fact and overrides the stale prior; the second creates an honest exit instead of an invented one.
Rule (2) surfaces something beginners get backwards: a refusal is a feature, not a failure. When the context is empty or irrelevant, "I don't have that information" is the correct output — far better than a confident fabrication that a user will act on. So the way you verify this fix isn't only "does it now quote the right answer?" but also "does it refuse when I hand it empty context?" A grounded system does both. The mistake to avoid is the reflex to reach back into the retriever — swapping embedders, tuning k — when the retriever already did its job. If the fact is demonstrably in the context and the answer contradicts it, no amount of retrieval tuning will help; the repair is in the prompt, and the empty-context refusal test is how you prove it landed.
Report: "I logged the prompt — the correct fact is right there in the context — and the model still gave a different, outdated answer." Now you've cleared retrieval and ranking; this is a generation / prompt failure. A weak prompt lets the model answer from its parametric memory instead of the supplied context.
Read it left to right: two knowledge sources on the left feed one model in the middle, and the prompt on the right decides which source the answer comes from.
- The green box (top-left) is the retrieved context — the correct, current fact "5 business days." The red box (bottom-left) is the model's parametric memory — a stale "30 days" baked into its weights.
- Both arrows feed the amber model, which by itself can't tell which to trust — the two sources conflict and nothing has told it the context wins.
- The two right-hand boxes are the payoff: a weak prompt lets the model answer from its fluent-but-stale prior (red, "30 days"), while a grounded prompt pins it to the context (green, "5 days") — or makes it honestly refuse when the context is empty.
In short: When context and priors conflict, the prompt is the switch — the two grounding rules force the retrieved fact to win, and turn an invented answer into an honest "I don't know."
s4_prompt.py# SYMPTOM 4 — "the right chunk IS in context, but the answer ignores it / hallucinates".
# This is a GENERATION/PROMPT failure, not retrieval. We stub the LLM as a deterministic
# function so the bug is reproducible offline: a weak prompt lets the model fall back on
# its (stale) prior; a strict grounded prompt forces it to answer from context or refuse.
CONTEXT = "Refunds are processed within 5 business days." # correct, retrieved chunk
PRIOR = "Refunds are processed within 30 days." # model's stale training memory
def llm(prompt, context):
# Stub 'model': if the prompt does NOT pin it to the context, it answers from PRIOR.
grounded = "answer only using the context" in prompt.lower()
if grounded:
if context.strip():
return context # obeys: quotes the retrieved fact
return "I don't have that information." # honest refusal when context is empty
return PRIOR # weak prompt -> parametric hallucination
WEAK = "You are a helpful assistant. Question: how long do refunds take?"
STRICT = ("You are a support assistant. Answer only using the context below; "
"if it is not there, say you don't know.\nQuestion: how long do refunds take?")
# BUG: the correct chunk is right there in context, but the weak prompt lets the
# model answer from memory -> WRONG '30 days'.
print("WEAK prompt ->", llm(WEAK, CONTEXT))
# FIX: a grounding prompt forces the answer to come from the retrieved context.
print("STRICT prompt ->", llm(STRICT, CONTEXT))
# VERIFY the escape hatch: strict prompt AND empty context -> honest refusal, no invention.
print("STRICT + empty->", llm(STRICT, ""))
WEAK prompt -> Refunds are processed within 30 days.
STRICT prompt -> Refunds are processed within 5 business days.
STRICT + empty-> I don't have that information.
Now you've cleared retrieval and ranking: you logged the prompt and the correct fact is right there in the context — yet the answer is still wrong. This isolates a generation/prompt bug, reproduced with a deterministic stub model so it runs offline.
- The stub
llm()answers from its stalePRIOR("30 days") unless the prompt explicitly pins it to the context — exactly how a real model behaves under a weak prompt. WEAKjust says "helpful assistant," so the model ignores the supplied "5 business days" fact and hallucinates from memory.STRICTadds the two grounding rules from Ch 3 Lab 3.4 — answer only from context, and admit when it's missing — so the answer now quotes the retrieved fact.- The third print feeds STRICT + empty context and gets an honest refusal. That refusal is a feature: better to say "I don't have that" than to invent.
What the output means: Three lines: the weak prompt's wrong "30 days," the strict prompt's correct "5 business days," and the strict-prompt refusal on empty context.
Try this: If your real system hallucinates over good context, this is where the fix lives — in the prompt, not the retriever. Verify with an empty-context case that it refuses instead of inventing.
The two grounding rules from Ch 3 Lab 3.4 are the whole fix: (1) answer only from the supplied context, and (2) say you don't know when it's absent. The third print proves the escape hatch — a strict prompt with empty context refuses instead of inventing. That refusal is a feature, not a failure.
§5 · The systematic method: isolate the layer expert
The first four sections gave you clean symptoms. Production won't. Real reports arrive as "the answers got worse this week" or "customers say it's unreliable" — no tidy screenshot pointing at one layer. Under that ambiguity the temptation is to start changing things: tweak the prompt, bump k, try a new embedder, and see what helps. That's guessing, and guessing on a five-stage pipeline means you'll often "fix" a layer that was never broken and declare victory right before the bug returns.
The professional move is to borrow the oldest trick in debugging: bisection. Instead of poking the system end-to-end, you cut it into stages and test each in isolation against a case whose answer you already know. The moment one stage fails the check, you've localized the fault — everything upstream is proven good, everything downstream is irrelevant until you fix this. It's git bisect for a data pipeline: you don't read every commit, you binary-search to the one that broke.
What makes bisection possible for RAG is a golden case: a single trusted triple of (question, expected_chunk_id, expected_answer). Because you know exactly which chunk should come back and what the answer should contain, you can interrogate the pipeline in order and get a yes/no at each stage: Did retrieval surface the expected chunk into the top-k? (No → retrieval bug.) Was it at rank 1, or buried? (Buried → ranking bug.) Given only that chunk, does generation actually use it? (No → prompt bug.) The first failing check is your layer, and you stop — you don't keep testing downstream stages of a pipeline you've already proven broken upstream.
The critical detail — the one that makes this method trustworthy — is testing generation on the gold chunk alone, not on whatever retrieval happened to return. If you feed the generator the real retriever's output and the answer is wrong, you still can't tell whether the prompt is weak or the retrieval was bad. By handing generation the known-correct chunk, you remove retrieval from the equation entirely: now a wrong answer can only be the prompt's fault. That's what "isolate the layer" really means — you don't just observe the pipeline, you sever it, so each stage is judged on its own. The mistake this kills is the most expensive one in RAG work: patching the prompt when retrieval is the real problem, because no prompt can rescue a chunk that was never retrieved.
When the symptom is ambiguous — and in production it usually is — stop guessing and bisect the pipeline. Hold one known-good (question, expected_chunk, expected_answer) case and test each layer against it in order: did retrieval surface the expected chunk? was it ranked #1? given only that chunk, does generation use it? The first failing check is your layer.
(question, expected_id, expected_answer); the first check that fails names the broken layer — and feeding generation the gold chunk alone removes retrieval from the equation.
Read the top row left to right as the pipeline; the arrows dropping down show what a failed check at that stage means.
- The gold case on the left is one trusted triple: the question, the chunk id you expect back, and text the answer must contain. It is the yardstick every layer is measured against.
- The three amber boxes are the checks in pipeline order: is the expected id in the top-k (retrieval), is it at rank 1 (ranking), and — given only that gold chunk — does the answer use it (generation)?
- The red down-arrows are the diagnosis: the first check that returns "no" names your layer, and you stop — everything upstream is proven good, everything downstream is irrelevant until you fix this one.
- The key trick is the last box: feeding generation the gold chunk alone severs retrieval from the test, so a wrong answer there can only be the prompt's fault.
In short: Sever the pipeline and judge each stage on its own against a known-good case — this is git bisect for RAG, and it stops you patching the prompt when retrieval was the real bug.
s5_isolate.py# SYMPTOM 5 — "the answer is wrong, but WHICH layer failed?" The systematic method:
# test retrieval and generation SEPARATELY against a known expected chunk, so you
# bisect the pipeline instead of guessing. One gold (question, expected_id, expected).
GOLD = {"q": "how do I reset my password",
"expected_id": "faq#3",
"must_contain": "Settings > Security"}
# --- stubs you can swap for the real retriever / generator ---
def retrieve(q):
return [("faq#3", "To reset your password: Settings > Security > Reset."),
("faq#1", "Passwords must be 12+ characters.")]
def generate(q, context):
# grounded stub: answers from context if a chunk is present, else refuses
return context[0][1] if context else "I don't have that information."
def diagnose(gold, retrieve, generate, k=2):
hits = retrieve(gold["q"])
ids = [i for i, _ in hits][:k]
# LAYER 1 — retrieval: did the expected chunk make the top-k at all?
if gold["expected_id"] not in ids:
return f"RETRIEVAL failed: {gold['expected_id']} not in top-{k} {ids} -> fix chunking/embedding/hybrid"
# LAYER 2 — ranking: is it at rank 1, or buried?
rank = ids.index(gold["expected_id"]) + 1
# LAYER 3 — generation: feed ONLY the gold chunk; does the answer use it?
gold_chunk = [h for h in hits if h[0] == gold["expected_id"]]
ans = generate(gold["q"], gold_chunk)
if gold["must_contain"] not in ans:
return f"GENERATION failed: right chunk in context but answer missing '{gold['must_contain']}' -> fix prompt"
if rank != 1:
return f"RANKING weak: correct chunk retrieved at rank {rank} (not 1) -> add a reranker"
return "ALL LAYERS PASS on this case"
print(diagnose(GOLD, retrieve, generate))
# Inject a RANKING regression: same chunk, but retrieval now returns it at rank 2.
def retrieve_buried(q):
return [("faq#1", "Passwords must be 12+ characters."),
("faq#3", "To reset your password: Settings > Security > Reset.")]
print(diagnose(GOLD, retrieve_buried, generate))
ALL LAYERS PASS on this case
RANKING weak: correct chunk retrieved at rank 2 (not 1) -> add a reranker
This is the method that ties the challenge together: when the symptom is ambiguous, stop guessing and test each layer separately against one known-good case. The first failing check names your layer — the same discipline as measuring Recall@k and groundedness apart (Ch 3 / FA5).
GOLDis one trusted case: a question, the chunk you expect retrieved, and text the answer must contain.diagnose()checks layers in pipeline order — retrieval (is the expected chunk in the top-k?), ranking (is it rank 1?), then generation (given only that chunk, does the answer use it?).- It returns at the first failure with a layer-specific message and a fix — so you never patch the prompt when retrieval is the real problem.
- The second call injects a ranking regression (right chunk, now at rank 2) and the harness correctly flags "add a reranker" rather than blaming the model.
What the output means: First case: ALL LAYERS PASS. Second: RANKING weak … rank 2 (not 1) → add a reranker — the harness pinpointed the layer for you.
Try this: A lead runs this across a versioned golden set of 15–20 cases in CI, reporting per-layer pass rates so a regression names its own layer (§6).
§6 · Tech-lead: a standing RAG debugging discipline tech-lead
Here's the failure mode that outlives any single bug you fix. You heroically root-cause a retrieval regression on Tuesday, ship the fix, and move on. Three weeks later a different engineer changes the chunk size for an unrelated reason, and your bug silently comes back — because the only place your hard-won diagnosis lived was in your head. A fix that isn't encoded in a test will regress; it's not a question of if, only when. The difference between an engineer who debugs RAG and a lead who owns it is that the lead makes the fix permanent and other-people-proof.
The way you do that is to stop treating the §5 harness as a one-off diagnostic and turn it into standing infrastructure. Keep a versioned golden set — 15 to 20 (question, expected_chunk_id, expected_answer) cases — in the repo, and make every bug you ever fix become a new row in it. Run the layer-bisecting harness across the whole set on every change and report per-layer pass rates, so a regression doesn't just say "worse," it says which layer got worse. This is the same reason the eval chapters insist on separate metrics — Recall@k for retrieval, context precision for ranking, groundedness for generation — because a single end-to-end "is the answer good?" score can't tell you where to look.
The payoff is a translation that changes how your team operates. An unactionable report — "the RAG feels worse this week" — becomes an actionable one: "retrieval Recall@5 fell from 0.9 to 0.7 right after the chunking change." The first sentence starts a week of guessing; the second points straight at the fix. Wire the golden set into CI as an eval gate: if retrieval recall or generation groundedness drops below the last release, the merge is blocked, and the regression is caught by a machine before a user ever sees it. That's the whole arc of this challenge, hardened into a process — diagnose in pipeline order, reproduce, root-cause, verify, and then gate so it can't quietly return.
A lead doesn't debug RAG by intuition — they institutionalize the bisection. Keep a golden set of 15–20 (question, expected_chunk_id, expected_answer) cases in version control. On every change, run the layer harness from §5 across the set and report per-layer pass rates, so a regression names its own layer. Wire it into CI as an eval gate (FA5): retrieval Recall@k and generation groundedness must not drop. This turns "the RAG feels worse this week" — an unactionable report — into "retrieval Recall@5 fell from 0.9 to 0.7 after the chunking change," which points straight at the fix.
Read it left to right as a pipeline that runs on every change: the golden set feeds CI, CI runs the harness, and the gate decides whether the change ships.
- The left stack is the versioned golden set — 15–20
(question, expected_chunk_id, expected_answer)cases. The green row shows the discipline: every bug you fix becomes a new row, so it can never silently return. - The first amber box is CI running the §5 layer-bisecting harness across the whole set, reporting per-layer pass rates rather than one blurry end-to-end score.
- The second amber box is the eval gate checking whether retrieval recall and generation groundedness held.
- The red vs green outcomes are the payoff: a drop blocks the merge, a hold ships — so a regression is caught by a machine before a user ever sees it, and it announces which layer got worse.
In short: Institutionalize the bisection — a golden set plus a CI eval gate turns a fix that lived in your head into an enforceable contract that names its own layer when it breaks.
| Dimension | Meets bar | Above bar |
|---|---|---|
| Isolated the layer | Named a single layer (retrieval / ranking / chunking / generation) as the fault. | Bisected with a §5-style harness and cited the exact failing check. |
| Reproduced it | Rebuilt the failure in a tiny corpus before changing anything. | Reproduction is deterministic and minimal — one chunk / one prompt toggles it. |
| Root-caused vs symptom-patched | Fix addresses the cause, not the surface (no OR-everything, no prompt-band-aid on a retrieval bug). | Explained why the tempting quick patch would fail and what it would break. |
| Verified the fix | Re-ran the reproduction and showed the broken behavior is gone. | Added the case to a golden set + eval gate so the regression can't return silently. |
Score each row Meets / Above. Four Meets is a solid, hireable RAG debug. Four Above — reproduced, root-caused, verified, and gated — is how a lead keeps a RAG system honest over time.
A user reports the RAG "makes things up." You paste the exact prompt that was sent to the model and confirm the correct, answer-bearing chunk is present in the context — yet the answer is still wrong. Which layer is the fault, and what's the fix?
Show answer
Your RAG returns empty answers for questions the docs clearly cover, and separately returns confident but wrong answers when the query contains an exact error code like ERR-42. You test retrieval alone and see the code's chunk isn't in the top-k. What single change addresses both, and why?
Show answer
Challenge · diagnose your own broken RAG
Context: You only trust a debugging skill once you've reproduced the failure and confirmed your harness names the right layer. Deliberately breaking one layer and watching the diagnosis fire is how you validate the whole §5 approach.
Your task: Take a RAG you've built (or the Ch 3 pipeline), break exactly one layer, confirm the harness names it, fix it, re-run, and add the case to a golden set.
Requirements:
- Break exactly one layer at a time (e.g. shrink chunk size to split a fact, swap the query embedder, or weaken the system prompt)
- Run the §5 harness and confirm it names the layer you actually broke
- Fix it, re-run to green, and add the failing case to a golden set so it can't regress
- For each layer, write one sentence on how the same symptom would look if a different layer were the cause
💡 Hint: Change one variable, observe, revert — if the harness blames a layer you didn't touch, your diagnostic is what needs fixing first.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: When a RAG query comes back empty, the instinct is to blame the model or the prompt — but retrieval usually never got a fair chance. Knowing the two cheapest checks first saves you from debugging the wrong layer.
Your task: Name the two cheapest checks to run first on a zero-result query, and state what a passing result of each one actually rules out.
Requirements:
- Check 1 inspects whether the index/store is non-empty (ingestion succeeded)
- Check 2 inspects whether the query itself embeds to a finite vector of the expected dimension
- For each check, say what a pass proves so you can move on, not just what a fail means
- Conclude where the fault must lie if both pass (the search call, not the data or the query)
💡 Hint: Order the checks upstream-first: data present, then query well-formed, then the search call — don't touch scores until the first two pass.
Show solution
Empty results almost always means retrieval never got a fair chance, not that the answer is missing.
- Is the index non-empty? Count rows/vectors in the store. Zero means ingestion failed silently — the bug is upstream of retrieval.
- Does the query embed at all? Embed the query string alone and check you get a finite vector of the expected dimension. A crash or all-zeros vector means the query never reached the similarity search.
If both pass, the store has data and the query is a real vector, so the fault is in the search call (wrong index name, filter that matches nothing, or a distance/threshold cutoff). Only then move to inspecting scores.
Context: Retrieval that returns results but returns nonsense is more dangerous than empty results, because it looks like it's working. Two silent misconfigurations — mismatched embedding models and the wrong distance metric — produce garbage that is non-empty.
Your task: Design a single diagnostic that catches both an embedding-model mismatch and a wrong distance metric at once.
Requirements:
- Assert the query vector length equals the index dimension (a hard, silent bug some stores hide by padding/truncating)
- Use a self-retrieval test: embed one corpus chunk and query with its own text
- State the expected result (rank 1 at ~1.0 for cosine, ~0 for L2) and what a miss implies
- Explain how the one test simultaneously proves dimensions align and the metric is sane
💡 Hint: The chunk that should win most obviously is the chunk queried with its own text — if that isn't rank one, stop and check the metric before anything else.
Show solution
Two silent failures produce garbage-but-nonzero results: the query and the corpus were embedded by different models (dimensions or semantics differ), or the store is configured with the wrong distance metric (L2 where you assumed cosine).
- Assert
len(query_vec) == index.dim. A mismatch is a hard bug — you cannot compare vectors of different length; some stores pad/truncate silently. - Take one corpus chunk, embed it, and query with its own text. It must come back rank 1 with score ~1.0 (cosine) or ~0 (L2). If it does not, the metric is misconfigured.
This self-retrieval test simultaneously proves dimensions align and the metric is sane — a single query that exercises both assumptions.
Context: The subtlest retrieval bug is when the right chunk is retrieved but ranks 40th, so it never reaches the top-5 you hand the model. This is a ranking-quality problem, not a missing-data problem, and it has known fixes.
Your task: Give the diagnosis for a correct-but-low-ranked chunk and two concrete fixes, explaining the failure mode each one addresses.
Requirements:
- Name the root cause: single-vector dense retrieval losing on lexical / rare-token overlap (IDs, error codes) that embeddings blur
- Fix A: hybrid keyword/BM25 retrieval fused with dense — say what it recovers
- Fix B: a cross-encoder re-ranker over the top-N — say what it recovers
- State an order of preference and how you'd verify the lift (recall@k before/after)
💡 Hint: Short queries with exact identifiers are where dense retrieval quietly loses; measure the change on a held-out set rather than eyeballing one query.
Show solution
Root cause: single-vector dense retrieval is losing on lexical/keyword overlap — the right chunk is semantically near but a distractor scores marginally higher, often because the query is short or contains rare tokens (IDs, error codes) that dense embeddings blur.
| Fix | Addresses |
|---|---|
| Add BM25 / keyword retrieval and fuse (reciprocal rank fusion) | Rare tokens & exact IDs that dense models under-weight |
| Add a cross-encoder re-ranker over the top-50 | Fine-grained relevance the bi-encoder cannot express |
Order of preference: try the re-ranker first (biggest quality lift per line of code), add hybrid fusion when queries carry exact identifiers. Verify with recall@5 on a held-out set before and after.
Context: Sometimes the right chunk is provably in the prompt and the model still answers from its own priors. Presence in context is not the same as being used, and the causes are subtle enough to be worth ranking.
Your task: Diagnose why a model ignores context it was given, listing the causes in priority order with a fix per cause.
Requirements:
- Cover ‘lost in the middle’ — position of the chunk in a long context
- Cover a weak grounding instruction (no ‘answer only from context; else say you don't know’)
- Cover prior conflict — the model trusting its own stale answer
- Give a fix for each, and a way to confirm the fix (e.g. a planted contradictory context the model must now follow)
💡 Hint: A citation requirement is the strongest lever: forcing a chunk-id citation makes an ungrounded answer structurally hard to fake.
Show solution
The chunk being present is not the same as the model using it. Rank the causes:
- Lost in the middle: the chunk sits mid-context where attention is weakest. Fix: put retrieved context near the top or bottom, not buried; reduce the number of chunks.
- Weak instruction: the prompt does not say ‘answer only from the context; if absent, say you don't know.’ Fix: add that grounding instruction and a citation requirement.
- Prior conflict: the model ‘knows’ a different (stale) answer and trusts itself. Fix: force citation of the chunk id, which makes ungrounded answers structurally impossible to fake.
Confirm the fix by planting a context that contradicts common knowledge (a fictional fact) and checking the model now follows the context.
Context: A fix that lives only in one engineer's memory will regress. The professional move after any production retrieval bug is to encode it as an executable contract so the next deploy can't quietly reintroduce it.
Your task: Describe the eval harness and the alert you add so this class of bug is caught before the next deploy rather than by a user.
Requirements:
- Capture the failing query + the chunk that should win as a labelled (query, expected_chunk_id) pair — the bug becomes row 1 of a golden set
- Run retrieval over the golden set in CI and block merges on a recall@k drop
- Add an online alert on a signal that moves first (empty-result rate, p50 top score) when an index or embedding model silently changes
- Explain why the eval turns a memory into an enforceable guarantee
💡 Hint: Separate the offline gate (blocks the regression shipping) from the online alert (catches drift that only shows in production).
Show solution
A fix that is not encoded in an eval will regress. Production discipline:
- Golden set: capture the failing query + the chunk that should win as a labeled (query, expected_chunk_id) pair. Add the bug you just fixed as row 1.
- Offline eval: in CI, run retrieval over the golden set and assert recall@k does not drop below the last release. Block the merge on regression.
- Online alert: log per-query top score and #chunks returned; alert when the empty-result rate or the p50 top score crosses a threshold — these move first when an embedding model or index silently changes.
The eval turns a memory (‘we had this bug once’) into an executable contract.
Context: Embedding-model upgrades are the classic silent RAG incident: a scheduled re-index swaps the model, old documents keep old vectors, and the store now mixes two incompatible embedding spaces. Walking this as a runbook is a staff-level skill.
Your task: Walk a post-reindex quality collapse from alert to root cause to fix as a mini runbook, given that new documents are fine but older ones degraded.
Requirements:
- Isolate the layer with a self-retrieval test that passes on new docs and fails on old ones — proving mixed embedding spaces, not bad ranking
- Contain first: roll back to the previous index snapshot to restore quality
- Fix forward: re-embed all docs into a new index and atomically swap the alias only after a full recall@k eval passes — never mutate a live index
- Prevent: stamp every vector with its embedding-model version and assert one version per index
- State the lesson: an embedding upgrade is an all-or-nothing index migration, not a row update
💡 Hint: The tell for mixed spaces is a self-retrieval score around 0.3 instead of ~1.0 on the affected documents — that pins it to the index, not the query or model.
Show solution
Symptom: quality drop correlated with the re-index job; new docs fine, old docs bad.
Root cause: a partial re-index — new documents were embedded with model v2, but the job failed midway and old documents still hold v1 vectors. The store now mixes two incompatible embedding spaces, so cross-model similarity is meaningless. The query embeds with v2 and systematically loses against v1 chunks.
- Isolate the layer: self-retrieval test on a v1 doc fails (score ~0.3, not ~1.0) while a v2 doc passes — confirms mixed spaces, not ranking.
- Contain: roll the index back to the v1 snapshot; quality restored immediately.
- Fix forward: re-embed all documents with v2 into a new index; atomically swap the alias only after a full re-index + recall@k eval passes. Never mutate a live index in place.
- Prevent: stamp every vector with its
embed_model_version; the job asserts a single version per index before going live.
Lesson: embedding-model upgrades are index migrations, not row updates — they must be all-or-nothing.
✓ Checkpoint — you can move on when you can…
- Walk the failure decision tree and map a symptom to one layer.
- Reproduce empty results and trace them to AND-matching / vocab mismatch, not the model.
- Show a case where semantic search picks wrong and keyword (hybrid) wins.
- Distinguish a low-ranked-but-retrieved chunk (reranking) from a bad chunk (chunking).
- Prove a hallucination-over-good-context is a prompt bug, and fix it with grounding rules.
- Bisect a pipeline with a golden case and test retrieval separately from generation.