feat(search): hybrid BM25 + vector ranking via RRF (promote lexical to first-class)#443
Conversation
Approved design for promoting LadybugDB BM25 to a first-class third RRF list on the vector/hybrid read path, plus a from-scratch recall/precision @k + MRR eval harness over ~/jrag-bench/shopizer. Co-Authored-By: Claude <noreply@anthropic.com>
8-task TDD plan: scoring refactor, RankConfig injection, bm25= explain, BM25 candidate fetch + 3-list fusion, and a from-scratch eval harness (metrics, Tier-A/Tier-B ground truth, shopizer k-sweep runner). Co-Authored-By: Claude <noreply@anthropic.com>
Adds a dep-free RankConfig dataclass in search_scoring.py describing which RRF lists to fuse (vector required; graph/bm25 optional) and the RRF k, then threads it as a keyword-only param through run_search -> _graph_expand_merge -> _rrf_merge. Behavior is unchanged: _graph_expand_merge still fuses [vector_rows, graph_rows] and the DEFAULT_RANK_CONFIG ships rrf_k=60. The "bm25" element of the default config is inert until Task 4 wires the BM25 candidate fetch; this task only makes k injectable (testable via the new test_graph_expand_merge_honors_injected_k). Co-Authored-By: Claude <noreply@anthropic.com>
Wire the FTS-based BM25 candidate fetch into the vector path's RRF fusion. A new _bm25_candidate_rows helper fetches BM25-ranked Symbols via the LadybugDB FTS index, maps each to its enclosing type fqn (deduping by max BM25 score), and resolves them to LanceDB chunk rows in BM25 rank order using a filter-only (non-vector) query so BM25 ordering is preserved. The helper degrades silently to [] on any failure (FTS unavailable / empty / filtered out), leaving the vector path unaffected. _graph_expand_merge now honors rank_config.lists: "graph" and "bm25" are each fetched only when configured, and the lists are fused with the configured rrf_k. The `query` string is threaded in as a new keyword-only param (forwarded from run_search) to drive the FTS candidate fetch. search_lexical exposes non-underscore aliases (fetch_fts_candidates, enclosing_type_fqn) for cross-module use without behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Task 7 of the hybrid-BM25 RRF plan. Adds eval/runner.py: EvalConfig + run_eval orchestrator that builds a fresh index via the operator CLI subprocess, enumerates Symbol nodes for Tier-A ground truth (+ optional Tier-B), sweeps BASELINE_2LIST_CONFIG + a 3-list RankConfig at each k in cfg.ks, computes recall@k/precision@5/MRR via eval.metrics, and persists report.md + report.json under cfg.results_dir. Metric-granularity resolution: normalizes member FQNs (com.x.A#foo()) to enclosing-type FQNs via search_lexical._enclosing_type_fqn on the relevant set, matching the type-level primary_type_fqn carried by run_search rows — so recall for member symbols is meaningful. Indexer choice: subprocess over `java-codebase-rag init` (the stable operator surface) rather than reaching into cocoindex/pipeline internals. Tests assert SHAPE + file persistence + tier-B optionality against a tiny fixture (cross_service_smoke); skip cleanly when the vector stack or cocoindex CLI is absent. Co-Authored-By: Claude <noreply@anthropic.com>
…eset API) Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
) Update read-path docs: vector search now fuses 3 RRF lists (vector + graph + BM25), list-set/k injectable via RankConfig. DESIGN principle #2 notes BM25 is first-class, not just the macOS-Intel fallback. Records the shopizer eval result (3-list beats 2-list on all metrics at ~0 latency). Co-Authored-By: Claude <noreply@anthropic.com>
📊 Load Test Report — hybrid BM25 RRF search pathDedicated load harness (not the eval) reusing the runner's exact setup: fresh v19 shopizer index, Memory (peak RSS): 450 MB (interpreter) → 2,270 MB after index open + model load + 300 warm calls. Flat across the warm sweep — no per-call leak. Cold start (first
|
| config | first call |
|---|---|
| baseline 2-list k60 | 373.5 ms |
| hybrid 3-list k60 | 275.7 ms |
Baseline ran first → it absorbs one-time cache warm-up (LanceDB pages, LadybugDB buffers, FTS extension). Treat ~374 ms as true cold-search overhead.
Warm steady-state latency (ms, n=150 each)
| config | min | mean | p50 | p90 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|
| baseline 2-list k60 | 148.4 | 298.1 | 300.5 | 359.5 | 377.1 | 481.2 | 756.0 |
| hybrid 3-list k60 | 159.8 | 298.1 | 298.9 | 359.9 | 370.8 | 388.7 | 390.5 |
3-list vs 2-list delta (BM25 hop cost): p50 −1.6 ms (−0.5%) · p95 −6.3 ms (−1.7%) · p99 −92.6 ms (−19.2%) · mean ±0.0 ms
Interpretation
- Latency-neutral at the center.
min/mean/p50/p90statistically identical (~298 ms mean). The BM25 hop costs ~0 ms at the typical query — confirms the eval's p50 finding across the full distribution. - The p99/max improvement (481→389, 756→390) is NOT claimed as a win — it's a warm-up-order artifact (baseline ran first, ate one-touch cache misses) plus n=150 p99 noise. Defensible read: no tail regression.
- No memory leak. Peak RSS plateaued at ~2.27 GB across 300 warm calls.
Coverage
- Covers: per-query latency percentiles, cold vs warm, peak memory + leak check, 2-list vs 3-list — on real shopizer (1,210 files) via the production path.
- Doesn't cover: concurrency (serial calls; MCP fans out via
asyncio.to_thread, but per-query cost is the right model). No multi-hour soak — but the 2,000-call eval showed the same flat RSS, and the fan-out OOM failure mode is removed (kind filter +max_queriescap).
Bottom line: the feature ships a measured ranking gain at zero measured latency cost and no memory regression — the BM25 anchor is effectively free on the primary path.
…ty + test rigor) PR #443 hybrid BM25+RRF review fixes: - A-I1 (Important): _bm25_candidate_rows passed the RAW query to fetch_fts_candidates; LadybugDB FTS does not split camelCase, so a query like DistributionChunkService matched nothing and BM25 silently no-op'd on the feature's target queries. Pre-split with search_scoring.build_fts_query (mirrors the lexical backend) and return [] early on a degenerate split. Added an integration test that runs a camelCase query through the UN-stubbed FTS path (real LadybugDB sym_fts index) and asserts BM25 contributed. - C-I1 (Important): eval/runner.py had no __main__, so `python -m java_codebase_rag.eval.runner` was a silent no-op. Added a minimal argparse CLI (corpus_dir, --index-dir, --results-dir, --max-queries, --ks, --tier-b, --device) that builds an EvalConfig, calls run_eval, prints out_dir; exit 1 + traceback on failure. - C-I2 (Important): run_eval called load_tier_b unconditionally on a truthy tier_b_path, raising FileNotFoundError despite the docstring's "missing = Tier-B disabled" contract. Now guards on Path.exists(); load_tier_b still raises when called directly. - D-I1 (Important): the _RecordingDb fake's _eval_pred destroyed parens before the "IN (" check, so primary_type_fqn IN (...) never evaluated. Rewrote to split on AND without paren-destruction and strip one outer paren layer. Added a test where ordered_types excludes a fetched row's FQN and asserts it is NOT emitted. - D-I2 (Important): test_bm25_candidate_rows_orders_by_bm25_score supplied rows already in score-desc order (tautological). Scrambled to [C(10),A(20),B(30)] so only a real score-sort yields the asserted [B,A,C]. - D-I3 (Important): dedup-keep-max test listed the max-score member first, so a first-wins bug also yielded 30. Reversed to [sm2(20),sm1(30)] so the ==30.0 assertion discriminates keep-max from first-wins. - B-M1 (Minor): rewrote stale "inert until Task 4" comments in search_scoring to reflect that the bm25 list is now wired in _graph_expand_merge. Existing _bm25_candidate_rows unit tests updated from the degenerate placeholder query "q" to "query" (build_fts_query now drops single-char tokens, returning early). Removed a pre-existing unused DEFAULT_RANK_CONFIG import from runner.py. Co-Authored-By: Claude <noreply@anthropic.com>
…sue #431) Post-A-I1-fix eval (build_fts_query now pre-splits camelCase): 3-list fusion at k=60 more than DOUBLES MRR (0.3044->0.6205, +104%) and recall@1 (0.220->0.490, +123%), recall@10 0.535->0.860. Latency cost ~+25-30ms p50 (~10%), not 0 as the bug-muted prior measurement suggested. Ship k=60 (conservative; k=30 narrowly best on identifier-heavy Tier-A, within noise). Records the query-preprocessing bug that muted the initial eval. Co-Authored-By: Claude <noreply@anthropic.com>
|
#445) PR #443 added BM25 as a 3rd RRF list in _graph_expand_merge, but search_v2 never passed graph_expand=True to run_search — so the vector+graph+BM25 fusion was dormant for every user-facing search (jrag search, MCP search). Tests + eval stayed green only because they invoke run_search(graph_expand=True) directly, never through search_v2. The design spec's "Current" diagram claimed graph-expand was already on the live path; it wasn't (false since #409). - search_v2 (mcp_v2.py): pass graph_expand=(table == "java") at both run_search call sites — the primary path and the hybrid->vector FTS-missing retry. run_search already guards fusion to the java single-table path and degrades silently when the graph/FTS index is unavailable, so table=all/sql/yaml and airgapped installs are unaffected. search_payload routes through search_v2, so this covers jrag search + the MCP search tool. - tests: two contract tests through search_v2 (the wiring gap's blind spot). Negative control: forcing graph_expand=False makes them FAIL, proving the guard is non-vacuous and catches the dormancy. - docs: correct the design spec's false "Current" diagram with a post-merge note so the next implementer doesn't inherit the premise. ARCHITECTURE.md / DESIGN.md claims ("BM25 first-class on primary path") were false before and are now true — no edit needed. Full suite: 1535 passed, 38 skipped, 0 failed. Co-authored-by: Claude <noreply@anthropic.com>
Closes #431. Promotes LadybugDB Okapi BM25 from a fallback-only signal (macOS-Intel lexical path) to a first-class, always-on third RRF list on the vector/hybrid search read path, so exact-identifier lexical matches anchor dense rankings on the primary path.
What
search_lancedb._graph_expand_merge: vector + graph-expand + BM25 (sourced from the existingSymbol.search_textFTS index viasearch_lexical._try_fts_candidates, fork A / PR feat(search): BM25 lexical fallback via LadybugDB FTS #432). BM25 candidates resolved to chunk rows in BM25 rank order, re-filtered by the same LanceDB predicates as the vector list._bm25_candidate_rowspre-splits the query withbuild_fts_queryso camelCase identifiers reach the FTS tokenizer (ladybug FTS doesn't split camelCase).RankConfig(search_scoring.py, dep-free): runtime-selectable list-set + RRFk. Default = 3-list @k=60.bm25=explain token inexplain_score_components(hybrid branch, underexplain=True).java_codebase_rag.eval): recall/precision@k + MRR, Tier-A auto ground truth + optional Tier-B, shopizer k-sweep runner (CLI:python -m java_codebase_rag.eval.runner). Bounded fan-out (type-level kind filter +max_queriescap).Result (shopizer, n=400 of 2322 type-level symbols)
The 3-list fusion more than doubles MRR (0.3044→0.6205, +104%) and recall@1 (0.220→0.490, +123%), and lifts recall@10 0.535→0.860 — exact-identifier anchoring delivering the hybrid win the issue predicted. The BM25 hop costs ~+25-30 ms p50 (~10%) per query.
k=30narrowly edgesk=60on this identifier-heavy Tier-A (within noise on n=400), but k=60 ships as the conservative, regime-robust choice (re-tune when a Tier-B NL ground truth exists). See the load-test report comment for latency percentiles + memory.Contracts unchanged (
SearchHit/SearchOutput/NodeFilter);mcp_v2.pyandabsence_diagnosis.pyuntouched.Tests
Full suite: 1531 passed, 38 skipped, 0 failures (pre-fix); post-fix
tests/search/+tests/eval/re-green (90 passed) including a new integration test (test_run_search_bm25_contributes_on_camelcase_query_via_real_fts) that runs a real camelCase query through the un-stubbed FTS path — the test that would have caught the bug. 4-scope fan-out review: APPROVED-WITH-FOLLOWUPS, all 6 Important findings fixed.Scope / non-goals
absence_diagnosisBM25 wiring, read-path unification,--no-vectorsmode, airgapped FTS install, NDCG, latency gating — all out of scope, filed as #434–#439. Review Minor findings tracked in #442.🤖 Generated with Claude Code