Look at the Passages First
An incident arrives as: "The bot told me parental leave is 20 weeks; the handbook says 14." The instinct — shared by almost every new team — is to open the prompt and start rewording it. That instinct is wrong often enough to be expensive, because a generation-side edit cannot fix evidence that never reached the model.
The first question is not "what did the model say?" but "what did the model see?" Log the three ids together and the argument becomes mechanical:
answer supported by the prompt?
|
+-- gold passage in the prompt? --+-- no --> in the retrieved set?
| | |-- no --> RETRIEVAL
| | | (chunking, embeddings,
| | | query rewriting)
| | +-- yes --> ASSEMBLY
| | (evicted by dedupe or budget)
| |
| +-- yes --> GENERATION
| (instruction, model, decoding)
|
+-- yes --> SOURCE or LABEL
(gold annotation wrong, document stale)
Read it as a series of eliminations. Retrieval is guilty when the evidence never entered the top-k. Assembly is guilty when retrieval found it and the token budget threw it away — a distinct bug with a distinct fix, and invisible unless you log prompt_pids separately from retrieved. Generation is guilty only once the right passage is provably sitting in the prompt.
The traversal itself is code, and it is the first thing to run on any report:
import re
from collections import Counter
REFUSAL = "Not covered by the provided documents."
def attribute(log: dict) -> str:
gold = set(log.get("gold_pids", []))
retrieved = {p["pid"] for p in log["retrieved"]}
prompt = set(log["prompt_pids"])
answer = log["answer"].strip()
if not gold:
return "eval-gap: no reference passage logged"
if not (gold & retrieved):
return "retrieval: gold passage outside top-k"
if not (gold & prompt):
return "assembly: retrieved then evicted before generation"
if answer == REFUSAL:
return "generation: refused despite evidence in the prompt"
cited = {int(m) for m in re.findall(r"\[(\d+)\]", answer)}
if cited - set(range(1, len(prompt) + 1)):
return "generation: citation id not in the prompt"
if re.search(r"\d", answer) and not cited:
return "generation: numeric claim with no marker"
return "source-or-label: prompt supported the answer"
Input: a log whose gold_pids = ["handbook:9a17:2"], retrieved contains it, prompt_pids does not. Output:
'assembly: retrieved then evicted before generation'
Every branch names a different owner. Fixing an assembly eviction by rewriting the prompt produces a second incident a week later, and the log would have said so.
The Failure Catalogue
These are the entries that recur across RAG systems. Severity is not how likely the failure is — it is what happens to the user when it fires:
- critical — a confidently wrong answer is served, or a citation is fabricated
- high — a common path returns wrong or missing information
- medium — quality degrades but the answer is still usable
| Failure | Stage | Severity | Detection signal |
|---|---|---|---|
| Stale index after a document edit | ingestion | high | source hash ≠ stored doc_hash, freshness metric drops |
| Parser flattened a table or heading | ingestion | medium | extraction coverage; sampled chunks read as word soup |
| Chunk boundary split a row from its header | ingestion | medium | citation quotes a number with no column |
| Vocabulary mismatch (query "severance", doc "exit package") | retrieval | high | recall@k low on a slice; keyword-only hits |
| Duplicate chunks starve top-k | retrieval | medium | duplicate rate inside retrieved sets |
| Gold passage evicted by the token budget | assembly | high | retrieved ∌ prompt_pids diff |
| Dilution — many weak passages, one good one | assembly | medium | context-relevance score; answer drifts toward filler |
| Model answers from memory over correct context | generation | critical | faithfulness low while recall@k is healthy |
| Fabricated citation id | generation | critical | cited − valid non-empty |
| Refusal on a question it had evidence for | generation | medium | exact refusal string on cases with gold in prompt_pids |
| Wrong or missing gold label | eval | medium | incidents where attribute returns source-or-label |
Two patterns are worth memorizing because they invert the obvious fix:
High recall, low faithfulness means retrieval is fine and generation is drifting — a prompt, model, or temperature problem. Re-chunking makes it worse by changing the one half that already works.
Low recall on one slice only — the benefits questions, the German pages — usually means vocabulary mismatch or a parse defect on that document type, not a global embedding problem. The slice tells you where to look before you change anything global.
Diagnosis that stops at "it's the model" is a coin flip. Diagnosis that names a stage and a signal is an actionable ticket, and the difference is which log fields you captured.
Replay the Incident Offline
A report you cannot replay is a report you can only fix once. Store the retrieved set with the answer at request time, and every incident becomes a reproducible input to attribute() long after the request finished.
def replay(logs: list[dict]) -> dict:
counts = Counter(attribute(log) for log in logs)
return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
Input: four stored logs — one with gold outside top-k, one retrieved-then-evicted, one refusal despite evidence, one clean. Output:
{'retrieval: gold passage outside top-k': 1,
'assembly: retrieved then evicted before generation': 1,
'generation: refused despite evidence in the prompt': 1,
'source-or-label: prompt supported the answer': 1}
Replaying a batch rather than a single case changes the decision you make. One retrieval failure might be a fluke; four incidents bucketing into three different stages says your problems are not concentrated, and a single global knob change will not fix them.
The same replay is also where fixes get validated. After re-chunking or raising the budget, re-run attribute() over the stored logs and compare bucket counts — a fix that moves incidents between buckets rather than reducing them is a fix that renamed the problem. That comparison loop, with a frozen question set and recorded metric deltas per change, is what the RAG iteration workflow topic formalizes.
Minimum fields to log per request: query, retrieved (pid, score, section), prompt_pids, answer, model, prompt_version, gold_pids where an eval label exists, and a timestamp. Missing prompt_pids is the single most common gap — without it, assembly and retrieval failures are indistinguishable and every diagnosis collapses into "retrieval was bad."
Quiz
1. An answer contradicts the handbook. You can log three things: retrieved pids, prompt pids, and the answer. What is the first comparison?
2. A gold passage is in the retrieved set but not in prompt_pids. Which stage failed, and what usually caused it?
3. What does "high retrieval recall with low answer faithfulness" tell you?
4. A batch replay shows incidents landing in four different attribute() buckets. What does that imply?
Flashcards
Question
What is the first question when a RAG answer is wrong, and why?
Click to reveal answer
Answer
"What did the model see?" — inspect retrieved and prompt ids before touching the prompt. A generation-side edit cannot repair evidence that never reached the model.
Question
The four attribution outcomes of the passage-first traversal
Click to reveal answer
Answer
gold absent from top-k → retrieval; gold retrieved but not in the prompt → assembly; gold in prompt but answer departs → generation; prompt supports the answer → source or gold label.
Question
Why log prompt_pids separately from retrieved?
Click to reveal answer
Answer
Because dedupe and the token budget can evict a passage retrieval found. Without both lists, assembly failures are indistinguishable from retrieval failures and every diagnosis collapses to "retrieval was bad."
Question
How is severity defined in the failure catalogue?
Click to reveal answer
Answer
By user impact when the failure fires, not by likelihood: critical = confidently wrong answer or fabricated citation; high = common path returns wrong or missing information; medium = degraded but usable.
Question
What makes an incident report fixable rather than anecdotal?
Click to reveal answer
Answer
The full request context stored with the answer — query, retrieved pids with scores, prompt pids, answer, model, prompt version, gold labels. The log is replayable offline, so the fix can be validated against the same inputs.
Revision Notes
Key Takeaways
- 1. Start every bad-answer report with what the model saw, not what it said — stage attribution is set arithmetic on logged ids
- 2. Log prompt ids separately from retrieved ids or assembly evictions become permanently misdiagnosed as retrieval failures
- 3. Severity measures user impact, not likelihood; that is what makes the catalogue triageable
- 4. High recall with low faithfulness indicts generation; low recall on a slice indicts vocabulary or parsing on those documents
- 5. A replayed batch tells you whether problems concentrate (one fix) or spread (fix the largest bucket, then re-measure)
Interview Tips
- • Walk an incident through the tree out loud, naming the log field that decides each branch — the traversal, not the vocabulary, is what gets evaluated
- • Defend the order: passages before prompts. Be ready with the counter-example where rewording the prompt could not have helped
- • Explain what you would log and why prompt ids are the field teams most often omit
- • Connect replay to iteration: same stored incidents, re-run after a change, compare buckets — that sentence signals you have shipped this before
Cheat Sheet
Diagnosing RAG Failure Modes — Cheat Sheet
Triage: passages first, prompts last
gold in prompt? --+-- no --> in retrieved? --+-- no --> RETRIEVAL
| +-- yes --> ASSEMBLY (evicted)
+-- yes --> answer supported? --+-- no --> GENERATION
+-- yes --> SOURCE / LABEL
def attribute(log):
gold, retrieved = set(log.get("gold_pids", [])), {p["pid"] for p in log["retrieved"]}
prompt, answer = set(log["prompt_pids"]), log["answer"].strip()
if not gold: return "eval-gap"
if not (gold & retrieved): return "retrieval"
if not (gold & prompt): return "assembly"
if answer == REFUSAL: return "generation: refusal despite evidence"
cited = {int(m) for m in re.findall(r"\[(\d+)\]", answer)}
if cited - set(range(1, len(prompt) + 1)):return "generation: bad citation id"
if re.search(r"\d", answer) and not cited:return "generation: unattributed claim"
return "source-or-label"
Catalogue shortcuts (stage / severity)
| Signal | Points at |
|---|---|
| recall@k low on one slice only | retrieval — vocabulary mismatch or parse defect on that doc type |
| duplicates crowding top-k | retrieval/assembly — dedupe threshold or doc fan-out |
| gold in retrieved, absent from prompt | assembly — budget eviction |
| recall healthy, faithfulness low | generation — instruction, model, decoding |
cited − valid non-empty |
generation — fabricated marker, never serve it |
| refusal with gold in prompt | generation — contract too aggressive |
| hash mismatch vs source | ingestion — stale index |
Log fields per request: query · retrieved (pid, score, section) · prompt_pids · answer · model · prompt_version · gold_pids · timestamp.
Replay rule: bucket counts before and after a fix. Incidents moving between buckets means the fix renamed the problem.
Continue Learning
Before You Start
- No prerequisites listed.