your rag eval number is hiding regressions

ragdiff: a small open-source tool that diffs two retriever configs per-query instead of scoring one in aggregate — because a metric that went up can still be hiding queries that got worse.

RAGretrievalevaluationopen sourcepython

after the graph-vs-vector benchmark, i kept running into the same small annoyance: every retrieval eval tool i touched wanted to give me a number. precision, recall, some blended score. but the question i actually had, every single time i changed something, was never “what’s my score.” it was “i just changed my chunking / embedding model / top-k / reranker — what got better, what got worse, and for which queries?”

a number can’t answer that. a diff can. so i built ragdiff, a minimal open-source CLI that treats the comparison as the primary object, not the score.

the design bet: one protocol, no lock-in

every RAG eval framework i tried wants to own your stack — your embedding pipeline, your vector store, your config format. ragdiff owns nothing. the entire integration surface is one structural protocol:

class Retriever(Protocol):
    def retrieve(self, query: str, k: int) -> list[str]: ...

anything with that method — a qdrant wrapper, a hybrid pipeline, a plain function in a class — can be scored or diffed. no base classes, no vendor SDK. you point the CLI at two zero-argument factory callables (mymodule:config_a, mymodule:config_b) and a golden set (JSONL of {"query": ..., "relevant_ids": [...]}), and it does the rest.

the pipeline. both configs run against the same frozen golden set; the diff engine works on per-query results, and only then aggregates.

the metrics are the boring, standard four — precision@k, recall@k, MRR, hit rate — implemented as pure functions that only ever see one query at a time. aggregation is the runner’s job. that separation is what makes the diff possible: the per-query results exist as first-class data before any averaging happens, so the diff engine compares query-by-query and sorts the movers, instead of subtracting two averages and calling it insight.

the demo diff makes the argument for me

the repo ships with a toy corpus and two example configs — one retrieves over raw text, the other normalizes (lowercase, strip punctuation) before matching. here’s the actual output of diffing them at k=3:

metric raw normalized Δ
precision@k 0.431 0.458 +0.028
recall@k 0.833 0.917 +0.083
MRR 0.750 0.681 −0.069
hit rate 0.833 0.917 +0.083

read the aggregate row and normalization looks like a clean win: precision up, recall up, hit rate up. ship it.

except MRR went down. and the per-query movers table says why:

editor role permissions
1.667
billing date
0.167
export account data
0.5
api rate limit
0.5
net per-query score delta (precision + recall + reciprocal-rank deltas), raw → normalized. one big win, three regressions the aggregate view averages away.

1 improved, 3 regressed, 8 unchanged. the headline gain comes almost entirely from one query that normalization rescued outright. meanwhile three queries got quietly worse — the relevant doc is still found (recall and hit rate can’t see the problem) but it’s ranked lower (MRR can, barely, as a −0.069 smudge). if your users see the top result first, three of twelve queries just got a worse experience, inside a change that every aggregate metric except one calls an improvement.

that’s the entire thesis of the tool, demonstrated by its own toy example: aggregate metrics answer “is it better on average.” per-query diffs answer “who did i just hurt.” the second question is the one that matters before you ship a retrieval change.

small decisions i’d defend

where it goes next

adapters for real vector stores (qdrant, pgvector) so diffing two live configs is one command, and a --json output mode so the diff can gate CI — fail the build if any query regresses past a threshold, not just if the average moves. the average moving is exactly the signal i no longer trust.

code is on github. MIT, uv pip install -e ., and the demo diff above reproduces with one command from the README.