Skip to content

Retire the in-process BM25 text stack; tests and evals measure the store-native FTS - #527

Merged
zzet merged 21 commits into
mainfrom
refactor/retire-in-process-bm25
Aug 14, 2026
Merged

Retire the in-process BM25 text stack; tests and evals measure the store-native FTS#527
zzet merged 21 commits into
mainfrom
refactor/retire-in-process-bm25

Conversation

@zzet

@zzet zzet commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What

Retires the in-process BM25 text stack — bm25.go, bigram.go, sparse_ngram.go, ngram_weights.go (~960 LOC plus their dedicated tests) — by actualizing every test and eval consumer onto what production actually runs: the store-native SQLite FTS. Ten commits; the tree is green at every one.

Stacked on the dead-code-sweep branch (#526); merges after it.

How the consumers were actualized (not just swept)

  • Search-asserting tests (the expensive set) moved onto real sqlite FTS fixtures whose helpers refuse a silently-empty corpus (SymbolFTSCount() > 0 gates), and every converted query was proven to fail when misspelled — no vacuous passes. Where the store's Tier-0 exact-name short-circuit would have answered from the graph instead of the ranked FTS tier, the fixtures use multi-word probes with the reasoning recorded in test comments.
  • Ranking-shape fixtures (escalation, soup split/merge, equivalence rewrites, explore diversification) now run on an explicit ordered stub whose ordering premise is stated in words — these tests were never testing BM25, only MCP-layer logic given a candidate ordering. Honesty probes: disabling each guarded behavior fails its test.
  • Incidental sites (~198 construction sites across 77 files) swapped to the new search.NewNull() — a backend implementing nothing beyond the interface, so the engine's corpus check routes to its existing substring fallback. The NewAuto seam is deleted outright.
  • stdbench now measures the shipping store-native FTS end-to-end (corpus ingested via BulkUpsertSymbolFTS, hard-error if the FTS row count mismatches the corpus; retrieval via SearchSymbols), with pinned Recall/MRR values that mutation-probes verified are discriminating.
  • eval recall: the redundant BM25Ranker wrapper is gone; the production bm25 row was already served by EngineRanker over the store FTS adapter, so no published number moves. The row key stays bm25 for artifact joinability; the prose stops lying about what it measures.

Behavior notes a reviewer should not have to reverse-engineer

  1. stdbench figures are discontinuous at this PR. The ranker changed (FTS5 prefix-OR MATCH; no stopwords, no Porter stemming, no sparse n-grams). Prior stdbench numbers must never share a table with new ones.
  2. The deleted typo-rescue tier in the query engine is unreachable-branch cleanup, not a recall regressionBigramCandidates existed only on the BM25 backend, nothing that reaches the engine forwards it, and its env gate was never set on a wired path.
  3. The daemon's "none" search-backend status arm is production-unreachable (serverstack opens sqlite only) and exists so a null backend reports honestly instead of "unknown".
  4. Two recall fixture cases (concept-ranker-bm25 and its typo mirror) gold-answered the deleted BM25Ranker symbol and were retargeted at the live EngineRanker; case IDs and count (156) are unchanged, so per-case history joins — but those two cases' difficulty drifted slightly at this PR.
  5. GORTEX_BIGRAM_TYPOS and GORTEX_SPARSE_NGRAM disappear with the code that read them; both were documented but dead on every wired path (docs updated in the same commits).

Measured cost

go test -race per-package deltas vs the pre-stack base, same machine, same flags: internal/mcp +12.4s (+11.2%), internal/indexer +12.5s (+6.5%) — combined +24.8s, under the design's +33s estimate. The stub conversions are net-negative (each drops an in-memory corpus build); deleting 23 BM25 tests recovers more.

Kept, deliberately

Tokenize and NormalizeFTSTokens (live in the FTS write/read paths), HybridBackend/Swappable/VectorBackend (not retired), the go-porterstemmer dependency (serves the live FTS normalizer), and generic "BM25" wording where it is still true — SQLite FTS5 genuinely ranks with bm25().

Open corpus decision (deliberately not made here)

Five recall-fixture case groups gold-answer the deleted internal/search/bm25.go itself (exact-BM25Backend, exact-NewBM25, concept-bm25-index, mh-bm25-api, and one of mh-search-backends' five IDs, plus typo twins). Four of the five are unfixable by retargeting — their query text is the deleted API — so "fixing" them means deleting case groups and shifting the 156-case corpus and its tier distribution. That is a bench-comparability decision for the corpus owner; until then those cases warn and score as misses, exactly as they began doing at the deletion commit.

Validation

  • Full go test -race ./... green at every commit and on the final head; golangci-lint 0 issues; fence test green; the forbidden-symbol grep (NewBM25|BM25Backend|SparseNgram|NgramTable|BigramIndex) returns nothing.
  • Every fixture conversion carries a proven failing-mutation check (misspelled queries, disabled behaviors, mis-paired corpus docs).
  • One known red herring in combined multi-package runs: cmd/gortex's user-state guard trips on the pre-existing internal/mcp query-log leak (internal/mcp tests write the real ~/.gortex/cache/query-log.jsonl, tripping cmd/gortex's user-state guard in multi-package runs #518) — reproduced identically on the base commit; GORTEX_QUERY_LOG_DISABLE=1 confirms. Every individual test passes.

@zzet
zzet force-pushed the chore/drop-code-orphaned-by-sqlite-only branch from 95b0365 to 7e44615 Compare August 14, 2026 14:11
@zzet
zzet changed the base branch from chore/drop-code-orphaned-by-sqlite-only to main August 14, 2026 16:09
zzet added 21 commits August 14, 2026 21:25
…xture

The tests that pin what indexing makes searchable ran against an
in-memory graph and an in-process text index the daemon never builds, so
they proved nothing about the corpus production serves. They now index
into a sqlite store and read its native symbol FTS through the
SymbolSearcherBackend, and each fixture refuses to run on an empty
corpus. Queries that would be answered by the exact-name short-circuit
were reshaped so the ranked tier decides the outcome.
…backend

These fixtures test MCP-layer logic — post-filter escalation, soup
split/merge, equivalence rewrite, over-fetch retention — given a
candidate ordering, not the ranker that produced it. Replace the
hand-tuned text corpora with an orderedBackend that answers a token
with a literal list of node IDs and records every Search limit, so the
premise each test rests on is stated instead of emergent.
… text backend

Production always runs on sqlite, whose native FTS already answers search
through SymbolSearcherBackend, so the in-process index the other arm built
was a second copy of a corpus nobody queried. NullBackend indexes nothing
and reports an empty corpus, which routes the query engine to the substring
fallback it already takes for an Engine with no search backend at all.
The remaining test construction sites move onto it; the two that still
exercise BM25 itself keep their old backend.
stdbench indexed its corpus into an in-process BM25 index and claimed to
measure what search_symbols runs; that stopped being true when search
moved to the store's native FTS, so the reported numbers described a
retriever no user queries.

It now opens a throwaway sqlite store, writes the corpus through
BulkUpsertSymbolFTS with search.Tokenize-split tokens (mirroring the
indexer's write side), asserts every document reached the index, and
ranks with SearchSymbols. The corpus never goes through search.Backend
because the shipping adapter's Add is a no-op and would silently score
an empty index.
…backend

BM25Ranker only ever adapted a raw search.Backend, and its sole caller
was its own unit test — the shipping eval path registers the "bm25" row
through EngineRanker over Engine.SearchSymbols, which reads the store's
native symbol FTS. Dropping it removes the last tie from this package to
the retired in-process text stack. The "bm25" row key stays put so bench
artifacts remain joinable; only the prose that mislabelled it changes.
…tive FTS

Every store now yields either a SymbolSearcherBackend or a NullBackend, so
the branches that fed an in-process text index have no live path: drop the
backend-shape probe, the ngram-boundary install, and the bulk Add loop.
buildSearchIndex is now vector-only and returns immediately without an
embedder, and the two rebuilds that existed only to bootstrap a
non-persistent backend go with it. Incremental Add/Remove stay put — the
native adapter keeps its document counter that way.
…s arms

Status resolution no longer recognises the in-process text backend,
which the indexer stopped building. In its place the null backend gets
its own arm reporting "none" with zero counts, so a store without native
symbol search reads as "nothing indexes text here" rather than falling
through to "unknown", which means "we could not identify the backend".
The status protocol doc lists the names the field can now carry.
The bigram typo-rescue branch type-asserted the active text backend to an
unexported bigramProvider interface that no wired backend satisfies: the
method exists only on the BM25 backend, and neither the swappable wrapper
nor the hybrid backend forwards it, so production's backend never matched.
Delete the tier, its interface declaration, and the camelCase-boundary
helper that only gated it. Behaviour-neutral: the branch was unreachable.
Search has run on the graph store's native symbol FTS since the indexer
stopped building a parallel corpus, so the in-process BM25 index, its
bigram typo rescue and the sparse-ngram tokenizer had no caller left.
Delete them with the tests that only exercised them, move the hybrid
de-chunk fixtures onto a map-backed text double and the path-scoping
fixtures onto the ordered backend, and retarget the prose that still
described the retired stack at the FTS adapter.
…g sweep

NullBackend was a field-less struct, so every escaping &NullBackend{}
pointed at runtime.zerobase and NewNull() == NewNull() held on any
production path. That made the type's own doc claim — a distinct
pointer per call, so identity comparisons behave as for every other
backend — false, and it quietly defeated Swappable.Swap's close gate:
a null-to-null swap took the "same backend, skip Close" arm instead of
the "displaced it, close it" arm. Harmless only because Close is a
no-op.

Fixed by giving the struct one unnamed byte rather than by returning a
package-level singleton. Swap's gate is an identity test that has to
separate "I installed something new" from "I re-installed what was
already here", and it only stays a real test if independently
constructed backends are independent objects. A singleton would make
the gate structurally unable to see a null-to-null replacement, and
would alias one instance across every Swappable in the process — safe
exactly as long as Close and the struct stay empty, and a leak the day
either stops being. One byte at wiring time buys the language's own
guarantee of distinct addresses instead.

TestNewNull_DistinctInstances passed only because inlining kept the two
values in distinct stack slots; it guarded nothing. It is replaced by a
test of the invariant it was standing in for: Swap closes the backend it
displaces, leaves a re-installed backend open, and takes the first arm
for two null backends. The implements-Backend-only assertions stay, with
the interface check lifted to a compile-time assertion.

Renderer fixtures that still carried a "bm25" backend name now use
"unknown" — the live name whose arm actually reports a heap figure,
since resolveSearchBackend emits only sqlite-fts5, none or unknown and
only the last carries bytes. Coverage is unchanged: the renderer prints
whatever name it is handed and branches on DiskResident alone. The
indexer's embed-cap warning, the skip_search config comment, the
SymbolSearcher contract doc and the FTS5 capability assertions no longer
describe an in-process text index as the live alternative.

Two families of reference remain and are deliberate: the eval "bm25" row
key, kept so historical bench artifacts stay joinable and already
annotated as involving no in-process index, and the search_symbols
timing fields and log keys, which measure the ranked lexical channel
that FTS5 still serves through its own bm25() function.
@zzet
zzet force-pushed the refactor/retire-in-process-bm25 branch from a8898f4 to d1ae1a2 Compare August 14, 2026 20:21
@zzet
zzet merged commit c277839 into main Aug 14, 2026
17 of 18 checks passed
@zzet
zzet deleted the refactor/retire-in-process-bm25 branch August 15, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant