AI EngineeringZero to ProductionHome·About·What’s new·Contact
Part IV · Chapter 5

Evaluation & Testing

LLM outputs are non-deterministic, so you can't test them with assertEqual alone. Evals are the CI of LLM systems: a repeatable way to know whether a prompt tweak or model swap made things better or worse. This chapter turns "it seems fine" into a number you can gate on.

⏱️ ~90 min🧪 5 labs🎯 Intermediate
⚙️ To run this for realThe code here is complete and correct as written. The LLM-as-judge lab needs an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on that code):The deterministic checks run offline with no key. Keep any key in a .env file (never in code).Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Build a golden dataset and grow it from real failures.
  • Write deterministic checks and LLM-as-judge evals.
  • Write gradeable rubrics that produce stable scores.
  • Measure RAG retrieval and generation separately.
  • Wire a regression gate so quality can't silently drop on a change.

Why you can't ship without evals essential

Here's the pattern that humbles almost everyone who builds with LLMs. You wire up a demo, throw it a handful of questions, and it dazzles — fluent, confident, right on the money. You show it around, everyone nods, you ship. Then real users arrive with the phrasings you never imagined, and the same "working" system starts hedging, hallucinating, and contradicting itself. Nothing in your code changed; the inputs did. That gap — between a demo that impresses and a product that holds up — is exactly the gap evals exist to close. The industry rule of thumb is blunt: most demos work, most products fail, and the difference is usually whether anyone bothered to measure.

The reason ordinary tests don't rescue you here is that LLM output is non-deterministic. Ask the same question twice and you can get two differently-worded answers, both perfectly acceptable. So assertEqual(output, "30 days") is useless — the model might reply "You have thirty days" and be entirely correct while your test screams failure. You're no longer checking for one exact string; you're asking a fuzzier, more honest question: is this good enough, on the dimensions I care about, often enough?

Think of an eval harness as the dashboard on a car. Without instruments you can still drive — for a while, on a clear road, by feel. But the moment conditions change you have no idea you're overheating or nearly out of fuel until the engine dies on the highway. The speedometer doesn't make the car faster; it makes your decisions informed. Evals are the same: they don't improve your prompt, they tell you — before your users do — whether the last change helped or quietly broke something.

The common mistake is "vibes-based development": you tweak a prompt to fix one annoying case, eyeball two or three outputs, decide it "seems better," and move on. What you can't see is the five other cases that tweak just broke. Swap to a cheaper model to save money and quality might sag 8% across the board — invisible in a spot-check, painfully visible in a support queue three weeks later. Without a scored dataset, every change is a coin flip you're not even watching land. With one, every change produces a number, and a regression fails your build like any other broken test. That is the whole promise of this chapter: turning "it seems fine" into a measurement you can gate a deploy on.

vibes: eyeball 2–3 outputs change spot-check3 samples "seems better?" 5 broken cases go unseen evals: score the whole golden set change every case scored one number regression fails the build Vibes vs a scored dataset. Eyeballing a few outputs hides the cases your change quietly broke; scoring the whole golden set collapses quality into one number a regression can trip.
🗺️ How to read this diagram

Read the two columns side by side: the left is the trap ("vibes-based development"), the right is what this chapter replaces it with.

  • The left column follows a change into a spot-check of just 2–3 samples and a gut-feel "seems better?" verdict. The red note underneath is the catch: the cases you didn't look at can regress unseen.
  • The right column feeds the same change through the whole golden set (the grid of boxes) — every case is scored, not a hand-picked few.
  • The green box is the payoff: the run collapses into one number, so a drop below your bar can fail the build automatically instead of surprising you in production.

In short: A few eyeballed samples can't see what broke; scoring the full set turns quality into a number you can gate on.

The shift in mindsetTraditional tests ask "is the output exactly X?" LLM evals ask "is the output good enough on these dimensions, often enough?" You measure aggregate quality over a dataset, not exact equality on one input.

The evaluation pyramid essential

The instinct of every beginner is to skip straight to the fanciest check. "I'll just have GPT grade everything!" — and now every commit costs money, takes minutes, and gives slightly different scores each run. The mistake isn't using an LLM judge; it's using it for things a single line of code could have decided for free. The evaluation pyramid is the map that stops you from making that mistake, and it borrows its shape from an idea you may already know from software testing.

If you've seen the classic test pyramid — lots of fast unit tests at the base, fewer integration tests in the middle, a handful of slow end-to-end tests at the top — this is the same wisdom pointed at LLM output. The principle is identical: the cheapest check that can catch a given bug should be the one that catches it. You don't hire a human reviewer to notice the JSON didn't parse; a two-line function does that instantly. You save the expensive, scarce judgment for the questions only expensive, scarce judgment can answer.

Read the four layers as a ladder of cost and coverage. At the wide base sit deterministic checks — plain code asking crisp yes/no questions (valid schema? banned word absent? under the length cap?). They cost nothing, never flake, and run on every commit, so they're the widest layer. Above them, reference-based checks compare the output to a known-correct answer you wrote down in advance — useful whenever there genuinely is a right answer. Higher still, LLM-as-judge grades open-ended quality where no rule fits ("is this helpful and grounded?"); it scales to thousands of cases but costs tokens and jitters slightly. At the narrow tip is the human — the gold standard, and also the slowest and priciest, so you spend it rarely, on small samples, on the things machines still can't judge.

The direction that matters is bottom-up. Build the base first and lean on it hard; only climb a layer when the one below genuinely can't answer your question. Teams that invert the pyramid — mostly human review, a little automation — end up with evals so slow and expensive that nobody runs them, which is the same as having none. A fat, fast base is what lets you check quality on every change instead of once a quarter.

Human LLM-as-judge Reference-based Deterministic (schema, rules) slow, gold standard scalable quality known answers fast, every commit
🗺️ How to read this diagram

This picture answers one question: if I can't just check the answer is exactly right, how do I know an LLM is any good? The answer is layers of checks, drawn as a pyramid — cheap-and-automatic at the bottom, expensive-and-human at the top.

  • Read it bottom to top. The wide green base (Deterministic) is plain code checks — is it valid JSON? is a required word present? These are free and run on every commit, which is why the layer is widest.
  • Reference-based (blue) compares the output to a known-correct answer you wrote down in advance — good when there is a right answer to compare to.
  • LLM-as-judge (amber) uses a strong model to grade open-ended answers where no simple rule works ("is this helpful and on-topic?"). It scales, but costs money.
  • Human (red, the tiny tip) is the gold standard but slow, so you do it rarely, on small samples. The labels on the right — fast, every commit up to slow, gold standard — are the trade-off each layer makes.

In short: Build bottom-up. Most of your checks should be the cheap automatic kind at the base; save the expensive human review for the few things machines can't judge.

Cheap deterministic checks run on every commit; expensive human review runs rarely on samples. Build bottom-up.

Lab 5.1 · Build a golden set essential

Every eval number you'll ever report is only as trustworthy as the dataset behind it. You can build the most elegant judge and the slickest CI gate in the world, but if you measure them against ten questions you happened to think of on a Tuesday afternoon, your "90% pass rate" means almost nothing — it measures how well you do on your imagination, not on reality. The golden set is the foundation the whole harness stands on, which is why it comes first.

A golden set is simply your test dataset: a list of representative inputs, each paired with a note about what a good output looks like. Think of it as the answer key a teacher writes before an exam. The teacher doesn't grade forty papers by re-deciding what "correct" means each time; she settles the answers once, in advance, then grades consistently against them. Your golden set does the same job — it fixes "what good looks like" once, so every future run is scored against the same ruler instead of your shifting mood.

The mistake that quietly poisons everything is an unrepresentative set. If your cases are all the tidy, well-phrased questions you'd ask yourself, your evals will glow green while real users — who type half-sentences, typos, and edge cases you never dreamed of — hit the failures your dataset can't see. A representative set deliberately includes the awkward phrasings, the ambiguous asks, the out-of-scope questions, and above all the specific cases your system has already gotten wrong. That last source is gold: a golden set grown from real production misses becomes a living record of "mistakes we must never make again," and it's the single highest-leverage habit in this chapter.

One more piece of hard-won advice on size: start small and start now. Fifteen to thirty carefully chosen cases beat a thousand hastily scraped ones, because every case is something you'll look at, debug against, and trust. A giant set you never curate is just noise with a large denominator. Grow it deliberately, one real failure at a time, and it stays sharp.

one golden-set case = input + answer key "id": "refund-window" "input": user question "expected" + "expected_chunk" "rubric": checkable pass rule production miss → bad answer in prod add as new case set grows, stays representative A golden case is a mini answer-key. Each case pairs an input with what "good" looks like (expected, expected_chunk, rubric); feeding real production misses back in keeps the set representative.
🗺️ How to read this diagram

The left box is a single case from GOLDEN_SET, opened up so you can see the fields the labs later depend on; the right side shows how the set grows.

  • Inside the left box, read top to bottom: the id (a label), the input (the question you feed the system), the expected/expected_chunk (the right answer and the chunk that should be retrieved), and the rubric (a checkable pass rule the Lab 5.3 judge uses).
  • The right column is the growth loop: a bad answer in production (red) becomes a brand-new case (blue) added straight back into the set.
  • The payoff line at the bottom: growing from real misses is what keeps the set representative of what users actually type, not just what you imagined.

In short: One case = an input plus its answer key; every production miss you fold back in makes the whole harness measure reality more honestly.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab 5.1
goldens.pyGOLDEN_SET = [
    {
        "id": "pw-reset",
        "input": "How do I reset my password?",
        "expected": "Settings > Security > Reset password; link emailed.",
        "expected_chunk": "faq.md#2",      # for RAG recall (Lab 5.4)
        "rubric": "Mentions Settings > Security and that a link is emailed.",
    },
    {
        "id": "refund-window",
        "input": "What is the refund window?",
        "expected": "30 days from purchase.",
        "expected_chunk": "policy.md#0",
        "rubric": "States the window is 30 days.",
    },
    # ... grow this from real misses
]
▶ How this works

A golden set is just a list of test cases you trust: some realistic inputs, and for each one, a note about what a good answer looks like. It's the ruler you'll measure every version of your system against. Here it's an ordinary Python list of dictionaries — no magic.

  1. Each case is a { } dictionary with named fields. "id" is a short label so you can talk about a specific case (e.g. "pw-reset").
  2. "input" is the question you'll feed the system; "expected" is roughly the right answer — what you'd accept as correct.
  3. "expected_chunk" names the document piece that should be retrieved to answer this — used later in Lab 5.4 to grade retrieval on its own.
  4. "rubric" is a plain-English pass/fail rule ("Mentions Settings > Security and that a link is emailed"). The LLM judge in Lab 5.3 grades against exactly this sentence, so make it specific and checkable.

What the output means: Nothing runs yet — this is just data. But it's the backbone of the whole chapter: every later lab loops over GOLDEN_SET to produce a score.

Try this: Start with 15–30 cases, not hundreds. And whenever the system flubs a real question in production, add that exact question here with the correct answer — the # grow this from real misses comment is the habit that makes the set valuable.

Grow it from incidentsEvery time the system gives a bad answer in production, add that input to the golden set with the correct expected output. Your eval set becomes a living record of "mistakes we must never make again."

Lab 5.2 · Deterministic checks intermediate

Before you spend a cent on an AI grader, ask a question that costs nothing: is the answer even the right shape? A shocking fraction of "the LLM gave a bad answer" bugs aren't subtle quality problems at all — they're the model returning malformed JSON, forgetting a required field, blowing past a length cap, or leaking a phone number it was told to redact. None of that needs judgment to catch. It needs a plain if. Deterministic checks are that base layer of the pyramid, and they earn their keep on every single commit.

A deterministic check is just a function that looks at an output and returns True or False for a property that is objectively right or wrong. Think of them as the metal detector at the airport: fast, automatic, unglamorous, and non-negotiable. The detector doesn't decide whether you're a nice person — that's a judgment for later — it only answers one crisp question ("carrying metal? yes/no") and answers it the same way every time, for everyone, in a fraction of a second. That determinism is the whole point: run the check a thousand times and you get the identical verdict, so it can never flake and never surprise you at 2am.

These checks split naturally into two jobs. Some verify structure — did the output parse into the schema you demanded, is the required field present, is it under the word limit? Others verify safety and policy — no Social Security numbers, no banned phrases, no disallowed category. Both kinds share the same virtue: they're free, instant, and utterly reliable, so there's no reason not to run all of them on every change.

The mistake to avoid runs in both directions. One camp reaches for an LLM judge to check things a regex could settle — burning money and adding noise to answer "is this valid JSON?" The other camp overreaches, trying to cram fuzzy quality into a rule ("the answer must contain the word 'sorry' to count as empathetic") and ends up with a brittle check that fails good answers and passes bad ones. The discipline is knowing the boundary: if a property is crisply true or false, a deterministic check owns it; the moment it requires taste or context, that's the judge's job (next lab). Lean on this layer as hard as you can — everything it catches is a case you never have to pay a model to look at.

output schema valid? no PII / SSN? contains required word? under length cap? True True True False free · instant · never flaky any False fails the check — no model needed Deterministic = crisp yes/no, in plain code. One output is run through several rule checks; each returns a hard True/False with no cost and no jitter, so a single False catches the bug before any model is called.
🗺️ How to read this diagram

Follow one output left to right as it's fanned out to the four checks from this lab, each answering a single crisp question.

  • The output box on the left is what the model produced. The arrows fan it into the stacked check boxes — schema, no-PII, contains-required-word, length cap.
  • Each check returns a hard verdict on the right: three green Trues and one red False. There is no "maybe" — that is the whole point of deterministic.
  • The labels free · instant · never flaky are why this is the widest base of the pyramid: any False catches a real bug without spending a token.

In short: Deterministic checks turn objective properties (valid JSON, no SSN, under the cap) into instant, repeatable yes/no gates — run them on every commit before you ever reach for a judge.

Lab 5.2
eval_deterministic.pydef check_schema(output_obj) -> bool:
    return output_obj is not None            # parsed & validated?

def check_no_pii(text) -> bool:
    import re
    return not re.search(r"\b\d{3}-\d{2}-\d{4}\b", text)  # no SSNs leaked

def check_contains(text, must_have) -> bool:
    return must_have.lower() in text.lower()

def check_length(text, max_words=150) -> bool:
    return len(text.split()) <= max_words
▶ How this works

These are the cheapest evals of all: plain Python functions that return True or False. No AI model, no cost, no waiting — they check hard facts about an output, the kind of thing that is simply right or wrong. Run them on every commit.

  1. check_schema returns True when the parsed output isn't None — i.e. the model gave back data in the shape you required (see the Pydantic pattern from Chapter 2), rather than garbage you can't use.
  2. check_no_pii uses a regular expression (a text-pattern search) to look for a US Social Security number like 123-45-6789. re.search finds that pattern; not flips it, so the check passes only when no SSN is present — a safety guard against leaking private data.
  3. check_contains asks "does the answer include this required word?", lower-casing both sides first so capitalization doesn't matter.
  4. check_length counts words with text.split() and passes only if the answer is at most max_words — a simple guard against rambling.

What the output means: Each function is a yes/no gate. You'll call them across your golden set and turn the results into a percentage (e.g. "100% of outputs were valid JSON").

Try this: Use these for anything with a crisp right/wrong: valid JSON, a required field, an allowed category, a banned phrase, a length cap. They're instant and never flaky — lean on them before reaching for a model-based judge.

Use these for anything with a crisp right/wrong: valid JSON, required fields present, enum membership, no forbidden content, length limits, format rules. They're free and instant — lean on them hard.

Lab 5.3 · LLM-as-judge intermediate

Sooner or later you hit a question no rule can answer. "Is this support reply actually helpful?" "Is this summary faithful to the source, or did it invent a detail?" "Does this answer stay grounded in the retrieved docs?" There's no regex for helpful, no if for faithful. You could read every output by hand — and for ten cases you should — but that doesn't scale to the thousands of cases a real eval set needs. This is the exact problem LLM-as-judge solves: use a strong model to grade open-ended quality, cheaply and at scale, against a rubric you write down.

The mental model is a grading rubric handed to a teaching assistant. A good professor doesn't tell the TA "grade these however you feel"; she hands over an explicit key — "1 point for stating the 30-day window, 1 point for citing the policy, 0 if it invents a number" — so that whoever grades, and whenever they grade, the scores land in the same place. The LLM is your tireless TA: it will read a thousand answers without getting bored, but it's only as consistent as the rubric you give it. A crisp rubric produces crisp, repeatable grades; a vague one produces mood-lighting.

Be honest about the biases, because they're real and they'll bite you. LLM judges are known to favor longer, more verbose answers over tight correct ones; they can prefer the first option shown in a comparison (position bias); and — the trap that catches everyone — a model asked to grade its own output tends to like what it produced. So the cardinal rule is independence: your judge should be a separate, strong model, at least as capable as the thing it's grading. Never let your production model be sole judge of its own answers, and for anything high-stakes, don't trust a single verdict at all.

The fix for a noisy judge is the same one democracies use for noisy individuals: vote. Run a panel of three judges and take the majority, and the random jitter of any one grader washes out — while the cases where the three disagree become a valuable signal, flagging the genuinely ambiguous answers that deserve a human's eye. The single most important thing you control, though, isn't the panel size; it's the rubric. Write it as a specific, checkable sentence — "States the refund window is 30 days" beats "Is the answer good?" every time — because vague rubric in means noisy score out.

question answer rubric (checkable) judge 1 → pass judge 2 → pass judge 3 → fail majority: PASS 2-1 split → flag for human independent judges, at least as capable as the graded model A judge panel votes; the rubric anchors it. The same question, answer and rubric go to three independent judges, and a majority vote washes out any single grader's jitter — while a 2-1 split flags the answer for a human.
🗺️ How to read this diagram

Read it left to right: what goes into a judgment, the three graders in the middle, and how their votes combine on the right.

  • The three left boxes are the judge's whole input: the question, the answer being graded, and the rubric (green, because it's the thing you most control — make it specific and checkable).
  • The amber judges each grade the same case independently. Two say pass, one says fail — exactly the kind of single-grader jitter a panel is designed to absorb.
  • The green majority box is the verdict; the note beneath it turns disagreement into signal — a 2-1 split is surfaced for a human rather than silently averaged. The bottom line is the cardinal rule: judges must be independent and at least as capable as the model they grade.

In short: Feed one crisp rubric to several independent judges and take the majority — noise cancels, and the splits you can't resolve become the cases worth a human's time.

Lab 5.3

LLM-as-judge — in Claude or OpenAI

The Judgment model and the strict-grader rubric are identical for both vendors — only the parse call differs. In Claude it's client.messages.parse(output_format=Judgment) → r.parsed_output; in OpenAI it's client.responses.parse(text_format=Judgment) → r.output_parsed. Toggle the tab to switch SDK.

eval_judge.pyfrom anthropic import Anthropic
from pydantic import BaseModel
client = Anthropic()

class Judgment(BaseModel):
    passed: bool
    score: int          # 1-5
    reason: str

def judge(question, answer, rubric) -> Judgment:
    r = client.messages.parse(
        model="claude-opus-4-8", max_tokens=400,
        system=("You are a strict grader. Score the answer against the "
                "rubric only. Be specific about any failure."),
        messages=[{"role":"user","content":
            f"Question: {question}\nAnswer: {answer}\n"
            f"Rubric (must satisfy): {rubric}"}],
        output_format=Judgment,
    )
    return r.parsed_output
▶ How this works

Some qualities can't be checked with a rule — "is this answer actually helpful and grounded in the docs?" There's no if for that. So you hand the answer to a strong model acting as a grader and make it score against an explicit rubric. This is LLM-as-judge.

  1. class Judgment(BaseModel) declares the exact shape the grade must come back in: passed (yes/no), score (a 1–5 number), and reason (why). Forcing structure means you get a clean verdict, not a paragraph you'd have to interpret.
  2. The system message tells the grader its job: be a strict grader, score against the rubric only, and name any failure. Strictness keeps scores consistent run to run.
  3. The user message stitches together the question, the answer being judged, and the rubric it must satisfy — everything the grader needs in one prompt.
  4. The SDK's .parse method — client.messages.parse(..., output_format=Judgment) in Claude, client.responses.parse(..., text_format=Judgment) in OpenAI (toggle the tab) — makes the model return data matching your Judgment shape; r.parsed_output / r.output_parsed hands you back a ready-to-use Python object with .passed, .score, .reason.

What the output means: For one answer you get a structured verdict, e.g. passed=True, score=4, reason="States the 30-day window" — a grade you can count and average.

Try this: Notice the judge is a big model (claude-opus-4-8) grading work that may come from a smaller one — you want your grader to be at least as capable as the thing it grades. For high-stakes evals, run three judges and take the majority vote.

Write gradeable rubrics"Is the answer good?" produces noisy, inconsistent scores. "States the refund window is 30 days" is checkable and stable. The judge scores each explicit criterion independently — vague rubric in, noisy score out. For high-stakes evals, run a panel of 3 judges and take the majority vote.

Lab 5.4 · Evaluate your RAG system intermediate

Picture the most frustrating kind of bug report: "the answers are just... bad." Bad how? Your RAG system does two entirely different jobs in sequence — first it finds the relevant chunk of your documents, then it writes an answer from what it found. A single end-to-end score smears those two jobs together into one number, and when that number is low you're left guessing which half broke. Guessing is expensive. This lab's whole idea is to refuse to guess: measure the two stages separately, so a bad score points a finger at exactly the thing to fix.

The analogy that makes this click is a restaurant kitchen. A dish can arrive disappointing for two completely different reasons: the shopper bought the wrong ingredients, or the chef cooked good ingredients badly. If you only taste the final plate you can't tell which happened — and you might retrain the chef when the real problem was the shopping list. RAG is the same two-stage pipeline: retrieval is the shopper (did it fetch the right source?) and generation is the chef (did it cook a good answer from that source?). Score each station on its own and the diagnosis is instant.

The two metrics that do this are worth naming precisely. Recall@k asks the retrieval question: of the top k chunks we fetched, was the answer-bearing one among them? It's a fraction — 0.90 means we grabbed the right source 90% of the time — and crucially it says nothing about the wording of the final answer. The generation pass rate asks the writing question: given the answer the system produced, did the judge (from Lab 5.3) rule it acceptable against the rubric? One measures the shopper, the other the chef.

Now the payoff, and the mistake it prevents. Read the two numbers together, not separately. High recall but low generation means the right documents were found and the answer was still weak — so the retriever is fine and the fix is in the prompt (grounding rules, format, instructions to actually use the context). Low recall means the answer never had a chance because the source wasn't even fetched — so leave the prompt alone and fix retrieval (chunking, hybrid search, reranking). The beginner error is to see a bad answer and immediately rewrite the prompt, when half the time the prompt was innocent and the retriever was starving it. Splitting the metrics turns a vague "it's bad" into a precise, one-line next action — that is the entire reason this lab exists.

query retrievalthe "shopper" generationthe "chef" answer recall@k = 0.90 gen pass = 0.60 recall low →fix retrieval recall ok, gen low →fix prompt Score the two stages separately. Retrieval (the shopper) is measured by recall@k; generation (the chef) by the judge's pass rate — and the pair of numbers names the fix instead of leaving you to guess.
🗺️ How to read this diagram

The top row is the RAG pipeline as a left-to-right flow; the two lower rows attach a metric and a diagnosis to each stage.

  • Along the top, a query goes through retrieval (the "shopper" that fetches source chunks) and then generation (the "chef" that writes the answer) to produce the final answer.
  • The arrow dropping from each stage lands on its own metric: recall@k measures retrieval, gen pass measures generation. They are scored independently, so neither hides the other.
  • The bottom boxes are the payoff — the decision tree. With this example's numbers (recall 0.90, gen 0.60) the right-hand red box applies: the source was found but the answer was weak, so fix the prompt, not the retriever.

In short: Split the pipeline, score each half, then read the pair together — high recall + low generation points at the prompt; low recall points at retrieval.

Lab 5.4

Illustrative fragment — defines demo values / files are needed before this runs standalone.

eval_rag.pyfrom goldens import GOLDEN_SET
from rag import answer, store

def eval_retrieval(k=5):
    """Recall@k — did we fetch the answer-bearing chunk?"""
    hits = 0
    for case in GOLDEN_SET:
        ids = [c["id"] for c,_ in store.search(case["input"], k=k)]
        hits += case["expected_chunk"] in ids
    return hits / len(GOLDEN_SET)

def eval_generation():
    """Groundedness/relevance via judge."""
    passed = 0
    for case in GOLDEN_SET:
        ans, _ = answer(case["input"])
        j = judge(case["input"], ans, case["rubric"])
        passed += j.passed
    return passed / len(GOLDEN_SET)

print("recall@5:", eval_retrieval())
print("generation pass rate:", eval_generation())
recall@5: 0.90
generation pass rate: 0.85
▶ How this works

A RAG system has two jobs — find the right document, then write a good answer from it. If you only score the final answer, a low score can't tell you which job failed. This lab measures the two separately, so a bad number points straight at the fix.

  1. eval_retrieval measures recall@k: for each case it searches the store and collects the ids of the top k chunks, then checks whether the case's expected_chunk is among them. hits += (... in ids) adds 1 when the right chunk was found.
  2. It returns hits / len(GOLDEN_SET) — the fraction of cases where the answer-bearing chunk was retrieved. 0.90 means "we fetched the right source 90% of the time".
  3. eval_generation measures answer quality: it generates a real answer with answer(...), then calls the judge from Lab 5.3 against that case's rubric, and counts j.passed.
  4. It too returns a fraction — the pass rate of the judged answers. The two print lines report both numbers side by side.

What the output means: recall@5: 0.90 = the retriever found the right chunk 90% of the time; generation pass rate: 0.85 = the judge passed 85% of the written answers.

Try this: Read the two numbers together. Low recall → fix retrieval (chunking, reranking). High recall but low generation → the docs were found but the answer was weak, so fix the prompt. That's the whole point of splitting them.

Reading the two numbersLow recall + high generation-on-retrieved = fix retrieval (chunking, hybrid, rerank). High recall + low generation = fix the prompt (grounding rules, format). Separating them turns vague "it's bad" into a clear next action.

Lab 5.5 · The regression gate advanced

Here's the uncomfortable truth about evals nobody runs: they're worthless. You can build a beautiful golden set, sharp deterministic checks, and a well-calibrated judge panel — and if firing them off is a manual chore someone does "when they remember," quality will still rot. People forget. Deadlines loom. The one prompt tweak that quietly drops your pass rate 6% ships on a Friday because no one thought to re-run the suite. The regression gate is the fix: it takes everything you've built and makes it automatic and unskippable, wiring your evals into CI so a change literally cannot ship if quality drops.

This is the moment your evals stop being a report and become a gate — and the analogy is exactly the unit-test suite you already trust in normal software. When a code change breaks a unit test, the pull request goes red and the merge button locks. You don't rely on the developer to "remember to check"; the machine refuses. The regression gate gives LLM systems that same reflex: every change to a prompt, a retrieval setting, or a model runs the golden set, and a drop below your committed baseline fails the build like any other broken test.

Two design details make this work in practice, and both come from the fact that LLM scores wobble. First, a baseline: a committed record of the scores you've already achieved and now refuse to fall below — the high-water mark future changes must clear. Second, a tolerance: a small slack (a couple of percent) that absorbs the natural run-to-run jitter of model outputs, so you fail on real regressions, not on noise. Set tolerance too tight and the gate cries wolf on every commit; too loose and a genuine slide slips through. And the direction matters — when you intentionally raise a metric, you must bump the baseline to lock in the win, or the old, lower bar keeps letting quality drift back down.

The mistake here is gating on quality alone. A change that lifts your pass rate 2% but triples the cost per case, or doubles latency, is often not a win at all — and a version that's a hair cheaper but leaks PII is a catastrophe, not a trade-off. So real gates carry more than a quality floor: cost and latency get ceilings, and safety checks get a hard fail with zero tolerance — no amount of quality buys back a safety regression. Get this right and you've built the thing that lets a team move fast without fear: change boldly, ship often, and trust that the gate will catch you before your users do.

every change runs the golden set; a drop blocks the deploy change run evals pass_rate ≥baseline−tol? yes deploy ✓ no block ✗ also gate on safety evals (hard-fail) & cost per case The gate turns "seems fine" into a hard check. CI runs the golden set on every change and compares to a committed baseline; a regression fails the build and blocks the deploy — the mechanism that lets you move fast without silently dropping quality (see also A8).
🗺️ How to read this diagram

This is the payoff of the whole chapter, drawn as a flow: it shows how your evals become an automatic quality gate that stands between a code change and shipping to users — the same role unit tests play in normal software.

  • Follow the arrows left to right. A change (a new prompt, model, or retrieval tweak) triggers run evals — the golden-set suite from the earlier labs.
  • The diamond is the decision: is pass_rate ≥ baseline − tol? — did quality stay at or above the committed baseline, allowing a tiny tolerance for normal noise?
  • The yes path (green) leads to deploy ✓ — quality held, so ship it. The no path (red) leads to block ✗ — a regression, so the build fails and nothing ships.
  • The caption at the bottom adds two more gates real systems use: safety evals (a hard fail, never negotiable) and cost per case (a cheaper answer isn't a win if quality dropped).

In short: This is CI for LLMs: every change is scored automatically, and a drop below baseline blocks the deploy. It's what lets you change prompts fast without silently getting worse.

Lab 5.5

Illustrative fragment — defines demo values / files are needed before this runs standalone.

run_evals.pyimport json, sys

BASELINE = {"recall@5": 0.85, "gen_pass": 0.80, "schema_ok": 1.0}
TOLERANCE = 0.02                     # allow tiny noise

def main():
    results = {
        "recall@5": eval_retrieval(),
        "gen_pass": eval_generation(),
        "schema_ok": eval_schema_rate(),
    }
    print(json.dumps(results, indent=2))

    regressed = [m for m,v in results.items()
                 if v < BASELINE[m] - TOLERANCE]
    if regressed:
        print("❌ REGRESSED:", regressed); sys.exit(1)   # fail the build
    print("✅ all metrics within tolerance")

if __name__ == "__main__":
    main()
▶ How this works

This is the gate from the diagram, in code — the script CI actually runs. It gathers every metric, compares each to a saved baseline, and exits with an error if anything slipped. An error exit is how CI knows to fail the pull request.

  1. BASELINE is a dictionary of the scores you've already achieved and want to protect. TOLERANCE = 0.02 allows a tiny wobble, since LLM scores jitter a little between runs even with no real change.
  2. results re-runs the actual evals (retrieval, generation, schema rate) to get this version's numbers, then prints them as readable JSON.
  3. The regressed line is the heart of it: it keeps any metric m whose new value v dropped below its baseline minus the tolerance. If that list has anything in it, quality regressed.
  4. if regressed: prints what broke and calls sys.exit(1) — a non-zero exit code, which tells CI "this build failed." Otherwise it prints the all-clear.

What the output means: On a healthy change: the metrics JSON, then ✅ all metrics within tolerance. On a regression: ❌ REGRESSED: ['gen_pass'] and the build stops.

Try this: When you intentionally improve a metric, raise its number in BASELINE and commit that — otherwise the new, higher quality becomes the floor future changes must clear. The baseline is a living record of "how good we are right now."

Run it in CI on any change to prompts, retrieval, or model. A prompt edit that drops gen_pass below baseline now fails the pull request — exactly like a broken unit test. When you intentionally improve a metric, update the baseline.

Offline then onlineEvals prove gains before you ship. After shipping, confirm with an online A/B test on real traffic and your feedback signal (Chapter 6). Offline evals catch regressions; online tells you if the win is real.

Common pitfalls advanced

PitfallFix
Vibes-based "seems better"Score against a golden set; make it a number
Vague rubrics → noisy judge scoresWrite explicit, checkable criteria
Testing only the happy pathAdd edge cases & every past failure to the golden set
Only measuring qualityTrack cost & latency too — a 2% gain at 3× cost may not ship
Judge model = production model, graded on its own outputPrefer a strong independent judge; use a panel for high stakes
Evaluating RAG end-to-end onlySplit retrieval vs generation metrics

Exercises advanced

Exercise 5.1 — Grow the golden set

Context: A golden set is only as good as the failures it remembers. Feeding real weak answers back into it is the habit that makes evals a living record of mistakes you must never repeat.

Your task: Find 5 inputs where your Chapter 3 RAG system gives a weak answer, add each to the golden set with the correct expected output and rubric, and re-run the suite.

Requirements:

  • At least 5 new cases drawn from actual weak answers, not invented ones
  • Each case has an input, an expected, and a checkable rubric
  • The suite is re-run over the enlarged set
  • The pass rate drops, revealing the real gaps you just captured

💡 Hint: Write the rubric as a specific, checkable sentence ("States the window is 30 days") so the Lab 5.3 judge can grade it consistently.

Exercise 5.2 — Judge stability

Context: An LLM judge's reliability lives in its rubric. The fastest way to feel that is to run the same judgment repeatedly and watch how rubric quality controls the variance.

Your task: Run the judge on the same (answer, rubric) pair 5 times and check whether you get the same verdict; then make the rubric vaguer and repeat.

Requirements:

  • The identical (answer, rubric) pair is judged 5 times
  • Verdict stability across the 5 runs is recorded
  • The rubric is then made deliberately vaguer and the runs repeated
  • You observe that a vague rubric yields noisier, less consistent scores

💡 Hint: "Is the answer good?" versus "States the refund window is 30 days" — the gap between their variances is the whole lesson.

Exercise 5.3 — Prove a change

Context: "It feels better" is not evidence. Toggling one retrieval knob and reading the recall number on both sides turns a tuning decision into a fact.

Your task: Turn hybrid search off in Chapter 3 and run eval_retrieval() to record recall; turn it back on and re-run, producing a number that proves whether hybrid helped on your corpus.

Requirements:

  • Recall is measured with hybrid search off, then on, on the same golden set
  • Only the hybrid toggle changes between the two runs
  • Both recall numbers are reported side by side
  • You state which setting wins on your corpus, backed by the numbers

💡 Hint: Reuse eval_retrieval() unchanged from Lab 5.4 — the only variable is the retriever config, so the comparison is clean.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Deterministic checks on the golden setBeginner

Context: The widest, cheapest layer of the eval pyramid is plain code: yes/no checks that run on every commit with no model and no cost. Turning a bunch of them into one number is the foundation everything else builds on.

Your task: Given the chapter's check_length and check_contains, write a pass_rate(cases, check) function that runs one check across a golden set and returns the fraction that pass.

Requirements:

  • pass_rate applies a single check function to every case
  • It returns a fraction (mean of the booleans), not a count
  • An empty case list returns 0.0 rather than dividing by zero
  • Demonstrated with both check_length and check_contains over a small golden list
  • Runs offline — the checks are pure Python, no API

💡 Hint: A boolean is 0/1 when summed, so the pass rate is just sum(...)/len(cases) — guard the empty-list case first.

Show solution

Deterministic checks are cheap yes/no gates; a pass rate is just the mean of the booleans.

def check_contains(text, must_have):
    return must_have.lower() in text.lower()

def check_length(text, max_words=150):
    return len(text.split()) <= max_words

def pass_rate(cases, check):
    if not cases:
        return 0.0
    hits = sum(1 for c in cases if check(c))
    return hits / len(cases)

GOLDEN = [
    {"answer": "Reset it in Settings > Security."},
    {"answer": "The refund window is 30 days."},
    {"answer": "word " * 200},
]
print(round(pass_rate(GOLDEN, lambda c: check_length(c["answer"])), 2))    # 0.67
print(round(pass_rate(GOLDEN, lambda c: check_contains(c["answer"], "30 days")), 2))  # 0.33
Exercise 2 · Recall@k + generation, read togetherIntermediate

Context: A RAG system has two jobs — find the chunk, then write the answer. Scoring only the final answer can't tell you which job failed; reading recall@k and generation together names the fix.

Your task: Combine the two Lab 5.4 metrics: compute recall@k from retrieved chunk ids and a generation pass rate, then return the one-line diagnosis the chapter prescribes (fix retrieval vs fix the prompt).

Requirements:

  • recall@k counts cases whose expected_chunk is in the retrieved ids
  • gen_pass is the fraction of cases the judge passed
  • Low recall → diagnosis says fix RETRIEVAL (chunking/hybrid/rerank)
  • High recall but low generation → diagnosis says fix the PROMPT
  • Both healthy → a passing diagnosis; all runnable on plain dict cases

💡 Hint: Check the recall floor first, then the generation floor — the order encodes the callout's decision tree exactly.

Show solution

The whole point of splitting the metrics is that the pair of numbers names the fix.

def recall_at_k(cases):
    hits = sum(1 for c in cases if c["expected_chunk"] in c["retrieved_ids"])
    return hits / len(cases)

def gen_pass(cases):
    return sum(1 for c in cases if c["judge_passed"]) / len(cases)

def diagnose(cases, k_recall_floor=0.85, gen_floor=0.80):
    r, g = recall_at_k(cases), gen_pass(cases)
    if r < k_recall_floor:
        return f"recall {r:.2f} low -> fix RETRIEVAL (chunking/hybrid/rerank)"
    if g < gen_floor:
        return f"recall {r:.2f} ok but gen {g:.2f} low -> fix the PROMPT"
    return f"recall {r:.2f}, gen {g:.2f} -> both healthy"

cases = [
    {"expected_chunk": "faq.md#2", "retrieved_ids": ["faq.md#2"], "judge_passed": True},
    {"expected_chunk": "policy.md#0", "retrieved_ids": ["policy.md#0"], "judge_passed": False},
    {"expected_chunk": "faq.md#9", "retrieved_ids": ["faq.md#9"], "judge_passed": False},
]
print(diagnose(cases))  # recall 1.00 ok but gen 0.33 low -> fix the PROMPT
Exercise 3 · A 3-judge panel with majority voteAdvanced

Context: A single LLM judge is noisy. For high-stakes evals the chapter recommends a panel of three and a majority vote — and the cases where the judges disagree are themselves a signal worth surfacing.

Your task: Simulate a 3-judge panel: given a list of per-judge passed booleans per case, return the majority verdict and flag 2-1 splits as low-confidence.

Requirements:

  • panel_verdict passes a case when a majority of the votes are True
  • It flags a case as confident only when the judges are unanimous
  • eval_panel returns the overall pass rate plus the indices of contested cases
  • A 2-1 split is reported as contested (low-confidence), not silently averaged
  • Runs offline over hard-coded vote lists

💡 Hint: Count the True votes once: majority is yes > len(votes)/2, and unanimity is yes == 0 or yes == len(votes).

Show solution

Majority vote reduces single-judge noise; disagreement is itself a signal worth surfacing.

def panel_verdict(judge_votes):
    """judge_votes: list[bool] from 3 judges for ONE case."""
    yes = sum(1 for v in judge_votes if v)
    passed = yes > len(judge_votes) / 2
    unanimous = yes == 0 or yes == len(judge_votes)
    return {"passed": passed, "confident": unanimous, "yes": yes}

def eval_panel(cases):
    verdicts = [panel_verdict(c["votes"]) for c in cases]
    pass_rate = sum(1 for v in verdicts if v["passed"]) / len(verdicts)
    contested = [i for i, v in enumerate(verdicts) if not v["confident"]]
    return pass_rate, contested

cases = [
    {"votes": [True, True, True]},    # unanimous pass
    {"votes": [True, False, True]},   # 2-1 pass, contested
    {"votes": [False, False, True]},  # 2-1 fail, contested
]
rate, contested = eval_panel(cases)
print(round(rate, 2), contested)  # 0.67 [1, 2]
Exercise 4 · The regression gate — correctness of the comparisonExpert

Context: The regression gate is the payoff of the chapter — CI for LLMs. The subtlety is all in the comparison: an improvement must never trip the gate, tolerance absorbs noise, and safety metrics bypass tolerance entirely.

Your task: Reimplement Lab 5.5's gate carefully: a metric regresses only if it drops below baseline minus tolerance, safety metrics are a hard fail with no tolerance, and the gate returns an exit code (0 pass, 1 regressed) plus the offender list.

Requirements:

  • Each metric is compared against baseline[m] - tolerance
  • Metrics in a HARD_FAIL set use zero tolerance
  • An improved metric (above baseline) never appears in the offenders
  • Returns 1 with the offender list when anything regressed, else 0 with an empty list
  • Demonstrated on a noise-absorbed pass and on a real-regression + safety-drop fail

💡 Hint: Compute the per-metric floor as base - (0.0 if m in HARD_FAIL else tol), then collect every metric whose value falls below its floor.

Show solution

The subtlety: an improvement must never trip the gate, tolerance absorbs noise, and safety metrics bypass tolerance entirely.

BASELINE = {"recall@5": 0.85, "gen_pass": 0.80, "schema_ok": 1.0, "safety": 1.0}
TOLERANCE = 0.02
HARD_FAIL = {"safety", "schema_ok"}   # never allow ANY drop

def gate(results, baseline=BASELINE, tol=TOLERANCE):
    regressed = []
    for m, v in results.items():
        floor = baseline[m] - (0.0 if m in HARD_FAIL else tol)
        if v < floor:
            regressed.append(m)
    return (1 if regressed else 0), regressed

# noise on gen_pass is absorbed; an improvement never trips it
print(gate({"recall@5": 0.85, "gen_pass": 0.79, "schema_ok": 1.0, "safety": 1.0}))  # (0, [])
# a real regression AND a hard-fail safety drop
print(gate({"recall@5": 0.70, "gen_pass": 0.90, "schema_ok": 1.0, "safety": 0.99}))
# -> (1, ['recall@5', 'safety'])
Exercise 5 · Track cost & latency alongside qualityProfessional

Context: Quality is necessary but not sufficient: a 2% quality gain at 3× cost may not ship. Production gates treat cost and latency as first-class metrics, and a failed build must explain itself in the CI log.

Your task: Extend the gate to also veto a change whose cost_per_case or p95_latency_ms regresses beyond budget, and print a per-metric report so the block is self-explaining.

Requirements:

  • Higher-is-better metrics (quality) use a floor; lower-is-better metrics (cost, latency) use a ceiling
  • Each metric carries its own tolerance (e.g. 10% latency headroom, zero cost slack)
  • A per-metric OK/FAIL report line is printed for every metric
  • A quality-up-but-cost-3× change is BLOCKED, naming cost_per_case as the offender
  • The direction of the comparison is chosen by which metric-set the name is in

💡 Hint: Split the check by direction: new <= base*(1+tol) for lower-is-better, new >= base-tol for higher-is-better; keep a set of the lower-is-better names.

Show solution

Quality is necessary but not sufficient — production gates cost and latency as first-class metrics, and the report makes a failed build self-explaining.

BASELINE = {"gen_pass": 0.80, "cost_per_case": 0.010, "p95_latency_ms": 1500}
# higher-is-better vs lower-is-better matters for the direction of the check
LOWER_IS_BETTER = {"cost_per_case", "p95_latency_ms"}
TOL = {"gen_pass": 0.02, "cost_per_case": 0.0, "p95_latency_ms": 0.10}  # 10% latency headroom

def check_metric(name, new, base):
    if name in LOWER_IS_BETTER:
        ceiling = base * (1 + TOL[name])
        return new <= ceiling, f"{name}: {new} (ceiling {ceiling:.4f})"
    floor = base - TOL[name]
    return new >= floor, f"{name}: {new} (floor {floor:.4f})"

def gate(results):
    offenders, report = [], []
    for name, new in results.items():
        ok, line = check_metric(name, new, BASELINE[name])
        report.append(("OK  " if ok else "FAIL") + " " + line)
        if not ok:
            offenders.append(name)
    return offenders, report

offenders, report = gate({"gen_pass": 0.83, "cost_per_case": 0.030, "p95_latency_ms": 1550})
print("\n".join(report))
print("BLOCK" if offenders else "SHIP", offenders)  # quality up but 3x cost -> BLOCK ['cost_per_case']
Exercise 6 · Your team ships a model swap under an eval SLAIndustry scenario

Context: Your team wants to swap the production model to cut cost, under a hard constraint: quality must not regress and the swap must be revertible in seconds. A model swap changes behavior and invalidates prompt caches, so it is a risky change.

Your task: Design the offline+online eval flow (golden-set gate → canary → instant rollback) and write the CI gate authorize_canary(control, candidate) that decides whether the swap even reaches the canary.

Requirements:

  • Offline first: run the full golden-set suite (deterministic + recall@k + judge panel) on the candidate model
  • The gate hard-fails on any safety drop
  • It blocks if gen_pass regresses beyond a small margin vs the control
  • It blocks if there is no cost win (candidate cost must drop)
  • The model id is a flag so rollback is a seconds-long flip, not a redeploy
  • The gate logic runs offline over returned metric dicts; only the real suite and canary need a key

💡 Hint: Accumulate failure reasons into a list and pass only when it is empty — the same offenders-list pattern as the plain regression gate, applied to a control-vs-candidate comparison.

Show solution

Design. A model swap changes behavior and invalidates prompt caches (Ch 6), so treat it as a risky change:

  1. Offline first (Ch 5): run the full golden-set suite (deterministic + recall@k + LLM-judge panel) on the candidate model. Gate on gen_pass not regressing, safety hard-fail, and cost_per_case within budget.
  2. Canary online (Ch 6): if the offline gate passes, route a small % of traffic to the candidate behind a feature flag; compare live quality-feedback, p95 latency, and cost vs the control arm.
  3. Instant rollback: the model id is a flag, so a dip flips it back in seconds — no redeploy. Re-baseline (token counts, prompt tuning) only after the win is confirmed.

The offline gate that authorizes the canary:

def eval_suite(model):
    # in real life these call the golden-set harness against `model`;
    # returned here as a dict so the gate logic is testable offline.
    return {"claude-opus-4-8":  {"gen_pass": 0.86, "safety": 1.0, "cost_per_case": 0.012},
            "claude-haiku-4-5": {"gen_pass": 0.85, "safety": 1.0, "cost_per_case": 0.003}}[model]

def authorize_canary(control, candidate, min_gen=0.02, cost_must_drop=True):
    c0, c1 = eval_suite(control), eval_suite(candidate)
    reasons = []
    if c1["safety"] < 1.0:
        reasons.append("safety hard-fail")
    if c1["gen_pass"] < c0["gen_pass"] - min_gen:
        reasons.append(f"quality regressed {c0['gen_pass']}->{c1['gen_pass']}")
    if cost_must_drop and c1["cost_per_case"] >= c0["cost_per_case"]:
        reasons.append("no cost win")
    return (not reasons), reasons

ok, reasons = authorize_canary("claude-opus-4-8", "claude-haiku-4-5")
print("CANARY" if ok else "BLOCK", reasons)  # quality held within 0.02, cost dropped -> CANARY

This is offline gating logic and runs as-is. The real eval_suite and the online canary comparison need an API key and live traffic.

✓ Checkpoint — you can move on when you can…

  • Explain the eval pyramid and what runs at each level.
  • Build a golden set and grow it from failures.
  • Write deterministic checks and an LLM-judge with a gradeable rubric.
  • Measure RAG retrieval and generation separately and act on each number.
  • Wire a regression gate that fails CI when a metric drops.
🏗️ Toward the capstoneEvals are how the DevOps agent earns the right to act. It can't graduate from "diagnose" to "execute a rollback" until evals prove its diagnoses are correct on real past incidents. And the most important eval in the whole system is the capstone's hard-fail safety check: "did the agent ever attempt an action above its permission rung?" — the regression test that keeps it from ever touching prod uninvited. See infra evals in the capstone →

Knowledge check check yourself

✓ Knowledge check

A RAG eval reports recall@5 = 0.90 but generation pass rate = 0.60. What does that combination tell you to fix, and why is measuring the two metrics separately the whole point?

Show answer
High recall with low generation means the right chunks were retrieved but the answer was weak — so fix the prompt (grounding rules, format), not retrieval. Splitting the metrics turns a vague 'it's bad' into a clear next action: low recall points to chunking/hybrid/rerank, low generation points to the prompt.
✓ Knowledge check

The chapter warns against using your production model as its own judge, graded on its own output. Why is that a problem, and what does it recommend for high-stakes evals?

Show answer
A model grading its own output can be biased toward its own answers, giving an unreliable score. The lesson recommends a strong, independent judge at least as capable as the thing it grades, and for high-stakes evals a panel of three judges with a majority vote to reduce noise.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in