feat(search): BM25 lexical fallback via LadybugDB FTS#432
Merged
Conversation
Replace the macOS-Intel heuristic keyword scan with Okapi BM25 over a LadybugDB FTS index on Symbol.search_text, fetched DB-side — killing the bounded 5000-row Python scan that silently missed matches past the cap on large repos. The hand-rolled name/type/fqn/role heuristic now re-ranks the BM25 candidates, and remains the fallback when the FTS index or extension is unavailable (older graph, or an offline first run where INSTALL FTS can't fetch from extension.ladybugdb.com). LadybugDB (the embedded graph DB that is kuzu's ancestor, not a kuzu wrapper) ships this natively in the same code_graph.lbug the graph lives in — no new dependency, no sidecar store. The PR #403 note "upgradable to SQLite FTS5" pointed at the wrong store. Build (build_ast_graph): - Symbol.search_text column: camelCase-split token soup of name + fqn + signature + annotations + capabilities (same _split_identifier the query path uses, so index/query tokenization agree). - sym_fts FTS index (Okapi BM25, porter stemmer): ensured at the end of every full build and as a self-heal on incremental. Auto-maintains on COPY/MERGE/ DELETE, so increment stays fresh without drop+recreate. - _drop_all drops the FTS index first (LadybugDB refuses DROP TABLE on a table referenced by an FTS index); incremental_rebuild and _delete_file_scope LOAD EXTENSION FTS so Symbol DML can maintain the index. - ONTOLOGY_VERSION 18 -> 19 (semantic change; one-time reindex). Query (search_lexical): BM25-first — QUERY_FTS_INDEX fetches top-K candidates, re-MATCH re-applies the NodeFilter (role/module/path/kind), then the heuristic re-ranks and dedup_by_fqn runs as before. Per-connection FTS-load cache keyed by object (WeakSet), not id() — id() is reused after GC and broke under test batching. Same SearchHit/SearchOutput/NodeFilter contract; _score_components gains a bm25 entry. Bench (~/jrag-bench/shopizer, master vs branch): build phases flat (init 34.65->30.19s, reprocess --graph-only 7.63->7.21s, increment 18.98->17.30s — within run variance; vectors phase untouched); query mean 82.8ms -> 23.6ms (3.5x), max 111.7ms -> 25.0ms. Full suite green. Fork B (BM25 + vector hybrid via RRF on the primary path) tracked in #431. Co-Authored-By: Claude <noreply@anthropic.com>
HumanBean17
added a commit
that referenced
this pull request
Jul 11, 2026
…k + tests + docs) (#433) Follow-up to #432 (BM25 lexical fallback via LadybugDB FTS), applying the /requesting-code-review findings that came in after #432 merged. Reviewers found no Critical issues; the one real bug now fixed: - B1 (recall regression, now in master via #432): pasted camelCase queries (e.g. "DistributionChunkService") returned [] — LadybugDB FTS's tokenizer does not split camelCase, and the raw query was passed to QUERY_FTS_INDEX while Symbol.search_text is built from _split_identifier tokens. The heuristic fallback didn't save it (it also doesn't split camelCase). Fixed via a shared build_fts_query (search_scoring) that pre-splits the query the same way search_text is indexed; added a regression test. - B2/B3: degenerate queries and empty BM25 results now fall back to the heuristic scan (preserves the role-ranked listing for degenerate queries; defensive against stemming / selective-filter-thinning cases). - C1: test_cap_advisory_silent_below_cap now forces the heuristic path so it isn't passed vacuously on the BM25 default. - C2: added a Symbol.search_text schema-anchor test (guards against a silently empty search_text column breaking all searches with zero test signal). - Docs: swept 6 stale ontology-18 references (AGENT-GUIDE, ARCHITECTURE x2, DESIGN, CONFIGURATION x2) to 19; fixed the re-rank module attribution (search_lexical, not search_scoring) and added `package` to the search_text composition. Review push-back: one reviewer claimed the LadybugDB constraints behind the defensive build-side code (DROP TABLE refused while an FTS index references it; DELETE needs LOAD EXTENSION FTS) "don't reproduce" on the shipped version. Both were re-proved empirically (the DROP refusal reproduced in a fresh probe; the DELETE-needs-LOAD was the exact failure behind 22 incremental-test fixes in #432) — the defensive code stays. Bench (~/jrag-bench/shopizer): query mean ~26ms post-fix (the pre-split adds ~2ms; still ~3.3x faster than the heuristic-only baseline of 82.8ms); build phases unchanged. 21 lexical tests green. Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
PR #403 shipped a lexical
searchfallback for macOS Intel (where the vector stack can't install), ranking by a hand-rolled keyword-overlap heuristic capped at a 5000-symbol Python-side scan (search_lexical.py:_CANDIDATE_LIMIT_CAP). On any real enterprise repo that cap is a rounding error — the best match is silently dropped past row 5000. The PR note said "upgradable to SQLite FTS5 later," which pointed at the wrong store.Change
The graph DB is LadybugDB (the embedded Cypher/GNN store that is kuzu's ancestor, not a kuzu wrapper), and it ships a native FTS extension that scores with Okapi BM25 in the same
code_graph.lbugthe graph already lives in — no new dependency, no sidecar store. This PR makes the lexical path BM25-first:Build (
build_ast_graph.py)Symbol.search_textSTRING column: camelCase-split token soup of name + fqn + signature + annotations + capabilities, built with the same_split_identifierthe query path uses (so index- and query-time tokenization agree).sym_ftsFTS index (Okapi BM25, porter stemmer), ensured at the end of every full build and as a self-heal on incremental. It auto-maintains onCOPY/MERGE/DELETE, soincrementstays fresh without drop+recreate._drop_alldrops the FTS index first (LadybugDB refusesDROP TABLEon a table referenced by an FTS index);incremental_rebuildand_delete_file_scopeLOAD EXTENSION FTSso Symbol DML can maintain the index.ONTOLOGY_VERSION18 → 19 (semantic change → one-time reindex).Query (
search_lexical.py)QUERY_FTS_INDEXfetches the top-K candidates DB-side, a re-MATCHre-applies theNodeFilter(role / module / path / kind≠file,package) so filter logic stays in one place, then the existing name/type/fqn/role heuristic re-ranks and_dedup_by_fqnruns as before. The bounded Python scan is now the fallback for when the FTS index or extension is unavailable (older graph, or an offline first run whereINSTALL FTScan't fetch fromextension.ladybugdb.com).WeakSet), notid()—id()is reused after GC and broke under test batching.SearchHit/SearchOutput/NodeFilterunchanged);_score_componentsgains abm25entry.Tests + docs: BM25-active / heuristic-fallback / role-filter tests; two stale
ontology_version == 18assertions and the bank-chat baseline fixture bumped to 19; ARCHITECTURE / CONFIGURATION / AGENT-GUIDE / README updated (incl. the Kùzu→LadybugDB correction).Verification
Affected suites green; full suite green (the only failures during development were 65
tests/packageerrors from the uv worktree venv lackingpip— environment, not code — and hardcodedontology == 18assertions, all bumped).Probed the two make-or-break questions directly: the read-only
LadybugGraphconnection canLOAD EXTENSION FTS+QUERY_FTS_INDEX, and the FTS index auto-maintains onCOPY/DELETE.Bench on
~/jrag-bench/shopizer(master vs branch,bench_graph.py):initwallreprocess --graph-onlyincrement(1-file)Build phases are single-run variance (the vectors phase dominates
initand is untouched; the FTS-index build on shopizer-scale is negligible). The query win is the headline.Notes
INSTALL FTSfetches fromextension.ladybugdb.comon first use (cached after). Operators on airgapped hosts need a bundled/local-path install — flagged as open question on feat(search): hybrid BM25 + vector ranking via RRF (promote lexical to first-class) #431; the heuristic fallback covers them until then.🤖 Generated with Claude Code