“How do you evaluate it?” is three questions: is this change better, did we break something we fixed, and what is happening in production now. Pick metrics in pairs so neither can be gamed alone, score retrieval before the answer, and treat an LLM judge as a classifier you must validate against people.
Most teams build the first, skip the second and bolt on the third after an incident. On a phone the figure pans sideways.
Almost every team reports the same thing sooner or later: eval scores rising while user complaints rise too. It is rarely a broken metric. It is a test set that stopped looking like production weeks ago and kept returning a number. When the score and the complaints disagree, treat the complaints as ground truth and the eval as the suspect.
Choose metrics in pairs
| Metric | Answers | Blind to |
|---|---|---|
| Task success | did it do the thing? | how: including a path that broke policy |
| Recall@k | was the answer reachable? | whether the model used it |
| Faithfulness | is each claim in the sources? | whether the sources are right |
| Escalation rate | how often it hands off | whether handing off was right |
| Cost per task | is it viable? | quality, entirely |
| Thumbs down | are voters unhappy? | the users who just leave |
The pairing rule matters more than any row. Success without cost hides an agent that got expensive; cost without success hides one that got cheap by giving up. So count cost per resolved task:
| Current | Trimmed | |
|---|---|---|
| Tokens per task | 60K in, 3K out | 45K in, 3K out |
| Cost per task | $0.30 | $0.24 |
| Resolved, of 1,000 | 800 | 600 |
| Per resolved task | $0.375 | $0.40 |
Claude Opus 5.5 list prices, $4 in and $20 out per million tokens, no caching. The variant is 20% cheaper per task and 7% dearer per outcome, before the 200 extra tasks a person now handles (Metrics & Dashboards).
Score retrieval before the answer
| Recall@5 | Answers | Look at |
|---|---|---|
| low | low | chunking, search, filters: the model never saw it |
| high | low | reranker cut, prompt assembly, generation |
| high | high, users unhappy | the test set no longer looks like traffic |
In a RAG system the first question is not “was the answer good” but “was it reachable”. Recall@k needs no judge: label each golden question with the source span that answers it, and count how often that span is in the top k. Store spans, not chunk ids, because ids change with every re-chunk and a stale label reads as a retrieval failure. MRR and nDCG add rank order; precision@k says how much of what the model reads is useful (RAG Evaluation). The usual cause of a low first row is a boundary in the wrong place: RAG chunking strategies.
Use RAGAS for the claims a rule cannot check
| RAGAS metric | Reference? | Tells you |
|---|---|---|
| Faithfulness | no | share of claims the contexts support |
| Answer relevancy | no | on topic; never checks truth |
| Context precision | yes | useful chunks ranked first |
| Context recall | yes | the reference’s claims were retrieved |
import anthropic
from ragas.llms.base import llm_factory
from ragas.metrics.collections import ContextRecall, Faithfulness
client = anthropic.AsyncAnthropic() # ascore needs an async client
judge = llm_factory("claude-sonnet-5-5", provider="anthropic", client=client)
for arg in ("temperature", "top_p"): # ragas sends both; current models refuse them
judge.model_args.pop(arg, None)
# ragas parses content[0].text: no thinking first (Sonnet 5.5 rejects "disabled")
judge.model_args["thinking"] = {"type": "between_tools"}
async def score(row):
f = await Faithfulness(llm=judge).ascore(
user_input=row["q"], response=row["answer"],
retrieved_contexts=row["contexts"])
r = await ContextRecall(llm=judge).ascore(
user_input=row["q"], retrieved_contexts=row["contexts"],
reference=row["reference"]) # a human-written answer
return f.value, r.value # floats from 0 to 1, or NaN
# asyncio.run(score(row)) for one row; asyncio.gather for a batch
RAGAS 0.4.3 is still the newest release on PyPI (checked 2026-10-21); install it with pip install "ragas==0.4.3" "langchain-community==0.4.1", because langchain-community 0.4.2 removed a module ragas imports. Run end to end in a clean install with a scripted judge, not a live API call; the Sonnet 5.5 thinking settings are from Anthropic’s thinking docs. Make one live call and check the block types before a batch.
Each metric is a judge reading fields of one logged row, so a run has a price: about 11 judge calls per row for the four core metrics at k = 5 (RAGAS). Context precision and recall need a reference a person wrote, so they run on the golden set only; faithfulness and answer relevancy can run on sampled live traffic. A reply with no claims scores NaN, not 1: print the NaN count beside every mean, or an alert on the mean never fires.
Validate the judge like the classifier it is
from sklearn.metrics import accuracy_score, cohen_kappa_score
human = ["pass"] * 180 + ["fail"] * 20 # 200 human-labelled answers
lazy = ["pass"] * 200 # a judge that always says pass
judge = ["pass"] * 172 + ["fail"] * 8 + ["pass"] * 10 + ["fail"] * 10
for name, j in (("always-pass", lazy), ("judge", judge)):
print(f"{name:>11} accuracy {accuracy_score(human, j):.0%}"
f" kappa {cohen_kappa_score(human, j):.2f}")
# always-pass accuracy 90% kappa 0.00
# judge accuracy 91% kappa 0.48
Same 200 answers, 20 of them failures by human label; run on scikit-learn 1.9.1.
On a skewed set, accuracy flatters a judge, so measure agreement with Cohen’s kappa, which subtracts the agreement chance alone would give. Before a judge gates a release, have people label a few dozen to a hundred cases; the MT-Bench authors found a strong judge agreed with human raters over 80% of the time, about as often as humans agreed with each other (The Eval Stack).
| Bias | What happens | Fix |
|---|---|---|
| Position | the first answer shown wins more | run both orders; a win counts only if both agree |
| Verbosity | longer answers score higher | say length is not a criterion; check conciseness |
| Self-preference | a judge favours its own style | never judge a model with itself |
Give it one yes/no question per criterion with a worked example, not “rate helpfulness 1 to 10”. Pin the judge model and the rubric version, and re-measure agreement when either changes: a judge upgrade silently moves every historical score.
Grade agents on the end state, k times
An agent takes a different path each run and can land on the right result by luck. Grade the world it left behind in code (exactly one refund row, the right amount), then check the path against constraints such as “no issue_refund before verify_identity”, and run each task several times. For anything customer-facing, gate on pass^k, how often it succeeds every time (Grading Agents). Keep cost and steps per task in the same report: a prompt change can hold success at 92% and double the cost.
Size the set for the change you need to see
| Questions | 95% margin | Good for |
|---|---|---|
| 50 | ±9.9 points | a first look |
| 100 | ±7.0 points | a smoke set |
| 200 | ±4.9 points | a release gate |
| 500 | ±3.1 points | a fine comparison |
| 1,000 | ±2.2 points | small effects |
Margin on a pass rate near 0.85, 1.96 × √(p(1 − p) / n); the same table as RAG Evaluation.
A 3-point gain on 200 questions is inside the noise. Compare two systems question by question instead: count the questions only A gets right and only B gets right. Twelve disagreements split nine to three are not yet a result; thirty split twenty-five to five are. A launch gate should test the lower bound of the interval, not the point estimate (RAG Best Practices).
Build the golden set; it is the artifact
- Draw from trafficLogs, tickets, transcripts. Questions written from a document flatter search.
- Over-sample the strangeStratify by intent; keep about one case in five an edge case or an attack.
- Grow from incidentsEvery confirmed failure becomes a permanent case.
- Version itWith the prompt and the judge: a score means nothing without its set.
{"id": "refund-017",
"source": "prod trace 2026-08-14, redacted",
"input": "I was charged twice for order A-1042.",
"checks": {"must_call": ["get_order"], "must_not_call": ["issue_refund"]},
"rubric": ["explains the duplicate-charge policy"]}
Use the cheapest grader that can decide each check: a tool call happened or it did not, so code grades it; only tone and policy need a judge. Teams over-invest in harnesses and under-invest in these few hundred cases, which decide whether any harness tells the truth. Never write reference answers with the current model: they reward imitating it and punish a better successor.
Read production signals for how each one lies
| Signal | Suggests | Misleads when |
|---|---|---|
| Thumbs up / down | satisfaction | few vote, and the unhappy vote more |
| Re-ask within a minute | the answer missed | it was a real follow-up |
| Escalation | it could not resolve | policy required a human |
| Sampled judge, 1–5% | rubric quality live | it was never calibrated |
Divide by tasks, not by votes, and size the judged sample before you set an alert: at 100 judged answers a day, a fixed 0.80 line on a true 0.85 fires by chance on about 7% of days (RAG Monitoring). Pin exact model ids, cluster live queries weekly for new topics, and send each confirmed failure back down the dashed arrow in the first figure.
- One number. An average over gates or tasks hides the one that failed.
- Running each case once and reading a 3-point move as a win.
- An uncalibrated judge gating releases, or scores compared across judge versions.
- RAGAS v0.1 code (
evaluate()withcontext_relevancy): that metric is gone in 0.4, andevaluate()returns one value per row, not a float.
“How would you know a prompt change made our assistant better?” Walk the three layers: a CI smoke set with code graders, the full golden set with a calibrated pairwise judge, then a canary and an A/B test on resolution with cost and escalations as guardrails, pre-registered. Close with sampled live judging and failures becoming cases. Model answers: when to trust an LLM judge and evaluating a non-deterministic agent.
Your judge agrees with human labels 92% of the time on a set where 90% of answers pass. Is it ready to gate releases?
Not on that number. A judge that passes everything scores 90% here. Compute Cohen’s kappa, look at how many of the human fails it catches, and test both answer orders for position bias.
Faithfulness averages 0.91 on 400 sampled answers, but the dashboard alert below 0.8 has never fired, even on a bad day. What might be wrong?
NaN rows. Replies with no claims score NaN; a plain mean of a list with a NaN is NaN, and NaN < 0.8 is false, so the alert cannot fire. Use a NaN-aware mean, show the NaN count and coverage, and alert on them too.
Agents A and B both pass 80% of single runs. A’s pass^4 is 0.41, B’s is 0.62. Which goes to customers?
B. A fails about one run in five on nearly every task, so it sits at 0.84 = 0.41. B’s failures cluster on a few tasks it fails reliably, which you can detect and route to a person.
- Three layers: offline before ship, regression on every change, production all the time; production feeds the other two.
- Pick metrics in pairs; count cost per resolved task.
- Score retrieval with spans and recall@k before judging answers; use RAGAS 0.4.3 for claims, and count the NaNs.
- A judge is a classifier: kappa against human labels, both orders, pinned and versioned.
- Size the set for the change you need to see, and compare systems question by question.