feat(api): include search result totals (#791)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-05-04 14:57:04 -05:00
committed by GitHub
parent a661e924df
commit 09a4b09436
12 changed files with 652 additions and 147 deletions
@@ -4,6 +4,8 @@ This router uses external_id UUIDs for stable, API-friendly routing.
V1 uses string-based project names which are less efficient and less stable.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Path
import logfire
@@ -12,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,
@@ -65,7 +67,7 @@ async def search(
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
):
offset = (page - 1) * page_size
fetch_limit = page_size + 1
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
try:
with logfire.span(
"api.search.search.execute_query",
@@ -75,7 +77,14 @@ async def search(
page=page,
page_size=page_size,
):
results = await search_service.search(query, limit=fetch_limit, offset=offset)
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:
@@ -90,9 +99,15 @@ async def search(
phase="paginate_results",
result_count=len(results),
):
has_more = len(results) > page_size
if has_more:
results = results[:page_size]
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",
@@ -113,6 +128,7 @@ async def search(
results=search_results,
current_page=page,
page_size=page_size,
total=total,
has_more=has_more,
)