Evaluation at Scale
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-09-30 against RAGAS 0.4.3, DeepEval 4.2.7 and Open RAG Eval (open-rag-eval) 0.3.0 on
Python 3.12, Spring AI 2.0.1 with JUnit 5 on Spring Boot 4.1.1, and LangChain4j 1.20.2 (see the
version baseline). The RAGAS, DeepEval and Open RAG Eval calls were
checked against the released sources of those packages but not run, because they call a judge model; the gate, sampling
and statistics functions were run on hand-written data; the Java classes were compiled against the released jars.
Evaluating RAG Systems defines the core metrics — recall at , MRR, faithfulness, answer relevance — and builds the first golden set; LLM-as-judge and its biases are covered there too. A production service needs more: test sets that grow with the corpus, metrics that do not need a reference answer for every question, gates that stop a regression before it ships, and measurement on live traffic. Model- and prompt-level operations (registries, experiment tracking) are covered in LLMOps: Prompt & Model Management; this page covers what is specific to RAG.
Synthetic test sets
A hundred hand-written questions are the seed; they do not cover a corpus of thousands of pages or follow it as it changes. Synthetic generation asks a model to write questions (and reference answers) from sampled chunks: single-hop factual questions from one chunk, multi-hop questions from two related chunks, and abstract questions from a section. The generated set is only as good as its review: discard questions that are unanswerable, trivially lexical ("What is the title of…") or leak the answer, and keep the chunk IDs each question came from as the expected sources.
import os
from langchain.chat_models import init_chat_model
from langchain.embeddings import init_embeddings
from langchain_core.documents import Document
from ragas.testset import TestsetGenerator
def synthesize(chunks: list[Document], size: int = 200):
generator = TestsetGenerator.from_langchain(
llm=init_chat_model(os.environ["JUDGE_MODEL"], temperature=0.3),
embedding_model=init_embeddings(os.environ["EMBEDDING_MODEL"]),
)
testset = generator.generate_with_langchain_docs(chunks, testset_size=size)
df = testset.to_pandas() # user_input, reference, reference_contexts, synthesizer_name
return df[df["user_input"].str.len() > 20]
Source: RAGAS — Testset generation for RAG.
Open RAG Eval generates queries too (open-rag-eval generate-queries --config … --num-queries 200). Stratify the
sample by component, document type and parser (see
Document Parsing) so that the
set covers the hard parts of the corpus, not only the most common page type.
Reference-free metrics: UMBRELA and nuggets
A reference answer per question is expensive to write and goes stale. Two reference-free families, both developed for the TREC RAG track, evaluate retrieval and generation without one:
-
UMBRELA (an open reproduction of Bing’s LLM relevance assessor) grades each retrieved passage for a query on a 0 to 3 scale — 0 irrelevant, 1 related, 2 highly relevant, 3 perfectly relevant — with a judge model. With graded judgements, retrieval quality becomes nDCG at without human labels:
where is the UMBRELA grade of the passage at rank and the grades sorted in the ideal order.
-
Nuggets (the AutoNuggetizer framework) extract from the relevant passages the atomic facts a good answer should contain — nuggets, each labelled vital or okay — and then check which nuggets the generated answer supports. The vital nugget recall is
It rewards answers for covering what matters rather than for matching a reference’s wording.
Open RAG Eval implements both (UMBRELAMetric, AutoNuggetMetric) together with citation and hallucination metrics
(CitationMetric, HallucinationMetric with an HHEM backend) behind a single command. Export the RAG system’s results — query, retrieved passages with IDs, generated answer with citations — to its input format, and run
open-rag-eval eval --config eval_config.yaml. See Open RAG Eval.
RAGAS and DeepEval for the per-question metrics
RAGAS 0.4 moved its metrics to ragas.metrics.collections: each metric is a class with an async ascore method, built
with a judge LLM from llm_factory. The older module-level metrics still import with a deprecation warning. The judge
below is any OpenAI-compatible endpoint, which includes local runtimes:
import asyncio
import os
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from ragas.metrics.collections import Faithfulness
judge = llm_factory(os.environ["JUDGE_MODEL"], provider="openai",
client=AsyncOpenAI(base_url=os.environ["JUDGE_BASE_URL"], api_key=os.environ["JUDGE_API_KEY"]))
faithfulness = Faithfulness(llm=judge)
async def score(rows: list[dict]) -> list[float]:
"""rows: {question, answer, contexts} produced by running DocsAssistant on the golden set."""
results = await asyncio.gather(*(faithfulness.ascore(user_input=r["question"], response=r["answer"],
retrieved_contexts=r["contexts"]) for r in rows))
return [r.value for r in results]
Source: RAGAS — Faithfulness.
DeepEval packages the same idea as unit tests: an LLMTestCase per golden question and metrics with thresholds, run by
pytest, which makes it a natural CI gate:
import json
import pytest
from deepeval import assert_test
from deepeval.metrics import ContextualPrecisionMetric, FaithfulnessMetric
from deepeval.test_case import LLMTestCase
from docsassistant.eval.runner import run_docsassistant # returns (answer, retrieved_texts)
GOLDEN = [json.loads(line) for line in open("eval/golden.jsonl")]
@pytest.mark.parametrize("case", GOLDEN, ids=[c["id"] for c in GOLDEN])
def test_golden(case):
answer, contexts = run_docsassistant(case["question"])
test_case = LLMTestCase(input=case["question"], actual_output=answer,
expected_output=case["reference"], retrieval_context=contexts)
assert_test(test_case, [FaithfulnessMetric(threshold=0.85), ContextualPrecisionMetric(threshold=0.7)])
Source: DeepEval — Getting started and DeepEval — Faithfulness.
Run it with deepeval test run tests/test_rag_quality.py (or plain pytest). The judge model is configured in
DeepEval’s settings or passed as model= to each metric.
CI regression gates
Every change that can move quality — chunk size, splitter, parser version, embedding model, reranker, prompt, generator model, top- — runs the golden set in CI and is compared with the current production baseline. A gate that fails on any drop is noise, because judge scores are noisy; gate on drops larger than the metric’s run-to-run variation, and require a per-slice check so that a gain on common questions cannot hide a collapse on one component:
from collections import defaultdict
from statistics import mean
TOLERANCE = {"recall_at_5": 0.02, "faithfulness": 0.03, "nugget_recall": 0.03, "citation_validity": 0.01}
def by_slice(rows: list[dict], metric: str) -> dict[str, float]:
groups: dict[str, list[float]] = defaultdict(list)
for r in rows:
groups[r["component"]].append(r[metric])
return {k: mean(v) for k, v in groups.items()}
def gate(baseline: list[dict], candidate: list[dict]) -> list[str]:
"""Return the list of failures; an empty list means the change may ship."""
failures = []
for metric, tol in TOLERANCE.items():
b, c = mean(r[metric] for r in baseline), mean(r[metric] for r in candidate)
if c < b - tol:
failures.append(f"{metric}: {c:.3f} < baseline {b:.3f} - {tol}")
b_slices, c_slices = by_slice(baseline, metric), by_slice(candidate, metric)
for name, value in c_slices.items():
if name in b_slices and value < b_slices[name] - 2 * tol:
failures.append(f"{metric}[{name}]: {value:.3f} < baseline {b_slices[name]:.3f} - {2 * tol}")
return failures
Source: Python — statistics.
Keep the judge model and its prompt pinned in the gate: a judge upgrade is a change of the measuring instrument and must be evaluated separately, by re-scoring the baseline with the new judge.
On the JVM, the same gate is a JUnit test over the golden set with Spring AI’s evaluators; the test fails the build when the pass rate drops below the threshold:
package com.example.docsassistant.rag.eval;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.evaluation.FactCheckingEvaluator;
import org.springframework.ai.chat.evaluation.RelevancyEvaluator;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.beans.factory.annotation.Autowired;
// annotate with @SpringBootTest in the application's test sources
class GoldenSetQualityTest {
record GoldenCase(String id, String question) {
}
@Autowired
ChatClient docsChatClient;
@Autowired
ChatClient.Builder judgeBuilder;
@Autowired
List<GoldenCase> goldenSet; // loaded from eval/golden.jsonl by a test configuration
@Test
void passRateDoesNotRegress() {
var relevancy = RelevancyEvaluator.builder().chatClientBuilder(judgeBuilder).build();
var factCheck = FactCheckingEvaluator.builder(judgeBuilder).build();
long passed = goldenSet.stream().filter(c -> {
ChatResponse response = docsChatClient.prompt().user(c.question()).call().chatResponse();
List<Document> context = response.getMetadata().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT);
String answer = response.getResult().getOutput().getText();
var request = new EvaluationRequest(c.question(), context == null ? List.of() : context, answer);
return relevancy.evaluate(request).isPass() && factCheck.evaluate(request).isPass();
}).count();
double passRate = (double) passed / goldenSet.size();
assertTrue(passRate >= 0.90, "Golden-set pass rate " + passRate + " is below the 0.90 gate");
}
}
Source: Spring AI — Evaluation Testing and Spring AI: Evaluation.
For LangChain4j, run the golden set through the AI service and score it with the judge service of Hallucination Detection & Correction in the same kind of JUnit test.
Online evaluation: sampled judging
The golden set measures the questions you thought of; production traffic contains the ones you did not. Judge a sample of live answers asynchronously, off the request path, and write the scores onto the traces so that dashboards can break them down by route, component, model and prompt version (see Observability & Tracing):
-
sample uniformly at a low rate (1 to 5 per cent) for unbiased estimates, and additionally sample everything with a thumbs-down, an abstention, a grounding flag or a slow response;
-
score with the same metrics as the gate (faithfulness, citation validity, UMBRELA grades of the retrieved passages);
-
never send raw text to an evaluation service that the data may not reach; the privacy rules of Access Control & Privacy apply to evaluation copies.
With a sample of judged answers of which a fraction pass, the 95 per cent confidence interval of the pass rate is approximately : 400 samples a day give about percentage points at , enough to see a real regression within a day.
import hashlib
import math
def sampled(trace_id: str, rate: float, flagged: bool) -> bool:
"""Deterministic in the trace ID, so every service agrees on which requests are judged."""
if flagged:
return True
bucket = int(hashlib.sha256(trace_id.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
return bucket < rate
def pass_rate_interval(passed: int, n: int, z: float = 1.96) -> tuple[float, float]:
p = passed / n
half = z * math.sqrt(p * (1 - p) / n)
return max(0.0, p - half), min(1.0, p + half)
Source: Python — hashlib and Python — math.
Champion and challenger: A/B tests
A change that passes the gate may still be worse for users. Run it as a challenger against the production champion on a share of real traffic, assigning users (not requests) to arms by a hash of the user ID, so that each user sees one consistent behaviour. Compare on user-level outcomes — thumbs-up rate, follow-up rate ("I asked again"), handoff to a human, and cost per answer — together with the online judged quality. For a binary outcome such as thumbs-up, the two-proportion -statistic is
and the challenger wins at the 5 per cent level if (two-sided) while cost and latency stay within budget. Decide the sample size and the stopping rule before starting; peeking and stopping at the first significant result inflates false wins. The deployment mechanics — routing a share of traffic to a new prompt or model and rolling back — are on Deployment Patterns.
The feedback flywheel
Evaluation compounds when production failures flow back into the test set:
-
Users flag bad answers (thumbs-down with an optional reason, "source not relevant" on a citation), recorded with the trace ID (UX rules for sources).
-
A weekly triage labels the failure: parsing, retrieval, routing, generation, stale content, out of scope.
-
Each labelled failure becomes a golden-set case with its expected sources, so the next change is gated on it.
-
The failure-mode counts decide the roadmap: if most failures are retrieval misses on tables, the next project is table handling, not a bigger generator.
The failure taxonomy, IR metrics, reference-free evaluation (UMBRELA, AutoNuggetizer), Open RAG Eval, RAGAS and DeepEval, and online evaluation with asynchronous sampled judging, A/B tests and the evaluation flywheel follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapter 6), and synthetic QA test sets and context precision follow Polzer, RAG with Python Cookbook (Chapter 10), paraphrased; see the bibliography.