Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
27 changes: 26 additions & 1 deletion src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
94 changes: 94 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading