A RAG answer can be wrong because retrieval fetched the wrong passages or because generation ignored the right ones — and the fix lives in different code owned by different people. Acceptance testing that ends at "the answer was bad" files unroutable bugs. This playbook scores both layers and attributes every failure.
Tier: Advanced · Workflow area: Testing AI systems — judges, agents & robustness
You will run a RAG feature against an eval set, compute retrieval metrics and generation metrics separately, accept or reject against written thresholds, and file failures with a layer attribution the receiving engineer can act on immediately.
A retrieval-augmented feature — support answers, doc search, policy Q&A — is approaching release and needs pass/fail criteria. Users report "wrong answers" and nobody knows whether to tune the retriever or the prompt. A chunking, embedding-model or index change needs regression coverage.
The feature is a plain LLM call with no retrieval step — use prompt regression testing instead. You have no ground-truth questions yet — build the golden dataset first; layer attribution without reference answers is guesswork.
Instrument so every answer carries its contexts. Each eval run must record, per question: the retrieved chunks (with scores), the final prompt, and the answer. In Langfuse this is a retrieval span nested in the generation trace; in a bare pipeline, a JSONL with question, contexts[], answer, reference is enough.
Pick the four metrics and what each blames. Write this table into the test plan — it is the routing logic:
| Metric | Question it answers | Low score blames |
|---|---|---|
| Contextual recall | Did retrieval fetch the info the reference answer needs? | Retrieval |
| Contextual precision | Are relevant chunks ranked above irrelevant ones? | Retrieval (ranking) |
| Faithfulness | Is every claim in the answer supported by the contexts? | Generation |
| Answer relevancy | Does the answer address the question asked? | Generation (prompt) |
Encode the run as pytest with DeepEval.
from deepeval import assert_test
from deepeval.test_case import LLMTestCase
from deepeval.metrics import (
ContextualRecallMetric, ContextualPrecisionMetric,
FaithfulnessMetric, AnswerRelevancyMetric)
def test_rag_case(eval_item):
tc = LLMTestCase(
input=eval_item.question,
actual_output=eval_item.answer,
expected_output=eval_item.reference,
retrieval_context=eval_item.contexts)
assert_test(tc, [
ContextualRecallMetric(threshold=0.8),
ContextualPrecisionMetric(threshold=0.7),
FaithfulnessMetric(threshold=0.85),
AnswerRelevancyMetric(threshold=0.8)])
deepeval test run test_rag_acceptance.py
Thresholds are yours to set from a baseline run, not copied from a tutorial — run once, look at the score distribution, put the bar where your known-good cases sit.
Attribute each failure before filing anything. Use the decision order: recall low → retrieval bug (chunking, embedding model, index freshness, query rewriting). Recall fine but precision low → ranking bug (reranker, top-k). Contexts fine but faithfulness low → generation bug (the model invents or contradicts; tighten the prompt's grounding instructions). Everything high but relevancy low → the prompt answers a different question than asked. When metrics disagree with your reading, open the trace and check the contexts by eye — the metrics are judges, and judges have error bars.
File layer-attributed bugs. The report template that makes this playbook pay off:
Title: RAG failure — retrieval recall — refund-policy questions
Layer: RETRIEVAL (contextual recall 0.31 on 6/8 refund questions)
Evidence: reference answer requires §4.2 of refund-policy.md;
retrieved top-5 contains only marketing FAQ chunks.
Trace: langfuse.example.com/trace/tr_8842
Not a generation bug: faithfulness 0.94 — the model faithfully
answered from the wrong documents.
Suggested owner: search/indexing team.
Gate the release on the aggregate. A workable acceptance line: ≥90% of eval items pass all four thresholds, zero faithfulness failures on the compliance-sensitive slice, and no metric's mean regressed more than 5 points vs the last accepted run. Wire the same pytest suite into CI so retriever and prompt changes both trigger it.
"Faithfully answered from the wrong documents" and "invented an answer despite the right documents" look identical to the user and completely different to the fixer. Score retrieval and generation separately, and every bug report ships with its owner attached.
Keep one deliberately broken variant in the suite — retrieval pointed at an empty index. If faithfulness still scores above your bar on garbage contexts, your metric wiring is broken, and it is better to learn that from the canary than from a release.