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.

The metric spectrum priced per thousand evaluations, from string match to human review
The metric spectrum priced per thousand evaluations, from string match to human review

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.
python
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.
python
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.85

The 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=i=1k(2reli1)log2(i+1)\text{DCG@}k = \sum_{i = 1}^{{}k} \frac{(2^{{}\text{rel}_{i}} - 1)}{\log_{2}(i + 1)}. Normalized by the ideal ranking's DCG. Range: 0-1.
python
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.72

For 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.
python
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.85

Choosing 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.

← Previous