Skip to content

feat(search): add embedding-based semantic retrieval as a third backend (#35)#36

Merged
plind-junior merged 13 commits into
vouchdev:mainfrom
alpurkan17:feat/embedding-search-35
May 25, 2026
Merged

feat(search): add embedding-based semantic retrieval as a third backend (#35)#36
plind-junior merged 13 commits into
vouchdev:mainfrom
alpurkan17:feat/embedding-search-35

Conversation

@alpurkan17

@alpurkan17 alpurkan17 commented May 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a third search backend — embedding-based semantic retrieval — alongside the existing FTS5 and substring backends.

Closes #35

Changes

src/vouch/embeddings.py (new)

  • Lazy-loaded all-MiniLM-L6-v2 model via sentence-transformers
  • Graceful fallback when the optional dependency is not installed (embeddings_available())
  • Batch encode() with normalized embeddings

src/vouch/index_db.py

  • New embeddings table in state.db schema
  • index_embedding() — store embedding vectors
  • search_embeddings() — NumPy brute-force cosine similarity search

Search dispatch

  • kb_search (MCP) and _h_search (JSONL) accept backend param
  • "embedding" — semantic search only
  • "hybrid" — reciprocal rank fusion of FTS5 + embedding results
  • CLI vouch search --embedding <query>

Index rebuild

  • vouch index now also regenerates embeddings (when sentence-transformers is installed)
  • Batched encoding (64 items/batch)

Packaging

  • Optional dependency: pip install vouch[embeddings]

Tests

  • test_embeddings.py — encode shape, index + search, relevance ranking, empty index

Acceptance criteria covered

  • Semantic search returns lexically-disjoint but semantically related results
  • MCP and JSONL accept backend="embedding" and backend="hybrid"
  • pip install vouch (no extras) still works with FTS5/substring
  • vouch index rebuilds embeddings (idempotent)

Summary by CodeRabbit

  • New Features

    • Embedding-based semantic search (CLI flag and API/backend option).
    • Hybrid search combining semantic and full-text results.
    • Explicit backend selection for search (auto, embedding, fts5, substring, hybrid).
    • Search results now indicate which backend produced each hit.
  • Improvements

    • Automatic reconstruction of embeddings during index rebuilds.
    • Case-insensitive content validation for bundle import.
  • Chores

    • Updated packaging and development tool constraints and pre-commit tooling.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

Adds optional embedding support, persists/searches vectors in SQLite, rebuilds embeddings during index rebuild, exposes embedding/hybrid search via CLI and JSONL server with backend validation, and makes bundle member validation extension checks case-insensitive.

Changes

Semantic Embedding Search

Layer / File(s) Summary
Optional dependency and capability detection
pyproject.toml, src/vouch/capabilities.py
Declare sentence-transformers>=3,<4 and numpy>=1.26,<2 in the embeddings extra (remove sqlite-vec), remove duplicate mypy entry from dev, and dynamically report embedding and hybrid capabilities when the embedder loads.
Embeddings table and search APIs
src/vouch/index_db.py
Add embeddings table keyed by (kind,id) with JSON-serialized vectors; implement index_embedding() to upsert vectors, search_embeddings() to brute-force rank by dot product with a unit-normalized query, and _snippet_for() to load per-result snippets or fallback to id.
Index rebuild with embedding generation
src/vouch/health.py
Call _rebuild_embeddings() during rebuild_index() to load the embedder, collect texts from claims/pages/entities, batch-encode (64), and persist embeddings via index_db.index_embedding().
CLI search dispatch with embedding backend
src/vouch/cli.py
Add --embedding boolean flag to vouch search that loads the embedder (install hint on missing deps), encodes the query, and dispatches to embedding search; otherwise retains FTS5-first with substring fallback.
Server search routing and backend validation
src/vouch/jsonl_server.py
Validate backend param against {auto,embedding,fts5,substring,hybrid}; dispatch semantic/FTS/substring/hybrid flows (hybrid fuses semantic+FTS via rrf_fuse); include backend field on returned hits.

Bundle Validation Enhancement

Layer / File(s) Summary
Case-insensitive extension filtering
src/vouch/bundle.py
Schema/content validation now runs only for filenames whose lowercased path ends with .yaml, .yml, or .md.

Pre-commit tooling

Layer / File(s) Summary
Pre-commit configuration
.pre-commit-config.yaml
Adds/updates hooks for ruff (with --fix), ruff-format, and mypy pinned versions and types-pyyaml extra.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • vouchdev/vouch#41: Hybrid fusion logic (rrf_fuse) and tests that pair with the new hybrid backend path.

"I dug through vectors, nibbled the bytes,
Encoded the senses and chased down the lights,
FTS still hops, but embeddings now leap,
Hybrid and semantic — the burrow runs deep!" 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding embedding-based semantic retrieval as a new search backend alongside existing FTS5 and substring options.
Linked Issues check ✅ Passed The PR substantially implements the core requirements from #35: embeddings module, embeddings table in index_db.py, search dispatch with embedding/hybrid backends, CLI support, index rebuild, optional dependency packaging, and regression tests covering lexically-disjoint semantic retrieval.
Out of Scope Changes check ✅ Passed All code changes align with the scope defined in #35. Pre-commit configuration updates and pyproject.toml dependency adjustments support the embeddings feature as an optional extra.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/vouch/server.py (1)

95-108: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

backend="substring" does not force substring search.

At Line 95–108, explicit substring selection still executes FTS-first behavior. Please add a direct substring branch to honor the backend parameter contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/server.py` around lines 95 - 108, The backend parameter currently
doesn't short-circuit substring mode; add an explicit branch that checks if
backend == "substring" and directly calls and returns
store.search_substring(query, limit=limit) (using store and query/limit
variables) before the try/except that falls back to substring; ensure the branch
mirrors the return shape used by _search_embedding/_search_hybrid and the FTS
path so callers get the same hit format and set backend to "substring" if your
code relies on that variable.
src/vouch/jsonl_server.py (1)

81-94: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

backend="substring" is ignored.

When callers explicitly request substring search, execution still runs FTS-first fallback logic. Add an explicit substring branch so backend selection is deterministic.

Suggested patch
     if backend == "embedding":
         return _search_embedding_jsonl(s, q, limit)
     if backend == "hybrid":
         return _search_hybrid_jsonl(s, q, limit)
+    if backend == "substring":
+        hits = s.search_substring(q, limit=limit)
+        return [
+            {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": "substring"}
+            for k, i, sn, sc in hits
+        ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/jsonl_server.py` around lines 81 - 94, The backend="substring" case
is ignored by the current FTS-first logic; add an explicit branch for backend ==
"substring" before trying index_db.search so the code calls
s.search_substring(q, limit=limit) and returns those hits (and sets backend =
"substring") immediately; keep the existing branches for "embedding"
(_search_embedding_jsonl) and "hybrid" (_search_hybrid_jsonl) as-is, and
preserve the try/except fallback that calls s.search_substring when
index_db.search(s.kb_dir, q, limit=limit) fails or returns no hits.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/capabilities.py`:
- Around line 60-65: The retrieval list currently appends "embedding" when
embeddings_available() returns true but doesn't include the "hybrid" option;
update the block that builds retrieval (the retrieval variable) so that when
from .embeddings import embeddings_available and embeddings_available() is true
you also append "hybrid" (in addition to "embedding") so capability advertising
matches kb.search supported backends; modify the try/except block around
embeddings_available() to append both "embedding" and "hybrid" to retrieval.

In `@src/vouch/index_db.py`:
- Around line 110-113: In the loop that iterates rows (the for kind, eid,
vec_json in rows block) guard against vector-dimension mismatches before calling
np.dot(q, v): after constructing v from vec_json, verify v.ndim == 1 and
v.shape[0] == q.shape[0] (or v.size == q.size) and if not, skip that row
(optionally logger.warning about kind/eid) and continue; alternatively wrap the
np.dot(q, v) in a try/except (catching ValueError) to skip problematic rows and
continue processing; keep the call to _snippet_for unchanged.

In `@src/vouch/server.py`:
- Around line 146-167: The fusion logic currently adds separate entries for the
same (kind, id) instead of combining them; update the loop that builds fused
(using seen, fused, fts_hits, emb_hits and the weight-based first loop) to key
results by (kind, id) and accumulate/merge scores into a single dict per key
(e.g., add weighted score to an existing entry if key in seen), pick or merge
the best snippet/snippet source and set backend to "hybrid", and only append a
new entry when the key is first encountered; after processing both fts_hits and
emb_hits sort fused by the combined "score" as before.

In `@tests/test_embeddings.py`:
- Around line 42-62: In test_index_and_search_embedding, there's a copy-paste
bug where index_db.index_embedding is called for pages with vec=p.id (a string)
instead of the computed vector; update the call to pass the vector variable vec
(the result of encode([f"{p.title} {p.body}"])[0].tolist()) so
index_db.index_embedding(conn, kind="page", id=p.id, vec=vec) uses the actual
embedding.

---

Outside diff comments:
In `@src/vouch/jsonl_server.py`:
- Around line 81-94: The backend="substring" case is ignored by the current
FTS-first logic; add an explicit branch for backend == "substring" before trying
index_db.search so the code calls s.search_substring(q, limit=limit) and returns
those hits (and sets backend = "substring") immediately; keep the existing
branches for "embedding" (_search_embedding_jsonl) and "hybrid"
(_search_hybrid_jsonl) as-is, and preserve the try/except fallback that calls
s.search_substring when index_db.search(s.kb_dir, q, limit=limit) fails or
returns no hits.

In `@src/vouch/server.py`:
- Around line 95-108: The backend parameter currently doesn't short-circuit
substring mode; add an explicit branch that checks if backend == "substring" and
directly calls and returns store.search_substring(query, limit=limit) (using
store and query/limit variables) before the try/except that falls back to
substring; ensure the branch mirrors the return shape used by
_search_embedding/_search_hybrid and the FTS path so callers get the same hit
format and set backend to "substring" if your code relies on that variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3fcc76a8-1b80-4927-9206-dd77d0e4acce

📥 Commits

Reviewing files that changed from the base of the PR and between 056e88c and 839d191.

📒 Files selected for processing (9)
  • pyproject.toml
  • src/vouch/capabilities.py
  • src/vouch/cli.py
  • src/vouch/embeddings.py
  • src/vouch/health.py
  • src/vouch/index_db.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • tests/test_embeddings.py

Comment thread src/vouch/capabilities.py
Comment thread src/vouch/index_db.py
Comment thread src/vouch/jsonl_server.py Outdated
Comment thread src/vouch/server.py Outdated
Comment thread tests/test_embeddings.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/index_db.py`:
- Around line 103-105: Guard against empty or zero-norm query vectors before
normalizing: in the function that constructs q from query_vec (where you do q =
np.array(query_vec, dtype=np.float32) and q = q / np.linalg.norm(q)), check if
query_vec is empty or if np.linalg.norm(q) == 0 (or very close to 0) and return
an empty out list (out: list[tuple[str, str, str, float]] = []) or otherwise
short-circuit with appropriate return; update the code around q, query_vec and
the normalization to perform this check and avoid dividing by zero.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e9d6ba0a-ba19-4267-a2fc-b5cb34bcdc3f

📥 Commits

Reviewing files that changed from the base of the PR and between 839d191 and 5ff8276.

📒 Files selected for processing (2)
  • src/vouch/embeddings.py
  • src/vouch/index_db.py

Comment thread src/vouch/index_db.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_embeddings.py`:
- Around line 26-27: The test imports numpy directly which can hard-fail;
replace the direct import with a guarded import by calling
pytest.importorskip("numpy") and assigning its return to the np symbol before
gating sentence_transformers (i.e., ensure np is obtained via
pytest.importorskip("numpy") and keep the existing
pytest.importorskip("sentence_transformers") guard), so the test is skipped
cleanly when numpy is absent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 276178cc-ca45-41a3-b442-27ccae72d01c

📥 Commits

Reviewing files that changed from the base of the PR and between 5ff8276 and cfd491f.

📒 Files selected for processing (1)
  • tests/test_embeddings.py

Comment thread tests/test_embeddings.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/bundle.py`:
- Around line 212-213: The early-return condition starting at the if not
any(path.endswith(ext) ...) line bypasses validation for many artifact
directories; remove this global extension gate and instead branch validation by
the file's subdirectory (use the path variable or derive subdir from it). For
example, enforce only .yaml/.yml for claims/, sources/, entities/; allow .md
only for documentation-like dirs; and explicitly reject unexpected extensions
for known artifact subdirs (and raise an import_check/import_apply validation
error for unknown subdirs). Update the logic where path is inspected so
import_check/import_apply continue to validate per-subdir rules rather than
skipping files by extension.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a8f61738-2003-4619-b5d7-39ca9b881fba

📥 Commits

Reviewing files that changed from the base of the PR and between 065998f and 666f925.

📒 Files selected for processing (1)
  • src/vouch/bundle.py

Comment thread src/vouch/bundle.py Outdated
@alpurkan17

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
✅ Actions performed

Reviews resumed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/bundle.py`:
- Around line 216-217: The extension check using path.endswith(ext) is
case-sensitive and misses uppercase variants; update the conditional that tests
the variable path (the if block performing any(path.endswith(ext) for ext in
(".yaml", ".yml", ".md"))) to perform a case-insensitive comparison by
normalizing the filename (e.g., use path.lower() or path.casefold()) before
calling endswith so files like .YAML or .MD are correctly matched.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 688bbf9f-c98d-4dca-b474-9c95b5dd61e7

📥 Commits

Reviewing files that changed from the base of the PR and between 065998f and 8c69d7d.

📒 Files selected for processing (6)
  • src/vouch/bundle.py
  • src/vouch/capabilities.py
  • src/vouch/index_db.py
  • src/vouch/jsonl_server.py
  • src/vouch/server.py
  • tests/test_embeddings.py

Comment thread src/vouch/bundle.py Outdated
@plind-junior

Copy link
Copy Markdown
Member

Requesting changes. This is a solid MVP and I'd be happy to see it land, but there are a few bugs that I think need to be fixed first, plus one design choice that's going to hurt the next person who touches this.

The biggest one is in _snippet_for. You're building the path with kb_dir / kind / f"{eid}.yaml" where kind is the singular form stored in the embeddings table ("claim", "page", "entity"), but storage uses plural dirs on disk (claims/, pages/, entities/). So every embedding-backend hit falls through to return eid and the snippet ends up being the ID. Easy repro: index a claim with some text, search semantically, you'll see the ID echoed back as the snippet instead of the claim text. Either map kind → dir or store the plural in the table.

The "hybrid" path isn't actually doing RRF either. In _search_hybrid you've got:

seen[key] = sc + rank_score

which mixes raw BM25 (-bm25, unbounded positive in vouch's case) with cosine ([-1, 1]) with 1/(rank+1). Those three scales aren't comparable. Canonical RRF is just sum(1/(k + rank)) across the two lists with k≈60 — no raw scores at all. PR #41 has the right version of this if you want to lift it. While you're in there, the trailing loop

for item in fused:
    item["score"] = seen[(item["kind"], item["id"])]

is also a no-op — item["score"] was already set to that value two lines above.

On storage: vectors are going through json.dumps(vec) into a BLOB column and json.loads on every search row. That's roughly 3× the disk footprint and 10–50× the per-row parse cost versus np.asarray(vec, dtype=np.float32).tobytes() + np.frombuffer. PR #38 does it the fast way and the diff is ~5 LOC. Worth pulling in now while the schema's still fresh.

Related, and this one will bite you later: the schema declares model TEXT NOT NULL DEFAULT 'all-MiniLM-L6-v2' but nothing writes to it and nothing reads it. Issue #35 explicitly flagged this as "almost certainly" needed, and without it, if anyone ever swaps the default model the dimension filter (if v.shape[0] != q.shape[0]: continue) will silently drop every old row. Result: zero hits and no diagnostic. Either record + check, or document the migration story up front.

A bunch of smaller stuff:

For tests, I'd want to see one that pins snippet != id for an embedding hit (catches #1), and a hybrid-ranking test that fails on the current "mixed scales" implementation (catches #2).

One last thing — the dripsmvcp series (#37#44) is a much bigger rewrite of the same feature and addresses #4-#6 here. Worth a chat with that author about whether to land this as the MVP and stack their work on top, or close this and review their series. Probably shouldn't merge both.

@plind-junior

Copy link
Copy Markdown
Member

Fix the conflict or it will be closed in 12hr

…vouchdev#35)

MVP implementation:
- src/vouch/embeddings.py - lazy-loaded sentence-transformers model
- index_db.py - embeddings table + NumPy brute-force cosine search
- server.py/jsonl_server.py - backend='embedding' and 'hybrid' support
- cli.py - vouch search --embedding flag
- health.py - embeddings rebuilt during vouch index
- capabilities.py - advertises embedding backend when available
- pyproject.toml - optional vouch[embeddings] dep
- Use pytest.importorskip('numpy') instead of direct import
- Replace global extension gate with per-subdir validation
@alpurkan17
alpurkan17 force-pushed the feat/embedding-search-35 branch from 6bae7cc to cbbd05d Compare May 23, 2026 07:30
@plind-junior
plind-junior merged commit e20f91f into vouchdev:main May 25, 2026
5 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jun 2, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(search): add embedding-based semantic retrieval as a third backend

2 participants