Skip to content

Commit 2aa0be1

Browse files
jpheinclaude
andcommitted
fix(searcher): address Dialectician review on MemPalace#1005
- _count_in_scope: use col.count() for unfiltered scopes (O(1) vs 28 paginated round trips on 137K drawers when wing/room filter is absent) - allow_fallback gate: fire BM25 fallback when max_distance is permissive (<=0.0) OR when a vector error occurred (warnings non-empty). Fixes the MCP path: tool_search passes max_distance=1.5, which the old gate read as "strict" and silently skipped fallback — so agents on a drifted palace saw 0 hits instead of BM25 coverage + a warning - Add explanatory comment on vector_hit_count pre-mutation capture - Update test mocks with explicit count.return_value (required now that _count_in_scope calls col.count() for unfiltered scopes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 217ea56 commit 2aa0be1

2 files changed

Lines changed: 26 additions & 19 deletions

File tree

mempalace/searcher.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -306,27 +306,24 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
306306

307307

308308
def _count_in_scope(drawers_col, where: dict) -> int | None:
309-
"""Return the total number of drawers matching ``where`` via paginated
310-
``col.get(include=[], ...)`` calls.
309+
"""Return the total number of drawers matching ``where``.
311310
312-
ChromaDB's ``Collection.count()`` does not accept a ``where`` filter, so
313-
for a sqlite-authoritative per-scope count we paginate ``get()`` with
314-
``include=[]`` (ids are always returned by Chroma, even when include is
315-
empty) and sum the batch sizes. Pagination keeps each query well under
316-
the #950 "too many SQL variables" limit.
311+
When ``where`` is empty (unfiltered scope), uses ``Collection.count()``
312+
which is O(1). Otherwise paginates ``get(include=[])`` — ChromaDB's
313+
``count()`` does not accept a ``where`` filter. Pagination keeps each
314+
query well under the #950 "too many SQL variables" limit.
317315
318316
Returns ``None`` if the count could not be computed (e.g., filter
319317
planner error).
320318
"""
321-
PAGE = 5000
322-
offset = 0
323-
total = 0
324319
try:
320+
if not where:
321+
return drawers_col.count()
322+
PAGE = 5000
323+
offset = 0
324+
total = 0
325325
while True:
326-
kwargs: dict = {"limit": PAGE, "offset": offset, "include": []}
327-
if where:
328-
kwargs["where"] = where
329-
batch = drawers_col.get(**kwargs)
326+
batch = drawers_col.get(limit=PAGE, offset=offset, include=[], where=where)
330327
batch_ids = batch.get("ids") or []
331328
if not batch_ids:
332329
break
@@ -652,18 +649,25 @@ def search_memories(
652649
vector_hit_count = len(hits)
653650
vector_underdelivered = vector_hit_count < n_results
654651

655-
# Sqlite-authoritative scope count + top-up fallback. When max_distance
656-
# is set, the caller wants strict similarity — don't pad with BM25-only
657-
# hits that have no vector distance. Scope count still runs so callers
658-
# can see what they're missing.
652+
# Capture vector hit count before BM25 may extend hits. The scope warning
653+
# must fire whenever vector underdelivered — even when BM25 fills the
654+
# request to n_results — because vector is still the degraded layer.
655+
# BM25 fallback is a reliability mechanism: it fires when the distance
656+
# threshold is permissive (max_distance=0.0 means "no filtering") OR
657+
# when a vector error occurred (warnings non-empty at this point). This
658+
# ensures MCP callers on a drifted palace get fallback coverage even
659+
# though tool_search passes max_distance=1.5, without firing fallback
660+
# when a strict distance filter legitimately eliminates all results on
661+
# a working HNSW index.
662+
allow_fallback = (max_distance <= 0.0) or bool(warnings)
659663
available_in_scope, fallback_warnings = _sqlite_fallback_and_scope(
660664
drawers_col,
661665
query,
662666
where,
663667
hits,
664668
n_results=n_results,
665669
vector_underdelivered=vector_underdelivered,
666-
allow_fallback=(max_distance <= 0.0),
670+
allow_fallback=allow_fallback,
667671
)
668672
warnings.extend(fallback_warnings)
669673

tests/test_searcher.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def test_created_at_contains_filed_at(self, palace_path, seeded_collection):
6262
def test_created_at_fallback_when_filed_at_missing(self):
6363
"""created_at defaults to 'unknown' when filed_at is absent."""
6464
mock_col = MagicMock()
65+
mock_col.count.return_value = 1
6566
mock_col.query.return_value = {
6667
"ids": [["drawer_no_date"]],
6768
"documents": [["Some text without a date"]],
@@ -82,6 +83,7 @@ def test_search_memories_query_error_degrades_to_warning(self):
8283
would be worse than a crash because it makes the palace look empty
8384
when the data is actually there."""
8485
mock_col = MagicMock()
86+
mock_col.count.return_value = 0
8587
mock_col.query.side_effect = RuntimeError("query failed")
8688
# col.get is also called (for the sqlite fallback and pool count);
8789
# return an empty pool so the fallback finds nothing to promote.
@@ -226,6 +228,7 @@ def test_search_query_error_degrades_to_warning(self, capsys):
226228
fallback. The warning is printed so the user sees why the palace
227229
is returning fewer results than expected."""
228230
mock_col = MagicMock()
231+
mock_col.count.return_value = 0
229232
mock_col.query.side_effect = RuntimeError("boom")
230233
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
231234

0 commit comments

Comments
 (0)