From c61ed97e127305cd9b43a78a87698a1fc328aeaa Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Thu, 2 Jul 2026 23:58:48 +0100 Subject: [PATCH] feat(context): hybrid RRF retrieval backend fusing embedding and fts5 --- CHANGELOG.md | 3 ++ src/vouch/context.py | 27 ++++++++++++- tests/test_context.py | 94 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00aa7132..09078a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `hybrid` retrieval backend: RRF fusion of embedding and FTS5 results via `rrf_fuse`. Set `retrieval.backend: hybrid` in `.vouch/config.yaml` to activate. Degrades gracefully to FTS5-only when no embedding index is present, and to substring when both indexes are unavailable. Exposed in `kb.capabilities.retrieval` when an embedder is registered. Fixes #316. + ### Docs - example KBs now carry their own screenshots: `examples/README.md` and the `tiny/` + `decision-log/` READMEs embed terminal renders of `vouch status`, diff --git a/src/vouch/context.py b/src/vouch/context.py index 6e9b08ce..be517905 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -13,12 +13,14 @@ from __future__ import annotations +import contextlib import sqlite3 from typing import Any, Literal, cast import yaml from . import graph, index_db +from .embeddings.fusion import rrf_fuse from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality from .scoping import ( ViewerContext, @@ -42,7 +44,7 @@ ContextItemKind = Literal["claim", "page", "entity", "relation", "source"] -_VALID_BACKENDS = ("auto", "embedding", "fts5", "substring") +_VALID_BACKENDS = ("auto", "embedding", "fts5", "substring", "hybrid") def _configured_backend(store: KBStore) -> str: @@ -86,10 +88,33 @@ def _retrieve( - "embedding": semantic search only - "fts5": lexical FTS5 only - "substring": substring scan only + - "hybrid": RRF fusion of embedding + FTS5 results (#316) """ backend = _configured_backend(store) fetch_limit = scoped_fetch_limit(limit, viewer) + if backend == "hybrid": + # Reciprocal Rank Fusion of embedding and FTS5 results (#316). + # Both lists are fetched at fetch_limit so the fusion has enough + # candidates to fill the requested limit after dedup and filtering. + # Falls back to FTS5-only when no embedding index is present, and + # to substring when FTS5 is also unavailable — matching the same + # graceful degradation policy as "auto". + sem: list[tuple[str, str, str, float]] = [] + with contextlib.suppress(Exception): + sem = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) or [] + lex: list[tuple[str, str, str, float]] = [] + with contextlib.suppress(sqlite3.Error): + lex = index_db.search(store.kb_dir, query, limit=fetch_limit) or [] + if sem or lex: + fused = rrf_fuse(sem, lex, limit=fetch_limit) + filtered = filter_hits(store, fused, viewer, limit=limit) + return [(k, i, s, sc, "hybrid") for k, i, s, sc in filtered] + # Both indexes unavailable — fall through to substring. + substring_hits = store.search_substring(query, limit=fetch_limit) + filtered = filter_hits(store, substring_hits, viewer, limit=limit) + return [(k, i, s, sc, "substring") for k, i, s, sc in filtered] + if backend in ("auto", "embedding"): raw = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) if raw: diff --git a/tests/test_context.py b/tests/test_context.py index 0c155665..6365a08b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -158,3 +158,97 @@ def test_build_context_pack_explain_flag_returns_score_breakdown( pack = build_context_pack(store, query="hello", limit=5, explain=True) assert "explain" in pack assert any("backend" in row for row in pack["explain"]) + + +# --- hybrid RRF backend (#316) ------------------------------------------- + + +def test_hybrid_backend_returns_fused_results(tmp_path: Path) -> None: + """hybrid backend must return results with backend tag "hybrid" and + surface hits from both embedding and FTS5 indexes via RRF fusion.""" + from tests.embeddings._fakes import MockEmbedder + from vouch.context import build_context_pack + from vouch.embeddings import register + from vouch.embeddings.base import DEFAULT_MODEL_NAME + from vouch.storage import KBStore + + register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)) + store = KBStore.init(tmp_path) + src = store.put_source(b"evidence") + store.put_claim(Claim(id="c1", text="redis caching with expiry", evidence=[src.id])) + store.put_claim(Claim(id="c2", text="postgres query optimization", evidence=[src.id])) + health.rebuild_index(store) + + # Write hybrid backend into config + import yaml + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.setdefault("retrieval", {})["backend"] = "hybrid" + store.config_path.write_text(yaml.safe_dump(cfg)) + + pack = build_context_pack(store, query="redis", limit=5) + assert pack["backend"] == "hybrid" + assert any(it["id"] == "c1" for it in pack["items"]) + + +def test_hybrid_backend_degrades_to_substring_when_no_indexes(tmp_path: Path) -> None: + """When neither embedding nor FTS5 index is available, hybrid must + fall back to substring search rather than returning empty results.""" + from vouch.context import build_context_pack + from vouch.storage import KBStore + + store = KBStore.init(tmp_path) + src = store.put_source(b"evidence") + store.put_claim(Claim(id="c1", text="unique-term-xyzzy for substring", evidence=[src.id])) + # No index built — state.db absent, no embeddings registered. + + import yaml + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.setdefault("retrieval", {})["backend"] = "hybrid" + store.config_path.write_text(yaml.safe_dump(cfg)) + + pack = build_context_pack(store, query="unique-term-xyzzy", limit=5) + # Falls back to substring — item must still be found + assert any(it["id"] == "c1" for it in pack["items"]) + + +def test_hybrid_backend_deduplicates_items_appearing_in_both_lists( + tmp_path: Path, +) -> None: + """An item ranking in both the embedding and FTS5 lists must appear + exactly once in the fused output, not twice.""" + from tests.embeddings._fakes import MockEmbedder + from vouch.context import build_context_pack + from vouch.embeddings import register + from vouch.embeddings.base import DEFAULT_MODEL_NAME + from vouch.storage import KBStore + + register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)) + store = KBStore.init(tmp_path) + src = store.put_source(b"evidence") + store.put_claim(Claim(id="c1", text="jwt authentication token", evidence=[src.id])) + health.rebuild_index(store) + + import yaml + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.setdefault("retrieval", {})["backend"] = "hybrid" + store.config_path.write_text(yaml.safe_dump(cfg)) + + pack = build_context_pack(store, query="jwt authentication", limit=10) + ids = [it["id"] for it in pack["items"]] + assert ids.count("c1") == 1, f"c1 appeared {ids.count('c1')} times in hybrid results" + + +def test_hybrid_config_backend_accepted_by_configured_backend(tmp_path: Path) -> None: + """hybrid must be accepted as a valid value for retrieval.backend + in config.yaml without falling back to auto.""" + import yaml + + from vouch.context import _configured_backend + from vouch.storage import KBStore + + store = KBStore.init(tmp_path) + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.setdefault("retrieval", {})["backend"] = "hybrid" + store.config_path.write_text(yaml.safe_dump(cfg)) + + assert _configured_backend(store) == "hybrid"