diff --git a/CHANGELOG.md b/CHANGELOG.md index 0704c6a4..f5ffd780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,14 @@ All notable changes to vouch are documented here. Format follows per-prompt block, the session banner, `vouch status` and the opt-in question all say so rather than calling it "this repo's" knowledge. +### Fixed +- `vouch index` and artifact deletion now clear the legacy `embeddings` + vector table. `index_db.reset()` promised to drop every derived table + but skipped it, and `deindex()` only cleared `embedding_index` — so a + deleted artifact's vector kept returning as a semantic-search hit, and + fsck's `orphan_embedding` warning was permanent: the natural remedy + (reindexing) could never clear it. + ## [1.5.0] — 2026-07-20 ### Added diff --git a/src/vouch/index_db.py b/src/vouch/index_db.py index 38d904c0..30086927 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;" @@ -190,11 +191,12 @@ def deindex(conn: sqlite3.Connection, *, kind: str, id: str) -> None: """Remove every derived index row for a deleted artifact. FTS row for claim/page/entity (relations have no FTS table); the - embedding row for any kind (every put_* calls _embed_and_store, so an - embedding may exist for a relation too); and any provenance edge that - touches the id. prov_edges is otherwise rebuildable via - `kb.provenance_rebuild` — this keeps state.db consistent without a - full rebuild. + embedding rows for any kind — both the legacy `embeddings` vector + table that `search_embeddings` scans and the newer `embedding_index` + (every put_* calls _embed_and_store, so an embedding may exist for a + relation too); and any provenance edge that touches the id. + prov_edges is otherwise rebuildable via `kb.provenance_rebuild` — + this keeps state.db consistent without a full rebuild. """ if kind == "claim": conn.execute("DELETE FROM claims_fts WHERE id = ?", (id,)) @@ -202,6 +204,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_health.py b/tests/test_health.py index 26b15a6b..c99a6f0a 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -356,6 +356,25 @@ def test_fsck_orphan_embedding(store: KBStore) -> None: assert "orphan_embedding" in codes +def test_fsck_orphan_embedding_cleared_by_reindex(store: KBStore) -> None: + """A reindex is the natural remedy for an orphan embedding, but + reset() skipped the legacy `embeddings` table, so the ghost vector + survived `vouch index` and the fsck warning could never be cleared.""" + src = store.put_source(b"e") + c = Claim(id="real", text="t", evidence=[src.id]) + store.put_claim(c) + with index_db.open_db(store.kb_dir) as conn: + index_db.index_embedding(conn, kind="claim", id="ghost", vec=[0.1, 0.2]) + assert "orphan_embedding" in {f.code for f in health.fsck(store).findings} + + health.rebuild_index(store) + + report = health.fsck(store) + assert "orphan_embedding" not in {f.code for f in report.findings}, ( + "reindex did not clear the orphaned legacy embedding row" + ) + + def test_fsck_surfaces_invalid_claim_yaml_without_crashing( store: KBStore, ) -> None: diff --git a/tests/test_index.py b/tests/test_index.py index e962dea0..cc70a778 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -47,3 +47,30 @@ def test_index_via_approve(store: KBStore) -> None: approve(store, pr.id, approved_by="u") hits = index_db.search(store.kb_dir, "indexed") assert any(k == "claim" for k, *_ in hits) + + +def test_reset_clears_legacy_embeddings_table(store: KBStore) -> None: + """reset() promises to clear every derived table, embedding vectors + included — but the legacy `embeddings` table (the one + `search_embeddings` scans) was left out, so a deleted artifact's + vector survived a full reindex and kept coming back as a semantic + hit: exactly the orphaned-hits case the docstring warns about.""" + with index_db.open_db(store.kb_dir) as conn: + index_db.index_embedding(conn, kind="claim", id="ghost", vec=[0.1, 0.2]) + + index_db.reset(store.kb_dir) + + with index_db.open_db(store.kb_dir) as conn: + count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0] + assert count == 0 + + +def test_deindex_removes_legacy_embedding_row(store: KBStore) -> None: + """deindex() documents removing the embedding rows for a deleted + artifact; it cleared `embedding_index` but left the legacy + `embeddings` row behind.""" + with index_db.open_db(store.kb_dir) as conn: + index_db.index_embedding(conn, kind="claim", id="c1", vec=[0.1, 0.2]) + index_db.deindex(conn, kind="claim", id="c1") + count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0] + assert count == 0