Skip to content

Commit b6369d3

Browse files
phernandezclaude
andcommitted
fix: cap sqlite-vec knn k parameter at 4096 limit
sqlite-vec enforces k <= 4096 for nearest-neighbor queries. Projects with >4096 vector chunks would crash all vector/hybrid search because candidate_limit = max(100, (limit + offset) * 10) exceeded this hard limit. Clamp the knn k in _run_vector_query while keeping the outer SQL LIMIT unclamped. Only affects SQLite — pgvector has no such constraint. Fixes #604 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent db6d0dc commit b6369d3

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

src/basic_memory/repository/sqlite_search_repository.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,12 +438,17 @@ async def _prepare_vector_session(self, session: AsyncSession) -> None:
438438
"""Load sqlite-vec extension for the session."""
439439
await self._ensure_sqlite_vec_loaded(session)
440440

441+
# sqlite-vec hard limit for knn k parameter
442+
SQLITE_VEC_MAX_K = 4096
443+
441444
async def _run_vector_query(
442445
self,
443446
session: AsyncSession,
444447
query_embedding: list[float],
445448
candidate_limit: int,
446449
) -> list[dict]:
450+
# Constraint: sqlite-vec enforces k <= 4096 for knn queries
451+
vector_k = min(candidate_limit, self.SQLITE_VEC_MAX_K)
447452
query_embedding_json = json.dumps(query_embedding)
448453
vector_result = await session.execute(
449454
text(
@@ -458,12 +463,13 @@ async def _run_vector_query(
458463
"JOIN search_vector_chunks c ON c.id = vector_matches.rowid "
459464
"WHERE c.project_id = :project_id "
460465
"ORDER BY best_distance ASC "
461-
"LIMIT :vector_k"
466+
"LIMIT :candidate_limit"
462467
),
463468
{
464469
"query_embedding": query_embedding_json,
465470
"project_id": self.project_id,
466-
"vector_k": candidate_limit,
471+
"vector_k": vector_k,
472+
"candidate_limit": candidate_limit,
467473
},
468474
)
469475
return [dict(row) for row in vector_result.mappings().all()]

tests/repository/test_sqlite_vector_search_repository.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""SQLite sqlite-vec search repository tests."""
22

3+
import json
34
from datetime import datetime, timezone
5+
from unittest.mock import AsyncMock, MagicMock
46

57
import pytest
68
from sqlalchemy import text
@@ -264,3 +266,52 @@ async def test_sqlite_hybrid_search_combines_fts_and_vector(search_repository):
264266

265267
assert results
266268
assert any(result.permalink == "specs/search-index" for result in results)
269+
270+
271+
@pytest.mark.asyncio
272+
async def test_run_vector_query_caps_k_at_sqlite_vec_limit(search_repository):
273+
"""_run_vector_query must cap the knn k param at SQLITE_VEC_MAX_K (4096).
274+
275+
sqlite-vec raises OperationalError when k > 4096. The candidate_limit
276+
passed from the base class can exceed this for large projects, so
277+
_run_vector_query clamps k while keeping the outer LIMIT unclamped.
278+
"""
279+
if not isinstance(search_repository, SQLiteSearchRepository):
280+
pytest.skip("sqlite-vec k limit is SQLite-specific.")
281+
282+
_enable_semantic(search_repository)
283+
await search_repository.init_search_index()
284+
285+
# Track the parameters passed to session.execute
286+
captured_params: list[dict] = []
287+
original_execute = None
288+
289+
async def capturing_execute(stmt, params=None):
290+
if params and "vector_k" in params:
291+
captured_params.append(dict(params))
292+
# Return empty result set
293+
mock_result = MagicMock()
294+
mock_result.mappings.return_value.all.return_value = []
295+
return mock_result
296+
297+
async with db.scoped_session(search_repository.session_maker) as session:
298+
await search_repository._prepare_vector_session(session)
299+
original_execute = session.execute
300+
session.execute = capturing_execute
301+
302+
query_embedding = [0.1] * search_repository._vector_dimensions
303+
304+
# candidate_limit exceeds sqlite-vec limit
305+
await search_repository._run_vector_query(session, query_embedding, 10000)
306+
307+
assert len(captured_params) == 1
308+
assert captured_params[0]["vector_k"] == SQLiteSearchRepository.SQLITE_VEC_MAX_K
309+
assert captured_params[0]["candidate_limit"] == 10000
310+
311+
# candidate_limit within limit should pass through unchanged
312+
captured_params.clear()
313+
await search_repository._run_vector_query(session, query_embedding, 500)
314+
315+
assert len(captured_params) == 1
316+
assert captured_params[0]["vector_k"] == 500
317+
assert captured_params[0]["candidate_limit"] == 500

0 commit comments

Comments
 (0)