RAG Pipeline Testing: Retrieval Quality, Grounding, and Faithfulness Metrics
RAG (Retrieval-Augmented Generation) systems fail in ways that are invisible without the right tests. The LLM might generate confident-sounding output that's completely ungrounded in the retrieved documents. The retriever might fetch irrelevant documents. The response might be faithful to the retrieved context but that context might be wrong. Testing RAG requires evaluating the retrieval step and the generation step separately, then together.
The RAG Failure Taxonomy
Before writing tests, understand how RAG fails:
| Failure type | Description | Detection |
|---|---|---|
| Retrieval miss | Right documents not retrieved | Recall@K against golden corpus |
| Irrelevant retrieval | Retrieved docs don't answer the question | Context relevance metric |
| Faithfulness failure | Response claims not in retrieved docs | NLI/entailment checking |
| Grounding failure | Response correct but not based on context | Faithfulness metric |
| Hallucination | LLM adds information not in retrieved docs | LLM judge |
| Context window failure | Too much context dilutes the answer | Context utilization metric |
Component 1: Testing the Retriever
The retriever is testable independently of the LLM. For a given query, it should retrieve the most relevant documents.
Setting Up Retrieval Evaluation
# tests/rag/test_retrieval.py
import pytest
from typing import List
from src.rag.retriever import Retriever
from src.rag.document_store import DocumentStore
@pytest.fixture(scope="module")
def retriever():
"""Load retriever with test document corpus."""
store = DocumentStore()
store.load_documents("tests/fixtures/test_corpus/") # Your test documents
return Retriever(document_store=store, top_k=5)
# Golden retrieval dataset: query → expected document IDs
RETRIEVAL_TEST_CASES = [
{
"id": "ret-001",
"query": "How much does the Pro plan cost?",
"relevant_doc_ids": ["pricing-page", "pro-plan-features"],
"irrelevant_doc_ids": ["refund-policy", "enterprise-sla"],
},
{
"id": "ret-002",
"query": "How do I integrate with GitHub Actions?",
"relevant_doc_ids": ["ci-cd-guide", "github-actions-tutorial"],
"irrelevant_doc_ids": ["pricing-page", "mobile-testing"],
},
{
"id": "ret-003",
"query": "What testing frameworks are supported?",
"relevant_doc_ids": ["supported-frameworks", "framework-integrations"],
"irrelevant_doc_ids": ["billing-faq"],
},
]
def test_retrieval_recall_at_5(retriever):
"""At least one relevant document must be in top-5 results."""
failures = []
for case in RETRIEVAL_TEST_CASES:
results = retriever.retrieve(case["query"])
retrieved_ids = {r.doc_id for r in results}
found_relevant = any(doc_id in retrieved_ids for doc_id in case["relevant_doc_ids"])
if not found_relevant:
failures.append({
"id": case["id"],
"query": case["query"],
"expected": case["relevant_doc_ids"],
"retrieved": list(retrieved_ids),
})
assert not failures, f"Retrieval failures:\n{failures}"
def test_retrieval_precision_no_irrelevant_in_top_3(retriever):
"""Top 3 results should not contain known-irrelevant documents."""
failures = []
for case in RETRIEVAL_TEST_CASES:
results = retriever.retrieve(case["query"])
top_3_ids = {r.doc_id for r in results[:3]}
irrelevant_in_top_3 = [
doc_id for doc_id in case["irrelevant_doc_ids"]
if doc_id in top_3_ids
]
if irrelevant_in_top_3:
failures.append({
"id": case["id"],
"query": case["query"],
"irrelevant_found": irrelevant_in_top_3,
})
assert not failures, f"Irrelevant docs in top-3:\n{failures}"
def test_retrieval_consistency(retriever):
"""Same query run twice should return same top documents."""
query = "How do I run parallel tests?"
results_1 = [r.doc_id for r in retriever.retrieve(query)]
results_2 = [r.doc_id for r in retriever.retrieve(query)]
assert results_1[:3] == results_2[:3], \
f"Retrieval is non-deterministic: {results_1[:3]} vs {results_2[:3]}"Measuring MRR and NDCG
For teams that need precision metrics:
def compute_mrr(retriever, test_cases: list[dict], k: int = 10) -> float:
"""Mean Reciprocal Rank — measures rank of first relevant document."""
reciprocal_ranks = []
for case in test_cases:
results = retriever.retrieve(case["query"])
for rank, result in enumerate(results[:k], start=1):
if result.doc_id in case["relevant_doc_ids"]:
reciprocal_ranks.append(1 / rank)
break
else:
reciprocal_ranks.append(0) # No relevant doc in top-k
return sum(reciprocal_ranks) / len(reciprocal_ranks)
def test_mrr_above_baseline(retriever):
mrr = compute_mrr(retriever, RETRIEVAL_TEST_CASES)
assert mrr >= 0.7, f"MRR {mrr:.3f} below threshold 0.7"Component 2: Testing the Generation (Context Faithfulness)
The LLM generation step must be faithful to retrieved context. This is where hallucination happens.
Using RAGAS for Faithfulness
RAGAS is a framework specifically for RAG evaluation:
pip install ragas# tests/rag/test_generation_faithfulness.py
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_recall,
context_precision,
)
def test_rag_faithfulness():
"""Response must not contain claims outside the retrieved context."""
# Build test dataset in RAGAS format
test_data = {
"question": [
"What is the cost of the Pro plan?",
"Does HelpMeTest support parallel test execution?",
"How long is data retained on the free plan?",
],
"answer": [
# Actual RAG pipeline outputs
rag_pipeline.query("What is the cost of the Pro plan?").answer,
rag_pipeline.query("Does HelpMeTest support parallel test execution?").answer,
rag_pipeline.query("How long is data retained on the free plan?").answer,
],
"contexts": [
# Retrieved context for each query
rag_pipeline.query("What is the cost of the Pro plan?").retrieved_docs,
rag_pipeline.query("Does HelpMeTest support parallel test execution?").retrieved_docs,
rag_pipeline.query("How long is data retained on the free plan?").retrieved_docs,
],
"ground_truth": [
"The Pro plan costs $100 per month.",
"Yes, parallel test execution is available on the Pro plan.",
"The free plan does not include data retention.",
],
}
dataset = Dataset.from_dict(test_data)
results = evaluate(
dataset,
metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
)
print(f"\nRAGAS Scores:")
print(f" Faithfulness: {results['faithfulness']:.3f}")
print(f" Answer Relevancy: {results['answer_relevancy']:.3f}")
print(f" Context Recall: {results['context_recall']:.3f}")
print(f" Context Precision: {results['context_precision']:.3f}")
# Thresholds
assert results["faithfulness"] >= 0.8, \
f"Faithfulness {results['faithfulness']:.3f} below threshold 0.8"
assert results["answer_relevancy"] >= 0.75, \
f"Answer relevancy {results['answer_relevancy']:.3f} below threshold 0.75"
assert results["context_recall"] >= 0.7, \
f"Context recall {results['context_recall']:.3f} below threshold 0.7"Manual Faithfulness Testing
For simpler setups without RAGAS:
def test_response_does_not_hallucinate_pricing(rag_pipeline):
"""
Critical test: pricing information must come from context, not LLM's training data.
The LLM might "know" about a pricing tier that no longer exists.
"""
query = "What are the pricing plans?"
result = rag_pipeline.query(query)
response = result.answer
retrieved_docs = " ".join(result.retrieved_docs)
# Extract price mentions from response
import re
prices_in_response = re.findall(r'\$[\d,]+', response)
prices_in_context = re.findall(r'\$[\d,]+', retrieved_docs)
for price in prices_in_response:
assert price in prices_in_context, (
f"Response mentions {price} which is not in retrieved context.\n"
f"Response: {response}\n"
f"Context prices: {prices_in_context}"
)End-to-End RAG Evaluation
With individual components tested, run end-to-end evaluation:
# tests/rag/test_rag_end_to_end.py
import pytest
GOLDEN_QA_PAIRS = [
{
"id": "qa-001",
"question": "What does the Pro plan include?",
"expected_answer_contains": ["$100", "unlimited tests", "parallel"],
"must_not_contain": ["free", "enterprise", "SSO"],
},
{
"id": "qa-002",
"question": "Is there a free plan?",
"expected_answer_contains": ["free", "10 tests"],
"must_not_contain": ["credit card required", "payment"],
},
{
"id": "qa-003",
"question": "What's included in Enterprise?",
"expected_answer_contains": ["SSO", "priority support"],
"must_not_contain": ["$100"], # Should not conflate with Pro pricing
},
]
@pytest.mark.parametrize("case", GOLDEN_QA_PAIRS)
def test_rag_end_to_end(rag_pipeline, case):
result = rag_pipeline.query(case["question"])
answer = result.answer.lower()
missing = [
expected for expected in case["expected_answer_contains"]
if expected.lower() not in answer
]
present = [
must_not for must_not in case.get("must_not_contain", [])
if must_not.lower() in answer
]
assert not missing, (
f"Test {case['id']}: Missing expected content: {missing}\n"
f"Answer: {result.answer}"
)
assert not present, (
f"Test {case['id']}: Found forbidden content: {present}\n"
f"Answer: {result.answer}"
)Testing Context Window Management
When retrieved documents exceed the context window, some are dropped. Test that this doesn't silently degrade quality:
def test_context_overflow_handling(rag_pipeline):
"""
When more context is retrieved than fits in the context window,
the most relevant documents should be prioritized.
"""
# Query that returns many results
result = rag_pipeline.query("Tell me everything about the product")
# Verify the answer covers the most important topics
# even if some context was truncated
answer = result.answer.lower()
critical_info = ["pricing", "free plan", "pro plan"]
missing = [info for info in critical_info if info not in answer]
assert not missing, \
f"Context overflow dropped critical info: {missing}\nAnswer: {result.answer[:500]}"Monitoring RAG in Production
Tests catch regressions before deployment. In production, monitor:
# src/rag/monitoring.py
import time
from dataclasses import dataclass
@dataclass
class RAGTrace:
query: str
retrieved_docs: list[str]
retrieval_latency_ms: float
generation_latency_ms: float
answer: str
num_docs_retrieved: int
context_length: int
def instrument_rag_pipeline(pipeline):
"""Wrapper that logs traces for offline analysis."""
original_query = pipeline.query
def traced_query(question: str) -> RAGTrace:
t0 = time.perf_counter()
docs = pipeline.retriever.retrieve(question)
retrieval_time = (time.perf_counter() - t0) * 1000
t1 = time.perf_counter()
answer = pipeline.generator.generate(question, docs)
generation_time = (time.perf_counter() - t1) * 1000
trace = RAGTrace(
query=question,
retrieved_docs=[d.content for d in docs],
retrieval_latency_ms=retrieval_time,
generation_latency_ms=generation_time,
answer=answer,
num_docs_retrieved=len(docs),
context_length=sum(len(d.content) for d in docs),
)
# Send to your observability system
log_rag_trace(trace)
return trace
pipeline.query = traced_query
return pipelineKey Takeaways
- Test retrieval and generation separately — they fail independently
- Use Recall@K to measure retrieval coverage; use MRR or NDCG for ranking quality
- Use RAGAS faithfulness metric to detect hallucinations introduced by the LLM
- Run end-to-end golden QA pairs to catch failures that only appear when retrieval and generation interact
- Monitor context overflow — most RAG systems silently drop documents when the context window fills
- Log RAG traces in production to build evaluation datasets from real queries
- Update your golden dataset as your document corpus changes — stale tests give false confidence