LLM-as-Judge lets you evaluate thousands of AI responses automatically — but only if your rubric is sharp, your judge is calibrated, and your sampling strategy is deliberate. This lesson teaches you to build evaluation pipelines that actually predict human judgment, with production-grade code, calibration statistics, and regression detection.

You've built a production LLM application. Users are querying it thousands of times a day, and your product team wants to know: is it actually good? Not "does it return a response" — that's table stakes — but is the quality of those responses high? Are they accurate, appropriately confident, well-structured, and safe? Human review at scale is expensive and slow. Rule-based metrics like BLEU and ROUGE miss the nuance that matters for open-ended generation. This is exactly the problem that LLM-as-Judge evaluation pipelines were designed to solve.
In this lesson, you'll build a complete automated quality scoring system from the ground up. You'll learn how to design evaluation rubrics that capture what actually matters for your application, implement judge prompts that produce consistent and interpretable scores, calibrate those scores against human ratings to quantify their reliability, and operate the whole thing in production at scale. This isn't theoretical — by the end, you'll have working code, a calibration methodology, and a clear mental model of where these systems succeed and where they quietly fail.
What you'll learn:
You should be comfortable with the OpenAI API or a comparable LLM provider, Python async patterns, and basic statistical concepts like correlation and variance. Experience with prompt engineering fundamentals — especially system prompts and few-shot examples — is essential. If you haven't yet thought carefully about how to structure your LLM outputs as machine-readable data, read the guide on structured output first, because our judge will need to return JSON scores, not prose.
Before we build anything, let's be honest about why this approach exists and what it actually solves.
Traditional NLP metrics — BLEU, ROUGE, BERTScore — measure token-level similarity to a reference answer. They work reasonably well for narrow tasks like machine translation where a "gold standard" response exists. For most production LLM applications, this assumption falls apart. If your customer support bot correctly answers a billing question but uses different phrasing than your reference, ROUGE will penalize it. If it hallucinates a policy that doesn't exist but matches the reference structure, ROUGE won't catch it.
LLM-as-Judge sidesteps this by using a capable language model to evaluate the semantic and qualitative properties of a response, the same way a human reviewer would. The judge reads the input, the response, and optionally a reference answer or retrieved context, then scores along dimensions you define: accuracy, helpfulness, safety, tone, etc.
The practical argument is compelling: you can evaluate tens of thousands of responses overnight at a fraction of the cost of human annotation. The catches are real too: LLM judges have systematic biases (they tend to favor longer responses, prefer outputs from the same model family, and are sensitive to prompt wording), they can hallucinate their own evaluations, and their correlation with human judgment degrades in specialized domains. This lesson will teach you how to handle all of that.
Key insight: LLM-as-Judge is not a replacement for human evaluation — it's a force multiplier. The goal is to build a judge that agrees with human raters at a high enough rate that you can use it for continuous monitoring, catching regressions quickly, and flagging a manageable subset for human review.
The single biggest predictor of a useful evaluation pipeline is rubric quality. A vague rubric produces vague scores that tell you nothing actionable. Let's build this properly.
Before writing a single rubric criterion, collect 50-100 examples of real responses that were flagged as bad by users or your internal team. Cluster them by failure type. For a RAG-based question-answering system, you'll typically see:
Your rubric dimensions should map directly onto these failure categories. If you design an abstract rubric disconnected from your real failure modes, you'll build an evaluator that scores well on dimensions that don't matter.
Each rubric dimension needs to satisfy three properties:
Here's a concrete example for a customer service QA application. We'll use a 1-5 scale with behaviorally anchored descriptions:
RUBRIC = {
"factual_accuracy": {
"description": "Does the response correctly represent the information in the provided context?",
"scale": {
1: "Contains one or more direct factual errors or contradictions of the provided context.",
2: "Contains a significant omission or a misleading implication that could cause the user to form an incorrect belief.",
3: "Factually correct but incomplete — relevant facts from the context are not surfaced.",
4: "Factually correct and reasonably complete, with no misleading implications.",
5: "Factually accurate, complete, and appropriately qualified with uncertainty where the context is ambiguous.",
}
},
"answer_completeness": {
"description": "Does the response address all parts of the user's question?",
"scale": {
1: "The response is entirely off-topic or fails to address the question.",
2: "The response addresses the question but misses a major component (e.g., only answers one of two sub-questions).",
3: "The response addresses the main question but omits a meaningful secondary component.",
4: "The response addresses all components of the question with only minor gaps.",
5: "The response fully and directly addresses every component of the question.",
}
},
"appropriate_confidence": {
"description": "Does the response express appropriate confidence given the available information?",
"scale": {
1: "States uncertain information as definite fact, or refuses to answer a question the context clearly supports.",
2: "Significant overconfidence or underconfidence relative to what the context supports.",
3: "Generally appropriate confidence with one noticeable misstep.",
4: "Appropriate confidence throughout, with minor hedging imprecision.",
5: "Confidence perfectly calibrated to the evidence; uncertainty explicitly acknowledged where appropriate.",
}
},
"response_quality": {
"description": "Is the response well-structured, appropriately concise, and easy to understand?",
"scale": {
1: "Disorganized, excessively verbose, or so terse as to be unhelpful.",
2: "Noticeable structural or length problems that impede comprehension.",
3: "Readable and appropriately structured, but with some verbosity or organizational awkwardness.",
4: "Well-structured and appropriately sized, easy to read and act on.",
5: "Exceptionally clear, concise, and well-organized for the specific question asked.",
}
}
}
Notice what we've done: each score level is described in terms of observable behaviors, not abstract qualities. "Contains one or more direct factual errors" is testable. "Poor quality" is not.
Warning: Avoid rubric dimensions that require the judge to have knowledge it can't possibly have from the prompt alone. For instance, "accuracy against our internal knowledge base" requires grounding the judge with that knowledge base. If you can't include the ground-truth context in the judge's prompt, don't score it as a separate dimension — fold it into a context-grounded accuracy dimension with the context included.
Research on human inter-rater agreement and LLM judge consistency both suggest that quality starts degrading above five or six dimensions. Beyond that, you get:
For most applications, three to five well-designed dimensions with clear behavioral anchors will outperform seven or eight loosely defined ones. If you genuinely need more, consider splitting into separate evaluation passes.
With a rubric in hand, the judge prompt is where most implementations go wrong. Let's build it correctly.
A well-constructed judge prompt has five components, in this order:
Here's a production-grade implementation:
import json
from openai import AsyncOpenAI
from typing import Optional
client = AsyncOpenAI()
JUDGE_SYSTEM_PROMPT = """You are an expert evaluator for a customer service AI assistant. Your task is to assess the quality of AI-generated responses along multiple dimensions using a provided rubric.
You must evaluate objectively. Do not let response length, writing style, or similarity to your own outputs influence your scores. Focus exclusively on whether each dimension's criteria are met.
EVALUATION RUBRIC:
1. FACTUAL_ACCURACY (1-5)
1 = Contains one or more direct factual errors or contradictions of the provided context.
2 = Contains a significant omission or a misleading implication that could cause the user to form an incorrect belief.
3 = Factually correct but incomplete — relevant facts from the context are not surfaced.
4 = Factually correct and reasonably complete, with no misleading implications.
5 = Factually accurate, complete, and appropriately qualified with uncertainty where the context is ambiguous.
2. ANSWER_COMPLETENESS (1-5)
1 = The response is entirely off-topic or fails to address the question.
2 = The response addresses the question but misses a major component.
3 = The response addresses the main question but omits a meaningful secondary component.
4 = The response addresses all components of the question with only minor gaps.
5 = The response fully and directly addresses every component of the question.
3. APPROPRIATE_CONFIDENCE (1-5)
1 = States uncertain information as definite fact, or refuses to answer a question the context clearly supports.
2 = Significant overconfidence or underconfidence relative to what the context supports.
3 = Generally appropriate confidence with one noticeable misstep.
4 = Appropriate confidence throughout, with minor hedging imprecision.
5 = Confidence perfectly calibrated to the evidence.
4. RESPONSE_QUALITY (1-5)
1 = Disorganized, excessively verbose, or so terse as to be unhelpful.
2 = Noticeable structural or length problems that impede comprehension.
3 = Readable and appropriately structured, but with some verbosity or organizational awkwardness.
4 = Well-structured and appropriately sized, easy to read and act on.
5 = Exceptionally clear, concise, and well-organized for the specific question asked.
SCORING PROCESS:
- First, write a brief rationale (2-4 sentences) for each dimension.
- Then assign the integer score.
- Be conservative: only assign 5 if the response genuinely excels; only assign 1 if there is a clear failure.
- If the context does not contain enough information to evaluate a dimension, note this in the rationale and score 3 as a neutral baseline.
"""
JUDGE_USER_TEMPLATE = """Please evaluate the following AI response.
USER QUERY:
{user_query}
RETRIEVED CONTEXT:
{retrieved_context}
AI RESPONSE TO EVALUATE:
{ai_response}
{reference_section}
Return your evaluation as a JSON object with this exact structure:
{{
"factual_accuracy": {{
"rationale": "<your reasoning>",
"score": <integer 1-5>
}},
"answer_completeness": {{
"rationale": "<your reasoning>",
"score": <integer 1-5>
}},
"appropriate_confidence": {{
"rationale": "<your reasoning>",
"score": <integer 1-5>
}},
"response_quality": {{
"rationale": "<your reasoning>",
"score": <integer 1-5>
}},
"overall_assessment": "<one sentence summary of the response's main strengths and weaknesses>"
}}
"""
async def evaluate_response(
user_query: str,
retrieved_context: str,
ai_response: str,
reference_answer: Optional[str] = None,
judge_model: str = "gpt-4o",
temperature: float = 0.0
) -> dict:
"""
Run LLM-as-Judge evaluation on a single response.
Returns parsed scores or raises on parse failure.
"""
reference_section = ""
if reference_answer:
reference_section = f"\nREFERENCE ANSWER (use only to verify factual accuracy, not as a style template):\n{reference_answer}\n"
user_message = JUDGE_USER_TEMPLATE.format(
user_query=user_query,
retrieved_context=retrieved_context,
ai_response=ai_response,
reference_section=reference_section
)
response = await client.chat.completions.create(
model=judge_model,
messages=[
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": user_message}
],
temperature=temperature,
response_format={"type": "json_object"},
seed=42 # For reproducibility during calibration
)
raw_content = response.choices[0].message.content
try:
evaluation = json.loads(raw_content)
# Validate structure
required_keys = ["factual_accuracy", "answer_completeness",
"appropriate_confidence", "response_quality",
"overall_assessment"]
for key in required_keys:
if key not in evaluation:
raise ValueError(f"Missing key: {key}")
# Validate score ranges
score_dims = ["factual_accuracy", "answer_completeness",
"appropriate_confidence", "response_quality"]
for dim in score_dims:
score = evaluation[dim].get("score")
if not isinstance(score, int) or score < 1 or score > 5:
raise ValueError(f"Invalid score for {dim}: {score}")
return evaluation
except (json.JSONDecodeError, ValueError, KeyError) as e:
# Log the raw content for debugging
raise ValueError(f"Judge returned malformed output: {e}\nRaw: {raw_content[:500]}")
Notice that we require the judge to write a rationale before assigning each score. This isn't just nice-to-have — it's a significant reliability improvement. When LLMs articulate their reasoning first, they're less susceptible to anchoring on superficial features and more likely to catch their own errors before committing to a score. This mirrors the "think before you answer" effect documented in CoT research.
The rationale also gives you something critically important: an explanation for each score. When a judge marks a response as 2/5 on factual accuracy, you want to know exactly what it identified as wrong, not just that it gave a low score.
Tip: Always store the rationale in your evaluation database, not just the scores. Six months from now when you're debugging why accuracy scores dropped, the rationales will be far more diagnostic than the numbers alone.
LLM judges have documented systematic biases you need to actively counteract. Ignoring them is the most common reason evaluation pipelines produce numbers that don't match human judgment.
When comparing two responses (A vs. B format), LLM judges show a consistent preference for whichever response appears first in the prompt. This is well-documented and substantial — preference for the first option can be 10-20 percentage points above chance.
Mitigation: For pairwise comparisons, always evaluate both orderings and take the majority or average. For single-response absolute scoring (which is what we're doing here), position bias isn't a concern, but if you add pairwise comparison capabilities later, build the flip-and-average logic in from the start.
LLM judges tend to score longer responses higher, even controlling for content quality. A 400-word response will often outscore an equivalent 150-word response on metrics like "completeness" and "helpfulness" — even if the longer response is padded and the shorter one is tight.
Mitigation: Your rubric anchors should explicitly define what "appropriate length" means for your application. Include examples in your system prompt that demonstrate a concise, high-scoring response. If verbosity bias is persistent in your calibration data, add an explicit instruction: "Length is not a proxy for quality. A 150-word response that fully addresses the question scores higher than a 400-word response that adds unnecessary context."
Models from the same family tend to rate each other's outputs higher. If your production system uses GPT-4o and your judge is also GPT-4o, you may be systematically overestimating quality. This is particularly acute when the judge was trained on similar RLHF data that rewards the same stylistic patterns.
Mitigation: During calibration (covered next), check whether your GPT-4o judge agrees more highly with GPT-4o outputs than with Claude outputs when human raters rate them equivalently. If so, consider:
Warning: Self-enhancement bias is subtle and won't show up unless you run your calibration study with outputs from multiple model families. If you only calibrate on your production model's outputs, you'll never see this bias in your calibration data — it will just quietly inflate your scores.
If you provide a reference answer, judges have a strong tendency to score responses highly when they match the reference's phrasing, even when a different phrasing is equally correct. Conversely, a response that is better than the reference (more accurate, better structured) may score lower simply because it looks different.
Mitigation: In your prompt, explicitly instruct the judge about how to use the reference: "Use the reference answer only to verify factual accuracy. Do not penalize stylistic differences. Do not anchor on the reference answer's structure or phrasing."
You now have a functioning judge. But how much do you trust it? The only honest answer comes from calibration: systematically comparing judge scores to human ratings and quantifying their agreement.
You need a calibration dataset with these properties:
The calibration procedure:
The right metric depends on your scale and what failure mode you care about:
from scipy import stats
from sklearn.metrics import cohen_kappa_score
import numpy as np
from typing import List, Dict
def compute_calibration_metrics(
human_scores: List[int],
judge_scores: List[int],
dimension_name: str
) -> Dict[str, float]:
"""
Computes calibration metrics between human and LLM judge scores.
human_scores: list of human ratings (e.g., average of multiple raters, rounded)
judge_scores: list of LLM judge ratings for same examples
"""
human_arr = np.array(human_scores)
judge_arr = np.array(judge_scores)
# Spearman rank correlation — use this as primary metric
# More robust than Pearson for ordinal scales
spearman_r, spearman_p = stats.spearmanr(human_arr, judge_arr)
# Pearson correlation — useful supplementary metric
pearson_r, pearson_p = stats.pearsonr(human_arr, judge_arr)
# Cohen's Kappa with linear weights — accounts for ordinal proximity
# Two raters that differ by 1 point are penalized less than those differing by 4
kappa = cohen_kappa_score(human_scores, judge_scores, weights='linear')
# Exact agreement rate
exact_agreement = np.mean(human_arr == judge_arr)
# Adjacent agreement (within 1 point)
adjacent_agreement = np.mean(np.abs(human_arr - judge_arr) <= 1)
# Mean absolute error
mae = np.mean(np.abs(human_arr - judge_arr))
# Systematic bias: is the judge consistently higher or lower?
mean_bias = np.mean(judge_arr - human_arr) # positive = judge inflates
print(f"\n=== Calibration Report: {dimension_name} ===")
print(f"Spearman r: {spearman_r:.3f} (p={spearman_p:.4f})")
print(f"Pearson r: {pearson_r:.3f} (p={pearson_p:.4f})")
print(f"Linear-weighted κ: {kappa:.3f}")
print(f"Exact agreement: {exact_agreement:.1%}")
print(f"Adjacent agreement: {adjacent_agreement:.1%}")
print(f"Mean absolute error: {mae:.2f} points")
print(f"Systematic bias: {mean_bias:+.2f} points (+ = judge inflates)")
return {
"spearman_r": spearman_r,
"pearson_r": pearson_r,
"linear_weighted_kappa": kappa,
"exact_agreement": exact_agreement,
"adjacent_agreement": adjacent_agreement,
"mae": mae,
"mean_bias": mean_bias
}
def compute_inter_rater_agreement(
rater1_scores: List[int],
rater2_scores: List[int],
dimension_name: str
) -> float:
"""
Computes human-human agreement to establish a ceiling for judge calibration.
Your judge-human agreement should be at least 80% of human-human agreement.
"""
kappa = cohen_kappa_score(rater1_scores, rater2_scores, weights='linear')
print(f"Human-human κ ({dimension_name}): {kappa:.3f}")
return kappa
Here are the thresholds to use for production decision-making:
| Metric | Acceptable | Good | Excellent |
|---|---|---|---|
| Spearman r | ≥ 0.65 | ≥ 0.75 | ≥ 0.85 |
| Linear-weighted κ | ≥ 0.40 | ≥ 0.55 | ≥ 0.70 |
| Adjacent agreement | ≥ 75% | ≥ 85% | ≥ 92% |
| Judge/human ratio | ≥ 70% | ≥ 80% | ≥ 90% |
The "judge/human ratio" is the ratio of your judge-human κ to your human-human κ for the same dimension. If human raters agree at κ = 0.72 and your judge agrees with humans at κ = 0.58, your ratio is 80.6% — decent, not great. This ratio matters because it tells you what fraction of human-level discrimination your judge achieves.
Key insight: If a rubric dimension consistently produces low calibration scores (say, Spearman r < 0.5), this often means the dimension is ambiguously defined — not that LLMs can't evaluate it. Go back to the rubric anchors, look at the examples where human and judge disagree, and refine the language before concluding the judge is incapable.
If your calibration reveals a consistent +0.4 point inflation in the judge's scores (judge rates things higher than humans), you have two options:
Option 1: Score correction (simple, fragile) Apply a linear correction based on calibration data. This is fast but can overfit to calibration distribution.
def apply_bias_correction(
raw_score: float,
mean_bias: float,
scale_min: int = 1,
scale_max: int = 5
) -> float:
"""Subtract systematic bias and clamp to valid range."""
corrected = raw_score - mean_bias
return max(scale_min, min(scale_max, corrected))
Option 2: Rubric refinement (better, more work) Go back to your rubric anchors and figure out why the judge inflates scores on a specific dimension. Often you'll find that the rubric language itself is ambiguous at the high end — for instance, "factually accurate" without a clear definition of what "complete" means will cause judges to give benefit of the doubt. Make the 5-anchor harder to achieve by being more specific about what earns it.
One detail that breaks many evaluation pipelines: if you sample randomly from production logs, you'll get a heavily skewed distribution of quality (most responses are mediocre, fewer are excellent or terrible). Training your calibration on this distribution will give you poor estimates of judge accuracy at the tails — exactly where accurate discrimination matters most.
Use stratified sampling to ensure coverage across the quality range:
import random
from collections import defaultdict
from typing import List, Dict, Any
def stratified_sample_by_heuristic(
log_entries: List[Dict[str, Any]],
n_samples: int,
heuristic_fn,
n_strata: int = 5
) -> List[Dict[str, Any]]:
"""
Stratify production logs by a heuristic quality signal before sampling.
heuristic_fn: a function mapping a log entry to a rough quality bucket (1-5).
This could be user thumbs up/down, response length, keyword presence, etc.
The point is to ensure you don't undersample low-quality examples.
"""
strata = defaultdict(list)
for entry in log_entries:
bucket = heuristic_fn(entry)
bucket = max(1, min(n_strata, int(bucket))) # clamp
strata[bucket].append(entry)
per_stratum = n_samples // n_strata
sampled = []
for bucket in range(1, n_strata + 1):
available = strata[bucket]
take = min(per_stratum, len(available))
sampled.extend(random.sample(available, take))
random.shuffle(sampled)
return sampled
def heuristic_quality_bucket(entry: Dict[str, Any]) -> int:
"""
Example heuristic: combine user rating signals and response length
to create a rough quality bucket for stratification purposes.
Not a final quality score — just a stratification tool.
"""
score = 3 # neutral baseline
if entry.get("user_thumbs_up"):
score += 1
if entry.get("user_thumbs_down"):
score -= 1
if entry.get("escalated_to_human"):
score -= 1
if entry.get("session_continued_after_response"):
score += 0.5
# Penalize very short or very long responses as a rough proxy
response_len = len(entry.get("response", "").split())
if response_len < 20:
score -= 0.5
elif response_len > 500:
score -= 0.5
return round(score)
This ensures your calibration dataset — and your ongoing production sample that gets reviewed by humans — covers the quality spectrum rather than clustering in the mediocre middle.
With a working judge and a calibration baseline, let's look at how to run this at scale.
For high-throughput applications, you'll be evaluating hundreds to thousands of responses per day. Never do this synchronously. The parallel LLM call patterns lesson covers the full async machinery, but here's the evaluation-specific implementation:
import asyncio
from typing import List, Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
async def evaluate_batch(
examples: List[Dict[str, Any]],
judge_model: str = "gpt-4o",
max_concurrent: int = 20,
retry_attempts: int = 2
) -> List[Dict[str, Any]]:
"""
Evaluate a batch of examples with bounded concurrency.
Returns a list of result dicts with scores, rationales, and metadata.
"""
semaphore = asyncio.Semaphore(max_concurrent)
results = []
async def evaluate_with_semaphore(example: Dict[str, Any]) -> Dict[str, Any]:
async with semaphore:
for attempt in range(retry_attempts + 1):
try:
eval_result = await evaluate_response(
user_query=example["user_query"],
retrieved_context=example.get("retrieved_context", ""),
ai_response=example["ai_response"],
reference_answer=example.get("reference_answer"),
judge_model=judge_model,
temperature=0.0
)
return {
"example_id": example["id"],
"evaluation": eval_result,
"judge_model": judge_model,
"status": "success"
}
except Exception as e:
if attempt == retry_attempts:
logger.error(f"Failed after {retry_attempts} attempts: {e}")
return {
"example_id": example["id"],
"evaluation": None,
"error": str(e),
"status": "failed"
}
await asyncio.sleep(2 ** attempt) # exponential backoff
tasks = [evaluate_with_semaphore(ex) for ex in examples]
results = await asyncio.gather(*tasks)
return results
async def run_nightly_evaluation(
production_log_client, # your log fetching abstraction
eval_db_client, # your results storage abstraction
sample_size: int = 500,
judge_model: str = "gpt-4o"
):
"""
Orchestrates nightly evaluation run: sample, evaluate, store.
"""
# Fetch and stratify today's production logs
logs = await production_log_client.fetch_last_24h()
sample = stratified_sample_by_heuristic(
logs,
n_samples=sample_size,
heuristic_fn=heuristic_quality_bucket
)
logger.info(f"Evaluating {len(sample)} responses with {judge_model}")
results = await evaluate_batch(sample, judge_model=judge_model)
# Separate successes from failures
successes = [r for r in results if r["status"] == "success"]
failures = [r for r in results if r["status"] == "failed"]
logger.info(f"Completed: {len(successes)} success, {len(failures)} failed")
# Compute aggregate scores
dimension_scores = compute_aggregate_scores(successes)
# Store everything
await eval_db_client.store_batch_results(successes)
await eval_db_client.store_aggregate_metrics(dimension_scores)
# Flag low-scoring examples for human review
low_quality = [
r for r in successes
if any(
r["evaluation"][dim]["score"] <= 2
for dim in ["factual_accuracy", "answer_completeness",
"appropriate_confidence", "response_quality"]
)
]
if low_quality:
logger.warning(f"Flagged {len(low_quality)} low-quality responses for human review")
await eval_db_client.add_to_human_review_queue(low_quality)
return dimension_scores
def compute_aggregate_scores(results: List[Dict[str, Any]]) -> Dict[str, float]:
"""Compute mean scores per dimension across a batch."""
dimension_totals = defaultdict(list)
dims = ["factual_accuracy", "answer_completeness",
"appropriate_confidence", "response_quality"]
for result in results:
if result["evaluation"]:
for dim in dims:
score = result["evaluation"][dim]["score"]
dimension_totals[dim].append(score)
return {
dim: np.mean(scores)
for dim, scores in dimension_totals.items()
if scores
}
Running GPT-4o as your judge on 500 examples per day at roughly 2,000 input tokens + 600 output tokens per evaluation works out to approximately $0.65-0.85/day based on current pricing — very reasonable. But if you scale to 5,000 examples/day or start running judges on every single production call, costs scale proportionally.
Cost optimization strategies:
See the cost optimization guide for a deeper treatment of token accounting and model selection trade-offs.
Tip: Track your evaluation costs as a line item in your LLM budget. It's easy for an evaluation pipeline to consume 20-30% of your total LLM spend if you're not deliberate about sampling rates and model selection. A good evaluation program should cost 5-15% of your production inference cost.
The reason you're doing all this is to catch when your system's quality degrades — usually after a prompt change, a model update, or a data drift event. Here's how to make that operational:
from scipy import stats
from typing import Tuple
def detect_quality_regression(
baseline_scores: Dict[str, List[float]],
current_scores: Dict[str, List[float]],
significance_threshold: float = 0.05,
minimum_delta: float = 0.15 # minimum meaningful drop in average score
) -> Dict[str, Dict]:
"""
Compare current evaluation scores to a baseline period.
Returns regression flags per dimension.
Uses Mann-Whitney U test (non-parametric, appropriate for ordinal scores).
Requires both statistical significance AND a minimum practical delta.
"""
regressions = {}
for dim in baseline_scores:
if dim not in current_scores:
continue
baseline = baseline_scores[dim]
current = current_scores[dim]
baseline_mean = np.mean(baseline)
current_mean = np.mean(current)
delta = current_mean - baseline_mean
# Mann-Whitney U test
statistic, p_value = stats.mannwhitneyu(
baseline, current, alternative='greater'
)
is_significant = p_value < significance_threshold
is_meaningful = abs(delta) >= minimum_delta
is_regression = is_significant and is_meaningful and delta < 0
regressions[dim] = {
"baseline_mean": baseline_mean,
"current_mean": current_mean,
"delta": delta,
"p_value": p_value,
"is_regression": is_regression,
"severity": "high" if delta < -0.3 else ("medium" if delta < -0.15 else "low")
}
if is_regression:
logger.warning(
f"REGRESSION DETECTED: {dim} dropped {delta:.2f} points "
f"(p={p_value:.4f}). Baseline: {baseline_mean:.2f}, "
f"Current: {current_mean:.2f}"
)
return regressions
The double threshold — statistical significance AND a minimum practical delta — is important. With large enough samples, a 0.05-point drop will be statistically significant but operationally meaningless. Requiring a minimum_delta of 0.15 points ensures you're alerting on real degradation, not measurement noise.
For integration with your broader observability stack, the LLM observability guide covers how to wire these metrics into dashboards and alerting systems.
Build a complete evaluation pipeline for a RAG-based document Q&A system. Here's your scenario: you have a customer service bot that answers questions using a retrieved policy document as context. You've collected 50 example interactions (simulate these if needed). Your task:
Part 1 — Rubric Customization (30 minutes)
Take the four-dimension rubric from this lesson and add a fifth dimension specific to a customer service context: tone_appropriateness. Write full behavioral anchors at each level (1-5). Consider what "1" looks like (rude? dismissive? over-apologetic?) and what "5" looks like.
Part 2 — Judge Implementation (45 minutes)
Implement the evaluate_response function to include your new fifth dimension. Run it on 20 of your example interactions. Store the raw outputs (including rationales) to a local JSON file.
Part 3 — Calibration (45 minutes)
Rate 15 of those 20 examples yourself using the rubric. Then compare your ratings to the judge's using the compute_calibration_metrics function. For which dimensions does the judge agree with you most? Least? Look at the rationales for disagreements — do they reveal ambiguity in your rubric anchors, or genuine judge failures?
Part 4 — Bias Investigation (20 minutes)
Manually swap two responses in your dataset — put a short, high-quality response and a long, lower-quality response and check whether the judge gives the longer response a higher response_quality score despite it being worse. If yes, you've found verbosity bias. Adjust your rubric anchor language and re-run. Does it improve?
Mistake: Using temperature > 0 during calibration You need your judge to be deterministic during calibration. Any temperature above 0 introduces noise that makes it impossible to distinguish rubric ambiguity from model stochasticity. Lock temperature to 0.0 for all calibration and production evaluation runs. Reserve non-zero temperature for exploratory work.
Mistake: Evaluating judge quality using examples the rubric was designed on If you used certain failure examples to write your rubric, don't include those same examples in calibration. You've inadvertently pre-optimized the rubric for those cases. Your calibration dataset should be entirely held-out.
Mistake: Skipping the inter-human calibration Many teams calibrate their judge against a single human rater and declare success. But if that human rater is inconsistent, a judge that agrees with them at κ = 0.65 may actually be more reliable than the human. Without inter-rater agreement as a ceiling, you don't know what you're comparing against.
Mistake: Providing the entire production response as judge context when it contains PII Your judge prompt typically includes the user query and AI response verbatim. For applications handling sensitive data, you may be sending PII to an external judge API. Either scrub PII before evaluation, use an on-premise model as your judge, or structure your evaluation to work from paraphrased or anonymized examples.
Mistake: Treating the overall score as a primary signal Composite scores hide important information. A response that scores 5/5 on response quality but 1/5 on factual accuracy is a dangerous response, not a mediocre one. Always surface dimension-level scores in your monitoring and alerting, not just an aggregate.
Warning: Be careful about using your evaluation pipeline's scores as a training signal for your production model without careful filtering. If your judge has systematic biases (e.g., verbosity bias), RLHF-style training against those scores will push your production model toward the judge's biases, not toward genuine quality. This feedback loop is real and has burned production teams.
Mistake: Running evaluation only on random samples If your sampling is purely random, you'll under-sample the failure modes you care most about. Always use stratified sampling with behavioral signals (user escalations, short sessions, thumbs-down ratings) to oversample potential failures.
Troubleshooting: Judge returns malformed JSON
Even with response_format: json_object, complex rubrics sometimes produce malformed outputs. Build robust retry logic and log every parse failure. If you see more than ~2% parse failure rates, your prompt is too complex — consider shortening rubric descriptions or splitting into separate evaluation calls.
Troubleshooting: Calibration Spearman r < 0.5 on a specific dimension Don't immediately blame the model. Look at examples where humans and the judge disagree by 2+ points. Nine times out of ten you'll find that two of your human raters also disagreed significantly on those examples, indicating rubric ambiguity. Refine the anchor language before changing anything about the judge prompt.
You now have the full architecture: a rubric grounded in real failure modes, a judge prompt designed to minimize systematic bias, calibration metrics that quantify how much you can trust your scores, and a production pipeline that samples strategically, evaluates asynchronously, detects regressions statistically, and routes low-quality responses to human review.
The key principles to carry forward:
For your next steps, consider:
The teams that get the most value from LLM-as-Judge don't use it as a dashboard metric to report upward. They use it as an operational tool that catches regressions before users do, identifies systematic failure patterns worth engineering effort, and creates a continuous feedback loop between evaluation and improvement. Build it that way from the start.