RAG Evaluation
Score the search step and the writing step separately: one number for whether retrieval surfaced the right chunks, another for whether the answer stayed loyal to them. A single final-answer score can't tell you which step broke.
Why Does This Exist?
You ship a RAG assistant, collect fifty test questions, and grade the final answers. Say it gets thirty right. That headline number feels like progress, until someone asks the only question that matters: why did the other twenty fail? Did the search step never surface the right chunk, or did the model hold the right chunk and write past it? One end-to-end score answers neither. It blends two different failures into one shrug.
That blend costs real weeks. A team reading a low answer score as a writing problem will tune prompts, swap system instructions, and plead with the model to stick to context, while the actual cause sits upstream: the index never returned the one chunk with the answer. No prompt edit can summon text the model never received. You have to measure the two stages apart before you fix either.
This page is about that measurement. The pipeline itself, how chunks get indexed and stuffed into the prompt, lives in RAG architecture. The cheapest-first debugging order for a bad answer lives in RAG failure diagnosis. Here you learn the two score families, how to compute them on one small example, and what each result tells you to do next. If you came from model testing, note the scope difference: post-training evaluation grades the model in isolation, while this page grades the whole system, search step included.
The one idea you need first
Every score here needs a labeled eval set: a list of representative questions, each paired with the ids of the chunks that actually answer it, plus a short reference answer. Retrieval metrics read the chunk ids. Generation checks read the reference answer and the retrieved text. Without those labels you can stare at outputs all day, but you cannot compute a single number on this page. Building that set is unglamorous work, and it is the whole foundation.
Think of It Like This
A restaurant inspected on two clipboards
A health inspector walks into a restaurant carrying two clipboards, not one. The first clipboard follows the delivery truck: did the ingredients the menu needs actually arrive, fresh and complete? The kitchen can be brilliant, but it cannot serve a dish whose main ingredient never showed up. The second clipboard watches the kitchen: did the cooks prepare what arrived, without slipping in off-menu substitutes nobody ordered?
Your RAG system gets the same treatment. Retrieval is the delivery truck, and its clipboard asks whether the right chunks arrived. Generation is the kitchen, and its clipboard asks whether the answer was cooked from what arrived. A bad meal with a single overall rating leaves the owner guessing between supplier and chef. Two clipboards point at the fix.
Where it stops: a real kitchen cannot fake a missing crate. An empty shelf is visible, and nobody gets served. A language model can do something stranger: it can invent the missing ingredient, plate it beautifully, and serve it with confidence. That fluent fabrication is exactly why the second clipboard exists here, and why no restaurant analogy can make you feel how sneaky an unfaithful answer reads.
How It Actually Works
A RAG answer passes through two gates. First, the retriever picks the top chunks for the query. Then the model writes an answer from those chunks plus its own weights. Evaluation scores each gate on its own terms, using the same eval set, and reports two numbers side by side. Retrieval gets graded against the labeled chunk ids. Generation gets graded against the retrieved text and the reference answer.
Retrieval first: did the right chunks show up?
Call the labeled relevant chunks for a query the set , and call the retriever's top results the list , where is just your cutoff, how many chunks you asked for. Two ratios tell the story:
In plain words: recall@k is the share of the needed chunks that showed up anywhere in the top . Precision@k is the share of the retrieved chunks that were actually needed. The vertical bars mean set size, and is the overlap, chunks that are both relevant and retrieved.
Recall is the one you watch most, because a missing chunk is unrecoverable downstream. If recall@3 is 0.50, half the needed material never reached the prompt, and the model starts every answer half blind. Precision matters too, mostly as context budget: stuffing the prompt with irrelevant chunks wastes tokens and distracts the model. When recall is low, the levers sit on the retrieval side, a fuller index in your vector database, better chunk boundaries, or a reranking pass over the candidates. No prompt edit belongs in that list.
Generation next: did the answer stay loyal?
Assume retrieval did its job and grade only the writing. Split the answer into its checkable claims, call that count , and count how many are supported by the retrieved passages, call that :
Faithfulness is that share. A claim counts as supported only if some retrieved passage actually states it, not if it sounds plausible or matches the model's training memory. An answer can also fail a second, separate check: answer relevance, whether it addresses the question asked at all. A perfectly grounded summary of the wrong topic is faithful and useless. Keep the two apart, because they fail independently and get fixed differently.
Worked example: one query, two scores
Take the query "refund window on sale items?" Your labels say two chunks answer it, so : C2 states the 30-day refund policy, C4 states that sale items are final sale. The retriever returns , shipping times, the refund policy, and support hours. The overlap is just , size 1:
Half the needed material arrived. The sale exclusion, the crux of the question, never reached the prompt.
Now the model answers in four claims: refunds take 30 days (backed by C2), contact support through the portal (backed by C3), replies come within 2 business days (backed by C3), and sale items qualify for refunds (backed by nothing, and wrong, since the missing C4 says the opposite). So , :
Read the pair the way the diagram does. Recall 0.50 says the search step dropped C4, so the first fix is retrieval, not wording. Faithfulness 0.75 says the model additionally invented the sale-items claim instead of saying the passages were silent. One end-to-end grade, wrong answer, would hide both facts. The split hands you an ordered todo: fetch C4 first, then tighten grounding.
Why one final score cannot do this job
Four combinations produce the same wrong answer: bad retrieval with good writing, good retrieval with sloppy writing, both bad, and the sneaky one, bad retrieval papered over by a fluent invention. A single correctness number collapses all four into identical failure, so every fix becomes a guess. Two scores separate them, and the separation is the entire technique.
It costs something honest. Labels take real effort: representative questions, chunk ids per question, reference answers, all maintained as your docs change. That is the trade-off, labeling labor in exchange for knowing which half of the system to touch. The standing assumption is that your eval questions mirror real traffic; when they drift, every number on this page quietly goes stale. And one boundary: skip the full harness while the product is still a toy with no users and no direction, but keep a tiny golden set from day one, because the week you need it is the week you will not have time to build it. When the scores point at a failure, the debugging order continues in RAG failure diagnosis.
Code
Two tiny scorers, run on the worked example above. Retrieval and generation stay in separate functions on purpose, so the two numbers can never collapse into one.
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float: top_k = set(retrieved[:k]) return len(top_k & relevant) / len(relevant)
def precision_at_k(retrieved: list[str], relevant: set[str], k: int) -> float: top_k = retrieved[:k] return sum(1 for doc in top_k if doc in relevant) / k
def faithfulness(backed: list[bool]) -> float: return sum(backed) / len(backed)
retrieved = ["C1", "C2", "C3"]relevant = {"C2", "C4"}
print(f"{recall_at_k(retrieved, relevant, k=3):.2f}") # -> 0.50print(f"{precision_at_k(retrieved, relevant, k=3):.2f}") # -> 0.33print(f"{faithfulness([True, True, True, False]):.2f}") # -> 0.75Retrieval reports 0.50 while generation reports 0.75, the exact split verdict from the worked example. One blended score would have printed a single failure and told you nothing about where to look.
Watch Out For
Tuning prompts against a retrieval problem
Symptom: your answer scores sit flat no matter what you try. New system prompt, stricter grounding instructions, few-shot examples, and the needle never moves. The natural reading is that the model is stubborn. The actual state is that recall@k is low and nobody computed it, so every prompt edit operates on chunks that never contained the answer.
Fix: log the retrieved chunk ids for every eval query, and score recall@k before you touch a prompt. If the needed chunk is absent, stop writing prose and go fix search. Make this ordering a habit, retrieval numbers first, prompt edits second, and the wasted weeks disappear.
A tidy eval set your users would never write
Symptom: offline recall looks healthy, around 0.90 on your labeled set, while production answers keep missing. Your test questions are clean, grammatical, and use the docs' own vocabulary, because those were the easiest to label. Real users write fragments, misspell product names, and ask about two things at once. The metric is honest; the sample is flattering.
Fix: sample real query logs when you build and refresh the set, misspellings and vagueness included, and re-label on a schedule as docs change. An eval set is a perishable good. Date it, revisit it, and distrust any retrieval number computed against questions nobody asked.
The Quick Version
- RAG evaluation reports two numbers, a retrieval score and a generation score, because one final-answer grade cannot say which stage failed.
- Recall@k is the share of needed chunks that arrived in the top results, and low recall means the fix sits in search, never in the prompt.
- Faithfulness is the share of answer claims supported by retrieved passages, and it catches fluent inventions a correctness grade lumps together with retrieval misses.
- In the worked example, recall@3 of 0.50 plus faithfulness of 0.75 orders the todo: fetch the missing chunk first, then tighten grounding.
- Every metric here rests on a labeled eval set of representative questions, chunk ids, and reference answers, and stale questions make honest numbers lie.