ADR-0012 — The rerank eval stage reorders the best available upstream stage¶
- Status: Accepted (owner, 2026-08-15)
- Date: 2026-08-15
- Deciders: Andy Freeman (owner)
Context¶
SPEC.md §9 requires every Phase 3 retrieval feature to report an eval delta
against the BM25 baseline. Dense and fusion did, from Wave E. Rerank did not:
run_eval accepted no reranker and never appended the rerank stage, even
though rerank was already a legal StageName (evals/schema.py) and Wave D
had already landed retrieval/rerank.py — CrossEncoderReranker behind
RerankerProtocol, with ADR-0005 decision 4's sigmoid closing ADR-0001
hazard 2. So rerank was unmeasured: nothing in the repo supported a claim
that it improved retrieval on this corpus, and Phase 3 could not be called
done on that basis alone.
The reason this sat open through Wave D rather than being wired in alongside
it is that a reranker doesn't fit the shape the other two stages fit. Dense
and fusion are each a SearchMode — a way of producing a ranked candidate
list from a query. A reranker consumes a candidate list that already exists
and returns it reordered (RerankerProtocol.rerank(query, results, *,
top_k) -> results). It has no query-independent way to produce results of
its own, so it has no stage of its own to be measured "as" — measuring it
requires deciding what it reorders.
The Phase 3 spec (Wave D) named this as two open decisions, neither to be settled by omission:
- Which stage does rerank rerank?
- Does rerank reach
Retriever.search?
Decision¶
Decision 1 — the rerank stage reorders the best upstream stage the run
produced: fusion when the run was given a dense pair, bm25 otherwise.
_planned_stages (evals/runner.py) reads this off the plan it already
built for the dense stages rather than re-deriving it from the embedder/
vector-store pair — so the stage named in the artifact is, by construction,
the stage the report actually contains, not a second independent answer to
the same question that could disagree with the first.
This was chosen over the alternative of always reranking BM25, and it carries a real cost that alternative did not:
- Its meaning depends on run configuration. Two reports can agree on
corpus_hash,judgments_hash, and every otherRunConfigfield while theirrerankrows describe different experiments — one reranking BM25, the other reranking fusion. That is whyRunConfig.rerank_inputis mandatory, not documentation: without it, the incomparability between two such reports is silent, which is the one outcome this ADR refuses. - Its delta against
stages[0]is not the reranker's contribution when the input was fusion — it sums fusion's gain over BM25 with rerank's gain over fusion, one number with nothing separating the two effects.derive_rerank_attribution(evals/delta.py) diffs thererankstage against its own input stage rather than againststages[0], and_print_eval_summary(cli.py) prints both: the baseline delta every other stage gets, and the attribution, labelled bybaseline_stageso a renderer needs no special-casing to know which is which. That diff is a clean isolation of the cross-encoder's contribution only when the input stage's own ranking is invariant to fetch depth. BM25 scores each document independently oftop_k(index/bm25.py;top_konly truncates an already-sorted list), sobm25@50is a strict prefix ofbm25@10and the reranker sees a superset of exactly what the baseline stage scored — the attribution is clean. Fusion is not depth-invariant: RRF sums1/(rrf_k + rank)over the rankings a chunk is visible in at the fetched depth (retrieval/fusion.py), sofusion@50is a different ranking function fromfusion@10, not a deeper slice of it — verified empirically, a chunk absent entirely from the depth-10 fused list has ranked first at depth 50. A fusion-input attribution therefore measures the rerank pipeline atrerank_candidatesdepth against the fusion stage as reported in the same report, which is a real production comparison and not the cross-encoder's isolated contribution. See Consequences below, andevals/delta.py's module docstring, which this ADR must agree with rather than restate independently. - It needs a running embedding provider and the rerank extra to produce the number that matters on a dense run, where always-reranking-BM25 would have needed only the extra.
The reason it was still chosen over always-BM25: it measures the pipeline that would actually be deployed — a caller running hybrid search plus rerank gets a number about that combination, not about a configuration nobody runs.
Decision 1a — candidates are over-fetched to MAX_TOP_K (50) and
truncated back to top_k after reranking. _evaluate_judgment asks the
retriever for rerank_candidates results when a reranker is set, not
top_k, and the reranker truncates back down after reordering
(reranker.rerank(query, results, top_k=top_k)). The sharp point: reranking
truncates after reordering, so a candidate depth equal to top_k hands the
model a set it can only permute — every @k metric at the full cutoff is
then pinned to the input stage's by arithmetic rather than measured, and a
reader seeing recall@10 +0.000 would read a finding where there was none.
_print_rerank_provenance warns exactly on this condition
(rerank_candidates == top_k) so the artifact cannot present an arithmetic
inevitability as a result without a flag attached.
MAX_TOP_K is chosen as the default depth because it is the retriever's own
structural ceiling (retrieval/search.py), not a tuned multiplier picked
against this corpus. It is not exposed as a CLI flag — grk eval --rerank
always passes rerank_candidates=MAX_TOP_K — so two CLI runs cannot silently
differ on the one dimension that decides whether their @10 metrics were
free to move at all. run_eval keeps rerank_candidates as a library
parameter for callers who need a different depth, and RunConfig
.rerank_candidates records whatever value actually produced the report,
CLI or not.
Decision 2 — rerank does NOT reach Retriever.search; there is no fourth
SearchMode. No production code changed to accommodate it:
retrieval/search.py is untouched, and the eval runner asks the retriever
for the input stage's results in the input stage's existing mode, then
reranks what comes back as a step the runner itself performs. Three reasons,
independent of each other:
- The three existing
SearchModevalues name how candidates are produced — BM25 postings, a dense vector query, RRF fusion of the two. A reranker consumes candidates a mode already produced; folding it into the same literal would make oneSearchModevalue mean two different kinds of thing (a production strategy, and a post-processing step over one). - ADR-0007 turns the default-mode question on abstention: hybrid stays recommended-not-default because nothing today makes it abstain on a no-answer query. Rerank is a permutation of an existing result list plus a truncation — it cannot make a stage abstain that did not already abstain, so it answers none of ADR-0007's question and has no business inside the literal that question is about.
grk search --mode rerankon a default install could only ever raiseRerankerNotConfiguredError— the rerank extra is deliberately absent from the base install and the dev group (pyproject.toml). A mode that fails by default on every install that doesn't opt into a multi-gigabyte extra is a different kind of default than the other three.
This is deferred to Phase 4, where a service boundary makes "rerank this
request" a per-request option on an HTTP or MCP call rather than a mode
baked into SearchMode. ADR-0007 is untouched by this decision — its
default stays BM25, for its own reasons, unaffected by anything rerank does.
Decision 3 — the artifact records rerank_input, rerank_candidates and
rerank_model, all three or none. RunConfig's model validator
(_validate_rerank_fields, evals/schema.py) enforces this directly: a
present count that is neither 0 nor 3 raises. rerank_model extends
ADR-0004's silent-mixing argument one layer above the index — two
cross-encoders are two different measurements of the same stage name, in
exactly the way two embedding models are two different measurements of
dense.
rerank_model is read via getattr(reranker, "model_name", None)
(_reranker_identity, evals/runner.py), not by widening
RerankerProtocol with a model_name member. That protocol encodes
ADR-0001 hazard 4 and is held to exact signature parity by
tests/test_protocol_conformance.py
(test_cross_encoder_reranker_matches_reranker_protocol);
changing a seam to satisfy a reporting need is the wrong trade, and every
other protocol in this repo is held to the same rule. A test double with no
model_name attribute falls back to type(reranker).__name__, which makes a
stub run self-labelling in the artifact — "_StubReranker" where a real run
would show "cross-encoder/ms-marco-MiniLM-L-6-v2" — exactly as
embedding.provider == "inmemory" self-labels the dense stages.
Half a rerank record is unrepresentable on purpose: a rerank stage whose
RunConfig.rerank_input is None is precisely the silently-incomparable
artifact this ADR exists to prevent, and a rerank_input naming a stage with
no rerank stage present would claim a measurement the run never made.
Neither reads as valid.
Decision 4 — the stage's latency includes the rerank call, timed
separately in _evaluate_judgment and added to the retrieval latency
rather than replacing it or being tracked apart from it. The cross-encoder
is the dominant cost of the stage — by a wide margin, given it is a
multi-gigabyte model doing a forward pass over rerank_candidates pairs per
query — and it is exactly what a reader weighs the quality gain against.
Reporting retrieval time alone would publish the model as very nearly free,
which is the opposite of true and would show up as a suspiciously flat
latency_p50_delta_ms on a stage that is, in reality, the slowest one in the
report.
Alternatives considered¶
- Always rerank BM25, regardless of what else the run measured.
Rejected: it is simpler and configuration-independent, but it does not
measure the pipeline anyone would actually deploy. A caller running
--mode hybridin production and consulting arerankrow that reordered plain BM25 is reading a number about a configuration they don't run. The chosen alternative accepts the configuration-dependence cost explicitly (viarerank_input) rather than avoiding it by measuring the wrong thing. - Truncate candidates to
top_kbefore reranking, matching whatRetriever.searchwould return by default. Rejected: reranking then has nothing to reorder beyond a fixed set, and every@kmetric at the report cutoff becomes an arithmetic identity with the input stage rather than a measurement.MAX_TOP_Kwas chosen precisely to keep every metric free to move in both directions. - A fourth
SearchMode("rerank") wired intoRetriever.search. Rejected per decision 2 — it overloads what aSearchModevalue means, it answers no part of ADR-0007's abstention question, and it makesgrk search's default path capable of raisingRerankerNotConfiguredErrorfor a mode most installs cannot satisfy. - Widen
RerankerProtocolwith amodel_namemember so the identity read is type-checked rather thangetattr. Rejected per decision 3: the protocol is a signature-parity-tested seam encoding a named hazard, and an artifact's reporting need is not a reason to change a contract two unrelated implementations (CrossEncoderRerankerand any future backend) would then both have to satisfy. - Report rerank latency as retrieval time only, with the rerank call timed
and logged separately but not summed into
latency_p50_ms. Rejected per decision 4: it would make the stage's headline latency number describe a different, cheaper thing than what a caller enabling--rerankactually pays for every query.
Consequences¶
- The measured delta this ADR unblocks is meaningful only under
RERANK_GATED=1 uv run pytest tests/test_eval_rerank_gated.pywithuv sync --extra rerankinstalled — a stub reranker's delta (an unconfigured_StubRerankeror similar test double) is noise with a sign, the same trapInMemoryEmbeddersets for the dense stages (SPEC.md §2). Arerankrow produced without a real cross-encoder is structurally valid and semantically meaningless, andrerank_modelis what lets a reader tell the two apart without being told out of band. - torch stays out of the default install and the dev group.
rerankremains an optionalpyproject.tomlextra exactly as it was after Wave D; nothing about wiring in the eval stage changes that boundary. .github/workflows/rerank-gated.ymlstaysworkflow_dispatch-only during active development, matchingeval-gated.yml's rationale: every run pulls a multi-gigabyte model, the gate is run locally far more often than a schedule would add value during this phase, and a cron firing against a moving branch produces results nobody reads. That means this ADR's measured delta re-measures nothing automatically — it is only as current as the last manual dispatch.- A reranker cannot recover a document the upstream stage never retrieved.
Truncation to
top_khappens after reranking, so the reranker can only promote a candidate that was already inside thererank_candidates-wide list it was handed — the ceiling on any rerank gain is set by the upstream stage's own recall at that candidate depth, not by anything the cross-encoder does. - The fusion-input attribution is confounded, and closing it is a
deliberate non-fix.
derive_rerank_attributionisolates the cross-encoder's own contribution cleanly only whenrerank_inputisbm25; when it isfusion, the widerrerank_candidatesfetch changes RRF's ranking itself rather than merely exposing more of an unchanged one (decision 1 above), so the reported attribution measures the rerank pipeline at that candidate depth against fusion as reported, not the reranker's isolated effect. Closing that gap needs a fourth comparison stage — fusion recomputed at the rerank candidate depth — which is not built: on the current golden corpus it would sharpen a number that stays uninterpretable regardless, because the noise floor here is set by corpus size (R2,docs/specs/phase-3-hybrid-retrieval.md) rather than by this confound, andrecall@10is already saturated on the rerank stage. Revisit when the corpus is large enough that a two-to-three query difference clears that noise floor.
References¶
- ADR-0005 — decision 4, the sigmoid normalization this ADR's stage reports numbers through; this ADR does not restate or revisit that scoring decision.
- ADR-0007 — decision 3's abstention reopening condition is untouched by rerank per decision 2 above: a permutation-plus-truncation cannot make a non-abstaining stage abstain.
- ADR-0004 — the silent-mixing argument decision 3 reuses one layer up: two cross-encoders are two different measurements, exactly as two embedding models are.
- ADR-0001 hazard 2 (raw logits into a
ge=0.0contract, closed by ADR-0005) and hazard 4 (the seam this ADR declines to widen for a reporting convenience). - Phase 3 spec — Wave D's two open decisions this ADR settles, and Wave E's eval harness this stage extends.
- SPEC.md §2 (fail closed; no invented numbers; a hash-derived or
unconfigured-backend metric is noise, not a measurement), §3 (no job that
is the sole proof of a backend may be
continue-on-error), §9 (every Phase 3 retrieval feature reports an eval delta against the BM25 baseline). KNOWN_LIMITATIONS.md— the rerank-unmeasured entry this ADR's decision closes.