Skip to content

Commit 70ec34c

Browse files
committed
fix sqlite vec reindex cleanup
Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 5a34a42 commit 70ec34c

3 files changed

Lines changed: 126 additions & 9 deletions

File tree

src/basic_memory/repository/sqlite_search_repository.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,25 @@ async def delete_project_vector_rows(self) -> None:
620620
)
621621
await session.commit()
622622

623+
async def drop_vector_tables(self) -> None:
624+
"""Drop SQLite vector tables on a sqlite-vec-enabled connection."""
625+
async with db.scoped_session(self.session_maker) as session:
626+
vector_sql_result = await session.execute(
627+
text(
628+
"SELECT sql FROM sqlite_master "
629+
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
630+
)
631+
)
632+
vector_sql = vector_sql_result.scalar()
633+
if vector_sql and "using vec0" in vector_sql.lower():
634+
await self._ensure_sqlite_vec_loaded(session)
635+
636+
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
637+
await session.execute(text("DROP TABLE IF EXISTS search_vector_chunks"))
638+
await session.execute(text("DROP TABLE IF EXISTS search_vector_index"))
639+
await session.commit()
640+
self._vector_tables_initialized = False
641+
623642
async def delete_stale_vector_rows(self) -> None:
624643
"""Delete vector rows whose source entities no longer exist."""
625644
await self._ensure_vector_tables()

src/basic_memory/services/search_service.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,23 @@ async def init_search_index(self):
126126

127127
async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
128128
"""Reindex all content from database."""
129+
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
129130

130131
logger.info("Starting full reindex")
131132
# Clear and recreate search index
132133
await self.repository.execute_query(text("DROP TABLE IF EXISTS search_index"), params={})
133-
await self.repository.execute_query(
134-
text("DROP TABLE IF EXISTS search_vector_embeddings"), params={}
135-
)
136-
await self.repository.execute_query(
137-
text("DROP TABLE IF EXISTS search_vector_chunks"), params={}
138-
)
139-
await self.repository.execute_query(
140-
text("DROP TABLE IF EXISTS search_vector_index"), params={}
141-
)
134+
if isinstance(self.repository, SQLiteSearchRepository):
135+
await self.repository.drop_vector_tables()
136+
else:
137+
await self.repository.execute_query(
138+
text("DROP TABLE IF EXISTS search_vector_embeddings"), params={}
139+
)
140+
await self.repository.execute_query(
141+
text("DROP TABLE IF EXISTS search_vector_chunks"), params={}
142+
)
143+
await self.repository.execute_query(
144+
text("DROP TABLE IF EXISTS search_vector_index"), params={}
145+
)
142146
await self.init_search_index()
143147

144148
# Reindex all entities

tests/services/test_semantic_search.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from unittest.mock import AsyncMock
66

77
import pytest
8+
from sqlalchemy import text
89

10+
from basic_memory import db
911
from basic_memory.repository import EntityRepository
1012
from basic_memory.repository.search_repository_base import VectorSyncBatchResult
1113
from basic_memory.repository.semantic_errors import (
@@ -256,6 +258,98 @@ async def test_reindex_vectors_respects_embed_opt_out(search_service, monkeypatc
256258
}
257259

258260

261+
@pytest.mark.asyncio
262+
async def test_reindex_vectors_purges_sqlite_vectors_before_sync(search_service, monkeypatch):
263+
"""Regression for #829: stale vec0 rows must be purged before batch sync."""
264+
repository = _sqlite_repo(search_service)
265+
repository._semantic_enabled = True
266+
calls: list[str] = []
267+
268+
monkeypatch.setattr(
269+
search_service.entity_repository,
270+
"find_all",
271+
AsyncMock(return_value=[SimpleNamespace(id=42, entity_metadata={})]),
272+
)
273+
274+
async def delete_stale_vector_rows():
275+
calls.append("purge")
276+
277+
async def sync_entity_vectors_batch(entity_ids, progress_callback=None):
278+
assert entity_ids == [42]
279+
assert progress_callback is None
280+
calls.append("sync")
281+
return VectorSyncBatchResult(entities_total=1, entities_synced=1, entities_failed=0)
282+
283+
monkeypatch.setattr(repository, "delete_stale_vector_rows", delete_stale_vector_rows)
284+
monkeypatch.setattr(search_service, "sync_entity_vectors_batch", sync_entity_vectors_batch)
285+
286+
stats = await search_service.reindex_vectors()
287+
288+
assert calls == ["purge", "sync"]
289+
assert stats == {
290+
"total_entities": 1,
291+
"embedded": 1,
292+
"skipped": 0,
293+
"errors": 0,
294+
}
295+
296+
297+
@pytest.mark.asyncio
298+
async def test_reindex_all_uses_sqlite_vec_aware_drop(search_service, monkeypatch):
299+
"""Full service reindex should not drop vec0 tables through a raw connection."""
300+
repository = _sqlite_repo(search_service)
301+
executed_sql: list[str] = []
302+
calls: list[str] = []
303+
304+
async def execute_query(query, params=None):
305+
executed_sql.append(str(query))
306+
307+
async def drop_vector_tables():
308+
calls.append("drop_vector_tables")
309+
310+
monkeypatch.setattr(repository, "execute_query", execute_query)
311+
monkeypatch.setattr(repository, "drop_vector_tables", drop_vector_tables)
312+
monkeypatch.setattr(search_service, "init_search_index", AsyncMock())
313+
monkeypatch.setattr(search_service.entity_repository, "find_all", AsyncMock(return_value=[]))
314+
315+
await search_service.reindex_all()
316+
317+
assert calls == ["drop_vector_tables"]
318+
assert all("search_vector_embeddings" not in sql for sql in executed_sql)
319+
320+
321+
@pytest.mark.asyncio
322+
async def test_drop_vector_tables_skips_sqlite_vec_load_for_plain_table(
323+
search_service,
324+
monkeypatch,
325+
):
326+
"""Plain compatibility tables should not require sqlite-vec to be loaded."""
327+
repository = _sqlite_repo(search_service)
328+
await repository.drop_vector_tables()
329+
330+
async with db.scoped_session(repository.session_maker) as session:
331+
await session.execute(
332+
text("CREATE TABLE search_vector_embeddings (rowid INTEGER PRIMARY KEY)")
333+
)
334+
await session.commit()
335+
336+
async def fail_if_loaded(_session):
337+
raise AssertionError("plain tables should not load sqlite-vec")
338+
339+
monkeypatch.setattr(repository, "_ensure_sqlite_vec_loaded", fail_if_loaded)
340+
341+
await repository.drop_vector_tables()
342+
343+
async with db.scoped_session(repository.session_maker) as session:
344+
result = await session.execute(
345+
text(
346+
"SELECT name FROM sqlite_master "
347+
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
348+
)
349+
)
350+
assert result.scalar() is None
351+
352+
259353
@pytest.mark.asyncio
260354
async def test_reindex_vectors_force_full_clears_project_vectors_before_resync(
261355
search_service, monkeypatch

0 commit comments

Comments
 (0)