AI EngineeringZero to ProductionHome·About·What’s new·Contact
Advanced Challenges · Part 1

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.

⏱️ ~2 hours🧪 5 challenges🎯 Advanced
How this challenge worksYou're handed a RAG system that returns wrong, hallucinated, or empty answers. Your job is not to rewrite it — it's to find which layer is broken. Each section below is a symptom; you'll reproduce the failure with runnable stdlib Python, root-cause it to a single layer, and apply the minimal fix. If you haven't built RAG yet, do Ch 3 first — this assumes it.

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:

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.

Bad answer symptom Right chunkretrieved? no -> retrieval Ranked highenough? no -> reranking Chunk itselfgood? no -> chunking Generationused it? no -> prompt Pinpointedlayer fix + verify
🗺️ How to read this diagram

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.

BROKEN · require every word (AND) "how do I reset my password" no chunk has ALL words [ ] empty result → generator gets nothing → refuses FIXED · drop stopwords + score overlap password · reset · settings count meaningful-word overlap ranked, non-empty "change credentials" chunk surfaces Empty answer = empty retrieval. An AND-match demands every query word appear in a chunk, so one missing word returns []; dropping stopwords and scoring by overlap turns the brittle yes/no filter into a ranked list that surfaces the right chunk.
🗺️ How to read this diagram

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.

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.
Challenge 1 · reproduce empty retrieval, then fix it (runs)
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.']
▶ How this works

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.

  1. retrieve_broken requires 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.
  2. The tempting quick patch — OR the words together — is worse: how, do, i then match every chunk, trading empty results for noisy ones.
  3. retrieve_fixed is 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.

Symptom-patch vs root-causeThe lazy fix is to OR every query word together — but then 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.

meaning-space: closeness = similar meaning — the query sits between two neighbours api-credentials account-security query "reset the api token" password chunk nearest ✗ wrong api-token chunk the answer — farther angle keyword IDF: rare "api"+"token" appear only in the answer → it wins Semantic adjacency picks the wrong neighbour. The query sits at a smaller angle to the password chunk than to the correct API-token chunk, so pure vector search ranks the wrong one first — while keyword IDF, which prizes the rare exact tokens the embedding blurred, surfaces the right chunk.
🗺️ How to read this diagram

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 api and token huge 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.

Challenge 2 · semantic picks wrong, keyword wins (runs)
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.
▶ How this works

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.

  1. 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.
  2. cos() ranks by that angle, so the semantic top result is the wrong chunk (0.904 > the correct chunk's score).
  3. keyword() scores by inverse document frequency: the rare exact words api and token appear 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.

1 · over-fetch (recall) best-practices · rank 1 password rules · rank 2 the answer · rank 3 … 10–30 more reranker query×chunk score 2 · narrow top-k → generation the answer · now rank 1 password rules · rank 2 no reranker → model reads only rank 1 (best-practices fluff) → answer wrong with reranker → the buried answer climbs to rank 1 → answer right The retrieval funnel. First-stage retrieval optimizes recall and returns a wide set, but the answer can sit at rank 3; a reranker rescores by true query–chunk relevance so the precise chunk reaches the narrow top-k generation actually reads.
🗺️ How to read this diagram

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.

Challenge 3 · promote a buried chunk with a reranker (runs)
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.
▶ How this works

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.

  1. CANDIDATES comes back from first-stage retrieval with the vague "best practices" chunk scored highest and the actual password-reset chunk third.
  2. 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.
  3. 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.

The retrieval funnelOver-fetch wide and cheap (top 10–30), then rerank precisely to the top 3–5 before generation. First-stage similarity optimizes recall; the reranker optimizes precision. Skipping the rerank is the single 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.

retrieved context "5 business days" ✓ parametric memory "30 days" (stale) ✗ model which to trust? weak prompt → "30 days" ✗ grounded prompt → "5 days" ✓ / refuse the prompt is the switch that decides which source wins The prompt decides which knowledge wins. The model holds two conflicting sources — the retrieved context and stale parametric memory; a vague prompt lets it default to its fluent prior, while two grounding rules (answer only from context; else say you don't know) pin it to the retrieved fact.
🗺️ How to read this diagram

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

Challenge 4 · a weak prompt hallucinates over good context (runs)
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.
▶ How this works

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.

  1. The stub llm() answers from its stale PRIOR ("30 days") unless the prompt explicitly pins it to the context — exactly how a real model behaves under a weak prompt.
  2. WEAK just says "helpful assistant," so the model ignores the supplied "5 business days" fact and hallucinates from memory.
  3. STRICT adds 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.
  4. 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.

gold case q · id · answer retrieval id in top-k? ranking at rank 1? generation gold chunk alone → uses? no → RETRIEVAL bug no → RANKING bug no → PROMPT bug walk in pipeline order — the FIRST "no" is your layer, then stop Bisect the pipeline against a gold case. Each layer is tested in order against one trusted (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.
🗺️ How to read this diagram

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.

Challenge 5 · a layer-bisecting diagnosis harness (runs)
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
▶ How this works

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

  1. GOLD is one trusted case: a question, the chunk you expect retrieved, and text the answer must contain.
  2. 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?).
  3. 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.
  4. 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).

Measure each layer separatelyThe reason this method works is the same reason Ch 3 and FA5 insist on separate metrics: Recall@k grades retrieval, context precision grades ranking, and groundedness / answer-relevance grade generation. A single end-to-end "is the answer good?" number can't tell you which layer to fix. Test retrieval alone, first — no prompt can rescue context that was never retrieved.

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

golden set (versioned) case 1 (past bug) case 2 … + new fix = new row … 15–20 cases CI: layer harness per-layer pass rate eval gate recall / grounded? drop → block merge ✗ holds → ship ✓ a regression names its own layer — caught by a machine, not a user From one-off fix to standing guarantee. Every bug becomes a row in a versioned golden set; CI runs the §5 layer harness across it and reports per-layer pass rates, and an eval gate blocks the merge when retrieval recall or generation groundedness drops below the last release.
🗺️ How to read this diagram

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.

The debugging order is the pipeline orderAlways diagnose in pipeline order — retrieval, then ranking, then generation — because a fault upstream masquerades as a fault downstream. A missing chunk looks like a hallucination; a buried chunk looks like a bad prompt. Fixing the prompt when retrieval is the problem is the most common wasted week in RAG work.
📋 Grade your debugging
DimensionMeets barAbove bar
Isolated the layerNamed a single layer (retrieval / ranking / chunking / generation) as the fault.Bisected with a §5-style harness and cited the exact failing check.
Reproduced itRebuilt the failure in a tiny corpus before changing anything.Reproduction is deterministic and minimal — one chunk / one prompt toggles it.
Root-caused vs symptom-patchedFix 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 fixRe-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.

✓ Knowledge check

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
It's a generation / prompt failure, not retrieval. The right chunk was retrieved, ranked, and placed in context, so retrieval and ranking are fine. The model is ignoring the context and answering from its parametric memory. Fix the prompt with the two grounding rules: answer only from the supplied context, and say "I don't have that information" when it's absent (Ch 3 Lab 3.4). Verify with an empty-context case that it now refuses instead of inventing.
✓ Knowledge check

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
Add hybrid search — fuse dense (vector) retrieval with sparse keyword/BM25 retrieval via RRF. The empty answers come from the vector side missing paraphrase/vocab mismatches; the wrong-code answers come from vector search being fooled by semantic adjacency while it can't weight a rare exact token. Keyword search surfaces exact rare terms (high IDF) and OR-style recall; fusing the two lifts the correct chunk into the top-k in both cases. Then confirm Recall@k improved on your golden set.

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.

Exercise 1 · Empty results: first two checksBeginner

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.

  1. Is the index non-empty? Count rows/vectors in the store. Zero means ingestion failed silently — the bug is upstream of retrieval.
  2. 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.

Exercise 2 · Combine: dimension mismatch + wrong metricIntermediate

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

  1. Assert len(query_vec) == index.dim. A mismatch is a hard bug — you cannot compare vectors of different length; some stores pad/truncate silently.
  2. 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.

Exercise 3 · Right chunk retrieved but ranked lowAdvanced

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.

FixAddresses
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-50Fine-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.

Exercise 4 · Answer ignores the retrieved contextExpert

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:

  1. 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.
  2. 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.
  3. 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.

Exercise 5 · Turn a one-off fix into a regression guardProfessional

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:

  1. 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.
  2. 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.
  3. 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.

Exercise 6 · Post-reindex quality collapseIndustry scenario

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.

  1. 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.
  2. Contain: roll the index back to the v1 snapshot; quality restored immediately.
  3. 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.
  4. 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.
© 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