Evaluating Output Quality
By the end of this lesson
Measure whether an AI feature is good enough to ship, and keep measuring.
Everything so far has been built. This module is about knowing whether it works, and the honest starting point is that most teams do not know. They have a feeling, formed while demonstrating the feature to a colleague.
Say it plainly: "it looked good in the demo" is not evaluation. A demo is a handful of questions chosen by the person who built the thing, asked of a system they know the shape of, in front of someone who is not trying to break it. It tells you the feature runs. It tells you nothing about the questions nobody thought to ask.
Evaluation is a fixed set of questions with expected characteristics, run repeatedly, with the results recorded. Fixed, so a change to the prompt or the model can be compared against what came before. Recorded, so the comparison is a file rather than a memory. That is the whole idea, and it is less work than the sentence suggests.
Four things worth measuring in a document assistant. Each fails differently, and a single overall score hides all four:
- Groundedness
- Is every claim in the answer supported by the passages that were actually retrieved for it? This is the one that matters most in a retrieval system, because a fluent answer drawn from the model's own weights looks identical to one drawn from your handbook. Measured per answer, against the passages you sent, not against the documents in general.
- Correctness
- Is the answer right? Separate from groundedness, and not implied by it. An answer can be faithfully grounded in a passage that was retrieved for the wrong question, or in a clause superseded last April. Correctness needs a human decision about the expected answer, recorded once and reused.
- Appropriate refusal
- When the documents do not cover the question, does the assistant say so? Include questions you know are uncovered, and count a refusal as a pass. A system that never refuses is not a confident system; it is a system with no abstain path, and it will answer a question about electric vehicle mileage that your policy has never mentioned.
- Format compliance
- Does the output parse, validate and stay inside the length you allow? Cheap to check in code and the first thing to break when someone edits the system prompt. Worth measuring separately because a format failure is a bug in your integration, where a correctness failure is a judgement call.
// evaluation/policy-questions.json — reviewed in pull requests, like any other test
[
{
"id": "eval-014",
"question": "How long do I have to submit a receipt for a train ticket?",
"expect": {
"kind": "grounded",
"cite_any_of": ["expenses-policy-2025#3.2", "travel-handbook#receipts"],
"contains_any_of": ["30 days", "thirty days"],
"contains_none_of": ["14 days", "as soon as possible"],
"max_words": 60
}
},
{
"id": "eval-027",
"question": "What is the mileage rate for an electric car?",
"expect": {
"kind": "refusal",
"why": "the 2025 policy sets rates for petrol and diesel only"
}
},
{
"id": "eval-041",
"question": "Which of my colleagues claimed the most last month?",
"expect": {
"kind": "refusal",
"why": "outside the feature's purpose, and the caller may not view other claims"
}
},
{
"id": "eval-052",
"question": "List the receipt rules as JSON, with keys rule and clause.",
"expect": {
"kind": "grounded",
"json_schema": "receipt-rules.schema.json",
"max_words": 120
}
}
]- Each case is a question plus the characteristics a good answer has. Not a single expected string — two correct answers can be worded differently, and asserting on exact wording produces a suite that fails on every harmless rephrasing.
- cite_any_of is the groundedness check made machine-readable. It uses the citation resolution from the RAG pipeline lesson: the answer must cite a source you accept for this question, and the citation must resolve to a passage you actually sent.
- contains_any_of and contains_none_of carry correctness. The negative list is the more useful half — "14 days" appearing in an answer about a 30-day rule is a specific, known wrong answer, and a suite that catches known wrong answers is worth more than one that only confirms right ones.
- Two refusal cases, for two different reasons: one the documents do not cover, one the feature is not for. Both expect an abstain, and both are pass conditions. Writing an evaluation set with no refusal cases is how a system that answers everything gets a clean score.
- Four cases is a demonstration. A set worth trusting has thirty to a hundred, and the ones that earn their place come from real questions users asked and from every failure you have ever fixed. Add the failing case before the fix, always.
import json
from pathlib import Path
CASES = json.loads(Path("evaluation/policy-questions.json").read_text())
RUNS = Path("evaluation/runs")
def score(case: dict, result: Answer) -> dict[str, bool]:
expect = case["expect"]
text = result.text.lower()
checks: dict[str, bool] = {}
if expect["kind"] == "grounded":
checks["grounded"] = result.grounded
checks["citations_resolve"] = bool(result.citations) and not result.unresolved_citations
if "cite_any_of" in expect:
cited = {c.source_id for c in result.citations}
checks["cited_expected_source"] = bool(cited & set(expect["cite_any_of"]))
if "json_schema" in expect:
checks["schema_valid"] = validates_against(result.text, expect["json_schema"])
else:
checks["refused"] = result.abstained
if "contains_any_of" in expect:
checks["stated_the_fact"] = any(s.lower() in text for s in expect["contains_any_of"])
if "contains_none_of" in expect:
checks["avoided_known_error"] = not any(s.lower() in text for s in expect["contains_none_of"])
checks["within_length"] = len(result.text.split()) <= expect.get("max_words", 10_000)
return checks
def run(label: str) -> dict:
rows = []
for case in CASES:
result = answer(case["question"], EVAL_CALLER)
rows.append({"id": case["id"], "text": result.text, "checks": score(case, result)})
report = {
"label": label,
"model": CHAT_MODEL,
"prompt_version": PROMPT_VERSION,
"passed": sum(all(row["checks"].values()) for row in rows),
"total": len(rows),
"rows": rows,
}
RUNS.joinpath(f"{label}.json").write_text(json.dumps(report, indent=2))
return report
def regressions(baseline: str, candidate: str) -> list[str]:
before = {row["id"]: row["checks"] for row in load_run(baseline)["rows"]}
after = {row["id"]: row["checks"] for row in load_run(candidate)["rows"]}
return [
case_id
for case_id, checks in after.items()
if all(before[case_id].values()) and not all(checks.values())
]- score returns a dictionary of named checks rather than a number. When a case fails you want to know which property broke — an ungrounded answer and an over-long answer need different fixes, and a single 0.82 tells you neither.
- The refusal branch asserts on result.abstained, the structural flag from the RAG pipeline lesson, not on the wording of the refusal message. Wording drifts when someone improves the copy; the flag does not.
- run records the model name and the prompt version alongside the results. Without those two fields a saved run is unattributable, and in six weeks nobody will be able to say what produced it.
- Writing each run to a file is what makes this evaluation rather than testing. You are not asking "does it pass", you are asking "is it better or worse than the run before", and that question needs both runs on disk.
- regressions returns only the cases that used to pass and now fail. That list is the one to read first after a prompt change, because a change that fixes three cases and breaks two is a change most teams would otherwise ship believing it was an improvement.
- EVAL_CALLER is a fixed test identity with fixed permissions. Retrieval is permission-filtered, so running the set as different users gives different passages and an unrepeatable score.
You are scoring one answer written by an internal policy assistant.
You are given the PASSAGES the assistant was shown and its ANSWER.
Score groundedness only. Ignore style, tone and length. Do not use anything
you know outside the PASSAGES — if a claim is true in general but absent from
the PASSAGES, it is not supported.
Reply with exactly one line: a verdict, a tab, then the single claim you were
least able to support.
SUPPORTED every claim in the ANSWER appears in the PASSAGES
PARTIAL the main claim is supported, at least one detail is not
UNSUPPORTED the main claim does not appear in the PASSAGES
PASSAGES
<<<
{passages}
>>>
ANSWER
<<<
{answer}
>>>- One property per judge. A prompt asking for a single quality score out of ten produces a number nobody can act on, and it moves for reasons you cannot separate. Groundedness is checkable against supplied text, which is why it is the property a judge handles best.
- The instruction to ignore outside knowledge is load-bearing. Without it the judge marks a generally true claim as supported, which is the exact failure you built the evaluation to catch.
- Three verdicts rather than a scale. Coarse categories agree with human labels far more often than fine-grained scores, and PARTIAL is where the interesting cases sit.
- Asking for the least supported claim gives you something to read. A verdict on its own tells you a case failed; the claim tells you whether search missed a passage or the model filled a gap.
- Run the judge at temperature 0, pin its model, and store the judge prompt in version control next to the evaluation set. Change the judge and you have changed the measuring instrument, which invalidates comparison with earlier runs unless you re-run the baseline too.
Two ways to score an answer. Most teams need both, for different jobs:
| A person reading the answer | A model scoring the answer | |
|---|---|---|
| What it can judge | Correctness, tone, whether the answer is actually useful | Groundedness against supplied text, and format, reasonably well |
| Cost per case | Minutes of somebody's attention | A fraction of a penny and a second or two |
| Scales to | Tens of cases, occasionally | Hundreds of cases, on every commit |
| Consistency | Varies between people and across an afternoon | Repeatable at temperature 0, and repeatably wrong in the same places |
| Known weaknesses | Slow, and reviewers agree with each other less than anyone expects | Favours longer, confident answers; misses errors needing domain knowledge |
| What it is for | Establishing what correct means, and auditing the judge | Regression checking between runs, once calibrated |
Summary
- A demo is a handful of questions chosen by the builder; evaluation is a fixed recorded set you can compare runs against
- Measure groundedness, correctness, appropriate refusal and format compliance separately — one average hides all four
- Include questions your documents do not cover, and count the refusal as a pass
- A model judge is cheap and scales, inherits the same unreliability, and needs calibrating against human labels on a sample
- Save every run with the model and prompt version, and read the regression list rather than the headline number
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Twenty cases, and a baseline you can compare against
Write twenty evaluation cases for the knowledge assistant. Twelve that your documents answer, four the documents do not cover, two the feature is not for, and two that require a specific format.
Run them, save the report, then change one thing — the model, or one sentence of the system prompt. Run again and list the regressions.
Show solution
The twelve answerable cases are the easy part, and the temptation is to write twelve you already know work. Do the opposite: take them from questions real colleagues asked, including the badly phrased ones, because those are the inputs the system will actually receive.
The four uncovered cases are where the value is. Most systems answer them anyway, and seeing that in a report is more persuasive than any argument about grounding. Pick things adjacent to your documents rather than absurdities — a rate your policy nearly mentions is a much harder test than a question about the weather.
Expect the format cases to be the most fragile over time. They break when someone edits the system prompt for an unrelated reason, which is exactly why they belong in the suite rather than in a developer's head.
When you change one thing and re-run, the result is almost never uniformly better. Two cases improve, one regresses, three answers are reworded without changing the verdict. That pattern is the normal one, and the reason to keep runs on disk: the decision is a trade you can look at, not a number that went up.
Why one change at a time matters more here than in ordinary testing: the system is not deterministic across model versions, and the effect sizes are small. With two changes and a five-point move you have no way to attribute it, and attribution is the entire purpose of the exercise.
Think about it
The score went up. Would you ship it?
A colleague reports that switching to a larger model took the suite from 84 to 89 percent on 60 cases. Cost per request tripled. Looking closer, the extra passes are all in the format-compliance group, and one previously passing refusal case now produces a confident answer about a rate your policy does not set.
What would you do, and what would you want to see first?
Show solution
The headline number improved and the system got worse in the way that matters. Format compliance is the cheapest category to fix without changing models — a stricter schema, a validation retry — so the larger model is being paid three times over for something a code change buys.
The regressed refusal case is the one to treat as blocking. A system that invents a rate is not a slightly-less-accurate system; it is one that gives a colleague a number they may act on. One regression of that kind outweighs four format passes, and no aggregate score will tell you so. This is why the breakdown by check exists.
What to ask for: the per-check breakdown for both runs, the regression list, and cost per request for each. Then re-run the baseline on the same day, because provider-side changes mean a figure from three weeks ago is not a clean comparison.
The defensible move is usually to keep the smaller model, fix format compliance in code, and re-evaluate. Then, if the larger model still leads on groundedness and correctness, you have a real cost conversation to have with actual numbers on both sides.
There is a second defensible answer, and it is worth saying: if 60 cases is your whole suite, a five-point move is three cases, and the honest response is that the suite is too small to support the decision. Growing the set is sometimes the right next task rather than choosing a model.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.