fix(api): avoid semantic count searches

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-05-04 14:21:27 -05:00
parent 3589e21278
commit fa4845119d
4 changed files with 92 additions and 25 deletions
@@ -14,7 +14,7 @@ from basic_memory.repository.semantic_errors import (
SemanticDependenciesMissingError,
SemanticSearchDisabledError,
)
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.schemas.search import SearchQuery, SearchResponse, SearchRetrievalMode
from basic_memory.deps import (
SearchServiceV2ExternalDep,
EntityServiceV2ExternalDep,
@@ -67,6 +67,7 @@ async def search(
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
):
offset = (page - 1) * page_size
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
try:
with logfire.span(
"api.search.search.execute_query",
@@ -76,10 +77,14 @@ async def search(
page=page,
page_size=page_size,
):
results, total = await asyncio.gather(
search_service.search(query, limit=page_size, offset=offset),
search_service.count(query),
)
if exact_count_available:
results, total = await asyncio.gather(
search_service.search(query, limit=page_size, offset=offset),
search_service.count(query),
)
else:
results = await search_service.search(query, limit=page_size + 1, offset=offset)
total = 0
except SemanticSearchDisabledError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SemanticDependenciesMissingError as exc:
@@ -94,7 +99,15 @@ async def search(
phase="paginate_results",
result_count=len(results),
):
has_more = offset + len(results) < total
if exact_count_available:
has_more = offset + len(results) < total
else:
# Trigger: semantic modes would need another vector/hybrid retrieval to count.
# Why: search requests should not pay for a second semantic pass.
# Outcome: preserve probe pagination for semantic search and leave total at 0.
has_more = len(results) > page_size
if has_more:
results = results[:page_size]
with logfire.span(
"api.search.search.hydrate_results",
@@ -260,25 +260,10 @@ class SearchRepositoryBase(ABC):
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
min_similarity: Optional[float] = None,
) -> int:
"""Count results for retrieval modes that cannot use a backend COUNT query."""
# Trigger: vector and hybrid modes rank after embedding lookup, filtering, and fusion.
# Why: that scoring pipeline is not expressible as a portable database COUNT query.
# Outcome: fetch the bounded candidate set and count the final in-memory results.
results = await self.search(
search_text=search_text,
permalink=permalink,
permalink_match=permalink_match,
title=title,
note_types=note_types,
after_date=after_date,
search_item_types=search_item_types,
metadata_filters=metadata_filters,
retrieval_mode=retrieval_mode,
min_similarity=min_similarity,
limit=VECTOR_FILTER_SCAN_LIMIT,
offset=0,
)
return len(results)
"""Count results when a backend-specific COUNT query is available."""
if retrieval_mode != SearchRetrievalMode.FTS:
raise ValueError("Exact counts are only supported for full-text search retrieval.")
raise NotImplementedError("Backend search repositories must implement full-text counts.")
# ------------------------------------------------------------------
# Abstract methods — semantic search (backend-specific DB operations)