Semantic search over a Markdown knowledge base, served over MCP.
groove eval — Retrieval quality evaluation日本語版: eval.ja.md
You only need this subcommand if you want to compare retrieval quality across model/config changes or guard against regressions when tuning.
Regular users running groove index + groove serve never need to touch this.
eval is an independent, opt-in subcommand. Without a golden file, it does
nothing but print an error with a hint.
Given a small file of “questions with known answers” (golden queries),
groove eval runs each question through the same hybrid search used by the
MCP search tool, then computes how well the returned chunks match what you
expected. On the second run onwards it diffs against the previous run, so you
can see whether a config change improved or regressed quality.
Place it at <kb>/.groove-eval.yml:
queries:
- id: rrf-basics # optional, used as the diff row key
query: "What does the k parameter in RRF do?"
expected:
- path: "docs/ARCHITECTURE.md"
heading: "Data flow" # optional; omit for file-level hit
- path: "src/db.rs" # heading omitted → any hit in this file counts
- query: "How are chunks deduplicated?"
expected:
- path: "src/indexer.rs"
groove eval --kb-path ./knowledge-base
Output:
groove eval — 2026-04-24T05:32:01+00:00
model: bge-m3 reranker: none limit: 10 queries: 2
corpus: 646 docs / 11215 chunks
Aggregate
recall@1 0.250
recall@5 0.500
recall@10 0.500
MRR 0.500
nDCG@10 0.460
Per-query (regressions and misses, 1 of 2)
✗ How are chunks deduplicated? recall@10: 0.00
On the next run it will automatically show a diff against this one.
| Field | Type | Required | Meaning |
|---|---|---|---|
queries |
list | yes | Queries to evaluate |
queries[].query |
string | yes | The search query text |
queries[].expected |
list | yes | Ground-truth hits (at least one entry) |
queries[].expected[].path |
string | yes | KB-relative path, e.g. docs/foo.md |
queries[].expected[].heading |
string | no | If given, the returned chunk must match this heading (case- and whitespace-insensitive) |
queries[].id |
string | no | Stable identifier for diff row keys (default: first 32 chars of query) |
queries[].tags |
list | no | Reserved for future drill-down filtering |
defaults.limit |
int | no | Reserved; currently ignored — use CLI --limit |
defaults.rerank |
bool | no | Reserved; currently ignored — use CLI --reranker |
Hit rule: an expected entry counts as a hit if a returned chunk has the
same path, and (if heading is given) the same normalized heading
(.trim().to_lowercase()). No heading = any chunk in that file counts.
Each query has some number of expected hits. After running the query, we look at the top-k returned chunks and compare.
“Of all expected hits, what fraction appeared in the top k?”
Formula: |expected ∩ top_k| / |expected|. Range: 0.0 – 1.0.
Read this as coverage. recall@10 = 0.8 means 80 % of what you expected
was in the top 10. It doesn’t care about the order within top-k.
“How quickly did we find the first correct answer?”
For each query, MRR = 1 / rank of the first expected hit (0 if none).
A value of 1.0 means “first result was correct”, 0.5 means “second result
was first correct”, etc. The report shows the mean across all queries.
Use this when you care more about the top result than the whole set.
“Are the expected hits concentrated at the top?”
Rewards expected hits that appear early in the ranking more than those near the bottom. Normalized so 1.0 means “all expected hits at the very top” (ideal ordering). Range: 0.0 – 1.0.
Use this to detect improvements in ordering, not just presence. If
recall@10 is unchanged but nDCG@10 improved, you moved correct answers
higher.
Arrows annotate the change since the previous run:
regression_threshold (default 0.05)regression_thresholdThe per-query section only lists queries that regressed or missed
(current recall@max_k = 0). For the full list, use --format json.
If you edit the golden file between runs, the fingerprints differ and the diff is disabled:
⚠️ golden changed since last run, diff disabled
The current numbers still print. The next run will diff against this one.
Every run records the index it measured, and the header repeats it:
corpus: 646 docs / 11215 chunks
When that differs from the compared run, the change is named and the numbers below are qualified:
corpus: 646 docs / 11215 chunks
⚠️ corpus changed since last run (642 -> 646 documents, 11090 -> 11215 chunks)
a delta below may reflect that, not retrieval
The digest covers the indexed chunks, not the source files — chunks are what the search reads, so a rebuild that parses unchanged files differently is caught even though every file hash held. A document rewritten in place moves neither count, so counts alone would call that “unchanged”:
⚠️ corpus changed since last run (same document and chunk counts, different contents)
Unlike a golden change, this does not disable the diff. That is deliberate.
A knowledge base is normally growing, so treating every added document as a
reason to stop comparing would make --fail-on-regression inert exactly when it
is wanted. The corpus is therefore reported but kept out of the compatibility
test: runs stay comparable, and a drop can be read with the knowledge that the
competition changed.
The consequence is worth stating plainly: a reported regression may be caused
by the corpus rather than by retrieval, and only this line tells you which to
suspect. --format json carries corpus and a corpus_changed boolean, which
is null when there is nothing to compare against — distinct from false.
Runs recorded before this existed carry no corpus, and are never reported as changed; the first run after that writes one, and the next run compares normally.
If you keep notes about your evaluation inside the knowledge base you are evaluating, those notes become search results. A note that quotes a golden query verbatim is the strongest possible match for that query, so it takes the top slot and pushes the real answer down — the golden set gets harder to pass the more you write about it.
Every run scans the indexed corpus for this and reports what it finds on stderr, leaving the exit code alone:
groove eval: 1 document(s) quote 2 or more golden queries verbatim (golden-queries-quoted).
engineering/deep-dive/rag/evaluation.md
torch-compile (not in top_k)
cross-encoder-reranker (rank 8)
Either these notes leaked into the corpus, or the queries came from them
and the documents belong in `expected`. groove eval changes neither.
The same findings are in --format json under findings, as an array that is
present (and empty) even when nothing was found, so a consumer can tell “checked,
nothing” from “an older version that never checked”:
"findings": [
{
"check": "golden-queries-quoted",
"path": "engineering/deep-dive/rag/evaluation.md",
"quoted": [
{ "query_id": "torch-compile", "rank_in_top_k": null },
{ "query_id": "cross-encoder-reranker", "rank_in_top_k": 8 }
]
}
]
rank_in_top_k is null when the document quotes the query but did not reach
that query’s top_k — it is in the corpus but has not taken a slot yet.
The report does not say which of the two causes it is, because it cannot: a
document that quotes a query is either a note about the test, or the source
the query was written from — in which case it belongs in that query’s expected
and the golden file is what needs fixing. Only the person who wrote the golden
set knows. eval reports and changes nothing.
Why “two or more” queries, and not one. A single verbatim match is not
evidence of anything: golden queries are often topic names (cross-encoder,
torch.compile), which naturally appear in the documents that explain them.
Measured on a healthy 662-document corpus with 26 golden queries, reporting
every single match produced 8 findings, all false positives, while requiring
two distinct queries in one document produced exactly one — the note that was
in fact documenting the golden set. A document quoting several golden queries
is what “a note about the test” looks like; one quoting a single query is what
“a document about that topic” looks like.
Two consequences worth knowing:
The scan is one pass over the indexed chunk bodies, inside the same read snapshot as the searches, so it reports on exactly the index the metrics came from.
All knobs are optional in groove.toml:
[eval]
golden = ".groove-eval.yml" # default: <kb_path>/.groove-eval.yml
history_size = 10 # default: 10
k_values = [1, 5, 10] # default: [1, 5, 10]
regression_threshold = 0.05 # default: 0.05
CLI flags override config values. Recognized flags: --golden, --k 1,5,10,
--model, --reranker, --limit, --format text|json, --no-history,
--no-diff, --no-color, --fail-on-regression. Pipeline flags (v0.7.0+):
--mmr <bool> / --mmr-lambda <0..1> / --mmr-same-doc-penalty <0..1> /
--parent-retriever <bool> — exact same semantics as on groove search,
see retrieval-pipeline.md for what each knob does.
--fail-on-regression (CI gate)Exit with code 1 if any aggregate metric (recall@k for any k, MRR, or
ndcg@k for any k) regressed from the previous compatible run by more
than regression_threshold (default 0.05; tune via [eval].regression_threshold
in groove.toml). “Compatible” means the previous run had the same
fingerprint — model, reranker, limit, k_values, the golden YAML’s
content hash, the metric implementation version, and (v0.7.0+) the effective
[search.mmr] / [search.parent_retriever] settings, plus (v0.13.0+) a
non-default [search.fusion], (v0.14.0+) the index’s context mode when it
was built with [contextual].enabled = true, and (v0.16.0+) the FTS query
compilation version (fts_query_version). Toggling MMR or parent retriever, or moving
the fusion parameters off their built-in defaults, therefore breaks
fingerprint compatibility (intentionally — comparing recall@k with the
diversity stage on vs off is apples-to-oranges). Switching [contextual] therefore breaks compatibility too, as it should:
that setting changes every chunk’s embedding and FTS text and requires a
--force re-index, so the runs on either side measure different indexes even
though the model and golden file are identical. The mode recorded is the one
the index carries (index_meta.context_mode), not what the config asked
for. Context-off runs record nothing, so they stay comparable with every
baseline taken before this existed.
A history file that cannot be read stops the run. Both files eval keeps
default to living inside the knowledge base, so both are read through the same
checks .grooveignore gets: a hard link, something that is not a regular file,
or a size past the cap (1 MiB for the golden, 64 MiB for the history) is
refused — and, on Unix, a symlink. That last one is deliberately Unix-scoped:
creating a symlink on Windows needs a privilege this threat model’s attacker
does not have, and refusing reparse points there would refuse every OneDrive
and Dropbox placeholder. For the history that refusal is an error, not an empty
history — the new run would otherwise be saved over the file, replacing every
baseline with one run, and --fail-on-regression would pass without having
compared anything. Content that was read and does not parse still starts
fresh, because those bytes held no baseline. --no-history skips the file.
Saving is bounded by the same number, so eval cannot write a history it will
refuse: the oldest runs are dropped until the file fits, with a warning
naming how many were kept. history_size remains what you asked for; this is
the floor under it. A single run that does not fit is reported instead of
written — that means a very large golden set or a very high --limit.
Note that history written before v0.13.0 is incompatible regardless of
fusion settings: metric_version went 1 → 2 when the metric implementation
was corrected, and the fingerprint is compared as a whole. Those runs are
skipped rather than compared, which is the intended behavior — the older
numbers were computed by a different formula.
The same holds for history written before v0.16.0: fts_query_version
went 1 → 2 when the query-to-MATCH compilation changed (see
retrieval-pipeline.md), and 2 → 3 when v1.1.0 added
-term exclusion (see
ADR-0011),
so those runs — including a frozen baseline — drop out of the comparison
either way. That is intentional as well: each version sends a different
expression to FTS5, so they measure different retrieval even with the same
model, index, and golden file.
Updating the golden file likewise does not trigger a false regression
on the next run; it just means the comparison is skipped.
History is still written before the process exits, so the new run is recorded for the next comparison.
Typical CI shape:
- name: groove eval gate
run: groove eval --kb-path knowledge-base --fail-on-regression
The flag is a no-op when there is no previous run yet, when --no-history
is set, when --no-diff is set (since the comparison is suppressed), or
when the previous run’s fingerprint differs.
| Symptom | Cause | Fix |
|---|---|---|
no golden file at ... |
Missing golden YAML | Create .groove-eval.yml or pass --golden <path> |
No index found at ... |
KB not indexed | Run groove index --kb-path <kb> first |
✗ <id> recall@N: 0.00 (per-query) |
Nothing the query retrieved matched this entry’s expected paths — often a typo, a path that was never indexed, or a genuinely missed document |
Check the path spelling, then search for a phrase you know is inside that document and look at the path of the hits (searching for the path itself proves nothing: FTS indexes heading / context / content, and the embeddings do not include the path either). A real miss is a retrieval result, not a config error |
golden changed since last run, diff disabled |
Golden file edited | Expected; the next run will diff normally |
| Model mismatch error | --model does not match the indexed model |
Pass the model used for indexing, or re-index |
Graded relevance (0 / 1 / 2): not supported, and not silently ignored — every golden struct is deny_unknown_fields, so a relevance: key aborts the run before anything is evaluated:
Error: failed to parse golden file: golden.yaml
Caused by:
unknown field `relevance`, expected `path` or `heading`
eval
twice against two separately indexed databases — one eval run measures one
index. (Sweeping the fusion parameters against a single index is supported:
that is groove tune, described in the next section.)eval does not change anything about
index / serve / search. It is a purely auxiliary toolgroove tune — measuring the fusion parameters (v0.13.0+)groove eval tells you how good retrieval is. groove tune tells you whether
the two fusion knobs (rrf_k and the three bm25 column weights) can move that
number on your KB at all. It applies nothing — the output is either a
paste-ready [search.fusion] snippet or the conclusion that the built-in
defaults should stay.
groove tune --kb-path knowledge-base
groove tune --kb-path knowledge-base --format json > tune.json
groove tune --kb-path knowledge-base --golden ./ci-golden.yml --limit 20
It reads the same golden set as groove eval and takes the same flags for
finding it: --golden <PATH> to use a file other than .groove-eval.yml,
--limit to change how many hits each query fetches, --no-color to drop ANSI
from the tables, and --model to match the index being measured. Unlike
eval, it takes no --reranker: it measures the fusion stage, which sits
before reranking.
Since v0.16.0 groove compiles a query into per-token phrases joined with OR
(see retrieval-pipeline.md), so a query no longer
has to occur verbatim in the text to reach the bm25 stage — each of its
fragments can match on its own, which is what makes a natural-language golden
set measurable at all. What still leaves nothing to measure is a query from
which no phrase survives, or whose phrases match nothing: every grid point then
returns the same ranking. A golden query may use -term exclusion (v1.1.0+)
the same as any other query; one that excludes everything and leaves nothing
positive to search for is refused when the golden file loads, before any grid
point runs. tune therefore starts with a pre-flight pass:
A second warning fires — after the effective-N check above, so only on
runs that actually reach the grid — whenever the KB was indexed with
[contextual] off: every chunk’s context column is then empty, so sweeping
bm25_context_weight from 0.5 to 4.0 cannot change any score. Since
[contextual] is off by default, most runs see it, and it is a statement
about the index rather than about the parameter:
groove tune: WARNING — every chunk has an empty context column, so the
bm25_context_weight axis is a no-op on this KB (contextual retrieval is off).
Its rows below mean "not measured", not "has no effect".
To get a measurable golden set, include queries carrying distinctive terms:
proper nouns, API names, command names, error codes. Those compile to phrases
rare enough that bm25 can tell documents apart, whereas a query made only of
common fragments matches everywhere and the weights have little to separate.
Avoid queries under 3 characters (the trigram floor leaves nothing to send to
FTS) and column-filter syntax such as heading:foo (the : is a separator, so
the two halves become ordinary phrases and it never acts as a filter).
Small golden sets almost always overfit an argmax, so a candidate is only recommended when all of the following hold:
SD({d_j}) / sqrt(N))Why the multiplier is 3 rather than the 2 that “2 sigma” would suggest.
SD({d_j}) / sqrt(N)assumes the per-fold differences are independent. The N leave-one-out selections each share N−2 queries, which lets the folds pick different conditions — though sharing training data only makes correlation possible rather than creating it. Nor is fold agreement alone enough to restore independence: even when the folds agree, which condition they agreed on was itself chosen from the shared rows, so every difference still depends on it. What would decouple them is the selection being effectively fixed across sampled golden sets, which is a different property.Simulated against a known data-generating process, the reported SE came out at 0.53–0.60 of the real one in the three settings where the selection varied (114–184 distinct conditions chosen across 300 replications), and at 1.03 in the one where it did not vary at all — a single condition across all 7,800 fold selections. The count is of the fold selections that generate each difference, not of the refit chosen from all N rows; the two diverge (114 vs 64 in the first setting), so the refit would have understated the variation.
What that costs is measured directly rather than converted into a sigma level — the reported SE varies per run and can correlate with the observed mean delta, so a ratio of averages does not determine how often the gate fires. Run with no true winner at all, a multiplier of 2 fired in 12.7% of replications and carried the full five-criterion verdict to “adopt” just as often — where a calibrated one-sided 2 sigma test would be ~2.3%. A golden set with nothing to find yielded a recommendation about one run in eight.
The multiplier was therefore swept against that rate directly (2,000 replications per setting):
multiplier adopts under the null (N=26 / N=12) detects a findable edge 2 (previous) 12.7% / 9.7% 99.0% 3 (current) 3.4% / 3.1% 95.2% 4 0.5% / 0.8% 79.4% 3 buys a 3.7x cut in false adoptions for 3.8 points of power, which is why it ships. Tightening criterion 2 instead does not work: taking the 0.02 floor to 0.04 moves the null rate only 12.7% → 12.1%, while dropping that same power from 99.0% to 51.9%. Restricting to replications that pass criterion 4 lifts the SE ratio to 0.62–0.73 — the stability gate narrows the gap without closing it, and 192–300 of 300 replications passed it, so this is not a corner case.
Two caveats on those rates. The synthetic fixture writes the same value into nDCG, recall and MRR, which makes criterion 5 easy to satisfy, so they do not establish how much the secondary-metric guard binds on real golden sets. And the null rate is only part of the error budget: on a landscape that does have a real winner but is noisy, a sizable share of adoptions pick the wrong condition — at N=12, roughly half of them.
The simulations live in
tune.rsasau16_paired_se_versus_the_true_standard_errorandau68_adoption_rate_across_the_two_thresholds.
Otherwise the verdict is “keep the built-in defaults”, which is a normal and expected outcome: the RRF paper measured only ~0.4% relative MAP movement across k ∈ [30, 100], and Elasticsearch documents RRF as requiring no tuning.
The report also prints the per-query breakdown (how many queries got worse and by how much), because rank fusion routinely hides per-query losses behind an average gain.
tune always measures the plain RRF stage with no reranker, so a gain it
finds has not been shown to survive the full pipeline. If you get an adopt
verdict, paste the snippet into groove.toml and re-run eval with your real
configuration before keeping it:
groove eval --kb-path knowledge-base --reranker bge-v2-m3 --no-history
Compare against the same command run with [search.fusion] removed. If the
reranked numbers do not improve, drop the change — the reranker frequently
absorbs (or reverses) upstream ranking differences.