Metrics that matter
Metrics that matter
Every evaluation metric compresses a complex judgment into a single number. The choice of metric determines what you optimize for and what failures you tolerate. A faithfulness metric ignores fluency. A BLEU score rewards lexical overlap regardless of factual accuracy. Picking the wrong metric is worse than not evaluating — it creates a false sense of quality while the system fails on dimensions you aren't measuring.
Classification metrics for structured outputs
When an LLM produces structured outputs — sentiment labels, intent classification, entity extraction, yes/no decisions — standard classification metrics apply directly.
- Precision:
TP / (TP + FP). Of everything the model labeled positive, what fraction was actually positive? High precision means few false alarms. - Recall:
TP / (TP + FN). Of everything that was actually positive, what fraction did the model find? High recall means few missed cases. - F1:
2 * precision * recall / (precision + recall). Harmonic mean of precision and recall. Penalizes imbalance — a model with 99% precision and 1% recall gets F1 = 0.02. - Macro F1: Unweighted average of per-class F1 scores. Treats rare classes equally with common ones.
- Micro F1: Equivalent to accuracy when classes are mutually exclusive.
from sklearn.metrics import classification_report, precision_recall_fscore_support
import json
from openai import OpenAI
client = OpenAI()
test_cases = [
{"text": "I love this product!", "true_label": "positive"},
{"text": "Terrible experience, want a refund", "true_label": "negative"},
{"text": "It arrived on Tuesday", "true_label": "neutral"},
{"text": "Best purchase I've made all year", "true_label": "positive"},
{"text": "Completely broken, doesn't work", "true_label": "negative"},
{"text": "The package was blue", "true_label": "neutral"},
]
predictions = []
for case in test_cases:
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Classify sentiment as positive, negative, or neutral. Reply with only the label."},
{"role": "user", "content": case["text"]}
],
temperature=0.0
)
predictions.append(r.choices[0].message.content.strip().lower())
true_labels = [c["true_label"] for c in test_cases]
print(classification_report(true_labels, predictions, digits=3))
precision, recall, f1, _ = precision_recall_fscore_support(
true_labels, predictions, average="macro"
)
print(f"Macro P/R/F1: {precision:.3f} / {recall:.3f} / {f1:.3f}")For extraction tasks (NER, slot filling), compute metrics at the entity level: a predicted entity matches if both the span boundaries and the label are correct (strict match) or if spans overlap (partial match). GPT-4o achieves ~0.89 strict-match F1 on CoNLL-2003 NER zero-shot (Wang et al. 2023).
Generation metrics and their limitations
Traditional generation metrics compare a candidate output against one or more reference strings:
- BLEU (Papineni et al. 2002): Geometric mean of n-gram precisions (1-4 grams) with a brevity penalty. Originally designed for machine translation. Range: 0-1 (often reported as 0-100). A BLEU score of 0.30+ is considered good for MT.
- ROUGE-L (Lin 2004): Longest common subsequence between candidate and reference, normalized by reference length. Designed for summarization. Captures word order better than BLEU.
- BERTScore (Zhang et al. 2020): Computes cosine similarity between contextual embeddings (BERT) of candidate and reference tokens, then takes a greedy alignment. Correlates with human judgment at r=0.73 on WMT17, compared to BLEU's r=0.25.
from evaluate import load
import numpy as np
bleu_metric = load("bleu")
rouge_metric = load("rouge")
bertscore_metric = load("bertscore")
references = ["The cat sat on the mat."]
candidates_good = ["A cat was sitting on the mat."]
candidates_bad = ["The dog ran through the park."]
bleu_good = bleu_metric.compute(predictions=candidates_good, references=[references])
bleu_bad = bleu_metric.compute(predictions=candidates_bad, references=[references])
print(f"BLEU (good): {bleu_good['bleu']:.3f}") # ~0.35
print(f"BLEU (bad): {bleu_bad['bleu']:.3f}") # ~0.05
rouge_good = rouge_metric.compute(predictions=candidates_good, references=references)
rouge_bad = rouge_metric.compute(predictions=candidates_bad, references=references)
print(f"ROUGE-L (good): {rouge_good['rougeL']:.3f}") # ~0.70
print(f"ROUGE-L (bad): {rouge_bad['rougeL']:.3f}") # ~0.20
bert_good = bertscore_metric.compute(
predictions=candidates_good, references=references, lang="en"
)
bert_bad = bertscore_metric.compute(
predictions=candidates_bad, references=references, lang="en"
)
print(f"BERTScore F1 (good): {np.mean(bert_good['f1']):.3f}") # ~0.95
print(f"BERTScore F1 (bad): {np.mean(bert_bad['f1']):.3f}") # ~0.85The critical limitation: these metrics require reference answers. For open-ended generation (creative writing, complex reasoning, multi-step explanations), there is no single correct reference. BLEU and ROUGE correlate poorly with human judgment in these settings — Pearson r of 0.25-0.40 on summarization tasks (Fabbri et al. 2021). BERTScore improves to r=0.50-0.73 depending on the domain but still misses factual errors (a fluent, well-structured hallucination scores high on BERTScore).
Retrieval metrics for RAG
RAG systems have a retrieval stage that selects context chunks before generation. Retrieval quality directly bounds generation quality — if the right information isn't retrieved, the generator cannot produce a correct answer regardless of its capability.
- Recall@k: Fraction of relevant documents in the top-k retrieved results.
recall@5 = |relevant ∩ retrieved_top5| / |relevant|. The most important retrieval metric for RAG — you need the right context to be in the window. - Precision@k: Fraction of top-k results that are relevant.
precision@5 = |relevant ∩ retrieved_top5| / k. Matters when context window is expensive (more irrelevant chunks = more noise for the generator). - MRR (Mean Reciprocal Rank):
1/rank_of_first_relevant_result, averaged across queries. MRR=1.0 means the first result is always relevant. - NDCG@k (Normalized Discounted Cumulative Gain): Accounts for graded relevance (not just binary) and position.
DCG@k = sum_{i=1}^{k} (2^{rel_i} - 1) / log2(i+1). Normalized by the ideal ranking's DCG. Range: 0-1.
import numpy as np
def recall_at_k(relevant_ids, retrieved_ids, k):
retrieved_top_k = set(retrieved_ids[:k])
relevant_set = set(relevant_ids)
return len(retrieved_top_k & relevant_set) / len(relevant_set)
def mrr(relevant_ids, retrieved_ids):
relevant_set = set(relevant_ids)
for i, doc_id in enumerate(retrieved_ids):
if doc_id in relevant_set:
return 1.0 / (i + 1)
return 0.0
def ndcg_at_k(relevance_scores, k):
dcg = sum(
(2**rel - 1) / np.log2(i + 2)
for i, rel in enumerate(relevance_scores[:k])
)
ideal = sorted(relevance_scores, reverse=True)[:k]
idcg = sum(
(2**rel - 1) / np.log2(i + 2)
for i, rel in enumerate(ideal)
)
return dcg / idcg if idcg > 0 else 0.0
relevant = ["doc_3", "doc_7", "doc_12"]
retrieved = ["doc_1", "doc_3", "doc_5", "doc_7", "doc_9", "doc_12", "doc_15"]
print(f"Recall@3: {recall_at_k(relevant, retrieved, 3):.3f}") # 0.333
print(f"Recall@5: {recall_at_k(relevant, retrieved, 5):.3f}") # 0.667
print(f"Recall@7: {recall_at_k(relevant, retrieved, 7):.3f}") # 1.000
print(f"MRR: {mrr(relevant, retrieved):.3f}") # 0.500
graded_relevance = [0, 2, 0, 2, 0, 1, 0] # per retrieved doc
print(f"NDCG@5: {ndcg_at_k(graded_relevance, 5):.3f}") # ~0.72For production RAG systems, target recall@5 >= 0.85 and MRR >= 0.60. Below these thresholds, the generator is missing critical context on >15% of queries.
Faithfulness, relevance, and coherence
For open-ended generation where reference-based metrics fail, you need reference-free metrics that evaluate intrinsic qualities:
- Faithfulness/Groundedness: Is every claim in the output supported by the provided context? Measured by decomposing the output into atomic claims and checking each against the source. A faithfulness score of 0.90 means 10% of claims are hallucinated.
- Answer relevance: Does the output actually address the user's question? An output can be faithful (everything it says is true) but irrelevant (it answers a different question).
- Coherence: Is the output logically structured and internally consistent? Sentences follow a clear progression, no contradictions, appropriate transitions.
- Completeness: Does the output cover all aspects of the question? Relevant but incomplete answers miss key information.
from deepeval.metrics import (
FaithfulnessMetric,
AnswerRelevancyMetric,
GEval
)
from deepeval.test_case import LLMTestCase
faithfulness = FaithfulnessMetric(threshold=0.8, model="gpt-4o")
relevance = AnswerRelevancyMetric(threshold=0.7, model="gpt-4o")
coherence = GEval(
name="Coherence",
criteria="Evaluate whether the output is logically structured with clear transitions between ideas.",
evaluation_params=["actual_output"],
model="gpt-4o",
threshold=0.7
)
test_case = LLMTestCase(
input="What causes inflation?",
actual_output="Inflation occurs when the general price level rises. Key causes include demand-pull (excess demand), cost-push (rising production costs), and monetary expansion (increasing money supply faster than GDP growth).",
retrieval_context=[
"Inflation is a sustained increase in the general price level. Demand-pull inflation happens when aggregate demand exceeds supply. Cost-push inflation occurs when production costs rise. Monetary inflation results from excess money supply growth."
]
)
faithfulness.measure(test_case)
relevance.measure(test_case)
coherence.measure(test_case)
print(f"Faithfulness: {faithfulness.score:.2f}") # ~0.95
print(f"Relevance: {relevance.score:.2f}") # ~0.90
print(f"Coherence: {coherence.score:.2f}") # ~0.85Choosing metrics by use case
Different application types demand different metric combinations:
- Chatbot/QA: Faithfulness (primary), answer relevance, recall@5 for retrieval. Tolerance for hallucination: near zero.
- Summarization: ROUGE-L for regression testing, faithfulness for factual accuracy, completeness for coverage. BERTScore as a sanity check.
- Code generation: pass@k (functional correctness via test execution), exact match for simple outputs, compilation rate as a floor metric.
- Classification/extraction: Precision, recall, F1 per class. Macro F1 as the aggregate.
- Creative writing: Coherence, relevance, human preference ranking. No faithfulness requirement.
The cheapest metric that correlates >0.70 with human judgment on your task distribution is the right one for CI. Use more expensive metrics (LLM-as-judge, human review) for periodic audits and calibration.