Skip to content

Commit 7f9fc80

Browse files
committed
Add exact totals to search pagination
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a661e92 commit 7f9fc80

10 files changed

Lines changed: 536 additions & 135 deletions

File tree

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ async def search(
6565
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
6666
):
6767
offset = (page - 1) * page_size
68-
fetch_limit = page_size + 1
6968
try:
7069
with logfire.span(
7170
"api.search.search.execute_query",
@@ -75,7 +74,8 @@ async def search(
7574
page=page,
7675
page_size=page_size,
7776
):
78-
results = await search_service.search(query, limit=fetch_limit, offset=offset)
77+
results = await search_service.search(query, limit=page_size, offset=offset)
78+
total = await search_service.count(query)
7979
except SemanticSearchDisabledError as exc:
8080
raise HTTPException(status_code=400, detail=str(exc)) from exc
8181
except SemanticDependenciesMissingError as exc:
@@ -90,9 +90,7 @@ async def search(
9090
phase="paginate_results",
9191
result_count=len(results),
9292
):
93-
has_more = len(results) > page_size
94-
if has_more:
95-
results = results[:page_size]
93+
has_more = offset + len(results) < total
9694

9795
with logfire.span(
9896
"api.search.search.hydrate_results",
@@ -113,6 +111,7 @@ async def search(
113111
results=search_results,
114112
current_page=page,
115113
page_size=page_size,
114+
total=total,
116115
has_more=has_more,
117116
)
118117

src/basic_memory/repository/postgres_search_repository.py

Lines changed: 134 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,65 @@ 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+
mode = (
1016+
retrieval_mode.value
1017+
if isinstance(retrieval_mode, SearchRetrievalMode)
1018+
else str(retrieval_mode)
1019+
)
1020+
if mode != SearchRetrievalMode.FTS.value:
1021+
return await super().count(
1022+
search_text=search_text,
1023+
permalink=permalink,
1024+
permalink_match=permalink_match,
1025+
title=title,
1026+
note_types=note_types,
1027+
after_date=after_date,
1028+
search_item_types=search_item_types,
1029+
metadata_filters=metadata_filters,
1030+
retrieval_mode=retrieval_mode,
1031+
min_similarity=min_similarity,
1032+
)
1033+
1034+
(
1035+
from_clause,
1036+
where_clause,
1037+
params,
1038+
_order_by_clause,
1039+
_score_expr,
1040+
) = await self._build_fts_query_parts(
1041+
search_text=search_text,
1042+
permalink=permalink,
1043+
permalink_match=permalink_match,
1044+
title=title,
1045+
note_types=note_types,
1046+
after_date=after_date,
1047+
search_item_types=search_item_types,
1048+
metadata_filters=metadata_filters,
1049+
)
1050+
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
1051+
logger.trace(f"Count {sql} params: {params}")
1052+
try:
1053+
async with db.scoped_session(self.session_maker) as session:
1054+
result = await session.execute(text(sql), params)
1055+
return int(result.scalar_one())
1056+
except Exception as e:
1057+
if self._is_tsquery_syntax_error(e):
1058+
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
1059+
return 0
1060+
logger.error(f"Database error during search count: {e}")
1061+
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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,36 @@ 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 for retrieval modes that cannot use a backend COUNT query."""
264+
results = await self.search(
265+
search_text=search_text,
266+
permalink=permalink,
267+
permalink_match=permalink_match,
268+
title=title,
269+
note_types=note_types,
270+
after_date=after_date,
271+
search_item_types=search_item_types,
272+
metadata_filters=metadata_filters,
273+
retrieval_mode=retrieval_mode,
274+
min_similarity=min_similarity,
275+
limit=VECTOR_FILTER_SCAN_LIMIT,
276+
offset=0,
277+
)
278+
return len(results)
279+
250280
# ------------------------------------------------------------------
251281
# Abstract methods — semantic search (backend-specific DB operations)
252282
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)