Skip to content

Commit 271c883

Browse files
phernandezclaude
andauthored
fix(core): load sqlite-vec for embedding-status query (#901)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 816ee85 commit 271c883

4 files changed

Lines changed: 220 additions & 21 deletions

File tree

src/basic_memory/repository/project_repository.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
from loguru import logger
8-
from sqlalchemy import inspect as sa_inspect, select, text
8+
from sqlalchemy import Executable, inspect as sa_inspect, select, text
99
from sqlalchemy.exc import NoResultFound, OperationalError
1010
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
1111

@@ -258,6 +258,28 @@ async def delete(self, entity_id: int) -> bool:
258258
logger.debug(f"Deleted Project and search rows for project_id: {entity_id}")
259259
return True
260260

261+
async def scalar_vec_query(
262+
self, query: Executable, params: Optional[dict] = None
263+
) -> Optional[int]:
264+
"""Run a scalar COUNT query that reads the sqlite-vec vec0 table.
265+
266+
Extension loading is per-connection, so the bare pooled session used by
267+
`execute_query` cannot read vec0 virtual tables — SQLite raises
268+
"no such module: vec0". This helper loads sqlite-vec on the session it
269+
opens before running the query, reusing the same loader as project delete.
270+
271+
Returns None when sqlite-vec cannot be loaded on this Python build, so
272+
callers can fall back to the genuinely-missing-dependency path.
273+
"""
274+
async with db.scoped_session(self.session_maker) as session:
275+
# Trigger: query reads the vec0-backed search_vector_embeddings table.
276+
# Why: vec0 modules are only visible on a connection that loaded sqlite-vec.
277+
# Outcome: load the extension here, or signal absence so the caller degrades.
278+
if not await _load_sqlite_vec_on_session(session):
279+
return None
280+
result = await session.execute(query, params)
281+
return result.scalar()
282+
261283
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
262284
"""Update project path.
263285

src/basic_memory/services/project_service.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,10 +1047,27 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
10471047
f"WHERE c.project_id = :project_id {chunk_entity_exists}"
10481048
)
10491049

1050-
embeddings_result = await self.repository.execute_query(
1051-
embeddings_sql, {"project_id": project_id}
1052-
)
1053-
total_embeddings = embeddings_result.scalar() or 0
1050+
# The embeddings/orphan JOINs read search_vector_embeddings, a vec0
1051+
# virtual table. On SQLite that table is only visible on a connection
1052+
# that loaded sqlite-vec, so route these through scalar_vec_query which
1053+
# loads the extension first. Postgres has no per-connection extension
1054+
# and uses the bare pooled session.
1055+
async def _vec_scalar(vec_sql) -> int:
1056+
if is_postgres:
1057+
result = await self.repository.execute_query(
1058+
vec_sql, {"project_id": project_id}
1059+
)
1060+
return result.scalar() or 0
1061+
count = await self.repository.scalar_vec_query(vec_sql, {"project_id": project_id})
1062+
# Trigger: sqlite-vec genuinely can't load on this Python build.
1063+
# Why: without the extension the vec0 JOIN can't run at all.
1064+
# Outcome: raise the canonical error so the except block emits the
1065+
# true "sqlite-vec unavailable" fallback instead of reporting 0.
1066+
if count is None:
1067+
raise SAOperationalError(str(vec_sql), {}, Exception("no such module: vec0"))
1068+
return count
1069+
1070+
total_embeddings = await _vec_scalar(embeddings_sql)
10541071

10551072
# Orphaned chunks (chunks without embeddings — indicates interrupted indexing)
10561073
if is_postgres:
@@ -1065,11 +1082,7 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
10651082
"LEFT JOIN search_vector_embeddings e ON e.rowid = c.id "
10661083
f"WHERE c.project_id = :project_id AND e.rowid IS NULL {chunk_entity_exists}"
10671084
)
1068-
1069-
orphan_result = await self.repository.execute_query(
1070-
orphan_sql, {"project_id": project_id}
1071-
)
1072-
orphaned_chunks = orphan_result.scalar() or 0
1085+
orphaned_chunks = await _vec_scalar(orphan_sql)
10731086
except SAOperationalError as exc:
10741087
# Trigger: sqlite_master can list vec0 virtual tables even when sqlite-vec
10751088
# is not loaded in the current Python runtime.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Integration regression test for get_embedding_status against a real vec0 table.
2+
3+
Regression for #658: after a successful `bm reindex --embeddings`, `bm project info`
4+
still reported "sqlite-vec is unavailable", "Indexed 0/N", and "Chunks 0", and
5+
recommended an unnecessary reindex.
6+
7+
Root cause: get_embedding_status() ran the vec0 JOIN count queries on a bare pooled
8+
ProjectRepository session that never loaded the sqlite-vec extension, so SQLite raised
9+
"no such module: vec0", which the except block mis-reported as "unavailable".
10+
11+
This test exercises the real failure path: it builds a REAL vec0 virtual table, writes a
12+
real embedding into it via the search repository, then queries get_embedding_status through
13+
a ProjectRepository session that did NOT pre-load the extension (mirroring the bug). The
14+
healthy unit test substitutes a plain regular table for vec0 and therefore does not cover
15+
this path.
16+
"""
17+
18+
import os
19+
import sqlite3
20+
21+
import pytest
22+
from sqlalchemy import text
23+
24+
from basic_memory import db
25+
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
26+
from basic_memory.repository.entity_repository import EntityRepository
27+
from basic_memory.repository.project_repository import ProjectRepository
28+
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
29+
from basic_memory.services.project_service import ProjectService
30+
31+
32+
def _is_postgres() -> bool:
33+
return os.environ.get("BASIC_MEMORY_TEST_POSTGRES", "").lower() in ("1", "true", "yes")
34+
35+
36+
def _unit_vector(dimensions: int) -> list[float]:
37+
"""Return a deterministic unit-norm vector for the vec0 embedding column."""
38+
# vec0 stores float[dimensions]; the actual values don't matter for the count
39+
# queries, but using a normalized vector keeps the row well-formed.
40+
vec = [0.0] * dimensions
41+
vec[0] = 1.0
42+
return vec
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_embedding_status_reads_real_vec0_table(engine_factory, test_project, config_manager):
47+
"""get_embedding_status must report a populated real vec0 table as healthy.
48+
49+
Before the fix, the vec0 JOIN ran on a session without sqlite-vec loaded and
50+
raised "no such module: vec0", which the except block mapped to
51+
vector_tables_exist=False + reindex_recommended=True.
52+
"""
53+
# Trigger: Postgres test matrix executes the same suite.
54+
# Why: vec0 + per-connection sqlite-vec loading is SQLite-specific.
55+
# Outcome: keep the regression on the backend that can actually hit this path.
56+
if _is_postgres():
57+
pytest.skip("Real vec0 table handling is SQLite-specific.")
58+
59+
# Trigger: Python build without SQLite extension loading (#711 — python.org
60+
# macOS / some Windows interpreters lack enable_load_extension).
61+
# Why: this test creates a REAL vec0 virtual table during setup, which is
62+
# impossible without loading the sqlite-vec extension.
63+
# Outcome: skip the regression as an environment-capability gap; the codebase
64+
# already degrades gracefully in that scenario (covered by the unit test).
65+
_probe = sqlite3.connect(":memory:")
66+
if not hasattr(_probe, "enable_load_extension"):
67+
_probe.close()
68+
pytest.skip(
69+
"Python build does not support SQLite extension loading — "
70+
"cannot create real vec0 tables"
71+
)
72+
_probe.close()
73+
74+
_engine, session_maker = engine_factory
75+
project_id = test_project.id
76+
77+
# --- Build a REAL vec0 table via the search repository ---
78+
# Semantic enabled with a fastembed provider so _ensure_vector_tables creates
79+
# the vec0-backed search_vector_embeddings table (float[384]).
80+
app_config = BasicMemoryConfig(
81+
env="test",
82+
database_backend=DatabaseBackend.SQLITE,
83+
semantic_search_enabled=True,
84+
)
85+
search_repo = SQLiteSearchRepository(
86+
session_maker,
87+
project_id=project_id,
88+
app_config=app_config,
89+
)
90+
await search_repo._ensure_vector_tables()
91+
dimensions = search_repo._vector_dimensions
92+
93+
# --- Seed a real entity + search_index row so counts are non-zero ---
94+
# Use the repository so model-level defaults (external_id) are applied.
95+
entity_repo = EntityRepository(session_maker, project_id=project_id)
96+
entity = await entity_repo.create(
97+
{
98+
"title": "Vec Note",
99+
"note_type": "note",
100+
"content_type": "text/markdown",
101+
"project_id": project_id,
102+
"permalink": "vec-note",
103+
"file_path": "vec-note.md",
104+
}
105+
)
106+
entity_id = entity.id
107+
108+
async with db.scoped_session(session_maker) as session:
109+
await session.execute(
110+
text(
111+
"INSERT INTO search_index "
112+
"(id, entity_id, project_id, type, title, permalink, content_stems, "
113+
"content_snippet, file_path, metadata) "
114+
"VALUES (:id, :eid, :pid, 'entity', 'Vec Note', 'vec-note', "
115+
"'vec content', 'vec snippet', 'vec-note.md', '{}')"
116+
),
117+
{"id": entity_id, "eid": entity_id, "pid": project_id},
118+
)
119+
await session.commit()
120+
121+
# --- Insert a chunk + a real embedding into the vec0 table ---
122+
# _write_embeddings writes the embedding into the vec0 virtual table keyed by
123+
# rowid == chunk id, exactly like the reindex path.
124+
async with db.scoped_session(session_maker) as session:
125+
await search_repo._ensure_sqlite_vec_loaded(session)
126+
chunk_result = await session.execute(
127+
text(
128+
"INSERT INTO search_vector_chunks "
129+
"(entity_id, project_id, chunk_key, chunk_text, source_hash, "
130+
"entity_fingerprint, embedding_model) "
131+
"VALUES (:eid, :pid, 'chunk-1', 'vec content', 'hash', "
132+
"'fp-hash', 'bge-small-en-v1.5') "
133+
"RETURNING id"
134+
),
135+
{"eid": entity_id, "pid": project_id},
136+
)
137+
chunk_id = chunk_result.scalar_one()
138+
139+
await search_repo._write_embeddings(
140+
session,
141+
[(chunk_id, "vec content")],
142+
[_unit_vector(dimensions)],
143+
)
144+
await session.commit()
145+
146+
# Evict the vec-loaded connection from the pool. sqlite-vec is loaded
147+
# per-connection, so disposing forces get_embedding_status onto a brand-new
148+
# connection that never loaded the extension — exactly the #658 bug condition
149+
# (e.g. a fresh `bm project info` process after `bm reindex --embeddings`).
150+
await _engine.dispose()
151+
152+
# --- Query status through a fresh ProjectRepository (no extension preloaded) ---
153+
project_repository = ProjectRepository(session_maker)
154+
project_service = ProjectService(project_repository)
155+
156+
status = await project_service.get_embedding_status(project_id)
157+
158+
assert status.semantic_search_enabled is True
159+
# The vec0 JOIN must succeed, so the table is reported as present and healthy.
160+
assert status.vector_tables_exist is True
161+
assert status.reindex_recommended is False
162+
assert status.reindex_reason is None
163+
# Counts must reflect the real data, not the false "0" from the unavailable path.
164+
assert status.total_indexed_entities == 1
165+
assert status.total_chunks == 1
166+
assert status.total_entities_with_chunks == 1
167+
assert status.total_embeddings == 1
168+
assert status.orphaned_chunks == 0

tests/services/test_project_service_embedding_status.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import pytest
77
from sqlalchemy import text
8-
from sqlalchemy.exc import OperationalError as SAOperationalError
98

109
from basic_memory.schemas.project_info import EmbeddingStatus
1110
from basic_memory.services.project_service import ProjectService
@@ -153,20 +152,17 @@ async def test_embedding_status_orphaned_chunks(
153152
async def test_embedding_status_handles_sqlite_vec_unavailable(
154153
project_service: ProjectService, test_graph, test_project
155154
):
156-
"""Unreadable vec0 tables should degrade to unavailable status instead of crashing."""
155+
"""When sqlite-vec can't load at all, degrade to unavailable status instead of crashing."""
157156
# Trigger: Postgres test matrix executes the same unit suite.
158157
# Why: sqlite-vec loading failures are specific to SQLite virtual tables, not Postgres joins.
159158
# Outcome: keep the regression focused on the backend that can actually hit this path.
160159
if _is_postgres():
161160
pytest.skip("sqlite-vec unavailable handling is SQLite-specific.")
162161

163-
original_execute_query = project_service.repository.execute_query
164-
165-
async def _execute_query_with_vec0_failure(query, params):
166-
query_text = str(query)
167-
if "JOIN search_vector_embeddings" in query_text:
168-
raise SAOperationalError(query_text, params, Exception("no such module: vec0"))
169-
return await original_execute_query(query, params)
162+
# scalar_vec_query returns None when the extension can't be loaded on this
163+
# Python build (e.g. the python.org macOS interpreter). Simulate that here.
164+
async def _vec_query_unavailable(query, params=None):
165+
return None
170166

171167
with patch.object(
172168
type(project_service),
@@ -177,8 +173,8 @@ async def _execute_query_with_vec0_failure(query, params):
177173
):
178174
with patch.object(
179175
project_service.repository,
180-
"execute_query",
181-
side_effect=_execute_query_with_vec0_failure,
176+
"scalar_vec_query",
177+
side_effect=_vec_query_unavailable,
182178
):
183179
status = await project_service.get_embedding_status(test_project.id)
184180

0 commit comments

Comments
 (0)