← Awais Ahmed

I measured my RAG system before believing it

July 2026 · 12 min read

The eval suite for my RAG project was 10 queries over 5 documents, and it reported a perfect recall@5 of 1.00. Not because retrieval was perfect — because with five documents and top-5 retrieval, the system literally cannot miss. Every number that eval ever produced was a compliment, not a measurement. And I don't think my setup was unusual: most RAG pipelines I've seen are evaluated by a demo that looked right, a smoke test that saturated months ago, and everyone's memory of both.

I wanted to know what my retrieval upgrades were actually worth — chunking strategy, hybrid search, reranking — with numbers I could defend and rerun. So I built the measurement infrastructure first and made it open source: spanscore, an evaluation harness that scores RAG pipelines and agents from their OpenTelemetry traces. Then I ran the experiment: naive retrieval versus a properly engineered pipeline, under conditions anyone can reproduce. The improvement turned out to be real — and it looked nothing like I expected, or like any single number could describe.

Why eyeballing fails

Here's a concrete failure that never shows up when you spot-check a demo. One of my test queries asks: "What is the deductible on the premium tier of the home policy?" The correct answer — 150 EUR — sits in a coverage document, two lines below the other tiers. The baseline pipeline chunks documents into fixed 280-character windows, and the window boundary happened to fall between "the plus tier has a 300 EUR deductible" and the premium tier's line. The model answered:

"The deductible on the premium tier is not specified in the provided context. However, the plus tier has a 300 EUR deductible, and the basic tier has 500 EUR."

That answer looks great in a demo. It's grounded, it cites its source, it helpfully offers adjacent facts, and it's useless — the one number the user asked for was sliced off by a chunker. Retrieval "worked" (the right document came back), generation "worked" (nothing hallucinated), and the system still failed. You will not catch this class of bug by eyeballing, because every component looks locally correct. You catch it with a metric that checks whether the gold fact made it into the retrieved context — and that requires a gold set.

Building a gold set I could defend

A saturated eval set is worse than none, because it keeps emitting reassuring numbers. The fix is not more queries over the same tiny corpus — it's a corpus large and confusable enough that a retriever can actually be wrong, and gold cases precise enough to catch the chunk-boundary failure above. Eval sets sized for a smoke test quietly become the thing a team quotes; this one is sized to discriminate.

The benchmark is deliberately built to be confusable: 16 insurance policy documents, 60 cases, with near-collisions everywhere — four different deductibles (150, 300, 500, 1000 EUR) attached to different products, five different "within X days" periods, competing 5-percent discounts. Each case has the query, the relevant documents, a reference answer, and one or more gold facts: literal spans that must appear in the retrieved context for the case to be answerable. Two things I'd insist on in any gold set after this:

The experiment: v1 vs v2

Both pipelines run end-to-end and emit OpenTelemetry-style spans — a retrieval span with the ranked documents, an LLM span with the generated answer and token usage. spanscore scores the traces, not the code: the harness never imports the pipeline.

Every judge-backed metric below is a mean with a 95% bootstrap confidence interval — never a bare number. 60 cases each, identical for both systems:

metricv1 naive+BM25v2 heading+hybridΔ (95% CI, paired)
recall@10.8500.875+0.025 [+0.000, +0.067]
recall@30.9331.000+0.067 [+0.017, +0.133]
MRR0.9210.956+0.035 [+0.008, +0.068]
context sufficiency0.8421.000+0.158 [+0.075, +0.258]
faithfulness (judge)0.9190.983+0.064 [+0.017, +0.131]
answer correctness (judge)0.9080.988+0.079 [+0.021, +0.150]
citation coverage0.8500.931+0.081 [+0.003, +0.167]

Three things I would not have said out loud before measuring:

Your LLM judge is a measurement instrument. Calibrate it.

Two of those metrics come from an LLM judge (gpt-4o-mini scoring answers on an anchored 1–5 rubric). Judges are everywhere now; judge calibration is almost nowhere. If a human wouldn't agree with the judge, the metric is theater. So I labelled all 60 baseline answers by hand against the same rubric the judge sees, and spanscore's calibrate command compared us:

$ spanscore calibrate results-v1.json labels-v1.csv
n=60 labelled cases, levels=[1, 2, 3, 4, 5]
weighted kappa (quadratic): 0.954
kendall tau-b: 0.890
exact agreement: 91.7%
mean bias (judge - human): -0.017

Quadratic-weighted Cohen's κ of 0.95 is high — the judge is usable for this rubric on this domain. But exact agreement is only 91.7%, and the confusion matrix showed the disagreements aren't random noise; they're two systematic habits:

Neither pattern flips this experiment's conclusion — the biases are small and symmetric across both systems. But I only know that because I measured it, and now there's a number that says how much to trust the judge column. One design decision I'd defend hard: spanscore refuses to report κ on fewer than 30 labels. Agreement statistics on a handful of examples are noise wearing a lab coat; better no number than a false one.

Gating CI on evals without the gate crying wolf

A harness you run by hand is a harness you'll stop running, so the point of all this is a CI gate. The naive version — "fail if any metric dropped" — flaps constantly on judge variance and small samples, and a gate that flaps trains everyone to ignore it. spanscore's gate blocks only when a regression is statistically significant (a paired bootstrap over cases, so per-case correlation is preserved) and practically large (beyond a per-metric threshold you set in gate.toml). Noise warns; only real damage blocks. Running the gate "backwards" — v1 as a candidate against v2 as baseline, i.e. pretending someone shipped the downgrade — it blocks with exit code 1:

$ spanscore gate results-v1.json --baseline results-v2.json --config gate.toml
recall@3              block   -0.0667  [-0.1333, -0.0167]
context_sufficiency   block   -0.1583  [-0.2583, -0.0750]
answer_correctness    block   -0.0792  [-0.1500, -0.0208]
recall@1              pass    -0.0250  [-0.0667, +0.0000]   ← not significant
exit code 1

Note recall@1 correctly passing in both directions: the gate knows the difference between "worse" and "indistinguishable", which is precisely what keeps it credible. The traces and the judge cache are committed to the repo, so CI re-scores everything with no API key — a cold run of the whole experiment costs about $0.05; a warm run costs nothing.

What I'd cut with hindsight

The pipeline comparison was a day. The measurement infrastructure was the project. If I were starting again I'd skip the temptation to build evaluators for everything and start with exactly three: recall@k, context sufficiency, and one calibrated judge metric — that combination caught every real defect described here. I'd also write the gold-set grounding check first; it rejected several of my own hand-written cases for paraphrasing the corpus.

Everything here is reproducible from the repo: github.com/aawais-ai/spanscore — the harness (MIT, framework-agnostic, scores any OTel GenAI traces), the 60-case gold set, the committed traces, my hand labels, and the full interactive report this post's numbers come from. If you have a RAG pipeline you've been describing with an adjective — or a number you can no longer reproduce — pip install spanscore and spanscore init is the fastest way I know to find out what's actually true.

Next post: the same treatment for agent trajectories — scoring tool selection, argument correctness, and loop detection from OpenTelemetry spans, dogfooded on my sales-research-agent.