Skip to content

feat: hybrid rrf retrieval — fuse fts5 and embedding results instead of a fallback chain #316

Description

@plind-junior

today retrieval.backend: auto runs a degradation chaincontext._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 | auto
      fusion: rrf                # rrf | weighted (default rrf)
      rrf_k: 60                  # rrf smoothing constant, matches fusion.rrf_fuse default k=60
      fusion_weights:            # only read when fusion: weighted
        embedding: 0.5
        fts5: 0.5
      rerank: 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    embeddingsembedding-backed retrievalenhancementNew feature or requestretrievalcontext, search, synthesis, and evaluationsize: M200-499 changed non-doc lines

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions