From b1dbd08c63f29036d62ab11d85353c46aaacc988 Mon Sep 17 00:00:00 2001 From: kartik-mem0 Date: Sat, 21 Mar 2026 18:32:57 +0530 Subject: [PATCH] fix(qdrant): handle $or/$not keys injected by Memory middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory._process_metadata_filters() renames OR→$or and NOT→$not before passing filters to the vector store. Without this fix, $or/$not keys with list values hit _build_field_condition and crash with ValidationError (MatchValue rejects lists). Normalize $or→OR, $not→NOT, $and→AND at the start of _create_filter so both naming conventions work. Co-Authored-By: Claude Opus 4.6 (1M context) --- mem0/vector_stores/qdrant.py | 14 +++++--- tests/vector_stores/test_qdrant.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/mem0/vector_stores/qdrant.py b/mem0/vector_stores/qdrant.py index 5bcab11f2..32f60e1f1 100644 --- a/mem0/vector_stores/qdrant.py +++ b/mem0/vector_stores/qdrant.py @@ -222,24 +222,28 @@ class Qdrant(VectorStoreBase): must_not = [] for key, value in filters.items(): - if key in ("AND", "OR", "NOT"): + # Normalize $or/$not (injected by Memory._process_metadata_filters) + # to OR/NOT so they're handled uniformly. + normalized_key = {"$or": "OR", "$not": "NOT", "$and": "AND"}.get(key, key) + + if normalized_key in ("AND", "OR", "NOT"): if not isinstance(value, list): raise ValueError( - f"{key} filter value must be a list of filter dicts, " + f"{normalized_key} filter value must be a list of filter dicts, " f"got {type(value).__name__}" ) - if key == "AND": + if normalized_key == "AND": for sub in value: built = self._create_filter(sub) if built: must.append(built) - elif key == "OR": + elif normalized_key == "OR": for sub in value: built = self._create_filter(sub) if built: should.append(built) - elif key == "NOT": + elif normalized_key == "NOT": for sub in value: built = self._create_filter(sub) if built: diff --git a/tests/vector_stores/test_qdrant.py b/tests/vector_stores/test_qdrant.py index 77d6e0085..5913a40af 100644 --- a/tests/vector_stores/test_qdrant.py +++ b/tests/vector_stores/test_qdrant.py @@ -670,5 +670,58 @@ class TestQdrantEnhancedFilters(unittest.TestCase): self.assertIsInstance(cond.match, MatchValue) self.assertEqual(cond.match.value, "*") + # ------------------------------------------------------------------ # + # $or / $not normalization (Memory._process_metadata_filters injects # + # these keys alongside the original OR/NOT) # + # ------------------------------------------------------------------ # + + def test_dollar_or_handled_as_or(self): + """$or injected by Memory middleware should be treated as OR.""" + filters = { + "user_id": "alice", + "$or": [{"category": "programming"}, {"category": "data"}], + } + result = self.qdrant._create_filter(filters) + self.assertIsInstance(result, Filter) + self.assertIsNotNone(result.should) + self.assertEqual(len(result.should), 2) + + def test_dollar_not_handled_as_not(self): + """$not injected by Memory middleware should be treated as NOT.""" + filters = { + "user_id": "alice", + "$not": [{"category": "spam"}], + } + result = self.qdrant._create_filter(filters) + self.assertIsInstance(result, Filter) + self.assertIsNotNone(result.must_not) + self.assertEqual(len(result.must_not), 1) + + def test_memory_search_or_shape(self): + """Simulate exact shape Memory.search() sends for OR filters.""" + # Memory.search() preserves original OR AND adds $or + filters = { + "OR": [{"category": "programming"}, {"category": "data"}], + "user_id": "test_user", + "$or": [{"category": "programming"}, {"category": "data"}], + } + result = self.qdrant._create_filter(filters) + self.assertIsInstance(result, Filter) + # OR and $or both contribute to should — duplicates are harmless + self.assertIsNotNone(result.should) + self.assertGreaterEqual(len(result.should), 2) + + def test_memory_search_not_shape(self): + """Simulate exact shape Memory.search() sends for NOT filters.""" + filters = { + "NOT": [{"category": "spam"}], + "user_id": "test_user", + "$not": [{"category": "spam"}], + } + result = self.qdrant._create_filter(filters) + self.assertIsInstance(result, Filter) + self.assertIsNotNone(result.must_not) + self.assertGreaterEqual(len(result.must_not), 1) + def tearDown(self): del self.qdrant