Skip to content

fix(index): reset() and deindex() never clear the legacy embeddings table, leaking stale vectors forever #687

Description

@philluiz2323

What happened

src/vouch/index_db.py's reset() and deindex() never clear the
legacy embeddings table, even though it's the table
search_embeddings() actually queries for semantic search and
index_embedding() writes to.

# src/vouch/index_db.py: reset()
def reset(kb_dir: Path) -> None:
    """Drop everything; the rebuild caller re-populates.
    ...
    Leaving stale rows here means semantic search can return
    orphaned hits after a reindex.
    """
    with open_db(kb_dir) as conn:
        conn.executescript(
            "DELETE FROM claims_fts;"
            "DELETE FROM pages_fts;"
            "DELETE FROM entities_fts;"
            "DELETE FROM embedding_index;"
            "DELETE FROM query_embedding_cache;"
            "DELETE FROM embedding_dupes;"
            "DELETE FROM prov_edges;"
            "DELETE FROM index_meta WHERE key LIKE 'embedding_%';"
            "DELETE FROM index_meta WHERE key LIKE 'prov_%';"
        )

Note there's no DELETE FROM embeddings; here, despite the docstring
explicitly warning about exactly this failure mode ("Leaving stale rows
here means semantic search can return orphaned hits after a reindex").

# src/vouch/index_db.py: deindex()
def deindex(conn: sqlite3.Connection, *, kind: str, id: str) -> None:
    """Remove every derived index row for a deleted artifact.

    ... the embedding row for any kind (every put_* calls
    _embed_and_store, so an embedding may exist for a relation too) ...
    """
    ...
    conn.execute(
        "DELETE FROM embedding_index WHERE kind = ? AND id = ?", (kind, id)
    )
    conn.execute(
        "DELETE FROM prov_edges WHERE src_id = ? OR dst_id = ?", (id, id)
    )

deindex()'s own docstring says it removes "the embedding row for any
kind," but it only deletes from embedding_index — never from the
legacy embeddings table that search_embeddings() actually reads:

def search_embeddings(kb_dir: Path, query_vec: list[float], *, limit: int = 10):
    ...
    with open_db(kb_dir) as conn:
        rows = conn.execute("SELECT kind, id, vec FROM embeddings").fetchall()

What you expected

Both reset() and deindex() should clear the embeddings table the
same way they clear embedding_index, so a reindex or an artifact
deletion doesn't leave a permanently-orphaned vector behind.

Reproduction

import tempfile, pathlib
from vouch.storage import KBStore
from vouch import index_db

d = pathlib.Path(tempfile.mkdtemp())
store = KBStore.init(d)

with index_db.open_db(store.kb_dir) as conn:
    index_db.index_embedding(conn, kind="claim", id="ghost", vec=[0.1, 0.2, 0.3])
    conn.commit()

with index_db.open_db(store.kb_dir) as conn:
    index_db.deindex(conn, kind="claim", id="ghost")
    conn.commit()

with index_db.open_db(store.kb_dir) as conn:
    print(conn.execute("SELECT kind, id FROM embeddings").fetchall())
    # [('claim', 'ghost')]  -- still there after deindex

with index_db.open_db(store.kb_dir) as conn:
    index_db.index_embedding(conn, kind="claim", id="ghost2", vec=[0.4, 0.5, 0.6])
    conn.commit()
index_db.reset(store.kb_dir)
with index_db.open_db(store.kb_dir) as conn:
    print(conn.execute("SELECT kind, id FROM embeddings").fetchall())
    # [('claim', 'ghost'), ('claim', 'ghost2')]  -- both still there after reset

Concrete failure scenario

Delete any artifact that had an embedding. Its vector row in embeddings
is never removed, so it:

  1. Shows up as a semantic-search hit forever — even after running
    vouch index (a full reindex), since health.rebuild_index() calls
    reset() then only re-populates rows for artifacts that still exist
    via INSERT OR REPLACE; it can never remove a stale row.
  2. Permanently trips health.fsck's orphan_embedding finding, whose
    own code comment says "Both the legacy embeddings table and the
    newer embedding_index table are checked" — but nothing ever cleans
    the legacy one, so the finding can never actually be resolved by the
    documented remedy (reindexing).

Environment

  • vouch version: test branch @ current HEAD
  • Python version: 3.11+
  • OS: any
  • Host: any — affects semantic search and vouch fsck generally

.vouch/ state

Not required to reproduce — the repro above uses a fresh temp KB.

Anything else

This exact defect and fix were previously submitted as #543
(fix(index): clear the legacy embeddings table on reset and deindex)
but closed unmerged on 2026-07-29 purely for going stale against test
(a CHANGELOG.md conflict) — the maintainer's closing comment
explicitly said this wasn't a judgment on the change. Re-verified
independently against current test HEAD: the bug is still live in
both reset() and deindex().

Suggested fix: add "DELETE FROM embeddings;" to reset()'s
executescript, and add
conn.execute("DELETE FROM embeddings WHERE kind = ? AND id = ?", (kind, id))
in deindex() alongside the existing embedding_index delete.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions