A Failure Taxonomy for RAG Answers
Metrics tell you how much is broken. They don’t tell you what is broken, and the gap between those two is where most RAG work stalls — a faithfulness score of 0.78 supports no decision about what to do on Monday.
Error analysis closes it. Sit down with fifty failures, assign each a stage and a failure type, and count. The output is a ranked list of causes, which is a work queue.
Sample fifty failures properly
Fifty is the right order of magnitude. Enough to see which modes dominate, few enough to read carefully in a day. Twenty is enough to find the biggest one.
Stratify. Sampling only the worst-scoring cases finds the catastrophic modes and misses the common mediocre ones. A workable mix: half from low-scoring eval cases, a quarter from production cases with negative feedback or escalation, a quarter random from production. Record which stratum each came from so you can weight the counts afterwards.
Bring the trace. Coding a failure from the answer alone is guessing. You need, per case: the query, the retrieved chunk IDs with scores and ranks, the assembled prompt, the answer, and — critically — whether the answer exists in the corpus at all. Without that last item you cannot distinguish a retrieval failure from a corpus gap, and they get fixed by different teams.
If you don’t have traces yet, that’s the finding: stop and instrument, because error analysis without them takes ten times as long and produces guesses.
Code each failure with a stage and a type
Two fields, both required. The stage is where it broke; the type is how.
Stage — corpus, extraction, chunking, indexing, retrieval, reranking, prompt assembly, generation, or the question itself. The bisection procedure that determines it is in the five places a RAG pipeline breaks.
Type — a starter codebook, to be adapted:
| Code | Stage | Signature |
|---|---|---|
corpus-gap |
corpus | the answer isn’t in the corpus; correct behaviour was a refusal |
stale-doc |
corpus | the corpus contains an outdated answer and reported it faithfully |
extraction-loss |
extraction | text was mangled or dropped — tables, columns, scans, headers |
boundary-split |
chunking | the answer was cut across two chunks and neither is sufficient |
context-orphan |
chunking | the chunk lost the heading or subject that made it interpretable |
not-indexed |
indexing | the document exists and isn’t in the index |
vocab-mismatch |
retrieval | the user’s words and the corpus’s words don’t meet |
duplicate-crowding |
retrieval | near-duplicates filled top-k, squeezing out other relevant material |
version-confusion |
retrieval | several versions retrieved, wrong one ranked first |
filter-overreach |
retrieval | a metadata or permission filter excluded the answer |
rank-too-low |
reranking | retrieved but below the cutoff the generator reads |
truncated |
prompt assembly | the relevant chunk was cut by a context limit |
instruction-ignored |
generation | the model broke a rule it was given |
unsupported-claim |
generation | asserted something the context doesn’t support |
partial-answer |
generation | answered one part of a multi-part question |
over-refusal |
generation | refused when the context contained the answer |
ambiguous-question |
question | genuinely underspecified; a clarification was the right output |
Allow one primary code and optional secondary codes. Most failures have one dominant cause and a contributing one; forcing a single code loses information, and allowing five means nothing gets counted.
Iterate the codebook, then freeze it
The first pass through twenty cases will produce three codes you didn’t have and two that turn out to be the same thing. That’s expected — the codebook is an output of the exercise as much as an input.
Do a calibration pass: two people code the same fifteen cases independently and compare. Disagreements are either ambiguous cases or an underspecified code definition, and the second kind is fixable by writing a sharper definition with an example. The mechanics are the same as rubric calibration in writing a rubric two reviewers agree on.
Then freeze it for the round, so the counts mean one thing.
Count, weight, and rank
from collections import Counter
def rank_modes(cases, weights):
"""cases: [{'code': str, 'stratum': str}]. weights: {stratum: float}."""
tally = Counter()
for c in cases:
tally[c["code"]] += weights.get(c["stratum"], 1.0)
total = sum(tally.values())
return [(code, n / total) for code, n in tally.most_common()]
The weights matter. If you sampled a quarter of your cases from negative-feedback traffic that is 2% of real traffic, that stratum is heavily over-represented and unweighted counts will overstate its modes. Weight by the inverse of the over-sampling, or at minimum report counts per stratum separately rather than pooled.
What comes out is a short ranked list, and in practice a couple of codes usually dominate. That concentration is the useful property: it means there’s a fix with leverage, and it’s the argument for doing this before another round of parameter tuning.
From codes to fixes
Each code maps to a different kind of work, and the mapping is the reason the taxonomy is worth the day:
corpus-gap→ not an engineering fix. Either acquire the content or improve refusal behaviour (measuring whether your system knows when to refuse).stale-doc,version-confusion→ ingestion and metadata work, plus recency handling in ranking.extraction-loss,boundary-split,context-orphan→ the parsing and chunking layer, and a chunking change means relabelling (a re-chunk invalidates your eval set).vocab-mismatch→ retrieval strategy; the slice to watch when you evaluate a different embedder (testing a new embedding model).duplicate-crowding,filter-overreach,rank-too-low→ retrieval configuration and post-processing.truncated→ prompt assembly, and a snapshot test so it can’t recur silently.instruction-ignored,unsupported-claim,partial-answer,over-refusal→ prompt and generation work, measured end-to-end because retrieval metrics can’t see any of it (component or end-to-end).ambiguous-question→ product behaviour, not a bug. Clarifying questions are a feature.
Turn every coded case into a permanent test
The last step, and the one that compounds. Each coded failure becomes an eval case with its code attached: a retrieval label if the stage is upstream of generation, a must_not_say entry if the stage is generation, a must_not_retrieve entry for crowding and version cases.
Two consequences. Your eval set grows in the direction of your real failure distribution rather than your imagination. And the codes become a slice dimension, so future runs report metrics per failure mode — which lets you say “boundary-split cases went from 14 to 3” instead of “faithfulness went up a bit”. That sentence is what error analysis buys, and it’s worth more than the fifty answers took to read.
Re-run the exercise quarterly, or after any large change. The distribution moves as you fix things, and the point of a taxonomy is to keep telling you what’s dominant now rather than what was dominant when you built it.