From a2af8e6f40d574141aae9d9ff84f2df6eacb1802 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Thu, 30 Jul 2026 14:22:23 -0700 Subject: [PATCH] fix(index): clear the legacy embeddings table on reset and deindex reset()'s docstring warns "leaving stale rows here means semantic search can return orphaned hits after a reindex" and deindex()'s docstring promises to remove "the embedding row for any kind," but neither ever touched the legacy embeddings table alongside embedding_index. every artifact deletion or full reindex leaked a stale vector row that never gets cleaned up. search_embeddings (plural, the reader of the legacy table) currently has no callers in src/, so this isn't live search pollution today - but health._check_orphan_embeddings explicitly checks both tables, so a leaked row permanently trips the orphan_embedding fsck warning with no way to clear it via the documented remedy (reindexing), and grows state.db unbounded over a kb's lifetime. add the missing DELETE FROM embeddings to reset()'s executescript, and the missing per-row delete to deindex(), matching the existing embedding_index handling in both. this exact defect and fix were previously submitted as #543 (fix(index): clear the legacy embeddings table on reset and deindex), but that PR was closed unmerged for going stale against a fast-moving test branch, not for anything wrong with the change; the maintainer's closing comment explicitly invited a fresh PR. Fixes #687 --- CHANGELOG.md | 7 +++++++ src/vouch/index_db.py | 4 ++++ tests/test_index_db_embeddings.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed440d72..77265a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,13 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **`reset()`/`deindex()` now clear the legacy `embeddings` table too** + (#543 reopened, root-caused): both functions' own docstrings promise to + remove every embedding row for a reindex or a deleted artifact, but + neither ever touched the legacy `embeddings` table alongside + `embedding_index` — a leaked row permanently tripped `fsck`'s + `orphan_embedding` warning with no way to clear it via reindexing, and + grew `state.db` unbounded over a KB's lifetime. - **`hub_client` ETag lookup is now case-insensitive** (#662): `_request` flattened `resp.headers` (case-insensitive by design) into a plain `dict`, so `pull()`'s `resp_headers.get("ETag")` silently returned diff --git a/src/vouch/index_db.py b/src/vouch/index_db.py index 38d904c0..dc450697 100644 --- a/src/vouch/index_db.py +++ b/src/vouch/index_db.py @@ -122,6 +122,7 @@ def reset(kb_dir: Path) -> None: "DELETE FROM claims_fts;" "DELETE FROM pages_fts;" "DELETE FROM entities_fts;" + "DELETE FROM embeddings;" "DELETE FROM embedding_index;" "DELETE FROM query_embedding_cache;" "DELETE FROM embedding_dupes;" @@ -202,6 +203,9 @@ def deindex(conn: sqlite3.Connection, *, kind: str, id: str) -> None: conn.execute("DELETE FROM pages_fts WHERE id = ?", (id,)) elif kind == "entity": conn.execute("DELETE FROM entities_fts WHERE id = ?", (id,)) + conn.execute( + "DELETE FROM embeddings WHERE kind = ? AND id = ?", (kind, id) + ) conn.execute( "DELETE FROM embedding_index WHERE kind = ? AND id = ?", (kind, id) ) diff --git a/tests/test_index_db_embeddings.py b/tests/test_index_db_embeddings.py index ad4da1c3..16f9b09c 100644 --- a/tests/test_index_db_embeddings.py +++ b/tests/test_index_db_embeddings.py @@ -210,6 +210,34 @@ def test_legacy_search_embeddings_ranks_by_cosine(store: KBStore) -> None: assert hits[0][1] == "c1" +def test_deindex_removes_the_legacy_embeddings_row(store: KBStore) -> None: + # deindex()'s own docstring promises to remove "the embedding row for + # any kind" but only cleared embedding_index, never the legacy + # `embeddings` table search_embeddings (plural) reads — a deleted + # artifact's vector leaked there forever. + with index_db.open_db(store.kb_dir) as conn: + index_db.index_embedding( + conn, kind="claim", id="ghost", vec=_vec("gone now").tolist() + ) + conn.commit() + index_db.deindex(conn, kind="claim", id="ghost") + conn.commit() + assert index_db.search_embeddings(store.kb_dir, _vec("gone now").tolist()) == [] + + +def test_reset_clears_the_legacy_embeddings_table(store: KBStore) -> None: + # reset()'s own docstring warns "leaving stale rows here means semantic + # search can return orphaned hits after a reindex" but never cleared + # the legacy `embeddings` table itself. + with index_db.open_db(store.kb_dir) as conn: + index_db.index_embedding( + conn, kind="claim", id="c1", vec=_vec("will be reset").tolist() + ) + conn.commit() + index_db.reset(store.kb_dir) + assert index_db.search_embeddings(store.kb_dir, _vec("will be reset").tolist()) == [] + + def test_legacy_search_embeddings_rejects_an_empty_query(store: KBStore) -> None: assert index_db.search_embeddings(store.kb_dir, []) == []