Phase 3 — Hybrid retrieval + rerank¶
Feature spec for SPEC.md §9 Phase 3: dense (LanceDB w/ metadata filtering), RRF, optional cross-encoder (normalized scores); each with eval delta vs baseline.
Status: done. Nothing here overrides SPEC.md; where this document makes a decision that SPEC.md does not already contain, it names the ADR that carries it.
1. What Phases 1–2 already provide¶
Phase 3 is unusually well-seated: the seams exist and are conformance-tested, so this phase is mostly filling them in rather than reshaping anything.
| Asset | State | Phase 3 use |
|---|---|---|
index/protocols.py::VectorStoreProtocol |
defined, exact signature, no **kwargs |
implement twice (LanceDB + in-memory) |
retrieval/protocols.py::RerankerProtocol |
defined, param named query |
implement once |
RetrievalConfig.rrf_k |
present, default 60 | fusion constant |
EmbeddingConfig + Ollama/OpenAI embedders |
shipped Phase 1 | vector generation |
InMemoryEmbedder |
shipped, deterministic hash vectors | offline tests — not eval quality (§6) |
EvalReport.stages[].stage |
Literal["bm25","dense","fusion","rerank"] |
append stages, no schema change |
| Delta semantics | derived at read time vs stages[0] |
no stored-delta drift |
retrieval/* in core_subset |
glob | fusion.py/rerank.py auto-gated at 80% |
Two things are not seated and must be built before any vector is written:
Indexerhas no embedder and no vector store. It writes only toMetadataStoreProtocol. The dense write path does not exist.- The SQLite schema records no embedding identity and no schema version.
documentsandchunksonly. See §3.1 — this is the highest-consequence gap in the phase.
2. Done means¶
- Dense retrieval over LanceDB with metadata filtering that provably filters.
- RRF fusion of lexical + dense, pure and deterministic.
- Optional local cross-encoder rerank with normalized non-negative scores.
- ADR-0001 hazards 2, 3, and 5 each fixed with a named regression test (SPEC.md §1: the hazard list is a spec obligation, not advice).
- An eval report containing
bm25(baseline) +dense+fusion+rerankstages, with each stage's delta vs baseline reported — including if a stage loses to BM25 (SPEC.md §6 baseline discipline). - All gates green: ruff, ruff-format,
mypy --strict, pytest, both coverage gates, pip-audit, gitleaks. - SPEC.md §9 status updated,
KNOWN_LIMITATIONS.mdupdated, ADRs accepted.
3. Decisions that need an ADR before code¶
ADR-0004 and ADR-0005 are Accepted. The subsections below state the problems they were written to settle; the ADRs hold the decisions, alternatives, and citations.
A third decision was forced by Wave A rather than anticipated here:
ADR-0006. Phase 1
declared VectorStoreProtocol.search as returning RetrievalResult, which
turned out to be unsatisfiable without reading source from an ingest-time
chunk.metadata snapshot — a copy that disagrees with documents.source after
any re-ingest at a new path, so dense and BM25 hits could cite different paths
for the same document in one fused response. The seam now returns
(Chunk, score), matching BM25. This is the useful kind of surprise: a
Protocol with no implementation is a hypothesis, and Wave A was the first thing
to test it.
Wave B forced two implementation shapes the original plan did not spell out,
though neither is a new decision: ADR-0004 already settled what identity is
(decision 2) and that deletes must be reconciled (decision 6), and these are
how those decisions were realized in code, not departures from them. The
manifest seam (write_manifest/verify_manifest) takes the narrow
EmbeddingIdentity triple — provider, model_name, dimensions — rather
than a whole EmbeddingConfig, which also carries operational settings
unrelated to semantic-space identity and a provider Literal no
third-party embedder could satisfy. And Indexer's dense write order —
chunk, embed, write the manifest, delete the previous document's vectors, add
the new ones, commit SQLite last — is itself an invariant, chosen so SQLite
is never left ahead of the dense store (see KNOWN_LIMITATIONS.md for the
failure mode this avoids). No ADR was skipped for either; both are
implementation shapes serving decisions ADR-0004 already made.
3.1 ADR-0004 — Embedding identity binding (blocking, do first)¶
Problem. SPEC.md §2 forbids cross-provider embedding fallback because
"mixed semantic spaces corrupt an index silently". The current store cannot
detect that condition: nothing persists which provider, model, or dimension
produced the vectors in a collection. Index a collection with
nomic-embed-text (768-d), reopen it configured for a different model, and
every dense result is silently garbage — the exact failure the principle
exists to prevent, one layer below where the principle is currently enforced.
Decision to record. A collection manifest — provider, model name,
dimensions, plus a schema version — persisted in SQLite, written on first
dense write, and verified on every Retriever.open() and every subsequent
ingest. Mismatch is a typed error (ProviderNotConfiguredError family / new
IndexIdentityError), never a re-embed and never a fallback.
Also settle here: whether adding a schema_version to an existing Phase 1/2
SQLite file is a migration or a rebuild. Rebuild is defensible while the repo
is pre-1.0 and evals/results/ is gitignored anyway; say so explicitly rather
than leaving it implicit.
Why blocking: it changes the on-disk schema. Landing it after the first vector is written turns a schema addition into a migration.
3.2 ADR-0005 — Fusion and rerank scoring¶
- RRF formula and the
rrf_kdefault (60 is already in config — record why). - Deterministic tie-break for equal fused ranks. BM25 determinism was pinned in Phase 2; fusion must not reintroduce order instability.
- Rerank score normalization: raw cross-encoder logits are unbounded and
frequently negative, while
RetrievalResult.scoreisField(ge=0.0). This is ADR-0001 hazard 2 verbatim. Record the chosen mapping (sigmoid vs min-max) and its consequence — sigmoid preserves ordering and bounds to (0,1) without needing the whole batch; min-max is batch-dependent and makes scores incomparable across queries. - Model choice for the cross-encoder, and that it is a local, non-LLM model (SPEC.md §4 puts LLM-based reranking out of scope).
4. Wave plan¶
Sized to mirror Phase 1/2 commit granularity — each wave is a reviewable changeset that leaves the tree green.
Wave A — dense store, both paths¶
ADR-0004accepted; collection manifest +schema_versionin SQLite.index/dense.py:LanceDBVectorStoreimplementingVectorStoreProtocol.InMemoryVectorStorein the same seam. SPEC.md §5.3 requires metadata filtering "on both in-memory and LanceDB paths from the first dense-store commit, with regression tests on both paths" — both land together or neither.- LanceDB added as an optional extra, not a base dependency.
- Hazard regression tests:
-
Hazard 3 (silent filter drop): assert filtering actually removes non-matching chunks, and that no call spelling silently no-ops — a misspelled filter argument must raise, not be absorbed.
Corrected during Wave E. This bullet and the §5 table below previously read "a misspelled filter key must raise". That over-extended ADR-0001 hazard 3, whose defect is an argument (
**kwargsswallowingmetadata_filter=so the search silently ran unfiltered), into a rule about dict keys that the code does not and should not implement. Chunk metadata is open-ended, so a store cannot tell a typo'd key from one legitimately absent from everything indexed so far — filtering on a tenant before any tenant-tagged document exists is valid and must not be an error. The implemented behaviour is the fail-closed one: an unknown key matches nothing and the search returns empty, which is the opposite of the hazard rather than an instance of it. Wave E closed the genuinely untested half instead — see §5. - Hazard 5 (unescaped delete predicate): a document ID containing quote characters must not alter the delete expression. Parameterize or escape; test with a hostile ID. - Dimension/length mismatch onadd→StorageError.
Wave B — dense write path (Landed)¶
Indexeraccepts optional keyword-onlyembedderandvector_store; both absent leaves Phase 1 BM25-only behaviour unchanged and still tested, and exactly one supplied raisesConfigurationErrorat construction.replace_documentparity delivered: deleting, replacing, renaming, or emptying a document deletes its vectors in the same logical operation.- Incremental re-index re-embeds only changed documents — the existing incremental skip gate short-circuits before chunking, extended rather than duplicated. (That gate's key became a processing fingerprint in ADR-0009; the sharing described here is unchanged, and the fingerprint is what makes a chunking change re-embed rather than silently keep stale vectors.)
- Manifest verified on ingest and fails closed:
verify_manifestruns before any load/chunk/embed/delete work, andwrite_manifestbinds the collection on the first real dense write.Retriever.open()verification remains Wave C — see §3 andKNOWN_LIMITATIONS.md. IndexReportgainedvectors_writtenandvectors_deleted.
Wave C — dense retrieval + RRF fusion (Landed)¶
retrieval/fusion.py: pure RRF, no I/O, deterministic tie-break.Retrievergains a dense path and a hybrid mode;SearchResponse.metadatareports the stage honestly ("dense"/"fusion").- Snapshot semantics extended to the dense side and documented in the class docstring alongside the existing BM25 wording — a stale retriever must fail closed the same way on both.
- Citation resolution unchanged: dense hits resolve through the same
citations.pypath, so a dense result is exactly as verifiable as a lexical one. - Inherited from Wave B —
Retriever.open()manifest verification (closed). ADR-0004 decision 3 names two boundaries; Wave B closed ingest and left this one, because a retriever with no dense read path cannot introduce a second semantic space and so had nothing to verify. Wave C gave it something to read and, in the same commit, added the check:Retriever.open()now verifies the collection's manifest against the supplied embedder's identity before the O(corpus) BM25 rebuild runs, whenever a dense pair is passed. A mismatch isIndexIdentityError. Both ADR-0004 decision-3 boundaries are now closed. - Inherited from Wave B — CLI wiring (closed, default off).
grk ingestpreviously constructed itsIndexerwithout an embedder or vector store, so the command line wrote no vectors. It was deferred here deliberately rather than half-wired in Wave B: turning it on needed a provider flag and a running Ollama, and was only useful oncegrk searchcould read what it writes, so ingest and search got their flags together. Wave C addsgrk ingest --denseandgrk search --mode {bm25,dense,hybrid}, sharing--embed-provider/--embed-model/--embed-dimensions/--embed-base-url. Both remain opt-in: the default install, default commands, and CI need no Ollama, andgrk searchstill defaults tobm25(Q1, below).
Wave D — optional cross-encoder rerank (Landed)¶
retrieval/rerank.pybehindRerankerProtocol, optional extra. ✅CrossEncoderRerankerplus two pure functions,sigmoidandrerank_by_logits; thererankextra carries sentence-transformers and is deliberately not mirrored into the dev group.- Hazard 2 regression test: feed negative logits, assert no
ValidationErrorand that ordering is preserved. ✅TestHazard2NegativeLogits(tests/test_rerank.py), shown to fail first against two separate mutations of the source, per SPEC.md §8: score=logit(the literal ARP defect) → 4 failures, allpydantic ValidationErroron thege=0.0bound.score=max(0.0, logit)(the clamp ADR-0005 rejected) → 3 failures. Note this mutation passes the no-ValidationErrorassertion and is caught only by the ordering and tie assertions, which is precisely why those assertions exist: a clamp is contract-legal and corrupt.- Unconfigured reranker → typed error, never a silent passthrough (SPEC.md §2
fail-closed). ✅ New
RerankerNotConfiguredError, raised on a missing extra and on any model-load failure. Construction is total; nothing loads until the first non-emptyrerankcall. - Heavy deps (torch/sentence-transformers) must stay out of the base install;
CI's default job must not pull them. ✅ The import is deferred to
_import_cross_encoder, soretrieval/rerank.pyis fully importable — and 91% covered — in a base install. The real model is proved bytests/test_rerank_gated.pybehindRERANK_GATED=1and.github/workflows/rerank-gated.yml, which is the sole proof of this backend and is correspondingly notcontinue-on-error. - Eval delta (SPEC.md §9): a
rerankstage over the best upstream stage the run produced, with its own delta. ✅run_evalaccepts an optionalrerankerandrerank_candidates;grk eval --rerankwires the CLI, andderive_rerank_attribution(evals/delta.py) derives a second delta against the input stage rather than againststages[0]. That second delta is the reranker's own contribution only when the run rerankedbm25— rerankingfusionwidens the candidate fetch past what fusion itself was scored at, which changes RRF's ranking rather than merely revealing more of an unchanged one, so that case reports the rerank pipeline against fusion as measured, not an isolated cross-encoder effect (ADR-0012 Consequences;evals/delta.py's module docstring). Proved at two depths, deliberately: the default suite exercises the whole path —run_eval, the CLI flags,RunConfig'srerank_*fields, the attribution derivation, the CLI's provenance line — through a protocol-conformant stub reranker, so CI never loads a model; a delta that means anything about retrieval quality is proved only byRERANK_GATED=1withuv sync --extra rerank(tests/test_eval_rerank_gated.py, the companion totest_rerank_gated.pythat drives a real cross-encoder throughrun_evalitself),workflow_dispatch-only for now like the Ollama gate above. SeeKNOWN_LIMITATIONS.mdfor exactly what each of those two runs does and does not license claiming.
Decided during Wave D — ADR-0012.
The two decisions this section previously left open are now settled. Recording
the resolution alone would understate what each cost — both traded something
away, and the trade is what makes the artifact's shape (RunConfig's three
rerank_* fields, derive_rerank_attribution) necessary rather than
decorative:
- Which stage does rerank rerank? — Best-available upstream input:
fusionwhen the run has a dense pair,bm25when it does not. What that costs: thererankrow's meaning is now configuration-dependent — two reports can agree oncorpus_hashandjudgments_hashand still describe two different experiments, one reranking BM25 and the other reranking fusion. The artifact closes that rather than leaving it silent:RunConfig.rerank_input(withrerank_candidatesandrerank_model, all three together or none, enforced by a validator) names the input stage explicitly, andderive_rerank_attributiondiffs thererankstage against that stage instead ofstages[0]— because against the baseline, a rerank-over-fusion delta sums fusion's gain with the reranker's own, and the two numbers answer different questions. The CLI prints both deltas rather than picking one. - Does rerank reach
Retriever.search? — No. ADR-0012 decision 2 keeps rerank out ofretrieval/search.pyentirely: there is no fourthSearchMode, theLiteralADR-0007 settled is untouched, andretrieval/search.pyhas no diff in this wave. What that costs: rerank is reachable fromgrk eval --rerankonly, so nothing outside the eval harness benefits from it, and a caller who wants a rerankedgrk searchstill cannot get one. Deferred to Phase 4, where the fail-closed behavior of a default search path needing an uninstalled extra can be decided on its own rather than folded into an eval-harness change.
A third decision was forced by decision 1 rather than anticipated by the
original two, and it is load-bearing enough to belong here rather than only
in the ADR: candidate depth. Reranking truncates to top_k after
reordering, so if the upstream stage were asked for only top_k candidates,
the reranker could only permute a fixed set — recall_at_10 would then equal
the input stage's by arithmetic, not by measurement, and the run would look
like it tested rerank while actually testing nothing. Candidates are
therefore over-fetched to MAX_TOP_K (retrieval/search.py's own ceiling),
pinned in the runner rather than exposed as a CLI knob: a knob here would let
two runs be silently incomparable in the one dimension a reader is least
likely to check before comparing their numbers.
Wave E — eval deltas (the phase gate) (Landed)¶
Taken before Wave D deliberately. R1 names the eval delta as the phase's largest scheduling risk, and Wave E depends on nothing rerank provides, so building it first means Wave D reports into a harness that already works rather than one being built under it.
run_evalaccepts an optional keyword-onlyembedder/vector_storepair (both or neither,ConfigurationErrorotherwise — matchingIndexerandRetriever) and emitsbm25→dense→fusioninto one report. One index and one retriever serve every stage, so the stages differ by retrieval strategy alone; ground truth is resolved once per judgment and shared, so no two stages can disagree about what counted as relevant.- Deltas are derived at read time by
evals/delta.pyand never stored.StageDeltais a return type, never a field ofEvalReport. - Per-stage latency percentiles come from each stage's own per-query
timings — the existing
StageResultfields, now populated per stage. - The honest-loss path is tested, in both directions. A losing stage is
reported with its real numbers, flagged
is_regression, and printed with aREGRESSION vs baselineverdict. Verified per SPEC.md §8 by mutating the source twice —is_regressionforced toFalse, and a filter that drops stages losing to baseline — and observing 6 and 10 test failures respectively, then restoring. - No noise threshold.
is_regressionis a strict sign test. A tolerance band would be an invented number, and R2 says a real effect on this corpus can be smaller than any epsilon anyone would pick.is_improvementis tracked separately rather than as the negation, so a stage that gains one metric and loses another reports asMIXEDinstead of hiding half the result. EVAL_GATED=1gates a real-model run (tests/test_eval_gated.py,.github/workflows/eval-gated.yml). At the time this wave landed, the workflow wasworkflow_dispatchonly: every run pulls an embedding model, the eval was run locally far more often than a weekly cron would add value, and a schedule firing against a moving branch produces results nobody reads, which is how a gate becomes noise. SPEC.md §6's "on schedule/label" wording was therefore deliberately not satisfied yet, with the schedule and label trigger commented in place and the job's label-gating condition left inert rather than deleted — re-enabling was reserved for Phase 7, and has since happened: the workflow now runs on schedule, on demand, and on a labelled PR. Skipping cleanly is the default outcome; the gated tests assert the run reports honestly and deliberately do not assert that dense or fusion beats BM25, since a test that reddens on a loss would pressure the next person to grow the corpus until it passes.RunConfiggained two optional, defaulted fields —embedding(the ADR-0004 identity triple) andrrf_k, bothNone-meaning-absent. Without the first, two runs over identical golden data with different embedders agree oncorpus_hashandjudgments_hashwhile measuring two different semantic spaces: ADR-0004's silent-mixing failure one layer above the index. Additive-with-default rather than aschema_versionbump; the reasoning, and the condition that would force a bump instead, are inschema.py's module docstring.
Wave F — docs and gates¶
- SPEC.md §9 Phase 3 → done with date;
KNOWN_LIMITATIONS.mdupdated. - ADR-0004/0005 accepted and listed in
docs/adr/index.md. - Add
src/groundkit/index/dense.pyto[tool.groundkit.coverage].core_subset— decided, do it here. It is scoring (_cosine_similarity,_clamp_score,_matches_filter,_sort_by_score), the vector peer of the already-gatedbm25.py; it holds both live ADR-0001 hazards (3 and 5), and hazard 3 has now demonstrated that an ungated guard decays quietly; andretrieval/fusion.pyis already gated by theretrieval/*glob, so leavingdense.pyout gates the combiner but not one of its two inputs. It sits at 100% today, so the gate costs nothing to add now and would cost a fight later.
Two caveats that must be written into the same commit, not left to omission — the exact failure this bullet was created to avoid:
- Why
index/metadata.pystill stays out. "It is core, so gate it" proves too much:metadata.pyis the durable truth (ADR-0002) and is equally core by that argument. Theindex/entries are enumerated file-by-file precisely because the reasoning is per-file, unlike theretrieval/*glob. Say so explicitly, or the next reader reopens it. dense.pyis a mixed file — roughly two-thirds shared helpers plusInMemoryVectorStore, one-thirdLanceDBVectorStorebehind the optionaldenseextra. Gating it wholesale admits, inside a single file, the offsetting the subset exists to prevent: well-covered LanceDB rows can mask a thin in-memory path, or the reverse, and the gate cannot see the difference. Accepted becauselancedbis pinned in the dev group (pyproject.toml) so CI genuinely covers both halves — a convention, not an invariant, and worth recording as the thing that makes this safe.
The cleaner long-term fix is splitting LanceDBVectorStore into its own
module so the core half can be gated without caveat 2. That is a real
refactor and must not ride along inside Wave F; if it happens, it is
its own change with its own ADR.
- ~~Accept ADR-0007~~ — done, accepted 2026-08-14 during Wave E. Listed
as Accepted in docs/adr/index.md.
- No hardcoded metric numbers in any doc (SPEC.md §2).
5. Hazard obligations carried into this phase¶
ADR-0001's hazard list is a spec obligation: each named defect gets a fix and a regression test in the phase that ports it. Phase 3 ports the three that have been waiting:
| # | Defect | Where it lands | Test shape |
|---|---|---|---|
| 2 | Cross-encoder feeds raw negative logits into a ge=0.0 contract |
Wave D | negative logits → no crash, order preserved |
| 3 | **kwargs absorbs any metadata filter without error |
Wave A (completed Wave E) | filter works; absent key excludes; misspelled argument raises; unknown key returns empty, never everything |
| 5 | Document IDs interpolated into LanceDB's SQL-like delete expression | Wave A | hostile ID with quotes deletes exactly one document |
Hazard 3's untested half, closed in Wave E. Wave A's filter tests seeded
every chunk with the filter's key and varied only its value, so
key in metadata was true throughout and the membership guard in
_matches_filter was never exercised. Replacing it with one that treats an
absent key as a match left both tests passing — a guard that survives its own
mutation is not tested, and this is the purest form of the hazard: a chunk the
filter knows nothing about being returned as if it matched. Three tests now
cover it on both store paths: a chunk missing the key entirely is
excluded, a misspelled filter argument raises TypeError (the ARP defect
verbatim), and an unknown filter key returns empty rather than everything.
Verified per SPEC.md §8 — the mutation above fails the three new tests on both
stores and, tellingly, neither of the two original ones.
6. Risks and open questions¶
R1 — The eval delta cannot be measured in normal CI. InMemoryEmbedder
produces deterministic hash-derived vectors, which are semantically
meaningless. It is correct for exercising code paths and wrong for measuring
retrieval quality. A dense delta computed with it would be noise presented as
a number, which SPEC.md §2 ("real data only") forbids. Consequence: the
quality delta must come from a real-embedding run (Ollama, local or gated),
while normal CI runs only the deterministic structural assertions. The
EVAL_GATED=1 mechanism in SPEC.md §6 already anticipates this — Phase 3 is
the phase that has to actually build it. This is the single largest
scheduling risk in the phase.
Mechanism built in Wave E; measurement still outstanding. EVAL_GATED=1
now exists as a pytest gate (tests/test_eval_gated.py, skipped by default
and by design) and a workflow (.github/workflows/eval-gated.yml). Normal
CI remains offline and runs only structural assertions on the dense path;
no test anywhere asserts a dense or fusion quality value. Recording the
embedder identity in RunConfig.embedding makes the distinction
machine-checkable rather than a convention a reader has to remember: a
report whose embedding.provider is inmemory is self-labelling as
structural, and the CLI stamps an explicit warning on it.
Retired. A gated run against the committed golden corpus, using
nomic-embed-text through local Ollama, has produced a real delta — see Q1
for what it says and what it does and does not settle. The risk R1 named was
that the phase would reach its gate with no way to measure quality; that is
now false in both senses, mechanism and measurement. Worth noting for
calibration: the same harness run with InMemoryEmbedder reported dense and
fusion as clear regressions, and the real model reported them as clear
improvements. The two disagree in direction, on the same corpus, through the
same code — which is precisely why SPEC.md §2 refuses to let the hash
embedder produce a quality number, and is the sharpest available evidence
that the refusal is load-bearing rather than ceremonial.
R2 — The corpus may be too small for a meaningful delta. The Phase 2 baseline runs over 10 documents / 84 chunks / 44 judgments. On a corpus that size, dense-vs-BM25 differences may be within noise, and a "win" may not survive corpus growth. Mitigations, in preference order: state the corpus size alongside every delta; treat a loss or a wash as a legitimate reported outcome; consider whether corpus growth belongs in this phase or a later one — do not grow the corpus in response to a disappointing delta, which would be fitting the benchmark to the result.
R3 — Rerank dependency weight. A cross-encoder implies torch. Keep it an
extra, keep it out of the default install, and confirm pip-audit still runs
against the exported requirements without dragging the ML stack into the base
audit surface.
R4 — Retriever.open() cost compounds. BM25 already rebuilds in memory at
open, O(corpus), accepted in ADR-0002 with a revisit trigger. Adding a LanceDB
open and a manifest check makes open() heavier. Worth measuring in this
phase and checking against ADR-0002's stated revisit trigger rather than
discovering it in Phase 4 when the service opens retrievers per request.
Measured this wave. scripts/measure_retriever_open.py is the method:
for a range of synthetic corpus sizes, it ingests once BM25-only and once
dense-enabled (the in-memory hash embedder — only vector-plumbing cost is
timed, not embedding quality), then times repeated fresh Retriever.open()
calls per configuration, composing the full store-open + LanceDB-connect +
manifest-verify + snapshot-query + BM25-rebuild path exactly as Phase 4's
per-request service would pay it. Qualitative conclusion: the dense
additions — manifest verification, the LanceDB connection, and the
open()-time document snapshot query — add overhead that stays near-constant
as corpus size grows, leaving the BM25 rebuild as the only O(corpus) term in
open(). ADR-0002's revisit trigger is therefore not met by this wave's
measurements. Per repo policy, no number from a run of that script is quoted
here or anywhere else — regenerate them by running the script.
Q1 — Default retrieval mode after Phase 3. — Closed. Does grk search
default to hybrid, or stay BM25-only? grk search stays on bm25,
recorded in ADR-0007, Accepted
2026-08-14. The reasoning is below; reopening it requires a superseding ADR
and, per ADR-0007 decision 3, hybrid becoming able to abstain — not a larger
quality delta.
A gated run against the committed golden corpus (nomic-embed-text via local
Ollama) produced the first real delta. Qualitatively — the values live in the
generated artifact, not here (SPEC.md §2): both dense and fusion improved on
the baseline across recall@1, recall@5, MRR and nDCG@10, with no metric
regressing on either stage. Fusion additionally improved recall@10, where
dense tied. So the retrieval-quality argument for hybrid is real, and this is
the outcome R2 warned might not appear.
That does not settle Q1 on its own, because two costs sit outside the quality metrics and both cut against making hybrid the default:
- Abstention is lost. BM25 abstained on every no-answer query; dense and
fusion abstained on none. For hybrid that is structural — ADR-0005
decision 6 keeps
score_thresholdaway from rank-derived fused scores, so no configuration makes it abstain. For dense it is configurational: the dense branch does applyscore_threshold, but the default isNone, the eval runs unthresholded, and no defensible value has been measured (seeKNOWN_LIMITATIONS.md). Since hybrid is the mode proposed as the default, the structural half binds: defaulting to it would mean the default mode of a tool whose entire premise is grounded, citation-verifiable retrieval never says "I have nothing". - The default path would require a provider the default install lacks.
SPEC.md §10 makes "
grkworks end-to-end locally with zero cloud credentials" part of the v1 definition of done. Hybrid needs a running embedding model and costs roughly two orders of magnitude more latency per query than the lexical path. A default that fails without Ollama is a different product than the one §10 describes.
The honest reading is that the measurement licenses hybrid as the recommended mode where a provider is configured, not as the unconditional default. Recorded as ADR-0007, Accepted 2026-08-14. BM25 stays the default and no behaviour changes.
The reopening condition is hybrid becoming able to abstain — not dense.
The distinction is the whole point and is easy to lose: dense already
abstains when score_threshold is set, so calibrating a dense threshold
would feel like progress toward reopening while leaving the actual blocker
untouched. Hybrid is the mode proposed as the default, and ADR-0005 decision
6 keeps thresholding away from rank-derived fused scores deliberately, so
hybrid needs a genuinely different abstention rule rather than the dense one
extended. That is a design gap. Note also what does not reopen it: growing
the corpus, or re-running the gate until the delta is larger, moves no part
of this — deliberate, since R2 warns against exactly that reflex.
Q2 — Does the manifest live in SQLite or beside it? — Settled. ADR-0004 decision 1 puts it in-band, single-row, in SQLite. A sidecar file was rejected as a second source of truth that a copy or partial backup can silently decouple from the store it describes.