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.

flowchart LR C[Corpus] --> G[Synthetic question generation] G --> R[Human review] R --> GS[(Golden set)] P[Production traffic] --> S[Sampled judging] P --> F[User feedback] F --> T[Triage of bad answers] T --> GS GS --> CI[CI regression gate] CI -->|pass| AB[Champion vs challenger A/B] AB -->|wins| D[Promote] S --> DB[Quality dashboard]

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.

docsassistant/eval/synthesize.py — a synthetic test set from the corpus (Python, RAGAS 0.4.3)
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]

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:

docsassistant/eval/ragas_scores.py — faithfulness and context precision (Python, RAGAS 0.4.3)
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]

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:

tests/test_rag_quality.py — golden-set quality as pytest assertions (Python, DeepEval 4.2.7)
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)])

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:

docsassistant/eval/gate.py — compare a candidate run with the baseline (Python)
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

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:

GoldenSetQualityTest.java — a pass-rate gate with Spring AI evaluators (Java, Spring AI 2.0.1, JUnit 5)
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");
    }
}

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.

docsassistant/eval/online.py — deterministic sampling and the pass-rate interval (Python)
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)

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:

  1. 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).

  2. A weekly triage labels the failure: parsing, retrieval, routing, generation, stale content, out of scope.

  3. Each labelled failure becomes a golden-set case with its expected sources, so the next change is gated on it.

  4. 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.