-
Notifications
You must be signed in to change notification settings - Fork 45
feat(embeddings): hybrid fusion strategies (RRF, weighted, normalized) #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
plind-junior
merged 18 commits into
vouchdev:main
from
dripsmvcp:feat/semantic-search-phase-5-fusion
May 21, 2026
+963
−47
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
2abce16
feat(embeddings): state.db schema for embedding storage + put/get hel…
dripsmvcp d36b2e8
feat(embeddings): NumPy brute-force cosine search over embedding_index
dripsmvcp b769dab
feat(embeddings): sqlite-vec ANN path with NumPy fallback
dripsmvcp c6b0e8b
feat(embeddings): query embedding LRU cache
dripsmvcp 3722ee4
style: fix ruff E402/SIM105/E702/I001 violations from Phase 2 storage…
dripsmvcp d51474b
fix(embeddings): use outer-query for WHERE on cosine alias in search_…
dripsmvcp 3612f0b
feat(embeddings): write-time embedding hook + wire put_claim
dripsmvcp f7c3c89
feat(embeddings): hook all six artifact write paths
dripsmvcp d3bee3b
feat(embeddings): re-embed on update_claim
dripsmvcp b7e333a
style: fix ruff SIM105/E501/I001/RUF059 violations from Phase 3
dripsmvcp 1c2dc21
fix(ci): silence mypy import-untyped for forward-ref dedup import
dripsmvcp 807c326
feat(embeddings): search_semantic wrapper with query cache
dripsmvcp 062c149
feat(embeddings): kb_search defaults to embedding primary, fts5 fallback
dripsmvcp f5c2a95
feat(embeddings): JSONL _h_search parity with MCP semantic-primary di…
dripsmvcp 9cfdf78
fix(ci): silence mypy import-untyped for forward-ref fusion import
dripsmvcp 9e77d51
ci(mypy): collapse fusion import to single line for type-ignore
dripsmvcp f6b8427
feat(embeddings): RRF/weighted/normalized fusion strategies
dripsmvcp 1f1eeb8
fix(embeddings): address CodeRabbit review on hybrid + fusion + write…
plind-junior File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| """Query embedding LRU cache backed by `query_embedding_cache` table.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import datetime as dt | ||
| import hashlib | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import numpy as np | ||
|
|
||
| from .. import index_db | ||
|
|
||
|
|
||
| def _query_key(query: str) -> str: | ||
| return hashlib.sha256(query.encode("utf-8")).hexdigest() | ||
|
|
||
|
|
||
| def cache_query_vec( | ||
| kb_dir: Path, *, query: str, vec: np.ndarray, max_entries: int = 1024, | ||
| ) -> None: | ||
| h = _query_key(query) | ||
| blob = np.asarray(vec, dtype=np.float32).tobytes() | ||
| now = dt.datetime.now(dt.UTC).isoformat(timespec="seconds") | ||
| with index_db.open_db(kb_dir) as conn: | ||
| conn.execute( | ||
| "INSERT OR REPLACE INTO query_embedding_cache " | ||
| "(query_hash, vec, hit_count, last_used_at) " | ||
| "VALUES (?, ?, COALESCE(" | ||
| " (SELECT hit_count FROM query_embedding_cache WHERE query_hash=?), 0" | ||
| ") + 1, ?)", | ||
| (h, blob, h, now), | ||
| ) | ||
| n = conn.execute( | ||
| "SELECT COUNT(*) FROM query_embedding_cache" | ||
| ).fetchone()[0] | ||
| if n > max_entries: | ||
| conn.execute( | ||
| "DELETE FROM query_embedding_cache WHERE query_hash IN (" | ||
| " SELECT query_hash FROM query_embedding_cache " | ||
| " ORDER BY last_used_at ASC LIMIT ?)", | ||
| (n - max_entries,), | ||
| ) | ||
|
|
||
|
|
||
| def lookup_query_vec(kb_dir: Path, *, query: str) -> np.ndarray | None: | ||
| h = _query_key(query) | ||
| now = dt.datetime.now(dt.UTC).isoformat(timespec="seconds") | ||
| with index_db.open_db(kb_dir) as conn: | ||
| row = conn.execute( | ||
| "SELECT vec FROM query_embedding_cache WHERE query_hash = ?", | ||
| (h,), | ||
| ).fetchone() | ||
| if not row: | ||
| return None | ||
| conn.execute( | ||
| "UPDATE query_embedding_cache SET hit_count = hit_count + 1, " | ||
| "last_used_at = ? WHERE query_hash = ?", | ||
| (now, h), | ||
| ) | ||
| return np.frombuffer(row[0], dtype=np.float32).copy() | ||
|
|
||
|
|
||
| def query_cache_size(kb_dir: Path) -> int: | ||
| with index_db.open_db(kb_dir) as conn: | ||
| return int(conn.execute( | ||
| "SELECT COUNT(*) FROM query_embedding_cache" | ||
| ).fetchone()[0]) | ||
|
|
||
|
|
||
| def query_cache_clear(kb_dir: Path) -> None: | ||
| with index_db.open_db(kb_dir) as conn: | ||
| conn.execute("DELETE FROM query_embedding_cache") | ||
|
|
||
|
|
||
| def query_cache_stats(kb_dir: Path) -> dict[str, Any]: | ||
| with index_db.open_db(kb_dir) as conn: | ||
| row = conn.execute( | ||
| "SELECT COUNT(*), COALESCE(SUM(hit_count), 0), " | ||
| "COALESCE(MAX(hit_count), 0) FROM query_embedding_cache" | ||
| ).fetchone() | ||
| return {"entries": int(row[0]), "hits": int(row[1]), | ||
| "max_hits_per_entry": int(row[2])} | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| """Result-list fusion strategies for hybrid retrieval.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| Hit = tuple[str, str, str, float] | ||
| Hits = list[Hit] | ||
|
|
||
|
|
||
| def _key(h: Hit) -> tuple[str, str]: | ||
| return (h[0], h[1]) | ||
|
|
||
|
|
||
| def _coalesce_snippet(h: Hit, other: Hits) -> str: | ||
| if h[2]: | ||
| return h[2] | ||
| for o in other: | ||
| if _key(o) == _key(h) and o[2]: | ||
| return o[2] | ||
| return "" | ||
|
|
||
|
|
||
| def rrf_fuse(a: Hits, b: Hits, *, limit: int = 10, k: int = 60) -> Hits: | ||
| """Reciprocal Rank Fusion: score = sum(1 / (k + rank)) across lists.""" | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if limit < 0: | ||
| raise ValueError("limit must be >= 0") | ||
| if k < 0: | ||
| # rank starts at 1, so k must be > -1, but disallow negative | ||
| # entirely to match the canonical RRF definition (Cormack et al.) | ||
| # and to avoid division by zero when k == -rank. | ||
| raise ValueError("k must be >= 0") | ||
| scores: dict[tuple[str, str], float] = {} | ||
| for lst in (a, b): | ||
| for rank, h in enumerate(lst, start=1): | ||
| scores[_key(h)] = scores.get(_key(h), 0.0) + 1.0 / (k + rank) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| out: Hits = [] | ||
| seen: set[tuple[str, str]] = set() | ||
| for h in a + b: | ||
| if _key(h) in seen: | ||
| continue | ||
| seen.add(_key(h)) | ||
| out.append((h[0], h[1], _coalesce_snippet(h, a + b), scores[_key(h)])) | ||
| out.sort(key=lambda h: h[3], reverse=True) | ||
| return out[:limit] | ||
|
|
||
|
|
||
| def _minmax(xs: list[float]) -> list[float]: | ||
| if not xs: | ||
| return xs | ||
| lo, hi = min(xs), max(xs) | ||
| if hi - lo < 1e-9: | ||
| return [0.5] * len(xs) | ||
| return [(x - lo) / (hi - lo) for x in xs] | ||
|
|
||
|
|
||
| def weighted_fuse( | ||
| a: Hits, | ||
| b: Hits, | ||
| *, | ||
| w_a: float = 0.5, | ||
| w_b: float = 0.5, | ||
| limit: int = 10, | ||
| ) -> Hits: | ||
| """Min-max normalize each list, then score = w_a * a_score + w_b * b_score.""" | ||
| if limit < 0: | ||
| raise ValueError("limit must be >= 0") | ||
| a_norm = _minmax([h[3] for h in a]) | ||
| b_norm = _minmax([h[3] for h in b]) | ||
| a_score = {_key(h): a_norm[i] for i, h in enumerate(a)} | ||
| b_score = {_key(h): b_norm[i] for i, h in enumerate(b)} | ||
| merged: dict[tuple[str, str], float] = {} | ||
| for key in {*a_score, *b_score}: | ||
| merged[key] = w_a * a_score.get(key, 0.0) + w_b * b_score.get(key, 0.0) | ||
| out: Hits = [] | ||
| seen: set[tuple[str, str]] = set() | ||
| for h in a + b: | ||
| if _key(h) in seen: | ||
| continue | ||
| seen.add(_key(h)) | ||
| out.append((h[0], h[1], _coalesce_snippet(h, a + b), merged[_key(h)])) | ||
| out.sort(key=lambda h: h[3], reverse=True) | ||
| return out[:limit] | ||
|
|
||
|
|
||
| def normalized_fuse(a: Hits, b: Hits, *, limit: int = 10) -> Hits: | ||
| """Equal-weight weighted_fuse (0.5/0.5).""" | ||
| return weighted_fuse(a, b, w_a=0.5, w_b=0.5, limit=limit) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.