Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/vouch/index_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;"
Expand Down Expand Up @@ -190,18 +191,22 @@ 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,))
elif kind == "page":
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)
)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions tests/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading