Skip to content

Commit 09a4b09

Browse files
authored
feat(api): include search result totals (#791)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a661e92 commit 09a4b09

12 files changed

Lines changed: 653 additions & 148 deletions

src/basic_memory/api/v2/routers/search_router.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
V1 uses string-based project names which are less efficient and less stable.
55
"""
66

7+
import asyncio
8+
79
from fastapi import APIRouter, HTTPException, Path
810

911
import logfire
@@ -12,7 +14,7 @@
1214
SemanticDependenciesMissingError,
1315
SemanticSearchDisabledError,
1416
)
15-
from basic_memory.schemas.search import SearchQuery, SearchResponse
17+
from basic_memory.schemas.search import SearchQuery, SearchResponse, SearchRetrievalMode
1618
from basic_memory.deps import (
1719
SearchServiceV2ExternalDep,
1820
EntityServiceV2ExternalDep,
@@ -65,7 +67,7 @@ async def search(
6567
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
6668
):
6769
offset = (page - 1) * page_size
68-
fetch_limit = page_size + 1
70+
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
6971
try:
7072
with logfire.span(
7173
"api.search.search.execute_query",
@@ -75,7 +77,14 @@ async def search(
7577
page=page,
7678
page_size=page_size,
7779
):
78-
results = await search_service.search(query, limit=fetch_limit, offset=offset)
80+
if exact_count_available:
81+
results, total = await asyncio.gather(
82+
search_service.search(query, limit=page_size, offset=offset),
83+
search_service.count(query),
84+
)
85+
else:
86+
results = await search_service.search(query, limit=page_size + 1, offset=offset)
87+
total = 0
7988
except SemanticSearchDisabledError as exc:
8089
raise HTTPException(status_code=400, detail=str(exc)) from exc
8190
except SemanticDependenciesMissingError as exc:
@@ -90,9 +99,15 @@ async def search(
9099
phase="paginate_results",
91100
result_count=len(results),
92101
):
93-
has_more = len(results) > page_size
94-
if has_more:
95-
results = results[:page_size]
102+
if exact_count_available:
103+
has_more = offset + len(results) < total
104+
else:
105+
# Trigger: semantic modes would need another vector/hybrid retrieval to count.
106+
# Why: search requests should not pay for a second semantic pass.
107+
# Outcome: preserve probe pagination for semantic search and leave total at 0.
108+
has_more = len(results) > page_size
109+
if has_more:
110+
results = results[:page_size]
96111

97112
with logfire.span(
98113
"api.search.search.hydrate_results",
@@ -113,6 +128,7 @@ async def search(
113128
results=search_results,
114129
current_page=page,
115130
page_size=page_size,
131+
total=total,
116132
has_more=has_more,
117133
)
118134

src/basic_memory/repository/postgres_search_repository.py

Lines changed: 129 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,17 @@ async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> Non
686686
# FTS search (Postgres-specific)
687687
# ------------------------------------------------------------------
688688

689-
async def search(
689+
@staticmethod
690+
def _is_tsquery_syntax_error(exc: Exception) -> bool:
691+
msg = str(exc).lower()
692+
return (
693+
"syntax error in tsquery" in msg
694+
or "invalid input syntax for type tsquery" in msg
695+
or "no operand in tsquery" in msg
696+
or "no operator in tsquery" in msg
697+
)
698+
699+
async def _build_fts_query_parts(
690700
self,
691701
search_text: Optional[str] = None,
692702
permalink: Optional[str] = None,
@@ -696,31 +706,8 @@ async def search(
696706
after_date: Optional[datetime] = None,
697707
search_item_types: Optional[List[SearchItemType]] = None,
698708
metadata_filters: Optional[dict] = None,
699-
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
700-
min_similarity: Optional[float] = None,
701-
limit: int = 10,
702-
offset: int = 0,
703-
) -> List[SearchIndexRow]:
704-
"""Search across all indexed content using PostgreSQL tsvector."""
705-
# --- Dispatch vector / hybrid modes (shared logic) ---
706-
dispatched = await self._dispatch_retrieval_mode(
707-
search_text=search_text,
708-
permalink=permalink,
709-
permalink_match=permalink_match,
710-
title=title,
711-
note_types=note_types,
712-
after_date=after_date,
713-
search_item_types=search_item_types,
714-
metadata_filters=metadata_filters,
715-
retrieval_mode=retrieval_mode,
716-
min_similarity=min_similarity,
717-
limit=limit,
718-
offset=offset,
719-
)
720-
if dispatched is not None:
721-
return dispatched
722-
723-
# --- FTS mode (Postgres-specific) ---
709+
) -> tuple[str, str, dict, str, str]:
710+
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
724711
conditions = []
725712
params = {}
726713
order_by_clause = ""
@@ -868,10 +855,6 @@ async def search(
868855
params["project_id"] = self.project_id
869856
conditions.append("search_index.project_id = :project_id")
870857

871-
# set limit and offset
872-
params["limit"] = limit
873-
params["offset"] = offset
874-
875858
# Build WHERE clause
876859
where_clause = " AND ".join(conditions) if conditions else "1=1"
877860

@@ -884,6 +867,64 @@ async def search(
884867
else:
885868
score_expr = "0"
886869

870+
return from_clause, where_clause, params, order_by_clause, score_expr
871+
872+
async def search(
873+
self,
874+
search_text: Optional[str] = None,
875+
permalink: Optional[str] = None,
876+
permalink_match: Optional[str] = None,
877+
title: Optional[str] = None,
878+
note_types: Optional[List[str]] = None,
879+
after_date: Optional[datetime] = None,
880+
search_item_types: Optional[List[SearchItemType]] = None,
881+
metadata_filters: Optional[dict] = None,
882+
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
883+
min_similarity: Optional[float] = None,
884+
limit: int = 10,
885+
offset: int = 0,
886+
) -> List[SearchIndexRow]:
887+
"""Search across all indexed content using PostgreSQL tsvector."""
888+
# --- Dispatch vector / hybrid modes (shared logic) ---
889+
dispatched = await self._dispatch_retrieval_mode(
890+
search_text=search_text,
891+
permalink=permalink,
892+
permalink_match=permalink_match,
893+
title=title,
894+
note_types=note_types,
895+
after_date=after_date,
896+
search_item_types=search_item_types,
897+
metadata_filters=metadata_filters,
898+
retrieval_mode=retrieval_mode,
899+
min_similarity=min_similarity,
900+
limit=limit,
901+
offset=offset,
902+
)
903+
if dispatched is not None:
904+
return dispatched
905+
906+
# --- FTS mode (Postgres-specific) ---
907+
(
908+
from_clause,
909+
where_clause,
910+
params,
911+
order_by_clause,
912+
score_expr,
913+
) = await self._build_fts_query_parts(
914+
search_text=search_text,
915+
permalink=permalink,
916+
permalink_match=permalink_match,
917+
title=title,
918+
note_types=note_types,
919+
after_date=after_date,
920+
search_item_types=search_item_types,
921+
metadata_filters=metadata_filters,
922+
)
923+
924+
# set limit and offset
925+
params["limit"] = limit
926+
params["offset"] = offset
927+
887928
sql = f"""
888929
SELECT
889930
search_index.project_id,
@@ -915,17 +956,7 @@ async def search(
915956
result = await session.execute(text(sql), params)
916957
rows = result.fetchall()
917958
except Exception as e:
918-
# Handle tsquery syntax errors (and only those).
919-
#
920-
# Important: Postgres errors for other failures (e.g. missing table) will still mention
921-
# `to_tsquery(...)` in the SQL text, so checking for the substring "tsquery" is too broad.
922-
msg = str(e).lower()
923-
if (
924-
"syntax error in tsquery" in msg
925-
or "invalid input syntax for type tsquery" in msg
926-
or "no operand in tsquery" in msg
927-
or "no operator in tsquery" in msg
928-
):
959+
if self._is_tsquery_syntax_error(e):
929960
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
930961
return []
931962

@@ -966,3 +997,60 @@ async def search(
966997
)
967998

968999
return results
1000+
1001+
async def count(
1002+
self,
1003+
search_text: Optional[str] = None,
1004+
permalink: Optional[str] = None,
1005+
permalink_match: Optional[str] = None,
1006+
title: Optional[str] = None,
1007+
note_types: Optional[List[str]] = None,
1008+
after_date: Optional[datetime] = None,
1009+
search_item_types: Optional[List[SearchItemType]] = None,
1010+
metadata_filters: Optional[dict] = None,
1011+
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
1012+
min_similarity: Optional[float] = None,
1013+
) -> int:
1014+
"""Count indexed content matching the Postgres FTS query."""
1015+
if retrieval_mode != SearchRetrievalMode.FTS:
1016+
return await super().count(
1017+
search_text=search_text,
1018+
permalink=permalink,
1019+
permalink_match=permalink_match,
1020+
title=title,
1021+
note_types=note_types,
1022+
after_date=after_date,
1023+
search_item_types=search_item_types,
1024+
metadata_filters=metadata_filters,
1025+
retrieval_mode=retrieval_mode,
1026+
min_similarity=min_similarity,
1027+
)
1028+
1029+
(
1030+
from_clause,
1031+
where_clause,
1032+
params,
1033+
_order_by_clause,
1034+
_score_expr,
1035+
) = await self._build_fts_query_parts(
1036+
search_text=search_text,
1037+
permalink=permalink,
1038+
permalink_match=permalink_match,
1039+
title=title,
1040+
note_types=note_types,
1041+
after_date=after_date,
1042+
search_item_types=search_item_types,
1043+
metadata_filters=metadata_filters,
1044+
)
1045+
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
1046+
logger.trace(f"Count {sql} params: {params}")
1047+
try:
1048+
async with db.scoped_session(self.session_maker) as session:
1049+
result = await session.execute(text(sql), params)
1050+
return int(result.scalar_one())
1051+
except Exception as e:
1052+
if self._is_tsquery_syntax_error(e):
1053+
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
1054+
return 0
1055+
logger.error(f"Database error during search count: {e}")
1056+
raise

src/basic_memory/repository/search_repository.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ async def search(
5050
"""Search across indexed content."""
5151
...
5252

53+
async def count(
54+
self,
55+
search_text: Optional[str] = None,
56+
permalink: Optional[str] = None,
57+
permalink_match: Optional[str] = None,
58+
title: Optional[str] = None,
59+
note_types: Optional[List[str]] = None,
60+
after_date: Optional[datetime] = None,
61+
search_item_types: Optional[List[SearchItemType]] = None,
62+
metadata_filters: Optional[dict] = None,
63+
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
64+
min_similarity: Optional[float] = None,
65+
) -> int:
66+
"""Count indexed content matching the same filters as search."""
67+
...
68+
5369
async def index_item(self, search_index_row: SearchIndexRow) -> None:
5470
"""Index a single item."""
5571
...

src/basic_memory/repository/search_repository_base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,24 @@ async def search(
247247
"""
248248
pass
249249

250+
async def count(
251+
self,
252+
search_text: Optional[str] = None,
253+
permalink: Optional[str] = None,
254+
permalink_match: Optional[str] = None,
255+
title: Optional[str] = None,
256+
note_types: Optional[List[str]] = None,
257+
after_date: Optional[datetime] = None,
258+
search_item_types: Optional[List[SearchItemType]] = None,
259+
metadata_filters: Optional[Dict[str, Any]] = None,
260+
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
261+
min_similarity: Optional[float] = None,
262+
) -> int:
263+
"""Count results when a backend-specific COUNT query is available."""
264+
if retrieval_mode != SearchRetrievalMode.FTS:
265+
raise ValueError("Exact counts are only supported for full-text search retrieval.")
266+
raise NotImplementedError("Backend search repositories must implement full-text counts.")
267+
250268
# ------------------------------------------------------------------
251269
# Abstract methods — semantic search (backend-specific DB operations)
252270
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)