feat(search): hybrid retrieval fusing full-corpus BM25 with dense ranking#32
Merged
Conversation
scarletkc
force-pushed
the
feat/hybrid-search
branch
from
July 12, 2026 00:38
dc7a1ec to
25dd43e
Compare
…king Add `rerank=hybrid`: BM25 term statistics are persisted alongside the index (schema v7: bm25_doc + bm25_posting inverted index, FK cascades keep incremental updates consistent) and scored over the full chunk set per query, then fused with the dense ranking via weighted reciprocal rank fusion (0.7 dense / 0.3 BM25, k=60, scores normalized to [0,1]). Unlike the legacy bm25 reranker, lexical-only matches outside the dense candidate clamp become retrievable. Tokenization emits whole identifier tokens alongside BERT sub-tokens so exact-symbol queries rank precisely. Falls back to dense-only ranking when the query yields no tokens or the index has no BM25 data. In-memory/no-cache searches score lexically from transient chunk term data. Old caches rebuild automatically on next use; cached embeddings are reused so migration cost is extraction only. DEFAULT_RERANK stays "off": scripts/eval_hybrid.py (30 QA pairs over this repo) shows hybrid beats dense-only with the local model but not yet with a strong remote model, so the default flip stays gated per the roadmap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scarletkc
force-pushed
the
feat/hybrid-search
branch
from
July 12, 2026 00:55
2f2364c to
77256ff
Compare
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.
Motivation
Roadmap P0: pure semantic search is weak on exact identifiers, and the existing
--rerank bm25only reorders the dense top candidates (clamp(top*2, 20, 150)) — lexical-only matches that dense retrieval misses can never surface. True hybrid scores the full chunk set on both paths before fusing.Design
bm25_doc(chunk_id → token_count) +bm25_posting(index_id, term, chunk_id → tf,WITHOUT ROWIDinverted index). Query cost is O(query terms × matching postings), not O(corpus). Incremental updates ride the existing FK cascades (changed files rewrite postings in-transaction; removed files cascade; touched files are provable no-ops since tokens derive from the unchanged label). Corpus stats (N, avgdl) are computed per query by aggregate — no stats table to drift.load_index_vectorsis kept intact alongsideload/index/vectors), so exact-symbol queries get a high-IDF precise match while natural-language queries match through sub-tokens.0.7·(k+1)/(k+dense_rank) + 0.3·(k+1)/(k+bm25_rank), k=60, scores normalized to [0,1] (1.0 = ranked first on both paths). The 0.7/0.3 split mirrors the legacy reranker''s fusion weights; equal-weight RRF measurably let full-corpus BM25 noise displace correct dense top hits on common-word queries.reranker: nullwhen the query yields no tokens or the index has no BM25 data.--no-cache/in-memory searches score lexically from transient chunk term data.vexor config --rerank hybrid(fifthSUPPORTED_RERANKERSvalue), init wizard option 5, doctor check (warns whentokenizersis missing → regex fallback degrades CJK), MCPrerankerresponse field can now be"hybrid"(no tool-schema change; injectable viaVEXOR_CONFIG_JSON), bundled skill tip for exact-identifier lookups.DEFAULT_RERANKstays"off"— see evaluation below; the default flip remains gated per the roadmap.Evaluation (scripts/eval_hybrid.py, 30 QA pairs over this repo, top=10)
Local provider (
intfloat/multilingual-e5-small):Custom provider (
BAAI/bge-m3, OpenAI-compatible):Honest read: hybrid beats dense-only with the weaker local embedding model and structurally guarantees lexical-only matches are retrievable (unit-tested: a chunk dense-ranked last but lexically exact surfaces in hybrid top-k while
bm25rerank cannot see it), but a strong dense model still wins on aggregate MRR for this query mix. Two fusion iterations are already included (whole-token tokenization: hybrid MRR 0.531→0.671 on bge-m3; dense-weighted RRF). Further tuning belongs to the roadmap''s evaluation work; hybrid ships opt-in.Compatibility (reviewers please check)
rerankaccepts new value"hybrid". No Python API signature changes. Supervisor fix during review:cache._relative_pathnow returns posix form to match the rel_pathsindex_servicestores — without this,compare_snapshotwould mark nested paths permanently stale on Windows (regression-tested).Tests
python -m pytest tests -q(.venv, offline): 515 passed (19 new: bm25 math incl. hand-computed BM25/weighted-RRF values, cache postings persistence/incremental/cascade/fallback, hybrid ranking incl. the lexical-only-outside-clamp case and exact-identifier-vs-subtoken-flood case, config/init/doctor/MCP/CLI surface, nested-path staleness regression)VEXOR_CONFIG_JSON={"rerank":"hybrid"}+vexor search "_apply_bm25_rerank" --path .with the configured custom provider (BAAI/bge-m3) — correct file at Add Claude Skills template for Vexor repository #1,Reranker: hybridlabel shown. Both eval tables above produced with real embeddings (local + custom).Implementation by Codex CLI (gpt-5.6-sol, high reasoning) under Claude supervision; two supervisor-directed fix iterations (tokenization, fusion weights) driven by benchmark results.
🤖 Generated with Claude Code