TL;DR
Most teams test RAG like it's a black box. You need to evaluate retriever and generator independently. 5 critical metrics: Faithfulness, Contextual Precision, Retrieval Recall, Generation Hallucination Rate, and NDCG. Synthetic test data reveals failures that production users never do, until it's expensive. Real code examples for evaluating each component and detecting hallucination in real-time.
RAG (Retrieval-Augmented Generation) is supposed to fix LLM hallucination. Pass real documents to the model, let it ground its responses, ship with confidence.
Except it doesn't work that way. We've audited dozens of RAG systems in production, and they're failing silently. The retriever finds the right documents but the LLM ignores them. Or the retriever fails entirely and the model politely hallucinates. Or both components work fine in isolation but cascade-fail when combined.
The problem: you're testing RAG as a black box. You measure end-to-end output quality and call it good. Meanwhile, each component is degrading in ways you can't see.
Here's what you should be measuring instead.
The Five Metrics That Actually Matter for RAG
1. Faithfulness Score (Generator Quality)
Does the generated answer reflect what's actually in the retrieved documents? This is the most important metric nobody measures.
Faithfulness differs from accuracy. You can be accurate without being faithful. The model knows the right answer from its training data, ignores the retrieved context, and confidently hallucinates.
Measure it:
def measure_faithfulness(question, retrieved_docs, generated_answer):
"""Check if answer is grounded in retrieved context."""
# Method 1: Token overlap (crude but fast)
doc_tokens = set()
for doc in retrieved_docs:
doc_tokens.update(tokenize(doc))
answer_tokens = set(tokenize(generated_answer))
overlap = len(answer_tokens & doc_tokens) / len(answer_tokens)
# Method 2: NLI-based faithfulness (better)
# For each claim in the answer, check entailment to doc context
claims = extract_claims(generated_answer)
doc_context = " ".join(retrieved_docs)
faithfulness_scores = []
for claim in claims:
# Does the context entail this claim?
entailment = nli_model.predict(doc_context, claim)
# entailment_label: 'entailment', 'neutral', or 'contradiction'
faithfulness_scores.append(1.0 if entailment == 'entailment' else 0.0)
return {
'token_overlap': overlap,
'nli_faithfulness': sum(faithfulness_scores) / len(faithfulness_scores),
'unfaithful_claims': [c for c, s in zip(claims, faithfulness_scores) if s == 0.0]
}
Set a hard threshold: if faithfulness drops below 0.8 for your use case, the answer shouldn't be shown to users. Let it reject instead of hallucinate.
2. Contextual Precision (Retriever Quality)
Of the documents you retrieved, how many actually contain relevant information? Precision: out of 10 docs retrieved, how many are useful?
You might have perfect recall (found all relevant docs) but terrible precision (also retrieved 200 irrelevant ones). That degrades the LLM's ability to ground itself.
def measure_contextual_precision(question, retrieved_docs, ground_truth_docs):
"""Measure: are retrieved docs actually relevant?"""
# Simple version: human labels
relevant_count = 0
for doc in retrieved_docs[:10]: # Often we only care about top-k
if doc in ground_truth_docs:
relevant_count += 1
precision_at_10 = relevant_count / min(10, len(retrieved_docs))
# Harder version: semantic relevance without labels
# Use an NLI model to check if doc is relevant to question
query_embedding = embed(question)
relevance_scores = []
for doc in retrieved_docs:
doc_embedding = embed(doc)
relevance = cosine_similarity(query_embedding, doc_embedding)
relevance_scores.append(relevance)
# Documents in top-50% of similarity are "relevant"
median_relevance = sorted(relevance_scores)[len(relevance_scores)//2]
relevant_above_median = sum(1 for s in relevance_scores if s > median_relevance)
return {
'precision_at_10': precision_at_10,
'avg_relevance': sum(relevance_scores) / len(relevance_scores),
'relevant_above_median': relevant_above_median / len(retrieved_docs)
}
3. Retrieval Recall (Completeness)
Did you find the documents you needed? Out of all relevant documents in your corpus, what percentage did the retriever return?
If recall is low, the model has nothing good to work with. No amount of LLM prowess can fix that.
def measure_retrieval_recall(question, retrieved_docs, ground_truth_docs):
"""Measure: did we find all relevant documents?"""
# This requires ground truth labels
# Question → [list of doc IDs that actually answer it]
relevant_retrieved = len(set(retrieved_docs) & set(ground_truth_docs))
total_relevant = len(ground_truth_docs)
recall = relevant_retrieved / total_relevant if total_relevant > 0 else 1.0
return {
'recall': recall,
'found': relevant_retrieved,
'missing': total_relevant - relevant_retrieved
}
4. Hallucination Rate (End-to-End)
After retrieval and generation, what percentage of answers contain information not in the retrieved docs?
def detect_hallucination(retrieved_docs, generated_answer):
"""Flag answers that fabricate information."""
claims = extract_claims(generated_answer)
doc_context = " ".join(retrieved_docs)
hallucinations = []
for claim in claims:
# Can you entail this claim from the documents?
label = nli_model.predict(doc_context, claim)
if label == 'contradiction':
hallucinations.append({
'claim': claim,
'type': 'contradiction'
})
elif label == 'neutral':
# Claim is not covered by documents
hallucinations.append({
'claim': claim,
'type': 'unsupported'
})
hallucination_rate = len(hallucinations) / len(claims) if claims else 0.0
return {
'has_hallucination': len(hallucinations) > 0,
'hallucination_count': len(hallucinations),
'hallucination_rate': hallucination_rate,
'detected_hallucinations': hallucinations
}
5. NDCG for Ranking (Retrieval Quality)
Normalized Discounted Cumulative Gain. Penalizes retrieving the right documents in the wrong order. Position matters: is the most relevant doc in the top 3?
def compute_ndcg(retrieved_docs, relevance_scores, k=5):
"""Measure ranking quality, not just presence."""
# relevance_scores: dict of {doc_id: relevance_grade}
# Grades: 0=irrelevant, 1=somewhat relevant, 2=highly relevant
dcg = 0.0
for position in range(min(k, len(retrieved_docs))):
doc_id = retrieved_docs[position]
relevance = relevance_scores.get(doc_id, 0)
# Discount by position
dcg += (2 ** relevance - 1) / log2(position + 2)
# Ideal DCG: best possible ranking
ideal_relevances = sorted(relevance_scores.values(), reverse=True)[:k]
idcg = 0.0
for position, relevance in enumerate(ideal_relevances):
idcg += (2 ** relevance - 1) / log2(position + 2)
ndcg = dcg / idcg if idcg > 0 else 0.0
return ndcg
The single biggest mistake in RAG testing: measuring the end-to-end system without understanding which component is failing. Component-level testing reveals degradation that end-to-end metrics hide.
Building Your RAG Test Suite
Step 1: Component-Level Testing
Before you test the full pipeline, isolate each part:
- Retriever tests: Does it find relevant documents? Fast enough? Handles edge cases?
- Generator tests: Given good context, does it generate faithful answers?
- Integration tests: Does the pipeline work end-to-end?
Step 2: Synthetic Test Data
You can't wait for production failures. Generate adversarial test cases:
def generate_synthetic_rag_tests(documents, num_tests=100):
"""Create challenging test cases programmatically."""
tests = []
# Case 1: Questions about facts in docs
for doc in documents[:10]:
claims = extract_claims(doc)
for claim in claims[:2]:
question = paraphrase_claim_as_question(claim)
tests.append({
'question': question,
'ground_truth_docs': [doc],
'type': 'factual'
})
# Case 2: Questions that mix facts from multiple docs
for doc1, doc2 in combinations(documents[:20], 2):
claim1 = extract_claims(doc1)[0]
claim2 = extract_claims(doc2)[0]
question = f"How do {claim1} and {claim2} relate?"
tests.append({
'question': question,
'ground_truth_docs': [doc1, doc2],
'type': 'multi_document'
})
# Case 3: Questions that shouldn't be answerable from docs
for doc in documents[:10]:
topic = extract_topic(doc)
off_topic_question = generate_question_about(opposite_topic(topic))
tests.append({
'question': off_topic_question,
'ground_truth_docs': [], # No docs answer this
'type': 'out_of_domain',
'expected_behavior': 'reject'
})
return tests
Step 3: Continuous Monitoring
Set up production monitoring for your RAG system:
def monitor_rag_health(question, retrieved_docs, answer, user_feedback=None):
"""Track RAG degradation in real-time."""
metrics = {
'timestamp': now(),
'question': question,
'num_docs_retrieved': len(retrieved_docs),
'avg_doc_relevance': compute_avg_relevance(question, retrieved_docs),
'hallucination_rate': measure_hallucination(retrieved_docs, answer),
'faithfulness': measure_faithfulness(question, retrieved_docs, answer),
}
# If user provides feedback, track ground truth
if user_feedback:
metrics['user_satisfaction'] = user_feedback.get('helpful')
metrics['correction_needed'] = user_feedback.get('correction')
# Alert on degradation
if metrics['hallucination_rate'] > 0.15: # >15% hallucinating
alert('RAG_HIGH_HALLUCINATION', metrics)
if metrics['faithfulness'] < 0.7:
alert('RAG_LOW_FAITHFULNESS', metrics)
if metrics['num_docs_retrieved'] == 0:
alert('RAG_ZERO_RETRIEVAL', metrics)
return metrics
What to Do This Week
If you've shipped a RAG system:
- Audit your retriever. Take your top 20 user questions. Manually label: did retrieval find relevant docs? Compute precision and recall.
- Evaluate faithfulness. Use the NLI approach above on your last 100 generated answers. What percentage are faithful to the context?
- Generate synthetic adversarial tests. Create 50+ test cases covering facts, multi-document questions, and out-of-domain queries.
- Set up monitoring. Deploy the monitoring code above. Start collecting baseline metrics.
- Set thresholds. Decide what hallucination rate is acceptable (usually <5% in production). Enforce it with rejection.
The hard truth: If you're only measuring end-to-end accuracy, you don't know why your RAG system fails. You know that it fails, but not what to fix. Component-level testing tells you the truth.
RAG systems are powerful because they ground LLMs in real data. But that power is wasted if you can't see what's happening inside. Start measuring components. Start testing components. Start treating hallucination as a traceable bug, not an acceptable cost of doing business with LLMs.
Stop shipping RAG systems blindly.
alt.qa's RAG evaluation framework gives you component-level diagnostics and continuous hallucination monitoring.
Test your RAG system now