Agentic AI

Evaluation Metrics in RAG: How to Measure Retrieval, Grounding, and Answer Quality

July 15, 2026 · 16 min read

Learn the industry-standard metrics for RAG evaluation, including the RAG Triad, retrieval precision/recall, MRR, NDCG, groundedness, faithfulness, LLM judges, and continuous monitoring.

Evaluation Metrics in RAG: What to Measure and Why It Matters

The trap most teams fall into

I once watched a team launch a RAG system that looked perfect in demos. Fluent answers. Fast responses. No complaints for a week. Then a customer asked a slightly different question, and the system invented a policy that didn’t exist. The team had no idea because they’d measured “look and feel” but not groundedness or retrieval quality. That’s the trap.

A RAG system can retrieve the right documents and still answer badly. It can also produce a fluent answer while ignoring the context entirely. Good evaluation is the only way to know which part of the pipeline is broken.

What RAG evaluation actually measures

RAG evaluation should answer five questions:

The key point is that RAG evaluation is not one metric. It is a layered measurement system that scores retrieval, generation, and system health separately.

The RAG Triad

Modern RAG evaluation is often organized as the RAG Triad: Context Relevance, Groundedness, and Answer Relevance. This is the cleanest way to explain the architecture to both beginners and experienced teams.

text
[ User Query ]
╱ ╲
Context ╱ ╲ Answer
Relevance ╱ ╲ Relevance
▼ ▼
[ Retrieved Context ] ───> [ Final Answer ]
Groundedness
rag triad

If any one of these fails, the system risks drifting into hallucination or task mismatch.

The evaluation split

You cannot evaluate a RAG pipeline with one broad brush. Production systems split testing into two distinct environments.

Offline evaluation

Offline evaluation happens before deployment and uses a golden dataset with questions, retrieved context, and ground-truth answers. This is where you measure metrics that require labels, such as Precision@K, Recall@K, MRR, and NDCG.

Online evaluation

Online evaluation happens on live traffic where you usually do not have ground-truth answers. This is where you measure groundedness, faithfulness, latency, token cost, and drift using judge models or NLI-style validators.

Offline vs online

ModeWhenRequires Ground Truth?Best Metrics
Offline evaluationPre-deploymentYesPrecision@K, Recall@K, MRR, NDCG, correctness
Online evaluationRuntime monitoringNoGroundedness, faithfulness, latency, token count, drift

That split matters because the wrong metric in the wrong environment leads to false confidence.

Core retrieval metrics

Retrieval metrics tell you whether the right evidence reached the model.

MetricWhat It MeasuresWhy It Matters
Precision@KOf the top-K retrieved chunks, how many are relevant?Prevents noisy context
Recall@KOf the relevant chunks, how many were retrieved?Prevents missing key evidence
Context RelevanceWhether retrieved chunks match the query intentImproves retriever quality
Context SufficiencyWhether the retrieved context is enough to answerDetects weak retrieval before generation
MRRHow early the first relevant document appearsRewards strong ranking
NDCGRanking quality across multiple relevant chunksCaptures position sensitivity

Retrieval math metrics

When rank matters, simple hit counts are not enough.

MetricFormula / Mental ModelProduction Impact
Precision@KRelevant Retrieved / Total RetrievedKeeps context clean
Recall@KRelevant Retrieved / Total Ground Truth RelevantEnsures key evidence is not missed
MRRAverage of 1/rank of first relevant doc1 / \text{rank of first relevant doc}Rewards putting the best doc first
NDCGGain discounted by rank positionMeasures ranking quality across the list

MRR and NDCG code:

import math
from typing import List

def calculate_mrr(relevant_ranks: List[int]) -> float:
if not relevant_ranks:
return 0.0
return sum(1.0 / rank for rank in relevant_ranks) / len(relevant_ranks)

def calculate_ndcg(relevant_scores: List[float], k: int = 10) -> float:
if not relevant_scores:
return 0.0

dcg = 0.0
for i, score in enumerate(relevant_scores[:k]):
if score > 0:
dcg += score / math.log2(i + 2)

ideal_scores = sorted(relevant_scores, reverse=True)
idcg = 0.0
for i, score in enumerate(ideal_scores[:k]):
if score > 0:
idcg += score / math.log2(i + 2)

return dcg / idcg if idcg > 0 else 0.0

relevant_ranks = [1, 3, 5]
print(f”MRR: {calculate_mrr(relevant_ranks):.3f}”)

scores = [3, 2, 0, 1, 0]
print(f”NDCG@5: {calculate_ndcg(scores, k=5):.3f}”)

Core response metrics:

Response metrics measure what the LLM actually did with the context.

MetricWhat It MeasuresWhy It Matters
Answer RelevanceWhether the response answers the user’s questionPrevents off-topic answers
CorrectnessWhether the answer matches ground truthMeasures end-result quality
GroundednessWhether the answer is supported by retrieved contextDetects hallucination
FaithfulnessWhether claims are faithful to source materialCritical for trust
SafetyWhether the answer contains harmful contentImportant for enterprise use

A response can be correct by luck but still not be grounded. That makes it fragile.

Context sufficiency versus groundedness:

These terms are related, but they are not the same.

ConceptQuestionWhy It Matters
Context SufficiencyIs the evidence there?Prevents retrieval gaps before generation
GroundednessDid the model use the evidence?Prevents hallucination
FaithfulnessAre claims faithful to the source?Ensures trust

The judge pattern:

In production, teams usually avoid brittle keyword matching and use a judge model or semantic evaluator. A practical path is to use a framework like RAGAS, which provides built-in metrics for faithfulness, answer relevance, context precision, and context recall.

import os
import asyncio
from openai import AsyncOpenAI

client = AsyncOpenAI(api_key=os.environ.get(“OPENAI_API_KEY”))

async def evaluate_groundedness(context: str, answer: str) -> float:
judge_system_prompt = “””
You are a strict verification engine.
Evaluate whether the statement is fully supported by the context.

Return exactly one float from 0.0 to 1.0:
– 1.0 = fully supported
– 0.5 = partially supported
– 0.0 = unsupported or contradictory
“””
user_payload = f”CONTEXT:\n{context}\n\nSTATEMENT:\n{answer}”

response = await client.chat.completions.create(
model=”gpt-4o-mini”,
messages=[
{“role”: “system”, “content”: judge_system_prompt},
{“role”: “user”, “content”: user_payload}
],
temperature=0.0
)
return float(response.choices[0].message.content.strip())

LLM judge calibration note:

LLM judges need calibration. A judge model may be biased toward fluency over factual accuracy, or it may over-penalize style differences. The recommended practice is to validate judge scores against human ratings on a small subset of 50 to 100 examples before trusting the judge in production.

That calibration step matters because an uncalibrated judge can be confidently wrong.

Why RAGAS matters

RAGAS is useful because it gives teams a ready-made framework for evaluating RAG quality without building everything from scratch. It is especially helpful for faithfulness, context recall, context precision, and answer relevance, which are the core fields most teams need to monitor.

A practical workflow is to start with RAGAS for baseline scoring, then add custom judge logic for domain-specific edge cases.

Production-grade evaluation pipeline:

from typing import List, Dict, Optional
import time
import json

class RAGEvaluator:
def __init__(self, llm_judge_client=None):
self.judge = llm_judge_client
self.results = []

def evaluate_live_turn(
self,
query: str,
retrieved_chunks: List[str],
generated_answer: str
) -> Dict:
start_time = time.time()

context_relevance = self._judge_score(“context_relevance”, query, retrieved_chunks)
groundedness = self._judge_score(“groundedness”, generated_answer, retrieved_chunks)
answer_relevance = self._judge_score(“answer_relevance”, query, generated_answer)
latency = time.time() – start_time

return {
“query”: query,
“metrics”: {
“context_relevance”: context_relevance,
“groundedness”: groundedness,
“answer_relevance”: answer_relevance
},
“system”: {
“latency_seconds”: round(latency, 4)
},
“status”: “PASS” if (groundedness > 0.8 and answer_relevance > 0.8) else “FAIL”
}

def _judge_score(self, metric_type: str, primary, secondary) -> float:
if metric_type == “context_relevance”:
return 0.95 if secondary else 0.0
if metric_type == “groundedness”:
return 0.92
if metric_type == “answer_relevance”:
return 0.93
return 0.0

evaluator = RAGEvaluator()
report = evaluator.evaluate_live_turn(
query=”What is the internal casual leave policy?”,
retrieved_chunks=[“Casual leave is capped at 15 days annually.”, “Manager log required.”],
generated_answer=”Employees receive 15 days of casual leave per year.”
)
print(json.dumps(report, indent=2))

Important note on the code:

The code above is a structural pattern. In real production systems, teams plug in a live judge model, a semantic evaluator, or an NLI-based scorer instead of fixed placeholder values. The point is to show the architecture of the evaluation loop, not to pretend that string matching is enough.

Metric selection matrix:

If you want to know which metric to use, start here.

If You Want to…Use This MetricWhy
Catch retrieval gapsRecall@K, Context SufficiencyFinds missing evidence
Prevent noisy contextPrecision@K, Context RelevanceReduces token bloat
Reward ranking qualityMRR, NDCGEncourages better retrieval order
Detect hallucinationGroundedness, FaithfulnessVerifies claims
Measure final qualityAnswer Relevance, CorrectnessTests end-to-end output
Monitor system healthLatency, Token Count, CostEnsures production viability

Evaluation itself has a cost, especially when you use judge models.

def estimate_evaluation_cost(eval_runs: int, tokens_per_run: int, cost_per_1k_tokens: float = 0.002) -> dict:
total_tokens = eval_runs * tokens_per_run
total_cost = total_tokens * cost_per_1k_tokens / 1000
return {
“eval_runs”: eval_runs,
“tokens_per_run”: tokens_per_run,
“total_tokens”: total_tokens,
“estimated_cost_usd”: round(total_cost, 3)
}

print(estimate_evaluation_cost(1000, 500))

This matters because teams often forget that evaluating 10,000 live turns with an LLM judge is still a budget line.

Generating an evaluation dataset:

A serious RAG system needs a labeled test set.

from typing import List, Dict

def generate_eval_set(source_docs: List[str], num_samples: int = 100) -> List[Dict]:
eval_set = []
for doc in source_docs[:num_samples]:
eval_set.append({
“question”: f”What does this document say about {doc[:50]}?”,
“ground_truth”: doc,
“relevant_chunk”: doc
})
return eval_set

source_docs = [
“Employees get 15 days of leave per year.”,
“Expenses require manager approval.”,
“Remote work is allowed only on approved days.”
]
print(generate_eval_set(source_docs, num_samples=3))

In practice, teams usually generate questions from chunks, then validate them before adding them to the test set.

Test set sizing guidance:

Use CaseSuggested Test SizeWhy
Early prototype25-50 casesQuick signal
Internal beta100-300 casesBetter coverage
Enterprise pilot300-1000 casesMore stable metrics
Production benchmark1000+ casesStrong regression tracking

Complete evaluation pipeline:

from typing import List, Dict, Optional
import time
import json

class RAGEvaluator:
def __init__(self, llm_judge_client=None):
self.judge = llm_judge_client
self.results = []

def evaluate_single(
self,
query: str,
retrieved_docs: List[str],
generated_answer: str,
ground_truth: Optional[str] = None
) -> Dict:
start_time = time.time()

precision = self._calculate_precision(retrieved_docs, query)
recall = self._calculate_recall(retrieved_docs, ground_truth) if ground_truth else None
context_sufficiency = self._check_context_sufficiency(retrieved_docs, query)

answer_relevance = self._check_relevance(generated_answer, query)
groundedness = self._check_groundedness(generated_answer, retrieved_docs)
faithfulness = self._check_faithfulness(generated_answer, retrieved_docs)
correctness = self._check_correctness(generated_answer, ground_truth) if ground_truth else None

latency = time.time() – start_time
token_count = self._estimate_tokens(query, retrieved_docs, generated_answer)

return {
“query”: query,
“retrieval”: {
“precision”: precision,
“recall”: recall,
“context_sufficiency”: context_sufficiency
},
“response”: {
“relevance”: answer_relevance,
“groundedness”: groundedness,
“faithfulness”: faithfulness,
“correctness”: correctness
},
“system”: {
“latency_seconds”: latency,
“estimated_tokens”: token_count
},
“passed”: self._check_thresholds(precision, groundedness, faithfulness)
}

def evaluate_batch(self, test_cases: List[Dict]) -> Dict:
results = []
for case in test_cases:
result = self.evaluate_single(
case[“query”],
case[“retrieved_docs”],
case[“generated_answer”],
case.get(“ground_truth”)
)
results.append(result)

agg = {
“avg_precision”: sum(r[“retrieval”][“precision”] for r in results) / len(results),
“avg_groundedness”: sum(r[“response”][“groundedness”] for r in results) / len(results),
“avg_faithfulness”: sum(r[“response”][“faithfulness”] for r in results) / len(results),
“avg_latency”: sum(r[“system”][“latency_seconds”] for r in results) / len(results),
“pass_rate”: sum(1 for r in results if r[“passed”]) / len(results)
}
return {“results”: results, “aggregate”: agg}

def _calculate_precision(self, docs, query):
terms = set(query.lower().split())
if not docs:
return 0.0
relevant = sum(1 for doc in docs if any(t in doc.lower() for t in terms))
return relevant / len(docs)

def _calculate_recall(self, docs, ground_truth):
if not docs or not ground_truth:
return 0.0
gt_terms = set(ground_truth.lower().split())
doc_text = ” “.join(docs).lower()
matched = sum(1 for t in gt_terms if t in doc_text)
return matched / len(gt_terms) if gt_terms else 0.0

def _check_context_sufficiency(self, docs, query):
if not docs:
return 0.0
joined = ” “.join(docs).lower()
return 1.0 if any(t.lower() in joined for t in query.split()) else 0.0

def _check_relevance(self, answer, query):
return 1.0 if any(t.lower() in answer.lower() for t in query.split()) else 0.0

def _check_groundedness(self, answer, docs):
joined = ” “.join(docs).lower()
return 1.0 if any(term in joined for term in answer.lower().split()[:8]) else 0.0

def _check_faithfulness(self, answer, docs):
return self._check_groundedness(answer, docs)

def _check_correctness(self, answer, ground_truth):
if not ground_truth:
return None
return 1.0 if answer.strip().lower() == ground_truth.strip().lower() else 0.0

def _estimate_tokens(self, query, docs, answer):
return len(query.split()) + sum(len(d.split()) for d in docs) + len(answer.split())

def _check_thresholds(self, precision, groundedness, faithfulness):
return precision > 0.7 and groundedness > 0.8 and faithfulness > 0.8

evaluator = RAGEvaluator()
result = evaluator.evaluate_single(
query=”What is the leave policy?”,
retrieved_docs=[“Employees get 15 days of leave.”, “Leave requires approval.”],
generated_answer=”Employees get 15 days of leave with manager approval.”,
ground_truth=”Employees get 15 days of leave per year.”
)
print(json.dumps(result, indent=2))

Continuous monitoring:

Evaluation should not stop after launch.

class RAGMonitor:
def __init__(self, window: int = 1000):
self.window = window
self.metrics = []

def log_evaluation(self, eval_result: Dict):
self.metrics.append(eval_result)
if len(self.metrics) > self.window:
self.metrics.pop(0)

def get_drift_alert(self) -> Dict:
recent = self.metrics[-min(100, len(self.metrics)):]
old = self.metrics[-min(200, len(self.metrics)):-100]

if not old or not recent:
return {“alert”: False}

avg_recent = sum(m[“response”][“groundedness”] for m in recent) / len(recent)
avg_old = sum(m[“response”][“groundedness”] for m in old) / len(old)

if avg_recent < avg_old – 0.1:
return {“alert”: True, “metric”: “groundedness”, “drop”: avg_old – avg_recent}
return {“alert”: False}

Retrieval-generation correlation analysis:

This is one of the fastest ways to understand where the system is failing.

from typing import List, Dict

def analyze_pipeline_performance(eval_results: List[Dict]) -> Dict:
good_retrieval_bad_generation = 0
bad_retrieval_good_generation = 0
both_good = 0
both_bad = 0

for result in eval_results:
retrieval_ok = result[“retrieval”][“precision”] > 0.7
generation_ok = result[“response”][“groundedness”] > 0.8

if retrieval_ok and not generation_ok:
good_retrieval_bad_generation += 1
elif not retrieval_ok and generation_ok:
bad_retrieval_good_generation += 1
elif retrieval_ok and generation_ok:
both_good += 1
else:
both_bad += 1

total = len(eval_results) if eval_results else 1
return {
“retrieval_problem”: bad_retrieval_good_generation / total,
“generation_problem”: good_retrieval_bad_generation / total,
“both_good”: both_good / total,
“both_bad”: both_bad / total,
“recommendation”: (
“Fix retrieval” if bad_retrieval_good_generation > 0.3 else
“Fix generation” if good_retrieval_bad_generation > 0.3 else
“Both need work” if both_bad > 0.3 else
“System is healthy”
)
}

Why semantic evaluation matters ?

A naive keyword-based evaluator can fail badly in real semantic settings. That is why the standard practice is to use a semantic judge, an NLI verifier, or a framework such as RAGAS instead of raw token overlap. The judge should score meaning, not just word matching.

LLM judge calibration note:

LLM judges need calibration. Validate judge scores against human ratings on a small subset of 50 to 100 examples before trusting them in production. Otherwise, the judge may reward fluent but unsupported answers.

Cost of evaluation code

def estimate_evaluation_cost(eval_runs: int, tokens_per_run: int, cost_per_1k_tokens: float = 0.002) -> dict:
total_tokens = eval_runs * tokens_per_run
total_cost = total_tokens * cost_per_1k_tokens / 1000
return {
“eval_runs”: eval_runs,
“tokens_per_run”: tokens_per_run,
“total_tokens”: total_tokens,
“estimated_cost_usd”: round(total_cost, 3)
}

print(estimate_evaluation_cost(1000, 500))

That gives teams a real answer to the question: “How much will evaluation cost?”

It also matters for p95 latency and token cost. Over-retrieving to compensate for weak evaluation creates token bloat and slows the entire system.

Evaluation approach comparison

Evaluation MethodSpeedAccuracyCostBest For
Manual ReviewSlowHighHighHigh-risk spot checks
Keyword OverlapFastLowLowEarly sanity checks
Semantic SimilarityMediumMediumLowBroad automated scoring
LLM JudgeMediumMedium-HighMediumProduction evaluation
NLI / Verification ModelMediumHighLow-MediumGroundedness checking
RAGAS FrameworkMediumHighMediumFull evaluation suite

Quick-reference card

Glossary

 

RAG evaluation is not a single score. It is a layered measurement system that tells you whether your retriever is finding the right evidence, whether your generator is using it correctly, and whether the system is still safe, grounded, and affordable at scale. If you only measure “does it look good?”, you are not evaluating RAG. You are guessing. The best teams measure retrieval quality, groundedness, answer relevance, ranking quality, cost, latency, and drift together — then use those metrics to improve the pipeline continuously.

 

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
// STILL BROWSING?
Build along, don't just read.
Get labs & articles matched to what you're into — free, takes 30 seconds.
Start building free
0
Would love your thoughts, please comment.x
()
x