From 30b82ba95f925d5bbcd7698f399830dcaab8b73a Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:50:08 -0300 Subject: [PATCH] fix: avoid qdrant lexical full scan on empty text hits --- mempalace/backends/qdrant.py | 4 +++- tests/test_qdrant_backend.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/mempalace/backends/qdrant.py b/mempalace/backends/qdrant.py index d43b6ed..2600b0f 100644 --- a/mempalace/backends/qdrant.py +++ b/mempalace/backends/qdrant.py @@ -1027,18 +1027,20 @@ class QdrantCollection(BaseCollection): q_filter = None if _requires_local_filter(where) else _qdrant_filter(where) rows = [] text_filter = _text_any_filter(query) + text_filter_success = False if text_filter: try: rows = self._scroll_all( qdrant_filter=_combine_filters(q_filter, text_filter), with_vector=False, ) + text_filter_success = True except BackendError: logger.debug( "Qdrant text filter failed; falling back to lexical scan", exc_info=True ) rows = [] - if not rows: + if not text_filter_success: rows = self._scroll_all(qdrant_filter=q_filter, with_vector=False) rows = [row for row in rows if _matches_where(row["metadata"], where)] scores = _bm25_scores(query, [row["document"] for row in rows]) diff --git a/tests/test_qdrant_backend.py b/tests/test_qdrant_backend.py index 827adc7..d7c873b 100644 --- a/tests/test_qdrant_backend.py +++ b/tests/test_qdrant_backend.py @@ -78,6 +78,7 @@ class _FakeQdrantClient: def __init__(self, _config): self.collections = {} self.query_calls = [] + self.scroll_calls = [] self.created_indexes = [] _FakeQdrantClient.instances.append(self) @@ -143,6 +144,7 @@ class _FakeQdrantClient: offset=None, with_vector=False, ): + self.scroll_calls.append(qdrant_filter) points = list(self.collections.get(collection, {"points": {}})["points"].values()) points = [point for point in points if _fake_match_filter(point, qdrant_filter)] start = int(offset or 0) @@ -312,6 +314,24 @@ def test_qdrant_complex_filters_use_exact_local_fallback(tmp_path, fake_qdrant): assert fake_client.query_calls == [] +def test_qdrant_lexical_empty_text_filter_does_not_full_scan(tmp_path, fake_qdrant): + _backend, col = _collection(tmp_path) + col.upsert( + ids=["a", "b"], + documents=["alpha backend note", "beta frontend note"], + metadatas=[{"wing": "project"}, {"wing": "project"}], + embeddings=[[1, 0], [0, 1]], + ) + fake_client = fake_qdrant.instances[0] + fake_client.scroll_calls.clear() + + hits = col.lexical_search(query="missingterm", n_results=2).hits + + assert hits == [] + assert len(fake_client.scroll_calls) == 1 + assert "text_any" in str(fake_client.scroll_calls[0]) + + def test_qdrant_dimension_mismatch(tmp_path, fake_qdrant): _backend, col = _collection(tmp_path) col.upsert(ids=["a"], documents=["one"], metadatas=[{}], embeddings=[[1, 0]])