<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ashen Asserts]]></title><description><![CDATA[Ashen Asserts]]></description><link>https://ashenasserts.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 31 Oct 2026 06:53:11 GMT</lastBuildDate><atom:link href="https://ashenasserts.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Tester's Guide to RAG Evaluation, Part 2: Building the Evals]]></title><description><![CDATA[Build a RAG test set, find failure modes with error analysis, write retrieval metrics and LLM judges, and validate the judges against human labels.]]></description><link>https://ashenasserts.hashnode.dev/testers-guide-to-rag-evaluation-part-2-building-the-evals</link><guid isPermaLink="true">https://ashenasserts.hashnode.dev/testers-guide-to-rag-evaluation-part-2-building-the-evals</guid><category><![CDATA[Testing]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[llm]]></category><category><![CDATA[QA]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Ashen Jayawardena]]></dc:creator><pubDate>Fri, 30 Oct 2026 20:51:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/2ae007dc-d9c6-4bde-8268-91c2f4271de1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In Part 1 you met the RAG system: retrieval finds passages in a corpus, generation writes an answer from them, and each half fails in its own way. You spiked the system to see how it really behaves, and built a foundation where every setting is code, every case runs in a fresh session, every version is pinned, and every run leaves a trace and a manifest.</p>
<p>Now you have something you can test reliably. Part 2 is about deciding what to test and building the checks. It covers four stages: building a dataset, finding out how the system fails, writing an evaluator for each failure, and measuring whether your AI judges can be trusted.</p>
<h2>Stage 2: Dataset</h2>
<h3>Goal</h3>
<p>Build a set of questions with known correct answers and the evidence for them, covering the kinds of questions your system will face.</p>
<h3>Why it matters</h3>
<p>Every number you report later is a statement about this dataset. If the questions are all easy, your scores will look great and mean little. If the expected answers are wrong, you'll fail correct answers and pass wrong ones. And if you tune the system on the same questions you report, your scores will flatter you. A dataset is the test suite of an evaluation; it deserves the same care.</p>
<h3>Key terms</h3>
<p>An <strong>evaluation dataset</strong> is the set of test cases you evaluate against. You'll also hear it called a <strong>golden dataset</strong>, and its items <strong>goldens</strong>: the industry's name for a carefully checked set of cases with trusted expected outputs. The trusted outputs are the <strong>ground truth</strong>.</p>
<p>For a RAG system, each case has three parts: the question, the <strong>expected answer</strong> (the reference answer from Part 1), and the <strong>evidence</strong>. Evidence is the exact text in the documents that supports the answer, usually stored as <strong>evidence quotes</strong>: verbatim spans copied from a document, with the document's name.</p>
<p><strong>Dimensions</strong> are the ways questions vary, decided up front so the dataset covers them on purpose. Typical dimensions for RAG:</p>
<ul>
<li><strong>Question type:</strong> a single fact, a procedure, a cause, a comparison, or a <strong>multi-document question</strong> that needs facts from two or more documents.</li>
<li><strong>Complexity:</strong> one evidence span, several spans, or an inference across them.</li>
<li><strong>Phrasing:</strong> close to the document's wording, or <strong>paraphrased</strong> in different words.</li>
<li><strong>Evidence location:</strong> where the answer sits, such as a table, the narrative or the conclusions.</li>
<li><strong>Chunk boundary:</strong> whether the evidence lies inside one chunk or crosses the cut between two.</li>
</ul>
<p>Some questions should be <strong>unanswerable</strong>, as Part 1 described. There are three useful kinds:</p>
<ul>
<li><strong>Missing detail:</strong> about a topic the corpus covers, asking for a detail it never states.</li>
<li><strong>Out of corpus:</strong> about something the corpus doesn't cover at all.</li>
<li><strong>False premise:</strong> built on an assumption that contradicts the documents.</li>
</ul>
<p>The research community added unanswerable questions to benchmarks for exactly this reason: when a test set only contains answerable questions, systems that guess on everything still score well, so the test never rewards knowing when not to answer [1].</p>
<p>A <strong>closed-book baseline</strong> runs the questions through the model with no documents at all, so it can only answer from its parametric knowledge (Part 1). Research on this setup shows large models can answer many factual questions from memory alone [2]. For your evaluation, the closed-book score tells you how much of a correct RAG answer could have come from memory rather than retrieval.</p>
<p>Finally, the dataset is split. The <strong>dev split</strong> (short for development) is the part you look at, learn from and tune against. The <strong>held-out split</strong> (or <strong>test split</strong>) is kept aside and used only for final measurements. Tuning prompts or settings while watching held-out scores is <strong>overfitting to the eval set</strong>: the system gets better at your test questions, not at the job.</p>
<h3>How it's done</h3>
<p><strong>1. Define the dimensions and targets.</strong> Write down each dimension, its values, and roughly what share of the dataset each should have. As a rule of thumb, make around 15–20% of questions unanswerable: enough to measure abstention properly, but not so many that the dataset stops looking like real traffic. Also make sure some evidence crosses a chunk boundary.</p>
<p><strong>2. Write or generate candidate questions.</strong> You can write them by hand, or use an LLM to draft candidates from the documents against a plan that fills each dimension. Generated candidates save time, but they are drafts: they need checking like any test data you didn't write yourself.</p>
<p><strong>3. Check them automatically.</strong> Rules catch a lot before a person reads anything:</p>
<ul>
<li>every evidence quote must appear verbatim in the document;</li>
<li>near-duplicate questions are removed;</li>
<li>questions that copy most of their wording from the evidence are flagged as too easy, because keyword search would find them;</li>
<li>where the evidence sits, and whether it crosses a chunk boundary, is measured from the real text, not assumed.</li>
</ul>
<p><strong>4. Review every item by hand.</strong> Read each question with its evidence in context, and approve, edit or reject it. Check the expected answer is right and complete, and that an unanswerable question really is unanswerable.</p>
<p><strong>5. Split into dev and held-out sets.</strong> Split so that each question type appears in both (a <strong>stratified</strong> split), with a fixed random seed so the split can be reproduced. Record a hash of each file.</p>
<p><strong>6. Run a closed-book baseline</strong> with the model and no documents, and check how many questions it gets right from memory.</p>
<p>Here's an example of a single case, with an invented question and document:</p>
<pre><code class="language-json">{
  "id": "q-042",
  "split": "dev",
  "question": "What speed was the ferry making when it hit the pier, and what time did the impact happen?",
  "answerable": true,
  "expected_answer": "About 6 knots, at 14:32.",
  "evidence": [
    {"doc_id": "report-17.pdf", "quote": "At 14:32 the ferry struck the north pier while making about 6 knots."}
  ],
  "dimensions": {"question_type": "factual", "complexity": "single_span", "phrasing": "paraphrased"}
}
</code></pre>
<p>And a false-premise question built on the same invented report: "Why did the ferry's captain ignore the harbour pilot's warning?", when the report says no pilot was aboard. The right answer points out the false assumption instead of inventing a reason.</p>
<blockquote>
<p>✅ <strong>Best practices</strong></p>
<ul>
<li><strong>Build from explicit dimensions.</strong> Questions you write freely cluster around what's easy to ask; a plan makes coverage deliberate and visible.</li>
<li><strong>Paraphrase away from the source wording.</strong> Real users don't quote documents; questions that copy the text test keyword matching, not retrieval of meaning.</li>
<li><strong>Include unanswerable and multi-document questions.</strong> Without them you can't measure abstention [1], and you won't find out whether the system can combine evidence.</li>
<li><strong>Label evidence as verbatim quotes, not chunk IDs.</strong> Chunks change whenever you change chunking; a quote stays valid, so your labels survive configuration changes.</li>
<li><strong>Review every item by hand.</strong> A wrong expected answer quietly corrupts every score built on it.</li>
<li><strong>Keep a held-out split you never tune on.</strong> It's the only honest estimate of how the system will do on questions you haven't optimised for.</li>
<li><strong>Run a closed-book baseline.</strong> If the model can answer from memory, a correct RAG answer doesn't prove retrieval worked [2].</li>
<li><strong>Plan for judge validation now.</strong> Stage 5 needs enough failing examples for each failure type; a rare failure type needs more questions of that kind.</li>
</ul>
</blockquote>
<blockquote>
<p>⚠️ <strong>Common mistakes</strong></p>
<ul>
<li>Only answerable, single-fact questions, so scores look high and abstention is never tested.</li>
<li>Trusting generated questions and expected answers without reading them.</li>
<li>One split for everything, so tuning and reporting use the same questions.</li>
<li>Evidence stored as chunk IDs, which break the first time chunking changes.</li>
</ul>
</blockquote>
<h2>Stage 3: Baseline runs and error analysis</h2>
<h3>Goal</h3>
<p>Find out how the system actually fails, before choosing a single metric.</p>
<h3>Why it matters</h3>
<p>It's tempting to open an evaluation framework, pick its built-in metrics and start measuring. The trouble is that generic metrics measure generic problems. Your system fails in its own specific ways, and you only find out which by looking. Experienced practitioners put this first: read real outputs, then build evals around the failures you see [3]. Measuring before looking gives you precise numbers about the wrong things.</p>
<h3>Key terms</h3>
<p>A <strong>baseline</strong> is the configuration you treat as the reference: your best current setup, which later changes are compared against.</p>
<p>A <strong>weak configuration</strong> is a deliberately worse variant, such as fewer retrieved passages or a prompt that doesn't ask for grounding. Running a few weak configurations alongside the baseline gives you a wider range of failures to learn from.</p>
<p><strong>Error analysis</strong> is the process of reading traces, describing what went wrong, and organising those descriptions into categories. It borrows two techniques from qualitative research [4]:</p>
<ul>
<li><strong>Open coding:</strong> reading each trace and writing a short note in your own words about what went wrong, without a predefined list of categories.</li>
<li><strong>Axial coding:</strong> grouping those notes into categories, merging similar ones and naming each group.</li>
</ul>
<p>Each resulting category is a <strong>failure mode</strong>, and the full set, with definitions and examples, is a <strong>failure taxonomy</strong>.</p>
<p>When several things go wrong in one trace, note only the <strong>first upstream failure</strong>: the earliest point in the pipeline where it broke. If retrieval missed the evidence, the wrong answer that follows is a consequence, not a separate failure. Recording only the first cause keeps you from double-counting one problem as two.</p>
<h3>How it's done</h3>
<p><strong>1. Run the baseline and a couple of weak configurations on the dev split.</strong> Never the held-out split: you'll be looking closely at every trace, and that's tuning-by-eye.</p>
<p><strong>2. Review the traces blind.</strong> Shuffle them and hide which configuration produced each one, so your expectations ("this is the weak config, it'll be bad") don't colour your notes. Show everything needed to judge each one: the question, expected answer and evidence, the retrieved passages (ideally with the evidence highlighted wherever it appears), and the answer.</p>
<p><strong>3. Write one short note per trace about the first upstream failure,</strong> or mark it as fine. For example, three invented notes:</p>
<ul>
<li>"The passage with the impact time wasn't retrieved; the answer says the documents don't say."</li>
<li>"The right passage was retrieved, but the answer gives 16 knots instead of 6."</li>
<li>"The answer explains why the captain ignored the pilot, but no pilot was aboard."</li>
</ul>
<p><strong>4. Group the notes into failure modes.</strong> The first note becomes "answer not retrieved", the second "right passage, wrong reading", the third "trick question not caught". Name each mode, define it in one sentence, and count how many traces fall into it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/4b8a06b1-4751-401a-a228-ad46ffdd743a.png" alt="Error analysis in three steps. Traces from the dev split, shuffled with the config hidden, are read one at a time. Open coding writes one note per trace about the first upstream failure. Axial coding groups the notes into named failure modes with counts, and each mode becomes one evaluator." style="display:block;margin:0 auto" />

<p>As a tester, you'll recognise this as exploratory testing plus defect triage. The difference is where the categories come from. In triage you usually file defects into categories that already exist; here you build the categories from what you find.</p>
<blockquote>
<p>✅ <strong>Best practices</strong></p>
<ul>
<li><strong>Do error analysis before choosing metrics.</strong> Evals built from observed failures measure what actually goes wrong in your system [3].</li>
<li><strong>Attribute each failure to its first upstream cause.</strong> It separates retrieval problems from generation problems, which need different fixes.</li>
<li><strong>Have a person do the reading.</strong> Open coding is where you decide what counts as a failure. If a model does it, its blind spots become your taxonomy, and the judges you build on that taxonomy will share them.</li>
<li><strong>Hide the configuration while reviewing.</strong> Knowing which config produced a trace biases what you see in it.</li>
<li><strong>Write notes in your own words first, group later.</strong> Starting from a fixed list of categories makes you see only those categories.</li>
<li><strong>Expect your criteria to sharpen as you read.</strong> Researchers call this criteria drift: grading outputs changes what you think the criteria should be [5]. Let the taxonomy settle before you build evaluators on it.</li>
</ul>
</blockquote>
<blockquote>
<p>⚠️ <strong>Common mistakes</strong></p>
<ul>
<li>Starting from a framework's list of metrics instead of from your own traces.</li>
<li>Blaming generation for a wrong answer when retrieval never found the evidence.</li>
<li>Doing error analysis on the held-out split.</li>
<li>Reading only the failures you expected, or too few traces to see the patterns.</li>
</ul>
</blockquote>
<h2>Stage 4: Evaluators</h2>
<h3>Goal</h3>
<p>Build one automated check for each failure mode that actually occurs in your taxonomy.</p>
<h3>Why it matters</h3>
<p>Error analysis tells you what goes wrong today. Evaluators let you measure it every time the system changes, without reading hundreds of traces by hand. Building them from the taxonomy keeps each check tied to a real problem, and keeps the set small: no check for a failure you've never seen, no failure without a check.</p>
<h3>Key terms</h3>
<p>An <strong>evaluator</strong> (or <strong>metric</strong>) is a check that scores one aspect of a trace.</p>
<p>A <strong>deterministic</strong> evaluator follows a fixed rule and always gives the same result for the same input. Exact-match checks and retrieval metrics are deterministic. An evaluator that calls an LLM is not.</p>
<p>A <strong>reference-based</strong> evaluator compares the output against something known in advance, such as the expected answer or the evidence. A <strong>reference-free</strong> evaluator judges the output on its own terms, for example checking that every claim is supported by the retrieved passages, which needs no expected answer.</p>
<p><strong>Retrieval metrics</strong> measure whether retrieval found the evidence, using the evidence quotes from your dataset. The three most common:</p>
<ul>
<li><strong>Hit rate:</strong> the share of questions where at least one of the top k retrieved passages contains evidence.</li>
<li><strong>Recall@k:</strong> for one question, the share of its evidence found within the top k retrieved passages, averaged over questions.</li>
<li><strong>MRR (mean reciprocal rank):</strong> for each question, take 1 divided by the rank of the first passage that contains evidence (1 if it's first, ½ if second, ⅓ if third, 0 if none), then average over questions. It rewards finding the evidence early.</li>
</ul>
<p>A quick worked example, with invented numbers. A question has two evidence quotes. Four passages come back. The first is unrelated, the second contains quote A, the third is unrelated, and the fourth contains quote B. That question scores a hit, recall@4 of 2/2, and a reciprocal rank of ½.</p>
<p>You can measure these at <strong>document level</strong> (did any passage come from the right document?) or at <strong>passage level</strong> (did a passage contain the actual evidence?). Document level is easier to pass and tells you less: finding the right report isn't the same as finding the paragraph with the answer. Match passages to evidence by their text, since chunk IDs change whenever chunking changes.</p>
<p>For everything a rule can't decide, you use <strong>LLM-as-a-judge</strong>, introduced in Part 1: a separate model that grades an output against your instructions. The instructions are the <strong>judge prompt</strong>. The criteria inside it, with what counts as passing and failing, are the <strong>rubric</strong>. A <strong>binary</strong> (pass/fail) judge answers one yes-or-no question. A <strong>scale</strong> judge gives a score, such as 1 to 5. Binary judges are easier to make consistent and to check against human labels, and nobody has to decide what separates a 3 from a 4 [6].</p>
<p>Good judge prompts include <strong>few-shot examples</strong>: a handful of worked examples inside the prompt showing the judge how to decide, without retraining the model [7]. A useful set is one clear pass, one clear fail and one borderline case with its decision explained.</p>
<p>Common judges for RAG, each matched to failure modes you might find:</p>
<ul>
<li><strong>Groundedness</strong> (also called <strong>faithfulness</strong>): is every claim in the answer supported by the retrieved passages? Catches unsupported claims, guesses and facts borrowed from the wrong document.</li>
<li><strong>Correctness:</strong> does the answer give the facts in the expected answer? Catches misreadings and wrong answers.</li>
<li><strong>Abstention:</strong> on an unanswerable question, does the answer decline or point out the false premise?</li>
<li><strong>Unnecessary refusal:</strong> on an answerable question, does the answer hold back something the passages contain?</li>
</ul>
<h3>How it's done</h3>
<p><strong>1. Use rules wherever a rule can decide.</strong> If the system returns a fixed refusal text when nothing is retrieved, an exact-match check identifies that case with no model call. Retrieval metrics need no judge either.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/ef51d746-3f0f-4a61-bc91-0fa793a41075.png" alt="The evaluation pyramid. Deterministic checks at the base, retrieval metrics (hit rate, recall@k, MRR) in the middle, binary LLM judges at the top. Cost and uncertainty rise towards the top, so use the lowest level that can answer the question." style="display:block;margin:0 auto" />

<p><strong>2. Write one binary judge per remaining failure mode.</strong> Give it one question to answer, explicit Pass and Fail definitions, three labelled examples, and a fixed output format. A shortened example:</p>
<pre><code class="language-text">You are grading one answer from a question-answering system over accident reports.
Judge one thing only: is every factual claim in the answer supported by the passages?

Fail if the answer states a number, name, time or cause the passages do not support,
uses facts from a passage about a different accident, or offers a guess ("likely",
"probably") as an answer. Pass if every claim is in the passages or follows directly
from them. Saying the passages do not contain the answer is a Pass.

[three short worked examples: clear pass, clear fail, borderline]

Reply with JSON only: {"result": "Pass" or "Fail", "reason": "&lt;one sentence&gt;"}
</code></pre>
<p>And a verdict it might return:</p>
<pre><code class="language-json">{"result": "Fail", "reason": "The passages give 6 knots; the answer says 16 knots."}
</code></pre>
<p><strong>3. Show each judge only what it needs.</strong> A groundedness judge needs the passages and the answer, not the expected answer, or it may start grading correctness instead. A correctness judge needs the expected answer, not the passages.</p>
<p><strong>4. Choose the judge model.</strong> Use a model from a different family than the system under test, because, as Part 1 mentioned, judges tend to favour outputs like their own. Pin its version like any other component.</p>
<p><strong>5. Control cost.</strong> Judges cost a model call each. Cache verdicts keyed on the judge model, the prompt and the inputs, so unchanged cases are never graded twice. Estimate the cost before each run, and set a cap that stops a run before it overspends.</p>
<p>If you think of a judge as a test oracle, here's where the comparison breaks: you trust an oracle, but a judge is a model with its own error rate. Until you've measured that error rate, a judge's pass rate is an opinion, not a measurement. That's the next stage.</p>
<blockquote>
<p>✅ <strong>Best practices</strong></p>
<ul>
<li><strong>Prefer deterministic checks.</strong> They're free, exact and repeatable; use a judge only where a rule can't decide.</li>
<li><strong>Use binary judges, one failure mode each.</strong> One clear question per judge is easier to make consistent and to validate than a single "quality" score [6].</li>
<li><strong>Give explicit definitions and labelled examples.</strong> The judge should never have to guess where the line is [7].</li>
<li><strong>Pick a judge from a different model family.</strong> It reduces the risk of the judge favouring answers in its own style [8].</li>
<li><strong>Measure retrieval at passage level against evidence text.</strong> Document-level metrics can look fine while the evidence itself is rarely retrieved.</li>
<li><strong>Cache verdicts and cap cost.</strong> A re-run should never pay twice for the same judgement.</li>
</ul>
</blockquote>
<blockquote>
<p>⚠️ <strong>Common mistakes</strong></p>
<ul>
<li>Scoring answers 1–10, so nobody can say what a 6 means or whether it changed.</li>
<li>One judge for "overall quality", which can't tell you what went wrong.</li>
<li>Showing a judge the expected answer when it's meant to check grounding.</li>
<li>Using built-in metrics that don't match any failure mode in your taxonomy.</li>
</ul>
</blockquote>
<h2>Stage 5: Judge validation</h2>
<h3>Goal</h3>
<p>Measure how often each judge agrees with a careful human, before you trust its numbers.</p>
<h3>Why it matters</h3>
<p>A judge that's wrong 30% of the time can make a broken system look fine, or a good one look broken. Researchers studying LLM evaluators put it plainly: LLM-based graders inherit the problems of the LLMs they evaluate, so they need checking against human judgement [5]. Validation is what turns "the judge says 85%" into a number with known error bars.</p>
<h3>Key terms</h3>
<p><strong>Human labels</strong> are Pass or Fail verdicts that a person assigns to real traces, for the same question each judge answers. They're the answer key for testing the judge, and <strong>judge validation</strong> is comparing the judge's verdicts with them.</p>
<p>Treat each judge as a classifier with two possible errors: it can pass an answer a human failed (a missed failure), or fail an answer a human passed (a false alarm).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/d126e50d-ee46-4eb5-8019-efbe1fdbea86.png" alt="A confusion matrix of judge verdict against human label. Both agreeing on Pass counts towards TPR, both agreeing on Fail counts towards TNR. Judge Pass on a human Fail is a missed failure; judge Fail on a human Pass is a false alarm." style="display:block;margin:0 auto" />

<p>From that table come two rates. Conventions vary, so here's how this series uses them:</p>
<ul>
<li><strong>True positive rate (TPR):</strong> of the answers a human passed, the share the judge also passed. Low TPR means false alarms.</li>
<li><strong>True negative rate (TNR):</strong> of the answers a human failed, the share the judge also failed. Low TNR means missed failures.</li>
</ul>
<p>Why not just report <strong>accuracy</strong>, the share of all verdicts that agree? Because it hides the error you care about when failures are rare. Imagine 100 answers, 90 of them good. A judge that passes everything scores 90% accuracy and catches none of the 10 failures: TPR 100%, TNR 0%. Reporting both rates exposes this immediately [6].</p>
<p>Judges also have known biases. Studies of LLM judges have found <strong>position bias</strong> (favouring the answer shown first when comparing two), <strong>verbosity bias</strong> (favouring longer answers) and <strong>self-enhancement bias</strong> (favouring their own outputs) [9]. Binary questions about one answer avoid the first, explicit rubrics reduce the second, and a judge from a different family reduces the third.</p>
<p>To validate without fooling yourself, split the labels like the dataset. <strong>Judge-dev</strong> labels are the ones you look at while improving the judge prompt. <strong>Judge-test</strong> labels are used once, at the end, for the numbers you report.</p>
<h3>How it's done</h3>
<p><strong>1. Collect traces with enough passes and fails for each judge.</strong> Use dev traces from the baseline and from weak configurations, which supply failures. Aim for roughly 50 passes and 50 fails per judge where you can. With far fewer, the uncertainty around TPR and TNR gets too wide to be useful (Part 3 shows how to put numbers on that uncertainty). If you have real user traffic, sample traces from it too: it contains failures your dataset didn't anticipate.</p>
<p><strong>2. Label them blind.</strong> A person marks Pass or Fail for each judge question, without seeing the judge's verdicts and without knowing which configuration produced the trace. Leave out any trace used as a few-shot example in a judge's prompt.</p>
<p><strong>3. Split the labels by question</strong> into judge-dev and judge-test, so the same question never appears on both sides.</p>
<p><strong>4. Improve on judge-dev.</strong> Run the judges, read the disagreements, and fix unclear wording in the prompts. Stop when the remaining disagreements are genuinely borderline, not when the numbers look good.</p>
<p><strong>5. Measure once on judge-test,</strong> and report TPR and TNR for each judge, with how many passes and fails each rate is based on.</p>
<p><strong>6. Re-validate whenever the judge model or its prompt changes.</strong> The numbers belong to that exact model and prompt.</p>
<blockquote>
<p>✅ <strong>Best practices</strong></p>
<ul>
<li><strong>Validate every judge against human labels before using its scores.</strong> An unvalidated judge's numbers have unknown error [5].</li>
<li><strong>Report TPR and TNR, not accuracy alone.</strong> Accuracy can look high while a judge misses every failure [6].</li>
<li><strong>Keep judge-dev and judge-test labels separate.</strong> Tuning a prompt on the labels you report makes the result look better than it is.</li>
<li><strong>Get enough failures per judge.</strong> If a failure type is rare, add questions or weak configurations that provoke it, or report the judge as not validated.</li>
<li><strong>Re-validate after any change</strong> to the judge model or prompt.</li>
</ul>
</blockquote>
<blockquote>
<p>⚠️ <strong>Common mistakes</strong></p>
<ul>
<li>Testing a judge on the same traces used as its few-shot examples.</li>
<li>Improving the prompt while watching judge-test results.</li>
<li>Labels made by another AI instead of a person, which measures how much two models agree, not whether the judge matches human judgement.</li>
<li>Reporting a judge as validated when it was tested on only a handful of failures.</li>
</ul>
</blockquote>
<h2>Recap and what's next</h2>
<p>You built a dataset from explicit dimensions, with verbatim evidence, unanswerable and multi-document questions, a held-out split, and a closed-book baseline to check for memorisation. You read real traces before measuring anything, noted the first upstream failure in each, and grouped the notes into a failure taxonomy.</p>
<p>From that taxonomy you built evaluators: rules and retrieval metrics wherever possible, and binary judges with explicit rubrics for the rest. Then you measured each judge against human labels, using TPR and TNR on a held-out set of labels.</p>
<p>You now have checks you can trust, with known error rates. But one question remains: when a score changes, is it a real change or noise? And would your evals actually catch the system getting worse? <strong>Part 3: Proving Your Evals Work</strong> covers statistics for small samples, breaking the system on purpose to test the evals, a quality gate for CI, and making every result reproducible.</p>
<p>How do you build test data and decide what to measure for your AI systems today? Tell me in the comments.</p>
<h2>References</h2>
<ol>
<li>Rajpurkar, P. et al. (2018). <a href="https://arxiv.org/abs/1806.03822">Know What You Don't Know: Unanswerable Questions for SQuAD</a>.</li>
<li>Roberts, A. et al. (2020). <a href="https://arxiv.org/abs/2002.08910">How Much Knowledge Can You Pack Into the Parameters of a Language Model?</a></li>
<li>Husain, H. (2024). <a href="https://hamel.dev/blog/posts/evals/">Your AI Product Needs Evals</a>.</li>
<li>Strauss, A. and Corbin, J. (1990). <em>Basics of Qualitative Research: Grounded Theory Procedures and Techniques.</em> Sage.</li>
<li>Shankar, S. et al. (2024). <a href="https://arxiv.org/abs/2404.12272">Who Validates the Validators? Aligning LLM-Assisted Evaluation of LLM Outputs with Human Preferences</a>.</li>
<li>Husain, H. (2024). <a href="https://hamel.dev/blog/posts/llm-judge/">Using LLM-as-a-Judge For Evaluation: A Complete Guide</a>.</li>
<li>Brown, T. et al. (2020). <a href="https://arxiv.org/abs/2005.14165">Language Models are Few-Shot Learners</a>.</li>
<li>Panickssery, A. et al. (2024). <a href="https://arxiv.org/abs/2404.13076">LLM Evaluators Recognize and Favor Their Own Generations</a>.</li>
<li>Zheng, L. et al. (2023). <a href="https://arxiv.org/abs/2306.05685">Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena</a>.</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[The Tester's Guide to RAG Evaluation, Part 1: Foundations]]></title><description><![CDATA[A ground-up guide for testers: how RAG systems work, how retrieval and generation fail, and how to set up reproducible evaluation runs.]]></description><link>https://ashenasserts.hashnode.dev/testers-guide-to-rag-evaluation-part-1-foundations</link><guid isPermaLink="true">https://ashenasserts.hashnode.dev/testers-guide-to-rag-evaluation-part-1-foundations</guid><category><![CDATA[Testing]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[QA]]></category><category><![CDATA[llm]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Ashen Jayawardena]]></dc:creator><pubDate>Fri, 30 Oct 2026 16:12:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/7dea6d8b-913e-4349-94a0-97c1b57b2258.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you test software, you already ask the question that matters most in AI evaluation: how would I know this is wrong? Now ask it of a system that answers questions from a company's documents. The same question can get two different answers. Both can sound right. And the tool you'd reach for to check them might be another AI, which can be wrong too.</p>
<p>This series is a ground-up guide to evaluating that kind of system, called a RAG system, written for testers. It assumes you know testing well and starts from zero on machine learning. Over three parts you'll learn what to measure, how to build the checks, and how to prove the checks themselves work.</p>
<p>Part 1 covers the foundations: what a RAG system is, where it fails, which settings you'll vary, which kinds of tools you need, and the first two stages of an evaluation: getting to know the system, and making every test run reproducible.</p>
<h2>Why testers are well placed to evaluate AI</h2>
<p>Most of what you already do carries over. You design test cases around risks. You write down expected results before you run anything. You keep a regression suite so a change that breaks something old gets caught. You triage defects to find where they really started. You put quality gates in CI so bad builds don't ship. Evaluating an AI system needs every one of these habits.</p>
<p>What changes is the thing you're testing. Three differences matter most.</p>
<p><strong>The output isn't deterministic.</strong> A <strong>large language model (LLM)</strong> is the kind of AI model that reads text and writes text, like the models behind ChatGPT or Claude. It writes its answer one small piece at a time, picking each piece from many possible continuations. Ask the same question twice and you can get different wording, different detail, or now and then a different answer. That's <strong>non-determinism</strong>. Settings can reduce it, but usually can't remove it: even with the randomness turned all the way down, the servers that run models can return different outputs for the same input, because of how they batch requests together [1]. You've dealt with flaky tests before; here, some flakiness comes with the territory. A single passing run tells you less than you're used to.</p>
<p><strong>There's often no single correct output.</strong> "Paris is the capital of France" and "France's capital is Paris" are both right. So instead of an exact expected result, you usually have a <strong>reference answer</strong>: a correct answer you compare against, allowing for different wording.</p>
<p><strong>The automated checker can be wrong.</strong> Comparing free-text answers by hand doesn't scale, so teams use another LLM to grade answers against rules. This is <strong>LLM-as-a-judge</strong>, and a model used this way is a <strong>judge</strong>. Think of a judge as a test oracle, the thing that decides whether a test passed, and the analogy works up to a point. You normally trust a test oracle. A judge is a model with its own error rate, so you have to measure how often it's right before its results mean anything. Part 2 shows how.</p>
<p>None of this makes AI untestable. You test it with the same discipline, plus some statistics and a way of checking the checker. A structured test of an AI system's quality is called an <strong>eval</strong> (short for evaluation), and that's what this series builds.</p>
<h2>What RAG is</h2>
<p>An LLM learns from a huge amount of text during training, then stops learning. It doesn't know your company's internal documents, or anything that happened after training. Ask it anyway and it may answer confidently from general knowledge, or simply make something up.</p>
<p><strong>Retrieval-augmented generation (RAG)</strong> fixes that. Before the model answers, the system looks up relevant text in a set of documents and hands it to the model along with the question. The model is told to answer from what it was given. Lewis and colleagues introduced the approach in a 2020 paper [2], and it's now one of the most common ways companies put LLMs to work on their own data.</p>
<p>A RAG system has two halves:</p>
<ul>
<li><strong>Retrieval</strong> finds the pieces of text most relevant to a question.</li>
<li><strong>Generation</strong> is the LLM writing an answer from the question plus those pieces.</li>
</ul>
<p>The full set of documents the system can search is the <strong>corpus</strong>. One file in it is a <strong>document</strong>, and a piece of a document that retrieval returns is a <strong>passage</strong>. The passages handed to the model are its <strong>context</strong>.</p>
<p>One request flows like this:</p>
<ol>
<li>A user asks a question.</li>
<li>The system converts the question into a form it can compare with the stored documents (more on that in the next section).</li>
<li>It searches the corpus and keeps the few passages that match best.</li>
<li>It builds a prompt: instructions, the retrieved passages, and the question.</li>
<li>The LLM reads the prompt and writes an answer.</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/feeb833a-783d-423a-ba56-174d2d87bf27.png" alt="RAG request flow: question, embed and search over the corpus, top passages, LLM, answer. Retrieval failures (missed passage, wrong part of the right document) and generation failures (not grounded, misreading, refusal problems) are marked at the step where they occur." style="display:block;margin:0 auto" />

<p>Each step can fail in its own way. If you remember one idea from this article, make it this one.</p>
<p>Retrieval can <strong>miss</strong>: the passage with the answer is in the corpus, but it isn't among the passages returned. Or retrieval finds the right document but the wrong part of it.</p>
<p>Generation can fail even when retrieval did its job:</p>
<ul>
<li><strong>Hallucination:</strong> the model states something that its context, or reality, doesn't support. The term is popular but loosely defined. For RAG, grounding is the more precise idea.</li>
<li><strong>Not grounded:</strong> a <strong>grounded</strong> answer is one where every claim is supported by the retrieved passages. An answer that adds facts from the model's general knowledge, or from a passage about something else, isn't grounded, even if the extra facts happen to be true.</li>
<li><strong>Misreading:</strong> the right passage is there, but the model takes the wrong number or credits an action to the wrong person.</li>
<li><strong>Refusal problems:</strong> a <strong>refusal</strong> is the system declining to answer, and sometimes that's correct. An <strong>unanswerable question</strong> is one the documents can't answer: the detail isn't there, the topic isn't covered, or the question rests on a false assumption. For those, the right behaviour is <strong>abstention</strong>: declining, or pointing out the false assumption. The opposite failure is refusing when the answer was in the passages all along.</li>
</ul>
<p>Why evaluate the two halves separately? Because a wrong answer alone doesn't tell you what to fix. If retrieval never found the right passage, rewriting the model's instructions won't help. You know this principle already: a failing end-to-end test needs narrower tests behind it before you can locate the defect.</p>
<h2>The building blocks you'll vary and test</h2>
<p>A RAG system has a lot of settings, and each one changes its behaviour. These are the variables you'll record, pin, and sometimes change on purpose to test your evals. They fall into four groups.</p>
<h3>Splitting documents into chunks</h3>
<p>Documents are usually too long to search or to hand to a model whole, so the system cuts them into smaller pieces called <strong>chunks</strong>. That process is <strong>chunking</strong>. The <strong>chunk size</strong> is how long each piece is, often counted in characters or tokens. A <strong>token</strong> is the unit a model reads, roughly a word or part of a word.</p>
<p>Cut a sentence in half at a chunk boundary and the two halves may never be retrieved together. <strong>Chunk overlap</strong> repeats a little text from the end of each chunk at the start of the next, which lowers that risk.</p>
<p>Chunk size is a trade-off. Small chunks are precise but can lose the surrounding context. Large chunks carry more context, and more noise.</p>
<h3>Finding the right passages</h3>
<p>To search by meaning rather than exact words, the system turns each chunk into an <strong>embedding</strong>: a long list of numbers that represents what the text is about. Texts with similar meaning get similar numbers. The model that does this is the <strong>embedding model</strong>, or <strong>embedder</strong>. It's a separate model from the LLM that writes answers, usually much smaller, and swapping it changes what retrieval finds.</p>
<p>Embeddings live in a <strong>vector database</strong>, a store built to find the embeddings closest to a given one. At question time the question is embedded the same way, and a <strong>similarity search</strong> returns the closest chunks. Each result comes with a <strong>similarity score</strong> saying how close it is. A common measure is cosine similarity, where higher means more similar. Some systems report a distance instead, where lower means closer, so check which one yours uses before you set a threshold.</p>
<p>Two settings decide what's kept:</p>
<ul>
<li><strong>Top-N</strong> (also written <strong>top-k</strong>) is how many of the best-scoring chunks go to the model, for example the top 4.</li>
<li>The <strong>similarity threshold</strong> is a minimum score. Chunks below it are dropped, even if they're in the top N.</li>
</ul>
<p>Set the threshold too strict and the model gets nothing. Too loose, and irrelevant text gets in.</p>
<h3>Producing the answer</h3>
<p>The text sent to the LLM is the <strong>prompt</strong>. Part of it is the <strong>system prompt</strong>: standing instructions from whoever configured the system, such as "Answer only from the provided documents. If they don't contain the answer, say so." The system prompt has a big effect on grounding and refusals.</p>
<p>Three model-level settings matter:</p>
<ul>
<li><strong>Context window:</strong> the most text, in tokens, the model can read at once. The system prompt, passages and question all have to fit.</li>
<li><strong>Temperature:</strong> how much randomness goes into picking each next token. At 0 the model always takes the most likely continuation, which gives the most repeatable output. Higher values give more varied output. As mentioned earlier, 0 reduces variation but doesn't guarantee identical results.</li>
<li><strong>The model itself:</strong> a <strong>local model</strong> runs on your own hardware; a <strong>hosted model</strong> runs on a provider's servers and you call it over an API. Some models are <strong>reasoning (thinking) models</strong>: they write out intermediate reasoning before the answer. That's usually slower, and it changes what you need to record.</li>
</ul>
<h3>How the system behaves</h3>
<p>Many RAG applications bundle a document set and its settings into a separate unit. AnythingLLM calls it a <strong>workspace</strong>. One workspace per configuration lets you compare configurations without them interfering with each other.</p>
<p>Most also let you pick a mode. In a document-only mode (AnythingLLM calls it <strong>query mode</strong>), the system answers only from retrieved context and returns a fixed refusal when nothing relevant turns up. In a conversational mode (<strong>chat mode</strong>), the model answers even with no relevant context, falling back on its general knowledge. If you're evaluating a system that's supposed to answer from documents, document-only mode matches the job. As a bonus, the fixed refusal text is something you can check exactly.</p>
<p>Here's an example configuration with the kind of settings you'll pin. The names and values are illustrative, not recommendations:</p>
<pre><code class="language-yaml"># Example only: one configuration of a RAG system under test
llm:
  model: example-model-8b
  temperature: 0
  context_window: 8192
embedder:
  model: example-embedder
chunking:
  size: 1000      # characters
  overlap: 20
retrieval:
  top_n: 4
  similarity_threshold: 0.25
generation:
  mode: query
  system_prompt: &gt;-
    Answer only from the provided context. If the context does not contain
    the answer, say that the documents do not contain it.
  refusal_response: NO_RELEVANT_CONTEXT
</code></pre>
<p>Every line in that file can change an answer. That's why the coming stages treat configuration as part of the test, not as background.</p>
<h2>The kinds of tools you'll need</h2>
<p>You don't need a particular product to follow this series. You need four kinds of tools:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>What it does</th>
<th>Examples</th>
</tr>
</thead>
<tbody><tr>
<td>RAG application or framework</td>
<td>Stores documents, retrieves passages and generates answers: the system under test</td>
<td>AnythingLLM, or an application built with LangChain or LlamaIndex</td>
</tr>
<tr>
<td>Model server</td>
<td>Runs the LLM and the embedding model</td>
<td>Ollama (runs models locally), or a hosted API</td>
</tr>
<tr>
<td>Evaluation framework</td>
<td>Runs metrics and judges over a set of test cases and collects the results</td>
<td>DeepEval, Ragas</td>
</tr>
<tr>
<td>Judge model</td>
<td>Grades answers against rules you write</td>
<td>Any capable model from a different family than the one you're testing</td>
</tr>
</tbody></table>
<p>Running models locally has real advantages for evaluation. Calls cost nothing, your documents stay on your machine, and you can pin the exact model version so it doesn't change under you. The catch: you need hardware that runs the model at a usable speed, and long runs keep that hardware busy and hot.</p>
<p>An evaluation framework saves you writing plumbing: it organises test cases, runs metrics and reports results. What it can't do is tell you which failures your system actually has, or whether its built-in judges are any good on your data. Those calls are yours, and most of this series is about making them well.</p>
<p>Why should the judge come from a different model family? Research has found that LLM judges can recognise their own outputs and tend to rate them more favourably [5], so a model grading its own family's answers can be biased. Part 2 comes back to this.</p>
<p>My view: the tools matter much less than the method. Everything in this series carries over to a different stack.</p>
<h2>The road ahead: ten stages</h2>
<p>The rest of the series follows an evaluation from first contact with a system to an automated quality gate. Each stage builds on the one before.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/f059c565-25a8-4693-b930-de5d6c094e2e.png" alt="The ten stages of a RAG evaluation in three columns. Part 1: Spike, Foundation. Part 2: Dataset, Baseline runs and error analysis, Evaluators, Judge validation. Part 3: Statistics, Mutations, Eval gate and CI, Documentation and reproducibility." style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Stage</th>
<th>Purpose</th>
<th>Part</th>
</tr>
</thead>
<tbody><tr>
<td>0. Spike</td>
<td>Find out how the system actually behaves</td>
<td>1</td>
</tr>
<tr>
<td>1. Foundation</td>
<td>Make every run reproducible and every test case independent</td>
<td>1</td>
</tr>
<tr>
<td>2. Dataset</td>
<td>Build questions with known answers and evidence</td>
<td>2</td>
</tr>
<tr>
<td>3. Baseline runs and error analysis</td>
<td>See how the system really fails before choosing metrics</td>
<td>2</td>
</tr>
<tr>
<td>4. Evaluators</td>
<td>Build one check per failure that actually happens</td>
<td>2</td>
</tr>
<tr>
<td>5. Judge validation</td>
<td>Measure how often each AI judge is right</td>
<td>2</td>
</tr>
<tr>
<td>6. Statistics</td>
<td>Tell real differences from noise at small sample sizes</td>
<td>3</td>
</tr>
<tr>
<td>7. Mutations</td>
<td>Break the system on purpose to prove the evals notice</td>
<td>3</td>
</tr>
<tr>
<td>8. Eval gate and CI</td>
<td>Fail a change automatically when quality drops</td>
<td>3</td>
</tr>
<tr>
<td>9. Documentation and reproducibility</td>
<td>Make every number traceable and every decision explained</td>
<td>3</td>
</tr>
</tbody></table>
<p>Each stage section has the same layout: the goal, why it matters, the key terms, how it's done, best practices and common mistakes.</p>
<h2>Stage 0: Spike</h2>
<h3>Goal</h3>
<p>Find out how the system under test really behaves before you design any evaluation around it.</p>
<h3>Why it matters</h3>
<p>Every eval rests on assumptions:</p>
<ul>
<li>that a setting does what its name says;</li>
<li>that two test cases don't affect each other;</li>
<li>that you can see what the model was given.</li>
</ul>
<p>If one of these is false, your results will be wrong in ways that are hard to spot later. Finding out now is cheap. Finding out after weeks of evaluation costs you the weeks.</p>
<h3>Key terms</h3>
<p>A <strong>spike</strong> is a short, time-boxed investigation that answers a technical question before you commit to a design. The term comes from Extreme Programming, so you may know it from agile teams.</p>
<p>The <strong>system under test</strong> is the RAG application in one specific configuration, treated as a black box behind its interface, the same way you'd treat any service you test.</p>
<p><strong>Test isolation</strong> means each test case runs without being affected by any other. With RAG systems this is less obvious than it sounds. Many applications keep chat history and feed earlier messages back into later ones. Some go further and reuse passages from earlier turns when retrieval comes up short, so the model sees text the current question never retrieved.</p>
<h3>How it's done</h3>
<p>List the questions your evaluation depends on, then test each one against the real system and keep the evidence: requests, responses, screenshots. Typical questions:</p>
<ol>
<li>Can you drive the system entirely through an API, without anyone clicking through a UI?</li>
<li>Which settings can you set and read back, and do changes actually take effect?</li>
<li>Does each response tell you which passages the model saw, and their scores?</li>
<li>If you send the same question twice at temperature 0, do you get the same answer?</li>
<li>What happens when nothing relevant is retrieved: a fixed refusal, or a guess?</li>
<li>Does conversation history carry over between questions, and can you start a fresh session for each one?</li>
<li>Are there settings you can't control, such as a random seed, that will limit repeatability?</li>
</ol>
<p>Here's the kind of experiment that pays off. Imagine you load a test system with two short, fictional documents, so any correct answer has to come from retrieval rather than the model's training. You ask a question whose answer exists in only one of them, with the similarity threshold set so high that nothing gets retrieved. In document-only mode you might get the fixed refusal text straight back. In conversational mode, the same setup might produce a confident, invented answer. One small experiment, and you know which mode to evaluate and what a refusal looks like in your traces.</p>
<blockquote>
<p>✅ <strong>Best practices</strong></p>
<ul>
<li><strong>Verify behaviour before designing evals.</strong> Every later stage assumes the system behaves as documented. Checking costs hours; finding a wrong assumption after weeks of evaluation costs the weeks.</li>
<li><strong>Use fictional test documents for the spike.</strong> The model can't have seen them in training, so a correct answer proves retrieval worked.</li>
<li><strong>Keep evidence for each finding.</strong> Saved requests and responses let you re-check a conclusion when the system is upgraded.</li>
<li><strong>Score retrieval on what the model actually received.</strong> Use the system's own record of the passages it passed to the model, not a separate search, so retrieval and generation are judged on the same input.</li>
</ul>
</blockquote>
<blockquote>
<p>⚠️ <strong>Common mistakes</strong></p>
<ul>
<li>Trusting setting names or documentation without testing them.</li>
<li>Running test cases in one long conversation, so earlier questions influence later answers.</li>
<li>Assuming temperature 0 means identical outputs, and treating a single run as the final word.</li>
<li>Testing only the happy path, and never checking what happens when retrieval comes back empty.</li>
</ul>
</blockquote>
<h2>Stage 1: Foundation</h2>
<h3>Goal</h3>
<p>Make every evaluation run reproducible and every test case independent, so that a difference in results means a difference in the system, not in how the run was set up.</p>
<h3>Why it matters</h3>
<p>You'll compare runs constantly: before and after a prompt change, one model against another, this week's build against last month's. A comparison only means something if everything you didn't intend to change stayed the same. Without a foundation, a score drop could come from many sources:</p>
<ul>
<li>a model that was quietly updated;</li>
<li>a setting someone changed in the UI;</li>
<li>a test case that inherited history from the one before it.</li>
</ul>
<h3>Key terms</h3>
<p><strong>Configuration as code</strong> means the system's settings live in version-controlled files and are applied automatically, never edited by hand. It's the same idea as infrastructure as code. <strong>Provisioning</strong> is the step that applies a configuration: creating the workspace, loading the documents and setting every value from the file. Provisioning should be idempotent: running it twice leaves the same state as running it once.</p>
<p>A <strong>test case</strong> (usually just <strong>case</strong>) is one question with its expected outcome. A <strong>run</strong> is one execution of a configuration over a set of cases.</p>
<p>For each case, a run records a <strong>trace</strong>: everything you need to understand what happened. That's the question, the passages retrieved with their scores, the answer, the model that answered, the latency, and any error. Traces are what you'll read in Part 2 to find out how the system fails.</p>
<p>Each run also writes a <strong>manifest</strong>: a record of exactly what ran. It holds:</p>
<ul>
<li>the code version;</li>
<li>the configuration and its hash (a short fingerprint that changes if any setting changes);</li>
<li>the versions of every component;</li>
<li>hashes of the dataset and documents.</li>
</ul>
<p><strong>Pinning</strong> means referring to an exact, unchangeable version of each component instead of a label that can move. Container images and many model registries give each version a <strong>digest</strong>, a content hash that identifies one exact version. A tag like <code>latest</code> can point to something different tomorrow [3].</p>
<p>The corpus needs care too. What an LLM knows from training is its <strong>parametric knowledge</strong>. If the model already knows the answers, a correct answer doesn't prove retrieval worked; it might have come from memory. When test material ends up in a model's training data, that's <strong>contamination</strong> (or leakage), and it quietly inflates scores [4]. Part 2 shows how to measure it directly with a closed-book baseline.</p>
<h3>How it's done</h3>
<p><strong>1. Write the configuration as a file,</strong> like the example earlier, and provision the system from it. Each variant for an experiment is a new file that changes one setting, not a hand edit.</p>
<p><strong>2. Run every case in a fresh session,</strong> so no history carries over. If the system offers threads or sessions, create a new one per case.</p>
<p><strong>3. Record a trace per case and a manifest per run.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae39fea70d93e191f662f3a/ee330f9c-771d-41e6-8a52-72fb856dd282.png" alt="A reproducible run: a config file in version control is provisioned, each test case runs in a fresh session, and the run writes a trace per case and a manifest per run." style="display:block;margin:0 auto" />

<p>An example trace record:</p>
<pre><code class="language-json">{
  "run_id": "2026-01-15T10:00:00Z-baseline",
  "case_id": "q-017",
  "question": "Example question about a document in the corpus?",
  "retrieved": [
    {"doc_id": "report-12.pdf", "score": 0.81, "text": "Example passage text..."},
    {"doc_id": "report-12.pdf", "score": 0.77, "text": "Another passage..."}
  ],
  "answer": "Example answer text.",
  "model": "example-model-8b",
  "latency_s": 1.4,
  "error": null
}
</code></pre>
<p>And an example manifest:</p>
<pre><code class="language-json">{
  "run_id": "2026-01-15T10:00:00Z-baseline",
  "git_commit": "a1b2c3d",
  "config_hash": "9f8e7d...",
  "app_image": "example/rag-app@sha256:5461...",
  "llm": {"name": "example-model-8b", "digest": "0aaa..."},
  "embedder": {"name": "example-embedder", "digest": "0a10..."},
  "dataset_sha256": "6dec...",
  "corpus_sha256": "bcb8..."
}
</code></pre>
<p><strong>4. Pin every version.</strong> That covers:</p>
<ul>
<li>the application image, by digest;</li>
<li>the models, by digest;</li>
<li>your own dependencies, through a lockfile (a file that records the exact version of every library);</li>
<li>for a hosted model, a dated model version rather than an alias that the provider can repoint.</li>
</ul>
<p>Check the pins at the start of each run, and stop if anything doesn't match.</p>
<p><strong>5. Choose the corpus deliberately.</strong> Look for documents the model is unlikely to have memorised, such as material published after the model's training data was collected. They should be deep enough to support questions that need several passages or several documents. And they should be licensed so you can use them for testing (and share them, if you plan to).</p>
<blockquote>
<p>✅ <strong>Best practices</strong></p>
<ul>
<li><strong>Keep configuration as code.</strong> A setting that isn't in version control can't be reproduced or reviewed. Same reasoning as infrastructure as code.</li>
<li><strong>Isolate every test case.</strong> Shared history changes what the model sees, so results stop being independent and traces stop being accurate (see Stage 0).</li>
<li><strong>Pin by digest, not by tag.</strong> A tag can be moved to a new version; a digest always means the same bytes [3].</li>
<li><strong>Write a manifest for every run.</strong> Any number you report later should trace back to the exact system that produced it.</li>
<li><strong>Choose a corpus that limits memorisation, and check the licence.</strong> Otherwise correct answers may reflect training data rather than retrieval [4].</li>
</ul>
</blockquote>
<blockquote>
<p>⚠️ <strong>Common mistakes</strong></p>
<ul>
<li>Changing settings by hand in a UI between runs, then not being able to say what changed.</li>
<li>Using a <code>latest</code> tag for a model or application, so the system changes under you mid-project.</li>
<li>Saving only the final answer, so you can't tell later whether a failure came from retrieval or generation.</li>
<li>Testing on famous public documents (well-known articles, popular books) that the model has probably seen in training.</li>
</ul>
</blockquote>
<h2>Recap and what's next</h2>
<p>A RAG system retrieves passages from a corpus and writes an answer from them, and each half fails in its own way. Retrieval can miss the passage; generation can stray from it, misread it, or get refusals wrong.</p>
<p>Evaluating it takes the testing habits you already have, with three adjustments:</p>
<ul>
<li>outputs vary between runs;</li>
<li>correctness is judged against a reference, not matched exactly;</li>
<li>an automated judge has to be measured before you trust it.</li>
</ul>
<p>Before writing a single metric, you spiked the system to confirm how it really behaves. Then you built a foundation where every setting is code, every case runs in isolation, every version is pinned, and every run leaves a trace and a manifest.</p>
<p>In <strong>Part 2: Building the Evals</strong>, you'll build a test dataset with known answers and evidence, read real failures to decide what to measure, write deterministic checks and AI judges for those failures, and measure how often each judge is right.</p>
<p>If you already test a RAG system, how do you handle these first two stages? Tell me in the comments.</p>
<h2>References</h2>
<ol>
<li>He, H. and Thinking Machines Lab (2025). <a href="https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/">Defeating Nondeterminism in LLM Inference</a>.</li>
<li>Lewis, P. et al. (2020). <a href="https://arxiv.org/abs/2005.11401">Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks</a>.</li>
<li>Docker documentation. <a href="https://docs.docker.com/reference/cli/docker/image/pull/">Pull an image by digest (immutable identifier)</a>.</li>
<li>Sainz, O. et al. (2023). <a href="https://arxiv.org/abs/2310.18018">NLP Evaluation in trouble: On the Need to Measure LLM Data Contamination for each Benchmark</a>.</li>
<li>Panickssery, A. et al. (2024). <a href="https://arxiv.org/abs/2404.13076">LLM Evaluators Recognize and Favor Their Own Generations</a>.</li>
</ol>
]]></content:encoded></item></channel></rss>