AI EngineeringZero to ProductionHome·About·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

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 chunk retrieved? no -> retrieval Ranked high enough? no -> reranking Chunk itself good? no -> chunking Generation used it? no -> prompt Pinpointed layer 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

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.

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 resetchange; 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

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.

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

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.

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

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.

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

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.

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

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.

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