Skip to content

Commit 1815370

Browse files
committed
fix(core): reuse retrieval for stable rerank pagination
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0159119 commit 1815370

9 files changed

Lines changed: 539 additions & 108 deletions

src/basic_memory/repository/search_repository_base.py

Lines changed: 108 additions & 95 deletions
Large diffs are not rendered by default.

src/basic_memory/repository/semantic_vector_index.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ class VectorMatch:
5353

5454
key: VectorKey
5555
similarity: float
56+
# Zero-based position before adapter-side filtering, when filtering consumes
57+
# top-k slots (sqlite-vec). Other adapters use their returned match order.
58+
candidate_rank: int | None = None
5659

5760

5861
@runtime_checkable

src/basic_memory/repository/sqlite_vec_index.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,25 +327,34 @@ async def search(
327327
query: Sequence[float],
328328
*,
329329
limit: int,
330+
stable_prefix: bool = False,
330331
) -> list[VectorMatch]:
331332
if not query or limit <= 0:
332333
return []
333334
validate_query_dimensions(self.scope, query)
334335
await self.initialize()
335336
vector_k = min(limit, SQLITE_VEC_MAX_K)
337+
# vec0's equal-distance membership changes with k. Reranking needs one
338+
# repeatable universe to reconstruct smaller prefixes without a second query.
339+
# Reuse the adapter's existing ceiling; only the requested slots survive SQL.
340+
query_k = str(SQLITE_VEC_MAX_K) if stable_prefix else ":vector_k"
336341
async with db.scoped_session(self._session_maker) as session:
337342
await self._ensure_loaded(session)
338343
result = await session.execute(
339344
text(
340345
"WITH vector_matches AS MATERIALIZED ("
341346
" SELECT rowid, distance, source_hash FROM search_vector_embeddings "
342-
" WHERE embedding MATCH :query AND k = :vector_k"
343-
") "
344-
"SELECT c.entity_id, c.chunk_key, vector_matches.distance "
345-
"FROM vector_matches "
347+
f" WHERE embedding MATCH :query AND k = {query_k}"
348+
"), ranked_matches AS MATERIALIZED ("
349+
" SELECT *, ROW_NUMBER() OVER (ORDER BY distance, rowid) - 1 AS candidate_rank"
350+
" FROM vector_matches) "
351+
"SELECT c.entity_id, c.chunk_key, vector_matches.distance, "
352+
"vector_matches.candidate_rank "
353+
"FROM ranked_matches AS vector_matches "
346354
"JOIN search_vector_chunks c ON c.id = vector_matches.rowid "
347355
"AND c.source_hash = vector_matches.source_hash "
348356
"WHERE c.project_id = :project_id "
357+
"AND vector_matches.candidate_rank < :vector_k "
349358
"AND c.vector_index = 'sqlite-vec' "
350359
"AND c.embedding_status = 'ready' "
351360
"AND c.embedding_model = :embedding_identity "
@@ -366,6 +375,7 @@ async def search(
366375
entity_id=int(row["entity_id"]),
367376
chunk_key=str(row["chunk_key"]),
368377
),
378+
candidate_rank=int(row["candidate_rank"]),
369379
similarity=max(
370380
0.0,
371381
min(1.0, 1.0 - (float(row["distance"]) ** 2) / 2.0),

test-int/semantic/test_semantic_coverage.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ async def record_vector_query(
211211

212212
assert growing_prefix_results
213213
assert reranker.calls == 2
214-
assert candidate_limits == [90, 80]
214+
assert candidate_limits == [90]
215215

216216
candidate_limits.clear()
217217
large_page_with_probe = await search_service.search(
@@ -226,7 +226,7 @@ async def record_vector_query(
226226

227227
assert len(large_page_with_probe) == 101
228228
assert reranker.calls == 3
229-
assert candidate_limits == [890, 80]
229+
assert candidate_limits == [890]
230230

231231
# A larger retrieval window must extend the same sequence rather than
232232
# reordering rows already exposed by an earlier deep page.
@@ -243,7 +243,7 @@ async def record_vector_query(
243243
)
244244

245245
assert len(stable_window) == 40
246-
assert candidate_limits == [280, 80]
246+
assert candidate_limits == [280]
247247

248248
candidate_limits.clear()
249249
third_page = await search_service.search(
@@ -256,7 +256,7 @@ async def record_vector_query(
256256
limit=10,
257257
offset=20,
258258
)
259-
assert candidate_limits == [180, 80]
259+
assert candidate_limits == [180]
260260

261261
candidate_limits.clear()
262262
fourth_page = await search_service.search(
@@ -269,7 +269,7 @@ async def record_vector_query(
269269
limit=10,
270270
offset=30,
271271
)
272-
assert candidate_limits == [280, 80]
272+
assert candidate_limits == [280]
273273

274274
assert [row.permalink for row in third_page] == [row.permalink for row in stable_window[20:30]]
275275
assert [row.permalink for row in fourth_page] == [row.permalink for row in stable_window[30:40]]

test-int/test_multi_project_search.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,11 @@ def record(_conn, _cursor, statement, _params, _context, _many):
193193

194194
@pytest.mark.asyncio
195195
@pytest.mark.parametrize("mode", list(SearchRetrievalMode))
196+
@pytest.mark.parametrize("reranker_enabled", [False, True])
196197
async def test_global_pagination_is_independent_of_page_size(
197-
corpus: Corpus, mode: SearchRetrievalMode
198+
corpus: Corpus, mode: SearchRetrievalMode, reranker_enabled: bool
198199
) -> None:
200+
corpus.config.reranker_enabled = reranker_enabled
199201
repo = corpus.repository([project.id for project in corpus.projects[:2]])
200202
query = SearchService.prepare_query(SearchQuery(text="nebula", retrieval_mode=mode))
201203
assert query is not None
@@ -209,6 +211,8 @@ async def test_global_pagination_is_independent_of_page_size(
209211
(r.project_id, r.type, r.id, r.score) for r in complete
210212
]
211213
assert await repo.search(query, offset=100) == []
214+
# This database-scoped reader never invokes the single-project rerank pipeline.
215+
assert corpus.provider.query_calls == (5 if mode != SearchRetrievalMode.FTS else 0)
212216

213217

214218
@pytest.mark.asyncio
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Real API/MCP pagination over the same single-pass semantic repository."""
2+
3+
import json
4+
5+
import pytest
6+
from fastapi import FastAPI
7+
from fastmcp import Client, FastMCP
8+
from httpx import AsyncClient
9+
from sqlalchemy import func, update
10+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
11+
12+
from basic_memory import db
13+
14+
from basic_memory.deps.services import get_search_service_v2_external
15+
from basic_memory.models import Project
16+
from basic_memory.services.search_service import SearchService
17+
from tests.repository.test_rerank_pipeline import (
18+
BackendSearchRepository,
19+
_FakeReranker,
20+
rerank_search_repository as rerank_search_repository,
21+
)
22+
from tests.repository.test_stable_rerank_pagination import (
23+
pagination_repository as pagination_repository,
24+
)
25+
26+
27+
@pytest.fixture
28+
def session_maker(
29+
engine_factory: tuple[AsyncEngine, async_sessionmaker[AsyncSession]],
30+
) -> async_sessionmaker[AsyncSession]:
31+
return engine_factory[1]
32+
33+
34+
@pytest.mark.asyncio
35+
@pytest.mark.parametrize("mode", ["vector", "hybrid"])
36+
async def test_api_and_mcp_keep_probe_and_page_boundaries(
37+
pagination_repository: BackendSearchRepository,
38+
search_service: SearchService,
39+
app: FastAPI,
40+
client: AsyncClient,
41+
test_project: Project,
42+
mcp_server: FastMCP,
43+
mode: str,
44+
) -> None:
45+
v2_project_url = f"/v2/projects/{test_project.external_id}"
46+
repo = pagination_repository
47+
repo._rerank_provider = _FakeReranker({"Note 00": 0.8, "Note 01": 0.9})
48+
async with db.scoped_session(repo.session_maker) as session:
49+
await session.execute(
50+
update(Project).where(Project.id == test_project.id).values(last_indexed_at=func.now())
51+
)
52+
await session.commit()
53+
search_service.repository = repo
54+
app.dependency_overrides[get_search_service_v2_external] = lambda: search_service
55+
query = {"text": "auth session token", "retrieval_mode": mode, "min_similarity": 0.5}
56+
response = await client.request(
57+
"QUERY", f"{v2_project_url}/search/", json=query, params={"page_size": 100}
58+
)
59+
assert response.status_code == 200, response.text
60+
expected = response.json()["results"]
61+
pages = []
62+
async with Client(mcp_server) as mcp:
63+
for page in range(1, (len(expected) + 4) // 5 + 2):
64+
result = await mcp.call_tool(
65+
"search_notes",
66+
{
67+
"project": test_project.name,
68+
"query": "auth session token",
69+
"search_type": mode,
70+
"min_similarity": 0.5,
71+
"page": page,
72+
"page_size": 5,
73+
"output_format": "json",
74+
},
75+
)
76+
payload = json.loads(result.content[0].text)
77+
assert payload["has_more"] == (page * 5 < len(expected))
78+
assert payload["total_is_exact"] is False
79+
pages.extend(payload["results"])
80+
assert [row["permalink"] for row in pages] == [row["permalink"] for row in expected]

tests/repository/test_rerank_pipeline.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ async def record_vector_query(
620620
)
621621

622622
assert [row.permalink for row in results] == ["specs/bravo", "specs/alpha"]
623-
assert candidate_limits == [18, 8]
623+
assert candidate_limits == [18]
624624

625625

626626
@pytest.mark.asyncio
@@ -795,7 +795,7 @@ async def record_vector_query(
795795

796796
assert growing_prefix_results
797797
assert reranker.calls == 2
798-
assert candidate_limits == [90, 80]
798+
assert candidate_limits == [90]
799799

800800

801801
@pytest.mark.asyncio

tests/repository/test_search_trace.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -847,7 +847,7 @@ async def test_fts_vector_hybrid_and_rerank_trace_variants(
847847
)
848848
assert isinstance(reranked_trace, VectorQueryTrace)
849849
assert reranked_trace.rerank is not None
850-
assert reranked_trace.rerank.stable_pool_refetched is True
850+
assert reranked_trace.rerank.stable_pool_refetched is False
851851
assert reranked_trace.rerank.entries[0].key == ("entity", 3)
852852
alpha_rerank = next(
853853
entry for entry in reranked_trace.rerank.entries if entry.key == ("entity", 1)

0 commit comments

Comments
 (0)