Debug a broken RAG
A RAG system is returning wrong, hallucinated, and empty answers, and the team says "the model is bad." It usually isn't. Wrong answers hide in one of five layers — chunking, embedding, retrieval, ranking, or generation. This challenge is a systematic diagnosis: for each symptom you'll reproduce the failure in runnable code, root-cause it to a single layer, fix it, and verify — the way a staff engineer debugs a RAG in production.
Learning objectives
- Turn a vague "the RAG is wrong" report into a layer-specific diagnosis.
- Reproduce a failure in a tiny corpus before touching a fix.
- Distinguish a retrieval failure from a ranking failure from a generation failure.
- Root-cause instead of symptom-patch, and verify the fix on the reproduction.
- Test retrieval separately from generation to bisect the pipeline.
The RAG failure decision tree essential
Almost every bad RAG answer maps to exactly one layer. Walk the tree top to bottom — each question isolates the next layer, and the first "no" is your bug. The cardinal rule from Ch 3: most "hallucinations" are retrieval failures — the model answered honestly from the wrong or missing context.
This is the map for the whole challenge: a decision tree that turns a vague "the answer is wrong" into one specific broken layer. Read it left to right — each amber box is a yes/no question, and the first "no" tells you which layer to fix.
- Start at the red Bad answer — the symptom a user reports (wrong, made-up, or empty).
- Right chunk retrieved? If no, the generator never saw the answer — that's a retrieval bug (chunking, embedding, or query mismatch). §1–2.
- Ranked high enough? The chunk came back but sits too low to be used — a ranking bug; add a reranker. §3.
- Chunk itself good? It's retrieved and ranked, but the chunk is junk (a fact split across a boundary) — a chunking bug.
- Generation used it? The right chunk is in the prompt but the answer ignores it — a generation / prompt bug. §4. The green box is the payoff: one pinpointed layer, which you fix and verify.
In short: Diagnose in this order every time. A fault upstream (missing chunk) disguises itself as a fault downstream (looks like a hallucination) — so you check retrieval before you ever touch the prompt.
The branches: not retrieved → chunking / embedding / query mismatch (§1–2). Retrieved but ranked low → add reranking (§3). Chunk retrieved and ranked but the chunk is junk → chunking. Right chunk in context but ignored → generation / prompt (§4). When you can't tell, §5 shows how to test each layer in isolation.
§1 · Symptom: empty results essential
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.
s1_empty.py# SYMPTOM 1 — "no results / empty". User asks "How do I reset my password?"
# The corpus DOES contain the answer, but the retriever returns nothing.
CORPUS = [
"To change your account credentials, open Settings then Security.",
"Billing questions are handled by the finance portal.",
"Our office hours are 9am to 5pm on weekdays.",
]
def retrieve_broken(query, k=2):
# BUG: require EVERY query word to appear in the chunk (AND match).
terms = query.lower().split()
hits = [c for c in CORPUS if all(t in c.lower() for t in terms)]
return hits[:k]
print("BROKEN:", retrieve_broken("how do I reset my password"))
# Root cause: the answer chunk shares NO words with the query
# ("reset/password" vs "change/credentials"). AND-matching guarantees zero hits.
# The lazy patch (OR every word) makes "how","do","i" match everything -> noise.
STOP = {"how", "do", "i", "the", "a", "my", "to"}
def retrieve_fixed(query, k=2):
# FIX: drop stopwords, SCORE by term overlap, never hard-require all terms.
terms = [t for t in query.lower().split() if t not in STOP]
scored = []
for c in CORPUS:
cl = c.lower()
score = sum(1 for t in terms if t in cl)
if score:
scored.append((score, c))
scored.sort(key=lambda x: -x[0])
return [c for _, c in scored[:k]]
print("FIXED (lexical):", retrieve_fixed("password reset settings security"))
BROKEN: []
FIXED (lexical): ['To change your account credentials, open Settings then Security.']
This reproduces the most common false alarm in RAG: the system says "I don't have that information" for a question the docs clearly answer. The instinct is to blame the model — but the model never got a chunk. The bug is in the retriever.
retrieve_brokenrequires every query word to appear in a chunk (all(t in c.lower() ...)). The answer chunk says change credentials; the query says reset password — they share no words, so the AND-match returns[]. Empty retrieval → empty answer.- The tempting quick patch — OR the words together — is worse:
how,do,ithen match every chunk, trading empty results for noisy ones. retrieve_fixedis the honest fix: drop stopwords, then score chunks by term overlap instead of hard-requiring all terms. It surfaces the right chunk without matching everything.
What the output means: BROKEN: [] proves the retriever returns nothing; FIXED (lexical): ['To change your account credentials…'] shows the answer chunk recovered once you score instead of AND-match.
Try this: A pure lexical fix still can't bridge reset→change; that's what the semantic embedding in §2 is for. This challenge only proves the failure was retrieval, not generation.
how, do, i match every chunk and you've traded empty results for noisy ones. The root cause is a rigid AND-match plus no synonym bridge; the real fix is scored overlap now and a semantic embedding next (see §2).§2 · Symptom: retrieves the wrong thing intermediate
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.
s2_wrong.py# SYMPTOM 2 — "retrieves the WRONG thing". Query: "reset the API token".
# The embedding puts the query CLOSEST to a chunk about resetting a *password*
# (semantically adjacent but wrong), beating the chunk that actually documents
# API-token reset. Keyword search, which sees the exact words, fixes it.
import math
# Per-chunk/query embeddings assigned directly so the failure is deterministic
# and inspectable. Axis intuition: [account-security, api-credentials].
Q_VEC = (0.80, 0.60) # "reset the api token"
CORPUS = [ # (text, embedding)
("To reset your password, open Settings > Security.", (0.98, 0.20)), # distractor
("Reset an API token via the developer portal token page.", (0.30, 0.95)), # THE ANSWER
]
def cos(a, b):
dot = sum(x*y for x, y in zip(a, b))
na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
return round(dot/(na*nb), 3)
# BUG: semantic ranking. 'password reset' sits at a SMALLER angle to the query
# than the true 'api token' chunk -> the WRONG chunk is retrieved at rank 1.
sem = sorted(((cos(Q_VEC, v), t) for t, v in CORPUS), reverse=True)
print("SEMANTIC top:", sem[0][0], "->", sem[0][1])
def keyword(query):
terms = query.lower().split()
N = len(CORPUS)
df = {t: sum(1 for txt, _ in CORPUS if t in txt.lower()) for t in terms}
scored = [(round(sum(math.log((N+1)/(df[t]+0.5)) for t in terms if t in txt.lower()), 3), txt)
for txt, _ in CORPUS]
return sorted(scored, reverse=True)
# FIX: keyword search matches the rare exact words 'api'+'token' (df=1), which
# only the correct chunk contains -> it wins. In production you FUSE the two (hybrid).
kw = keyword("reset the api token")
print("KEYWORD top:", kw[0][0], "->", kw[0][1])
SEMANTIC top: 0.904 -> To reset your password, open Settings > Security.
KEYWORD top: 2.262 -> Reset an API token via the developer portal token page.
Here the retriever confidently returns a chunk — the wrong one. This is the classic case where pure vector search loses to keyword search, because two topics that are close in meaning (password reset vs API-token reset) sit near each other in embedding space.
- The embeddings are hand-assigned so the failure is deterministic: the query leans toward account-security, and the password distractor sits at a smaller angle to it than the correct API token chunk.
cos()ranks by that angle, so the semantic top result is the wrong chunk (0.904 > the correct chunk's score).keyword()scores by inverse document frequency: the rare exact wordsapiandtokenappear in only the correct chunk (df=1), so they dominate and the right chunk wins.
What the output means: SEMANTIC top … password (the mistake) vs KEYWORD top … API token (the fix). The exact rare terms are what the embedding couldn't weight.
Try this: The production answer isn't "keyword instead of vector" — it's hybrid: fuse both rankings with RRF (Ch 3 Lab 3.5 / FA6) so paraphrases and exact terms both surface.
The embedding ranks password above API token because the two are close in meaning-space, while the exact rare words api/token only live in the correct chunk. The fix isn't "keyword instead of vector" — it's hybrid: fuse both rankings (RRF, from Ch 3 Lab 3.5 / FA6) so exact terms and paraphrases both surface.
§3 · Symptom: right chunk retrieved but ranked low advanced
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.
s3_rank.py# SYMPTOM 3 — "the right chunk IS retrieved, but ranked too low to be used".
# First-stage retrieval returns a wide set; the answer-bearing chunk sits at rank 3.
# Generation only reads the top-1 chunk, so the answer is wrong. A reranker that
# scores query-chunk relevance promotes the right chunk to rank 1.
CANDIDATES = [ # (first_stage_score, text) -- as retrieval returned them
(0.71, "General security best practices: rotate credentials regularly."),
(0.68, "Passwords must be at least 12 characters with a symbol."),
(0.66, "To reset your password: Settings > Security > Reset, then check email."), # answer
]
QUESTION = "how do I reset my password"
def top1(cands):
return max(cands, key=lambda c: c[0])[1]
# BUG: feed only the rank-1 chunk to the model. It's the vague one, so the model
# can't answer (or invents). The answer chunk WAS retrieved but never used.
print("USED (no rerank):", top1(CANDIDATES))
def rerank(question, cands):
# Cheap cross-encoder stand-in: relevance = overlap of meaningful query terms.
STOP = {"how", "do", "i", "the", "a", "my", "to"}
q = {w for w in question.lower().split() if w not in STOP}
scored = [(sum(t in text.lower() for t in q), text) for _, text in cands]
scored.sort(key=lambda x: -x[0])
return scored
# FIX: rerank the WHOLE candidate set by real query relevance before selecting.
ranked = rerank(QUESTION, CANDIDATES)
print("RERANKED top:", ranked[0][1])
print("USED (rerank):", ranked[0][1])
USED (no rerank): General security best practices: rotate credentials regularly.
RERANKED top: To reset your password: Settings > Security > Reset, then check email.
USED (rerank): To reset your password: Settings > Security > Reset, then check email.
This is the sneakiest symptom: dump the top-10 and you can see the answer in the list — but the final answer is still wrong. The chunk was retrieved; it was just ranked too low for generation to read. That's a ranking failure, not a retrieval one.
CANDIDATEScomes back from first-stage retrieval with the vague "best practices" chunk scored highest and the actual password-reset chunk third.top1()feeds only the rank-1 chunk to the model — so the model gets the fluff and can't answer. The answer chunk was retrieved but never used.rerank()is a cheap stand-in for a cross-encoder: it rescores the whole candidate set by real query relevance (meaningful-term overlap) and promotes the correct chunk to rank 1.
What the output means: USED (no rerank) is the vague chunk; after reranking, USED (rerank) is the real password-reset chunk. Same retrieval, better ordering.
Try this: The pattern is the retrieval funnel: over-fetch wide and cheap (top 10–30), then rerank precisely to the top 3–5 before generation. Skipping the rerank is the most common reason a retrieved answer never gets used.
§4 · Symptom: right chunk in context, answer ignores it professional
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.
s4_prompt.py# SYMPTOM 4 — "the right chunk IS in context, but the answer ignores it / hallucinates".
# This is a GENERATION/PROMPT failure, not retrieval. We stub the LLM as a deterministic
# function so the bug is reproducible offline: a weak prompt lets the model fall back on
# its (stale) prior; a strict grounded prompt forces it to answer from context or refuse.
CONTEXT = "Refunds are processed within 5 business days." # correct, retrieved chunk
PRIOR = "Refunds are processed within 30 days." # model's stale training memory
def llm(prompt, context):
# Stub 'model': if the prompt does NOT pin it to the context, it answers from PRIOR.
grounded = "answer only using the context" in prompt.lower()
if grounded:
if context.strip():
return context # obeys: quotes the retrieved fact
return "I don't have that information." # honest refusal when context is empty
return PRIOR # weak prompt -> parametric hallucination
WEAK = "You are a helpful assistant. Question: how long do refunds take?"
STRICT = ("You are a support assistant. Answer only using the context below; "
"if it is not there, say you don't know.\nQuestion: how long do refunds take?")
# BUG: the correct chunk is right there in context, but the weak prompt lets the
# model answer from memory -> WRONG '30 days'.
print("WEAK prompt ->", llm(WEAK, CONTEXT))
# FIX: a grounding prompt forces the answer to come from the retrieved context.
print("STRICT prompt ->", llm(STRICT, CONTEXT))
# VERIFY the escape hatch: strict prompt AND empty context -> honest refusal, no invention.
print("STRICT + empty->", llm(STRICT, ""))
WEAK prompt -> Refunds are processed within 30 days.
STRICT prompt -> Refunds are processed within 5 business days.
STRICT + empty-> I don't have that information.
Now you've cleared retrieval and ranking: you logged the prompt and the correct fact is right there in the context — yet the answer is still wrong. This isolates a generation/prompt bug, reproduced with a deterministic stub model so it runs offline.
- The stub
llm()answers from its stalePRIOR("30 days") unless the prompt explicitly pins it to the context — exactly how a real model behaves under a weak prompt. WEAKjust says "helpful assistant," so the model ignores the supplied "5 business days" fact and hallucinates from memory.STRICTadds the two grounding rules from Ch 3 Lab 3.4 — answer only from context, and admit when it's missing — so the answer now quotes the retrieved fact.- The third print feeds STRICT + empty context and gets an honest refusal. That refusal is a feature: better to say "I don't have that" than to invent.
What the output means: Three lines: the weak prompt's wrong "30 days," the strict prompt's correct "5 business days," and the strict-prompt refusal on empty context.
Try this: If your real system hallucinates over good context, this is where the fix lives — in the prompt, not the retriever. Verify with an empty-context case that it refuses instead of inventing.
The two grounding rules from Ch 3 Lab 3.4 are the whole fix: (1) answer only from the supplied context, and (2) say you don't know when it's absent. The third print proves the escape hatch — a strict prompt with empty context refuses instead of inventing. That refusal is a feature, not a failure.
§5 · The systematic method: isolate the layer expert
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.
s5_isolate.py# SYMPTOM 5 — "the answer is wrong, but WHICH layer failed?" The systematic method:
# test retrieval and generation SEPARATELY against a known expected chunk, so you
# bisect the pipeline instead of guessing. One gold (question, expected_id, expected).
GOLD = {"q": "how do I reset my password",
"expected_id": "faq#3",
"must_contain": "Settings > Security"}
# --- stubs you can swap for the real retriever / generator ---
def retrieve(q):
return [("faq#3", "To reset your password: Settings > Security > Reset."),
("faq#1", "Passwords must be 12+ characters.")]
def generate(q, context):
# grounded stub: answers from context if a chunk is present, else refuses
return context[0][1] if context else "I don't have that information."
def diagnose(gold, retrieve, generate, k=2):
hits = retrieve(gold["q"])
ids = [i for i, _ in hits][:k]
# LAYER 1 — retrieval: did the expected chunk make the top-k at all?
if gold["expected_id"] not in ids:
return f"RETRIEVAL failed: {gold['expected_id']} not in top-{k} {ids} -> fix chunking/embedding/hybrid"
# LAYER 2 — ranking: is it at rank 1, or buried?
rank = ids.index(gold["expected_id"]) + 1
# LAYER 3 — generation: feed ONLY the gold chunk; does the answer use it?
gold_chunk = [h for h in hits if h[0] == gold["expected_id"]]
ans = generate(gold["q"], gold_chunk)
if gold["must_contain"] not in ans:
return f"GENERATION failed: right chunk in context but answer missing '{gold['must_contain']}' -> fix prompt"
if rank != 1:
return f"RANKING weak: correct chunk retrieved at rank {rank} (not 1) -> add a reranker"
return "ALL LAYERS PASS on this case"
print(diagnose(GOLD, retrieve, generate))
# Inject a RANKING regression: same chunk, but retrieval now returns it at rank 2.
def retrieve_buried(q):
return [("faq#1", "Passwords must be 12+ characters."),
("faq#3", "To reset your password: Settings > Security > Reset.")]
print(diagnose(GOLD, retrieve_buried, generate))
ALL LAYERS PASS on this case
RANKING weak: correct chunk retrieved at rank 2 (not 1) -> add a reranker
This is the method that ties the challenge together: when the symptom is ambiguous, stop guessing and test each layer separately against one known-good case. The first failing check names your layer — the same discipline as measuring Recall@k and groundedness apart (Ch 3 / FA5).
GOLDis one trusted case: a question, the chunk you expect retrieved, and text the answer must contain.diagnose()checks layers in pipeline order — retrieval (is the expected chunk in the top-k?), ranking (is it rank 1?), then generation (given only that chunk, does the answer use it?).- It returns at the first failure with a layer-specific message and a fix — so you never patch the prompt when retrieval is the real problem.
- The second call injects a ranking regression (right chunk, now at rank 2) and the harness correctly flags "add a reranker" rather than blaming the model.
What the output means: First case: ALL LAYERS PASS. Second: RANKING weak … rank 2 (not 1) → add a reranker — the harness pinpointed the layer for you.
Try this: A lead runs this across a versioned golden set of 15–20 cases in CI, reporting per-layer pass rates so a regression names its own layer (§6).
§6 · Tech-lead: a standing RAG debugging discipline tech-lead
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.
| Dimension | Meets bar | Above bar |
|---|---|---|
| Isolated the layer | Named a single layer (retrieval / ranking / chunking / generation) as the fault. | Bisected with a §5-style harness and cited the exact failing check. |
| Reproduced it | Rebuilt the failure in a tiny corpus before changing anything. | Reproduction is deterministic and minimal — one chunk / one prompt toggles it. |
| Root-caused vs symptom-patched | Fix addresses the cause, not the surface (no OR-everything, no prompt-band-aid on a retrieval bug). | Explained why the tempting quick patch would fail and what it would break. |
| Verified the fix | Re-ran the reproduction and showed the broken behavior is gone. | Added the case to a golden set + eval gate so the regression can't return silently. |
Score each row Meets / Above. Four Meets is a solid, hireable RAG debug. Four Above — reproduced, root-caused, verified, and gated — is how a lead keeps a RAG system honest over time.
A user reports the RAG "makes things up." You paste the exact prompt that was sent to the model and confirm the correct, answer-bearing chunk is present in the context — yet the answer is still wrong. Which layer is the fault, and what's the fix?
Show answer
Your RAG returns empty answers for questions the docs clearly cover, and separately returns confident but wrong answers when the query contains an exact error code like ERR-42. You test retrieval alone and see the code's chunk isn't in the top-k. What single change addresses both, and why?
Show answer
Challenge · diagnose your own broken RAG
Context: You only trust a debugging skill once you've reproduced the failure and confirmed your harness names the right layer. Deliberately breaking one layer and watching the diagnosis fire is how you validate the whole §5 approach.
Your task: Take a RAG you've built (or the Ch 3 pipeline), break exactly one layer, confirm the harness names it, fix it, re-run, and add the case to a golden set.
Requirements:
- Break exactly one layer at a time (e.g. shrink chunk size to split a fact, swap the query embedder, or weaken the system prompt)
- Run the §5 harness and confirm it names the layer you actually broke
- Fix it, re-run to green, and add the failing case to a golden set so it can't regress
- For each layer, write one sentence on how the same symptom would look if a different layer were the cause
💡 Hint: Change one variable, observe, revert — if the harness blames a layer you didn't touch, your diagnostic is what needs fixing first.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: When a RAG query comes back empty, the instinct is to blame the model or the prompt — but retrieval usually never got a fair chance. Knowing the two cheapest checks first saves you from debugging the wrong layer.
Your task: Name the two cheapest checks to run first on a zero-result query, and state what a passing result of each one actually rules out.
Requirements:
- Check 1 inspects whether the index/store is non-empty (ingestion succeeded)
- Check 2 inspects whether the query itself embeds to a finite vector of the expected dimension
- For each check, say what a pass proves so you can move on, not just what a fail means
- Conclude where the fault must lie if both pass (the search call, not the data or the query)
💡 Hint: Order the checks upstream-first: data present, then query well-formed, then the search call — don't touch scores until the first two pass.
Show solution
Empty results almost always means retrieval never got a fair chance, not that the answer is missing.
- Is the index non-empty? Count rows/vectors in the store. Zero means ingestion failed silently — the bug is upstream of retrieval.
- Does the query embed at all? Embed the query string alone and check you get a finite vector of the expected dimension. A crash or all-zeros vector means the query never reached the similarity search.
If both pass, the store has data and the query is a real vector, so the fault is in the search call (wrong index name, filter that matches nothing, or a distance/threshold cutoff). Only then move to inspecting scores.
Context: Retrieval that returns results but returns nonsense is more dangerous than empty results, because it looks like it's working. Two silent misconfigurations — mismatched embedding models and the wrong distance metric — produce garbage that is non-empty.
Your task: Design a single diagnostic that catches both an embedding-model mismatch and a wrong distance metric at once.
Requirements:
- Assert the query vector length equals the index dimension (a hard, silent bug some stores hide by padding/truncating)
- Use a self-retrieval test: embed one corpus chunk and query with its own text
- State the expected result (rank 1 at ~1.0 for cosine, ~0 for L2) and what a miss implies
- Explain how the one test simultaneously proves dimensions align and the metric is sane
💡 Hint: The chunk that should win most obviously is the chunk queried with its own text — if that isn't rank one, stop and check the metric before anything else.
Show solution
Two silent failures produce garbage-but-nonzero results: the query and the corpus were embedded by different models (dimensions or semantics differ), or the store is configured with the wrong distance metric (L2 where you assumed cosine).
- Assert
len(query_vec) == index.dim. A mismatch is a hard bug — you cannot compare vectors of different length; some stores pad/truncate silently. - Take one corpus chunk, embed it, and query with its own text. It must come back rank 1 with score ~1.0 (cosine) or ~0 (L2). If it does not, the metric is misconfigured.
This self-retrieval test simultaneously proves dimensions align and the metric is sane — a single query that exercises both assumptions.
Context: The subtlest retrieval bug is when the right chunk is retrieved but ranks 40th, so it never reaches the top-5 you hand the model. This is a ranking-quality problem, not a missing-data problem, and it has known fixes.
Your task: Give the diagnosis for a correct-but-low-ranked chunk and two concrete fixes, explaining the failure mode each one addresses.
Requirements:
- Name the root cause: single-vector dense retrieval losing on lexical / rare-token overlap (IDs, error codes) that embeddings blur
- Fix A: hybrid keyword/BM25 retrieval fused with dense — say what it recovers
- Fix B: a cross-encoder re-ranker over the top-N — say what it recovers
- State an order of preference and how you'd verify the lift (recall@k before/after)
💡 Hint: Short queries with exact identifiers are where dense retrieval quietly loses; measure the change on a held-out set rather than eyeballing one query.
Show solution
Root cause: single-vector dense retrieval is losing on lexical/keyword overlap — the right chunk is semantically near but a distractor scores marginally higher, often because the query is short or contains rare tokens (IDs, error codes) that dense embeddings blur.
| Fix | Addresses |
|---|---|
| Add BM25 / keyword retrieval and fuse (reciprocal rank fusion) | Rare tokens & exact IDs that dense models under-weight |
| Add a cross-encoder re-ranker over the top-50 | Fine-grained relevance the bi-encoder cannot express |
Order of preference: try the re-ranker first (biggest quality lift per line of code), add hybrid fusion when queries carry exact identifiers. Verify with recall@5 on a held-out set before and after.
Context: Sometimes the right chunk is provably in the prompt and the model still answers from its own priors. Presence in context is not the same as being used, and the causes are subtle enough to be worth ranking.
Your task: Diagnose why a model ignores context it was given, listing the causes in priority order with a fix per cause.
Requirements:
- Cover ‘lost in the middle’ — position of the chunk in a long context
- Cover a weak grounding instruction (no ‘answer only from context; else say you don't know’)
- Cover prior conflict — the model trusting its own stale answer
- Give a fix for each, and a way to confirm the fix (e.g. a planted contradictory context the model must now follow)
💡 Hint: A citation requirement is the strongest lever: forcing a chunk-id citation makes an ungrounded answer structurally hard to fake.
Show solution
The chunk being present is not the same as the model using it. Rank the causes:
- Lost in the middle: the chunk sits mid-context where attention is weakest. Fix: put retrieved context near the top or bottom, not buried; reduce the number of chunks.
- Weak instruction: the prompt does not say ‘answer only from the context; if absent, say you don't know.’ Fix: add that grounding instruction and a citation requirement.
- Prior conflict: the model ‘knows’ a different (stale) answer and trusts itself. Fix: force citation of the chunk id, which makes ungrounded answers structurally impossible to fake.
Confirm the fix by planting a context that contradicts common knowledge (a fictional fact) and checking the model now follows the context.
Context: A fix that lives only in one engineer's memory will regress. The professional move after any production retrieval bug is to encode it as an executable contract so the next deploy can't quietly reintroduce it.
Your task: Describe the eval harness and the alert you add so this class of bug is caught before the next deploy rather than by a user.
Requirements:
- Capture the failing query + the chunk that should win as a labelled (query, expected_chunk_id) pair — the bug becomes row 1 of a golden set
- Run retrieval over the golden set in CI and block merges on a recall@k drop
- Add an online alert on a signal that moves first (empty-result rate, p50 top score) when an index or embedding model silently changes
- Explain why the eval turns a memory into an enforceable guarantee
💡 Hint: Separate the offline gate (blocks the regression shipping) from the online alert (catches drift that only shows in production).
Show solution
A fix that is not encoded in an eval will regress. Production discipline:
- Golden set: capture the failing query + the chunk that should win as a labeled (query, expected_chunk_id) pair. Add the bug you just fixed as row 1.
- Offline eval: in CI, run retrieval over the golden set and assert recall@k does not drop below the last release. Block the merge on regression.
- Online alert: log per-query top score and #chunks returned; alert when the empty-result rate or the p50 top score crosses a threshold — these move first when an embedding model or index silently changes.
The eval turns a memory (‘we had this bug once’) into an executable contract.
Context: Embedding-model upgrades are the classic silent RAG incident: a scheduled re-index swaps the model, old documents keep old vectors, and the store now mixes two incompatible embedding spaces. Walking this as a runbook is a staff-level skill.
Your task: Walk a post-reindex quality collapse from alert to root cause to fix as a mini runbook, given that new documents are fine but older ones degraded.
Requirements:
- Isolate the layer with a self-retrieval test that passes on new docs and fails on old ones — proving mixed embedding spaces, not bad ranking
- Contain first: roll back to the previous index snapshot to restore quality
- Fix forward: re-embed all docs into a new index and atomically swap the alias only after a full recall@k eval passes — never mutate a live index
- Prevent: stamp every vector with its embedding-model version and assert one version per index
- State the lesson: an embedding upgrade is an all-or-nothing index migration, not a row update
💡 Hint: The tell for mixed spaces is a self-retrieval score around 0.3 instead of ~1.0 on the affected documents — that pins it to the index, not the query or model.
Show solution
Symptom: quality drop correlated with the re-index job; new docs fine, old docs bad.
Root cause: a partial re-index — new documents were embedded with model v2, but the job failed midway and old documents still hold v1 vectors. The store now mixes two incompatible embedding spaces, so cross-model similarity is meaningless. The query embeds with v2 and systematically loses against v1 chunks.
- Isolate the layer: self-retrieval test on a v1 doc fails (score ~0.3, not ~1.0) while a v2 doc passes — confirms mixed spaces, not ranking.
- Contain: roll the index back to the v1 snapshot; quality restored immediately.
- Fix forward: re-embed all documents with v2 into a new index; atomically swap the alias only after a full re-index + recall@k eval passes. Never mutate a live index in place.
- Prevent: stamp every vector with its
embed_model_version; the job asserts a single version per index before going live.
Lesson: embedding-model upgrades are index migrations, not row updates — they must be all-or-nothing.
✓ Checkpoint — you can move on when you can…
- Walk the failure decision tree and map a symptom to one layer.
- Reproduce empty results and trace them to AND-matching / vocab mismatch, not the model.
- Show a case where semantic search picks wrong and keyword (hybrid) wins.
- Distinguish a low-ranked-but-retrieved chunk (reranking) from a bad chunk (chunking).
- Prove a hallucination-over-good-context is a prompt bug, and fix it with grounding rules.
- Bisect a pipeline with a golden case and test retrieval separately from generation.