Skip to main content

RAG Evaluation

In AI4J, "evaluating RAG" is not one thing but two things with completely different boundaries:

  • Offline retrieval quality evaluation: RagEvaluator, which measures "is the recall accurate"
  • Online answer quality evaluation: RagOnlineEvaluator, which measures "is the final answer faithful"

This page is centered on the former — RagEvaluator. It is most useful when you change chunking strategy, swap the embedding model, or switch the fusion algorithm (RRF/RSF/DBSF): you run a batch of queries that already have annotated "correct answer ids" and watch whether precision, recall, MRR, and NDCG move.

The premise for understanding this page is: offline evaluation only looks at retrieval results — it does not call models, count tokens, or judge whether the answer is good.

1. Core source entry points

  • rag/RagEvaluator.java — offline metric calculator
  • rag/RagEvaluation.java — evaluation result object
  • rag/RagHitSupport.javastableKey(...) used to align hits with annotations
  • rag/RagHit.java — the hit being evaluated

If you are looking for "score with a large model after the answer is generated", the corresponding classes are rag/RagOnlineEvaluator.java and rag/RagJudge.java; the last section of this page briefly points out the difference.

2. What it actually computes

RagEvaluator.evaluate(hits, relevantIds) currently returns 5 standard retrieval metrics in one shot:

MetricMeaningQuestion it addresses
precisionAtKProportion of relevant items among the top K hitsAre the top-ranked results accurate
recallAtKProportion of relevant documents recalledIs anything missing that should have come back
f1AtKHarmonic mean of precision and recallA combined view of the two
mrrReciprocal rank of the first relevant hitDid the first correct one arrive fast
ndcgRank-discounted cumulative gainOverall ranking quality

Plus a few counts:

  • evaluatedAtK — the position actually evaluated up to
  • retrievedCount — total number of retrieved hits
  • relevantCount — total number of relevant ids you annotated
  • truePositiveCount — number of hits that are both retrieved and relevant

3. How "hits" and "annotations" are aligned

This is the place where misunderstanding is most likely.

RagEvaluator decides whether a RagHit counts as "relevant" using:

String key = RagHitSupport.stableKey(hit, i);

That is, the same stable-key logic used for hybrid deduplication and rerank identity, with priority roughly:

  1. hit.id
  2. documentId + chunkIndex
  3. sourcePath + chunkIndex
  4. sourceUri + chunkIndex
  5. sourceName + sectionTitle + chunkIndex
  6. content
  7. fallback index

And relevantIds is the annotation set you provide. The two sides must match for it to count as a true positive.

note

This means: the id you use when annotating must be the same kind of thing as the stable key RagHit can compute. If your hits have no stable id and the documentId drifts, the offline metrics will silently compute wrong values — not because the formula is wrong, but because alignment failed.

4. Minimum usage

RagEvaluator is pure computation: it depends on no platform and calls no model. Hand it a List<RagHit> and it runs:

import io.github.lnyocly.ai4j.rag.RagEvaluator;
import io.github.lnyocly.ai4j.rag.RagEvaluation;
import io.github.lnyocly.ai4j.rag.RagHit;
import io.github.lnyocly.ai4j.rag.RagResult;

import java.util.Arrays;
import java.util.List;

// 1. First run a real retrieval to get the hits
RagResult result = rag.search(RagQuery.builder()
.query("How long do I have to submit an employee medical reimbursement")
.dataset("hr-docs")
.embeddingModel("text-embedding-3-small")
.topK(8)
.build());
List<RagHit> hits = result.getHits();

// 2. Provide the human-annotated relevant ids (from the evaluation set)
List<String> relevantIds = Arrays.asList("doc-1#chunk-0", "doc-7#chunk-3");

// 3. Compute the metrics
RagEvaluation evaluation = new RagEvaluator().evaluate(hits, relevantIds);

System.out.println("precision@K = " + evaluation.getPrecisionAtK());
System.out.println("recall@K = " + evaluation.getRecallAtK());
System.out.println("f1@K = " + evaluation.getF1AtK());
System.out.println("mrr = " + evaluation.getMrr());
System.out.println("ndcg = " + evaluation.getNdcg());

When evaluate(hits, relevantIds) is called without a third argument, K defaults to hits.size() — i.e. all recalled results are evaluated. If you only want to look at the top few, use the overload that takes topK:

// Only evaluate the metrics for the top 4 hits
RagEvaluation evaluation = new RagEvaluator().evaluate(hits, relevantIds, 4);

When topK <= 0 it falls back to "evaluate all"; when topK exceeds hits.size() it takes the actual hit count, so it never goes out of bounds.

5. How the metrics are computed (walked through with an example)

Suppose 4 hits are recalled, and the annotated relevant ones are b and d:

List<RagHit> hits = Arrays.asList(
RagHit.builder().id("a").content("A").build(), // rank 1
RagHit.builder().id("b").content("B").build(), // rank 2 relevant
RagHit.builder().id("c").content("C").build(), // rank 3
RagHit.builder().id("d").content("D").build() // rank 4 relevant
);

RagEvaluation eval = new RagEvaluator().evaluate(hits, Arrays.asList("b", "d"), 4);

The result is:

MetricValueDerivation
evaluatedAtK4All evaluated
truePositiveCount2b, d hit
precisionAtK0.52 / 4
recallAtK1.02 / 2 (all relevant recalled)
f1AtK0.66672·0.5·1.0 / (0.5+1.0)
mrr0.51 / 2 (first relevant at rank 2)
ndcg0.6509DCG / IDCG

Note that mrr takes the reciprocal rank of the first relevant hit; ndcg uses rank-based gain 1 / log2(rank+1) and normalizes against the ideal ordering. These are all standard formulas — AI4J invents no variants of its own.

6. How edge cases are handled

RagEvaluator has well-defined behavior for several degenerate scenarios:

  • hits is empty: all metrics are 0, truePositiveCount = 0.
  • No relevant hits at all: precision/recall/F1/MRR/NDCG are all 0.
  • relevantIds is empty or all blank: relevantCount = 0, recall is guarded to 0, no division by zero.
  • relevantIds contains blank strings or null: filtered out by normalize(...).
  • topK out of bounds or non-positive: safely clamped to the actual hit count, or falls back to evaluating all.

So it does not throw exceptions for normal degeneracies like "the annotation set is empty" or "nothing was recalled this time".

7. Typical usage: A/B comparison

The real value of RagEvaluator is running controlled-variable comparisons, not reporting a single absolute score. Common scenarios:

What you changedWhich metrics to watch
Switching the embedding modelrecall@K, ndcg
Tuning chunk size / overlaprecall@K, precision@K
Switching fusion from RRF to RSF/DBSFndcg, mrr
Adding rerankndcg, mrr (ranking correction gains)
Changing query planning to recall more variantsrecall@K (watch whether more is recalled, but precision may drop)
tip

When running offline evaluation, fix a batch of queries and an annotation set, and change only one variable at a time. Otherwise if precision goes from 0.5 to 0.6, you cannot tell whether the credit belongs to the embedding or the chunking.

8. Offline RagEvaluator vs online RagOnlineEvaluator

The two names look alike, but they do completely different things — do not conflate them:

DimensionRagEvaluator (offline)RagOnlineEvaluator (online)
Evaluation targetRetrieval result List<RagHit>Final answer + context
Judgment sourceRelevant-id annotations you provideRagJudge (usually a judge large model)
Metricsprecision/recall/F1/MRR/NDCGSubjective scores like faithfulness, relevance
Calls a modelNo, pure local computationYes, goes through the judge
Typical timingOffline regression before switching strategiesOnline/batch evaluation after an answer is generated
Writes to traceNoYes, writes to trace.judgeEvaluation

In one sentence: RagEvaluator answers "is the recall set accurate", and RagOnlineEvaluator answers "did the model hallucinate against the recall set". The former measures the retrieval pipeline; the latter measures generation faithfulness. They do not substitute for each other.

For a full explanation of the online judging layer (RagJudge SPI, the ChatRagJudge three-dimensional scoring protocol, and how judgments are written into RagTrace), see LLM-as-Judge.

If you want to see how the online judge is used, refer to Citations and Trace.

9. What this layer does not do for you

  • No annotation-set management or evaluation-set storage
  • No automatic batch query execution (you call evaluate(...) yourself in a loop)
  • No metric-difference attribution after cross-encoder rerank
  • No generation-side metrics (faithfulness/answer-relevance — that is the domain of RagOnlineEvaluator)
  • No report or chart output

It does exactly one thing: given hits and relevant ids, compute the standard retrieval metrics. Leaving batch orchestration and report rendering to the upper-layer application is a reasonable boundary.

10. The 3 most common pitfalls

10.1 Annotation id and hit key do not match

This is the number-one cause of distorted offline metrics. You annotate with doc-1, but the stable key the hit computes is doc-1#chunk-0; as a result every relevant item is treated as a false negative and recall collapses to zero.

10.2 Judging "good or bad" by an absolute score

The number precision@K = 0.6 is meaningless on its own; it only means something compared to "0.5 before the strategy change". The core of offline evaluation is comparison, not a single point.

10.3 Treating offline metrics as answer quality

Accurate retrieval does not mean a good answer. A system with recall@K = 1.0 can still go off-topic or hallucinate during generation. Answer quality is the job of RagOnlineEvaluator.

11. The conclusions to remember from this page

  • RagEvaluator is a pure local, model-free offline retrieval-quality evaluator
  • One call yields precision/recall/F1@K, MRR, and NDCG
  • Hits and annotations are aligned via RagHitSupport.stableKey(...); alignment failure silently distorts the metrics
  • It and RagOnlineEvaluator split the work — one owns recall, the other owns the answer — and neither can replace the other

12. Further reading