Skip to content

feat(search): BM25 lexical fallback via LadybugDB FTS#432

Merged
HumanBean17 merged 1 commit into
masterfrom
worktree-bm25-lexical-fork-a
Jul 11, 2026
Merged

feat(search): BM25 lexical fallback via LadybugDB FTS#432
HumanBean17 merged 1 commit into
masterfrom
worktree-bm25-lexical-fork-a

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

Problem

PR #403 shipped a lexical search fallback 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.lbug the graph already lives in — no new dependency, no sidecar store. This PR makes the lexical path BM25-first:

Build (build_ast_graph.py)

  • New Symbol.search_text STRING column: camelCase-split token soup of name + fqn + signature + annotations + capabilities, built with the same _split_identifier the query path uses (so index- and query-time 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. It 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.py)

  • QUERY_FTS_INDEX fetches the top-K candidates DB-side, a re-MATCH re-applies the NodeFilter (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_fqn runs 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 where INSTALL FTS can't fetch from extension.ladybugdb.com).
  • Per-connection FTS-load cache keyed by the connection object (WeakSet), not id()id() is reused after GC and broke under test batching.
  • No tool-contract change (SearchHit / SearchOutput / NodeFilter unchanged); _score_components gains a bm25 entry.

Tests + docs: BM25-active / heuristic-fallback / role-filter tests; two stale ontology_version == 18 assertions 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/package errors from the uv worktree venv lacking pip — environment, not code — and hardcoded ontology == 18 assertions, all bumped).

  • Probed the two make-or-break questions directly: the read-only LadybugGraph connection can LOAD EXTENSION FTS + QUERY_FTS_INDEX, and the FTS index auto-maintains on COPY/DELETE.

  • Bench on ~/jrag-bench/shopizer (master vs branch, bench_graph.py):

    metric master (heuristic) fork A (BM25) Δ
    init wall 34.65s 30.19s flat (variance)
    reprocess --graph-only 7.63s 7.21s flat
    increment (1-file) 18.98s 17.30s flat
    query mean 82.8ms 23.6ms −71% (3.5×)
    query max 111.7ms 25.0ms −78%

    Build phases are single-run variance (the vectors phase dominates init and is untouched; the FTS-index build on shopizer-scale is negligible). The query win is the headline.

Notes

🤖 Generated with Claude Code

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 HumanBean17 merged commit 326f241 into master Jul 11, 2026
4 checks passed
@HumanBean17 HumanBean17 deleted the worktree-bm25-lexical-fork-a branch July 11, 2026 19:47
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>
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