Evaluation metrics for LLM outputs
Why this matters
Every LLM feature you build needs a measurement plan. The engineering challenge is that LLM quality is not binary -- a response can be partially correct, well-written but unfaithful, or accurate but harmful. Single-number accuracy scores hide the structure of failures. This lesson gives you the vocabulary and tooling to decompose quality into measurable, actionable dimensions.
For a senior engineer transitioning into AI roles, evaluation fluency is a visible differentiator. Candidates who can specify a metric before writing a prompt signal production thinking.
Core concepts
Accuracy and task success. For tasks with deterministic correct answers -- classification, structured extraction, code generation -- exact-match or schema-validation accuracy is your first metric. It is cheap to compute and easy to track over time.
Faithfulness (groundedness). When your system retrieves context before answering -- as in RAG -- faithfulness measures whether the generated answer is supported by the retrieved documents. A faithful answer does not invent facts; every claim traces back to a source chunk. Faithfulness is distinct from correctness: a faithfulness score of 1.0 means the answer is grounded, not that the context was correct.
Relevance. Answer relevance measures whether the response actually addresses the user's question. A response can be faithful (grounded in context) but irrelevant (the context did not contain what the user needed). Context relevance measures how much of the retrieved context was actually useful for generating the answer.
Toxicity and safety. Toxicity scoring uses classifiers (Perspective API, custom fine-tuned models, or LLM-based judges) to detect harmful or inappropriate outputs. In production, you typically score a sample of outputs and alert when toxicity rates exceed a threshold.
Custom domain metrics. Most production systems need at least one metric specific to the task. A coding assistant might track whether generated code passes a test suite. A summarization feature might track compression ratio and coverage of key entities. Define these before you ship.
LLM-as-judge. When the correct answer is not enumerable -- open-ended generation, nuanced reasoning, style evaluation -- a language model grades the output. The judge receives the question, the response, and a rubric, then returns a score and rationale. Two main patterns:
- Reference-based: the judge compares the response to a gold reference answer.
- Reference-free: the judge scores the response against criteria only (coherence, helpfulness, groundedness).
LLM-as-judge scales to tasks where human annotation is prohibitively expensive, but it inherits model biases. Judges tend to prefer longer answers and their own style. Mitigate this with structured rubrics, calibration against human labels, and cross-model judging (do not use GPT-4o to judge GPT-4o outputs uncritically).
Deterministic scoring. For structured outputs -- JSON extraction, SQL generation, date parsing -- deterministic scoring is faster, cheaper, and more reproducible than LLM judges. Parse the output, validate against a schema, and compute field-level accuracy.
When to use which approach:
| Scenario | Use |
|---|---|
| Classification, extraction, structured output | Deterministic exact-match or schema validation |
| RAG answer quality | Faithfulness + answer relevance (LLM judge or RAGAS) |
| Open-ended generation quality | LLM-as-judge with explicit rubric |
| Safety screening | Classifier before LLM call |
| Regression detection | Deterministic for structured, embedding similarity for text |
Working example
A self-contained evaluation runner that handles both deterministic and LLM-as-judge scoring:
from dataclasses import dataclass, field
from typing import Callable, Literal
import json
@dataclass
class EvalCase:
case_id: str
input: str
expected: str
metadata: dict = field(default_factory=dict)
@dataclass
class EvalResult:
case_id: str
score: float
passed: bool
rationale: str
metric: str
def exact_match_scorer(response: str, expected: str) -> EvalResult:
norm_r = response.strip().lower()
norm_e = expected.strip().lower()
score = 1.0 if norm_r == norm_e else 0.0
return EvalResult(case_id='', score=score, passed=score == 1.0,
rationale=f'matched: {score == 1.0}', metric='exact_match')
def llm_judge_scorer(
question: str,
response: str,
context: str,
judge_fn: Callable[[str], str],
metric: Literal['faithfulness', 'relevance', 'helpfulness'] = 'helpfulness',
) -> EvalResult:
rubrics = {
'faithfulness': 'Does the response make only claims supported by the context? Score 1.0 if fully grounded, 0.5 if partial, 0.0 if not.',
'relevance': 'Does the response directly address the question? Score 1.0 if fully on-topic, 0.5 if partial, 0.0 if off-topic.',
'helpfulness': 'Would a real user find this helpful? Score 1.0 if clearly helpful, 0.5 if partly useful, 0.0 if not.',
}
judge_prompt = (
f'Evaluate this response.\nQuestion: {question}\nContext: {context}\nResponse: {response}\n\n'
f'Rubric: {rubrics[metric]}\n\nReturn JSON: {{"score": float, "rationale": str}}'
)
raw = judge_fn(judge_prompt)
try:
result = json.loads(raw)
score = max(0.0, min(1.0, float(result['score'])))
rationale = result.get('rationale', '')
except (json.JSONDecodeError, KeyError, ValueError):
score, rationale = 0.5, f'Parse error: {raw[:100]}'
return EvalResult(case_id='', score=score, passed=score >= 0.7, rationale=rationale, metric=metric)
def run_eval(
cases: list[EvalCase],
generate_fn: Callable[[str], str],
score_fn: Callable[[str, EvalCase], EvalResult],
) -> dict:
results = []
for case in cases:
response = generate_fn(case.input)
result = score_fn(response, case)
result.case_id = case.case_id
results.append(result)
total = len(results)
passed = sum(1 for r in results if r.passed)
avg_score = sum(r.score for r in results) / total if total > 0 else 0.0
return {
'total': total, 'passed': passed,
'pass_rate': passed / total if total > 0 else 0.0,
'avg_score': avg_score,
'failures': [{'case_id': r.case_id, 'score': r.score} for r in results if not r.passed],
}
The key design: scorers are pure functions that take a response and return a typed result. The runner separates generation from scoring, so you can swap scorers without touching the generation logic.
Common mistakes
-
Using one metric for everything. A single accuracy score averaged across all cases hides the pattern of failures. A system that fails 100% of multi-hop questions and passes 100% of simple lookups looks like 50% accuracy -- which tells you nothing actionable.
-
LLM judge without a rubric. Asking a model to rate quality without specifying criteria produces inconsistent, biased results. Rubrics should be specific enough that two humans reading them would make the same scoring decision.
-
Not calibrating your judge. Before trusting LLM-as-judge scores, label 30-50 cases manually and compare. If they disagree on more than 20% of cases, your rubric needs work.
-
Ignoring the cost of scoring. Running GPT-4o as a judge on every production response is expensive. Score 5-10% of traffic with the expensive judge; run cheaper deterministic checks on everything.
-
Treating toxicity as a binary flag. Toxicity exists on a spectrum. Set thresholds relative to your use case.
Try it yourself
Implement a faithfulness_scorer function that takes a response string and a list of context chunks. Using an LLM judge, score whether every factual claim in the response is supported by at least one context chunk. Return a FaithfulnessResult dataclass with: a score (0.0-1.0), a list of claims the judge identified, and a list of unsupported claims. Test it on three cases: one fully faithful response, one with one hallucinated fact, and one that ignores the context entirely.