diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c7cc8c..7ed8832f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,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. - **`recall`/`capture` no longer crash on malformed numeric config values** (#488 reopened, root-caused): both `load_config()` functions passed `max_chars`/`min_observations`/`dedup_window_seconds` straight 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, []) == []