You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
today retrieval.backend: auto runs a degradation chain — context._retrieve (src/vouch/context.py:76) tries embedding, and only if it returns nothing falls through to fts5, then substring (the shape #92 flags as hardcoded). a query the embedding side answers weakly never sees the lexical hits fts5 would have surfaced, and vice versa: recall is capped by whichever single backend happens to fire first. the fusion primitives already exist (rrf_fuse, weighted_fuse in src/vouch/embeddings/fusion.py), and a "hybrid" branch already lives in the kb.search path (src/vouch/server.py:158) and the vouch search cli (src/vouch/cli.py:1554) — but the shared context path doesn't know about it, so retrieval.backend: hybrid silently degrades to auto for kb.context, kb.synthesize, graph expansion, and volunteer-context scoring. this issue finishes and unifies hybrid so it runs everywhere, fusing fts5 and embedding results in parallel with reciprocal-rank fusion, optionally reranking on top.
distinct from #92 (the narrower bug that _retrieve ignores the legacy plural retrieval.backends config and hardcodes embedding-primary — that fix is honoring the pin, not adding fusion) and from #35 (which added embeddings as a third selectable backend; this builds on it by combining rather than selecting). the existing single-backend pins (embedding, fts5, substring) stay exactly as they are.
proposed surface
wire hybrid into the shared retrieval path so it is a first-class value everywhere config is read:
config .vouch/config.yaml:
retrieval:
backend: hybrid # new value alongside fts5 | substring | embedding | autofusion: rrf # rrf | weighted (default rrf)rrf_k: 60# rrf smoothing constant, matches fusion.rrf_fuse default k=60fusion_weights: # only read when fusion: weightedembedding: 0.5fts5: 0.5rerank: false # rerank the fused list (requires embeddings extras)
context._retrieve (src/vouch/context.py:76): add "hybrid" to _VALID_BACKENDS (currently ("auto", "embedding", "fts5", "substring") at src/vouch/context.py:45) and add a hybrid branch that calls index_db.search_semantic and index_db.search at a widened fetch limit, fuses via fusion.rrf_fuse (or weighted_fuse when fusion: weighted), tags emitted hits with backend="hybrid", and survives an fts5 sqlite3.Error the same way the fts5 branch does (empty fts side, still return the embedding side). this is the missing surface — normalize_relevance in src/vouch/volunteer_context.py:101 already special-cases "hybrid" but can never receive it today because scores flow through _retrieve.
optional rerank: when rerank: true, run the fused hits through rerank + default_reranker from src/vouch/embeddings/rerank.py before truncating to limit, degrading gracefully with a warning on ImportError when the extras aren't installed (same handling as src/vouch/cli.py:1562).
collapse the duplicated hybrid logic in src/vouch/server.py:158 and src/vouch/cli.py:1554 onto the same fusion helper so all three surfaces read rrf_k / fusion / rerank from one place — no per-surface drift.
vouch search --backend hybrid already exists on the cli; make sure --rerank composes with it.
review gate & scope
purely read-side. hybrid retrieval only ranks and returns already-approved artifacts; it creates and edits nothing, never touches proposals.approve(), and cannot introduce a write path around the review gate. fusion, weighting, and rerank all live under retrieval/embeddings modules (src/vouch/context.py, src/vouch/embeddings/) — no business logic leaks into src/vouch/storage.py, which stays pure i/o. everything runs locally against state.db and the on-disk .vouch/ artifacts; no network calls beyond the already-local embedding model, no new storage format, yaml stays the storage format. no new kb.* method is added — this rides the existing kb.context / kb.search / kb.synthesize surfaces — so the four-site registration dance does not apply; add coverage under tests/test_hybrid_retrieval.py.
acceptance criteria
_VALID_BACKENDS in src/vouch/context.py includes "hybrid", and retrieval.backend: hybrid no longer silently degrades to auto in the context path
context._retrieve returns fused (kind, id, summary, score, "hybrid") tuples when backend == "hybrid", running fts5 and embedding in parallel rather than as a fallback chain
fusion: rrf uses rrf_fuse with a configurable rrf_k (default 60); fusion: weighted uses weighted_fuse reading fusion_weights (currently weighted_fuse is defined but unwired)
hybrid survives an fts5 sqlite3.Error by falling back to the embedding side alone, matching the existing fts5-branch behavior in _retrieve
rerank: true reranks the fused list via src/vouch/embeddings/rerank.py and degrades with a clear warning on ImportError when embedding extras are absent
the server.py, cli.py, and context.py hybrid paths share one fusion helper — no duplicated inline rrf_fuse calls with divergent knobs
existing single-backend pins (embedding, fts5, substring, auto) behave identically to before — a regression test asserts the fallback chain is unchanged when backend != "hybrid"
templates/config.template.yaml documents hybrid and the fusion / rrf_k / rerank knobs (the template currently documents only fts5 | substring)
a labeled-query test in tests/test_hybrid_retrieval.py shows hybrid recall >= the better of fts5-only and embedding-only on a fixture where each single backend misses a relevant claim the other finds
today
retrieval.backend: autoruns a degradation chain —context._retrieve(src/vouch/context.py:76) tries embedding, and only if it returns nothing falls through to fts5, then substring (the shape #92 flags as hardcoded). a query the embedding side answers weakly never sees the lexical hits fts5 would have surfaced, and vice versa: recall is capped by whichever single backend happens to fire first. the fusion primitives already exist (rrf_fuse,weighted_fusein src/vouch/embeddings/fusion.py), and a"hybrid"branch already lives in thekb.searchpath (src/vouch/server.py:158) and thevouch searchcli (src/vouch/cli.py:1554) — but the shared context path doesn't know about it, soretrieval.backend: hybridsilently degrades toautoforkb.context,kb.synthesize, graph expansion, and volunteer-context scoring. this issue finishes and unifies hybrid so it runs everywhere, fusing fts5 and embedding results in parallel with reciprocal-rank fusion, optionally reranking on top.distinct from #92 (the narrower bug that
_retrieveignores the legacy pluralretrieval.backendsconfig and hardcodes embedding-primary — that fix is honoring the pin, not adding fusion) and from #35 (which added embeddings as a third selectable backend; this builds on it by combining rather than selecting). the existing single-backend pins (embedding,fts5,substring) stay exactly as they are.proposed surface
wire
hybridinto the shared retrieval path so it is a first-class value everywhere config is read:.vouch/config.yaml:context._retrieve(src/vouch/context.py:76): add"hybrid"to_VALID_BACKENDS(currently("auto", "embedding", "fts5", "substring")at src/vouch/context.py:45) and add a hybrid branch that callsindex_db.search_semanticandindex_db.searchat a widened fetch limit, fuses viafusion.rrf_fuse(orweighted_fusewhenfusion: weighted), tags emitted hits withbackend="hybrid", and survives an fts5sqlite3.Errorthe same way thefts5branch does (empty fts side, still return the embedding side). this is the missing surface —normalize_relevancein src/vouch/volunteer_context.py:101 already special-cases"hybrid"but can never receive it today because scores flow through_retrieve.rerank: true, run the fused hits throughrerank+default_rerankerfrom src/vouch/embeddings/rerank.py before truncating tolimit, degrading gracefully with a warning onImportErrorwhen the extras aren't installed (same handling as src/vouch/cli.py:1562).rrf_k/fusion/rerankfrom one place — no per-surface drift.vouch search --backend hybridalready exists on the cli; make sure--rerankcomposes with it.review gate & scope
purely read-side. hybrid retrieval only ranks and returns already-approved artifacts; it creates and edits nothing, never touches
proposals.approve(), and cannot introduce a write path around the review gate. fusion, weighting, and rerank all live under retrieval/embeddings modules (src/vouch/context.py, src/vouch/embeddings/) — no business logic leaks into src/vouch/storage.py, which stays pure i/o. everything runs locally againststate.dband the on-disk.vouch/artifacts; no network calls beyond the already-local embedding model, no new storage format, yaml stays the storage format. no newkb.*method is added — this rides the existingkb.context/kb.search/kb.synthesizesurfaces — so the four-site registration dance does not apply; add coverage undertests/test_hybrid_retrieval.py.acceptance criteria
_VALID_BACKENDSin src/vouch/context.py includes"hybrid", andretrieval.backend: hybridno longer silently degrades toautoin the context pathcontext._retrievereturns fused(kind, id, summary, score, "hybrid")tuples whenbackend == "hybrid", running fts5 and embedding in parallel rather than as a fallback chainfusion: rrfusesrrf_fusewith a configurablerrf_k(default 60);fusion: weightedusesweighted_fusereadingfusion_weights(currentlyweighted_fuseis defined but unwired)sqlite3.Errorby falling back to the embedding side alone, matching the existingfts5-branch behavior in_retrievererank: truereranks the fused list via src/vouch/embeddings/rerank.py and degrades with a clear warning onImportErrorwhen embedding extras are absentrrf_fusecalls with divergent knobsembedding,fts5,substring,auto) behave identically to before — a regression test asserts the fallback chain is unchanged whenbackend != "hybrid"templates/config.template.yamldocumentshybridand thefusion/rrf_k/rerankknobs (the template currently documents onlyfts5 | substring)tests/test_hybrid_retrieval.pyshows hybrid recall >= the better of fts5-only and embedding-only on a fixture where each single backend misses a relevant claim the other finds