qdrant-haystack 13.1.0__tar.gz → 13.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/CHANGELOG.md +7 -0
  2. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/PKG-INFO +1 -1
  3. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/document_store.py +48 -48
  4. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/filters.py +15 -1
  5. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_document_store.py +47 -0
  6. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_document_store_async.py +49 -0
  7. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_filters.py +76 -16
  8. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/.gitignore +0 -0
  9. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/LICENSE.txt +0 -0
  10. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/README.md +0 -0
  11. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/examples/embedding_retrieval.py +0 -0
  12. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/pydoc/config_docusaurus.yml +0 -0
  13. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/pyproject.toml +0 -0
  14. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  15. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/qdrant/__init__.py +0 -0
  16. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/qdrant/retriever.py +0 -0
  17. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/py.typed +0 -0
  18. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/__init__.py +0 -0
  19. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/converters.py +0 -0
  20. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/migrate_to_sparse.py +0 -0
  21. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/__init__.py +0 -0
  22. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/conftest.py +0 -0
  23. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_converters.py +0 -0
  24. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_dict_converters.py +0 -0
  25. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_embedding_retriever.py +0 -0
  26. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_hybrid_retriever.py +0 -0
  27. {qdrant_haystack-13.1.0 → qdrant_haystack-13.1.1}/tests/test_sparse_embedding_retriever.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/qdrant-v13.1.0] - 2026-10-22
4
+
5
+ ### 🚀 Features
6
+
7
+ - Add ability to specify qdrant client kwargs (#4048)
8
+
9
+
3
10
  ## [integrations/qdrant-v13.0.0] - 2026-10-16
4
11
 
5
12
  ### 🧪 Testing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qdrant-haystack
3
- Version: 13.1.0
3
+ Version: 13.1.1
4
4
  Summary: An integration of Qdrant ANN vector database backend with Haystack
5
5
  Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations
6
6
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/qdrant/README.md
@@ -467,7 +467,7 @@ class QdrantDocumentStore:
467
467
  wait=self.wait_result_from_api,
468
468
  )
469
469
 
470
- progress_bar.update(self.write_batch_size)
470
+ progress_bar.update(len(document_batch))
471
471
  return len(document_objects)
472
472
 
473
473
  async def write_documents_async(
@@ -522,7 +522,7 @@ class QdrantDocumentStore:
522
522
  wait=self.wait_result_from_api,
523
523
  )
524
524
 
525
- progress_bar.update(self.write_batch_size)
525
+ progress_bar.update(len(document_batch))
526
526
  return len(document_objects)
527
527
 
528
528
  def delete_documents(self, document_ids: list[str]) -> None:
@@ -694,8 +694,8 @@ class QdrantDocumentStore:
694
694
  """
695
695
  Build field name -> {type: ...} dict from Qdrant payload_schema.
696
696
 
697
- Used when payload_schema has indexed metadata fields (e.g. meta.category).
698
- Returns empty dict when schema has no metadata field info.
697
+ Covers only the indexed metadata fields (e.g. meta.category); unindexed fields
698
+ are added by scrolling. Returns empty dict when schema has no metadata field info.
699
699
  """
700
700
  fields_info: dict[str, dict[str, str]] = {}
701
701
  for field_name, field_config in payload_schema.items():
@@ -1082,9 +1082,9 @@ class QdrantDocumentStore:
1082
1082
  """
1083
1083
  Returns the information about the metadata fields in the collection.
1084
1084
 
1085
- Since Qdrant may not have a payload schema for unindexed metadata,
1086
- this method scrolls through documents to infer field types from
1087
- payload["meta"].
1085
+ Indexed metadata fields take their type from the collection's payload schema.
1086
+ Qdrant has no schema for unindexed metadata, so this method also scrolls
1087
+ through documents to infer the type of every other field from payload["meta"].
1088
1088
 
1089
1089
  :returns:
1090
1090
  A dictionary mapping field names to their type information e.g.:
@@ -1101,20 +1101,21 @@ class QdrantDocumentStore:
1101
1101
  payload_schema = collection_info.payload_schema or {}
1102
1102
  fields_info = self._metadata_fields_info_from_schema(payload_schema)
1103
1103
 
1104
- if not fields_info:
1105
- next_offset = None
1106
- while True:
1107
- records, next_offset = self._client.scroll(
1108
- collection_name=self.index,
1109
- scroll_filter=None,
1110
- limit=self.scroll_size,
1111
- offset=next_offset,
1112
- with_payload=True,
1113
- with_vectors=False,
1114
- )
1115
- self._process_records_fields_info(records, fields_info)
1116
- if self._check_stop_scrolling(next_offset):
1117
- break
1104
+ # Indexed fields only cover part of the metadata, so scroll even when the schema
1105
+ # has entries. Fields already typed by the schema keep their indexed type.
1106
+ next_offset = None
1107
+ while True:
1108
+ records, next_offset = self._client.scroll(
1109
+ collection_name=self.index,
1110
+ scroll_filter=None,
1111
+ limit=self.scroll_size,
1112
+ offset=next_offset,
1113
+ with_payload=True,
1114
+ with_vectors=False,
1115
+ )
1116
+ self._process_records_fields_info(records, fields_info)
1117
+ if self._check_stop_scrolling(next_offset):
1118
+ break
1118
1119
 
1119
1120
  return fields_info
1120
1121
  except Exception as e:
@@ -1125,9 +1126,9 @@ class QdrantDocumentStore:
1125
1126
  """
1126
1127
  Asynchronously returns the information about the metadata fields in the collection.
1127
1128
 
1128
- Since Qdrant may not have a payload schema for unindexed metadata,
1129
- this method scrolls through documents to infer field types from
1130
- payload["meta"].
1129
+ Indexed metadata fields take their type from the collection's payload schema.
1130
+ Qdrant has no schema for unindexed metadata, so this method also scrolls
1131
+ through documents to infer the type of every other field from payload["meta"].
1131
1132
 
1132
1133
  :returns:
1133
1134
  A dictionary mapping field names to their type information e.g.:
@@ -1144,20 +1145,21 @@ class QdrantDocumentStore:
1144
1145
  payload_schema = collection_info.payload_schema or {}
1145
1146
  fields_info = self._metadata_fields_info_from_schema(payload_schema)
1146
1147
 
1147
- if not fields_info:
1148
- next_offset = None
1149
- while True:
1150
- records, next_offset = await self._async_client.scroll(
1151
- collection_name=self.index,
1152
- scroll_filter=None,
1153
- limit=self.scroll_size,
1154
- offset=next_offset,
1155
- with_payload=True,
1156
- with_vectors=False,
1157
- )
1158
- self._process_records_fields_info(records, fields_info)
1159
- if self._check_stop_scrolling(next_offset):
1160
- break
1148
+ # Indexed fields only cover part of the metadata, so scroll even when the schema
1149
+ # has entries. Fields already typed by the schema keep their indexed type.
1150
+ next_offset = None
1151
+ while True:
1152
+ records, next_offset = await self._async_client.scroll(
1153
+ collection_name=self.index,
1154
+ scroll_filter=None,
1155
+ limit=self.scroll_size,
1156
+ offset=next_offset,
1157
+ with_payload=True,
1158
+ with_vectors=False,
1159
+ )
1160
+ self._process_records_fields_info(records, fields_info)
1161
+ if self._check_stop_scrolling(next_offset):
1162
+ break
1161
1163
 
1162
1164
  return fields_info
1163
1165
  except Exception as e:
@@ -1664,7 +1666,7 @@ class QdrantDocumentStore:
1664
1666
  with_vectors=return_embedding,
1665
1667
  score_threshold=score_threshold,
1666
1668
  ).groups
1667
- return self._process_group_results(groups)
1669
+ return self._process_group_results(groups, scale_score=scale_score)
1668
1670
  else:
1669
1671
  points = self._client.query_points(
1670
1672
  collection_name=self.index,
@@ -1726,7 +1728,7 @@ class QdrantDocumentStore:
1726
1728
  with_vectors=return_embedding,
1727
1729
  score_threshold=score_threshold,
1728
1730
  ).groups
1729
- return self._process_group_results(groups)
1731
+ return self._process_group_results(groups, scale_score=scale_score)
1730
1732
 
1731
1733
  else:
1732
1734
  points = self._client.query_points(
@@ -1924,7 +1926,7 @@ class QdrantDocumentStore:
1924
1926
  score_threshold=score_threshold,
1925
1927
  )
1926
1928
  groups = response.groups
1927
- return self._process_group_results(groups)
1929
+ return self._process_group_results(groups, scale_score=scale_score)
1928
1930
  else:
1929
1931
  query_response = await self._async_client.query_points(
1930
1932
  collection_name=self.index,
@@ -1988,7 +1990,7 @@ class QdrantDocumentStore:
1988
1990
  score_threshold=score_threshold,
1989
1991
  )
1990
1992
  groups = response.groups
1991
- return self._process_group_results(groups)
1993
+ return self._process_group_results(groups, scale_score=scale_score)
1992
1994
  else:
1993
1995
  query_response = await self._async_client.query_points(
1994
1996
  collection_name=self.index,
@@ -2579,7 +2581,7 @@ class QdrantDocumentStore:
2579
2581
 
2580
2582
  return documents
2581
2583
 
2582
- def _process_group_results(self, groups: list[rest.PointGroup]) -> list[Document]:
2584
+ def _process_group_results(self, groups: list[rest.PointGroup], scale_score: bool = False) -> list[Document]:
2583
2585
  """
2584
2586
  Processes grouped query results from Qdrant.
2585
2587
 
@@ -2587,11 +2589,9 @@ class QdrantDocumentStore:
2587
2589
  if not groups:
2588
2590
  return []
2589
2591
 
2590
- return [
2591
- convert_qdrant_point_to_haystack_document(point, use_sparse_embeddings=self.use_sparse_embeddings)
2592
- for group in groups
2593
- for point in group.hits
2594
- ]
2592
+ return self._process_query_point_results(
2593
+ [point for group in groups for point in group.hits], scale_score=scale_score
2594
+ )
2595
2595
 
2596
2596
  def _validate_collection_compatibility(
2597
2597
  self,
@@ -79,7 +79,9 @@ def _process_logical_operator(item: dict[str, Any]) -> models.Condition | None:
79
79
  elif operator == "OR":
80
80
  return models.Filter(should=nested_conditions)
81
81
  elif operator == "NOT":
82
- return models.Filter(must_not=nested_conditions)
82
+ # Haystack's NOT negates the conjunction of its conditions. `must_not` on its own excludes points
83
+ # that match any condition, which would negate the disjunction instead.
84
+ return models.Filter(must_not=[models.Filter(must=nested_conditions)])
83
85
 
84
86
  return None
85
87
 
@@ -134,7 +136,19 @@ def _build_comparison_condition(operator: str, key: str, value: Any) -> models.C
134
136
  return builder(key, value)
135
137
 
136
138
 
139
+ # Qdrant compares ranges as float64, so ints beyond 2**53 can't be matched exactly with a range.
140
+ _MAX_EXACT_FLOAT_INT = 2**53
141
+
142
+
137
143
  def _build_eq_condition(key: str, value: models.ValueVariants) -> models.Condition:
144
+ # Qdrant ranges are compared as float64, so a huge int can't be matched exactly: keep MatchValue for it.
145
+ if (
146
+ isinstance(value, (int, float))
147
+ and not isinstance(value, bool)
148
+ and not (isinstance(value, int) and abs(value) > _MAX_EXACT_FLOAT_INT)
149
+ ):
150
+ # MatchValue rejects floats and doesn't match float payloads for int values; a closed range does both.
151
+ return models.FieldCondition(key=key, range=models.Range(gte=value, lte=value))
138
152
  return models.FieldCondition(key=key, match=models.MatchValue(value=value))
139
153
 
140
154
 
@@ -429,6 +429,23 @@ class TestQdrantDocumentStoreUnit:
429
429
  getattr(document_store, method_name)(*args)
430
430
  assert exc_info.value.__cause__ is err
431
431
 
432
+ def test_get_metadata_fields_info_includes_unindexed_fields_when_some_are_indexed(self):
433
+ document_store = QdrantDocumentStore(location=":memory:")
434
+ schema = {"meta.category": rest.PayloadIndexInfo(data_type=rest.PayloadSchemaType.TEXT, points=1)}
435
+ client = MagicMock()
436
+ client.get_collection.return_value = SimpleNamespace(payload_schema=schema)
437
+ client.scroll.return_value = (
438
+ [SimpleNamespace(payload={"meta": {"category": "news", "priority": 1}})],
439
+ None,
440
+ )
441
+ document_store._client = client
442
+
443
+ fields = document_store.get_metadata_fields_info()
444
+
445
+ # "news" would be inferred as "keyword", so "text" proves the indexed type wins.
446
+ assert fields == {"category": {"type": "text"}, "priority": {"type": "long"}}
447
+ client.scroll.assert_called_once()
448
+
432
449
  def test_close(self):
433
450
  document_store = QdrantDocumentStore(location=":memory:")
434
451
  mock_client = MagicMock()
@@ -452,6 +469,36 @@ class TestQdrantDocumentStoreUnit:
452
469
 
453
470
  assert document_store._client is None
454
471
 
472
+ @pytest.mark.parametrize(
473
+ "method_name, query_kwargs",
474
+ [
475
+ ("_query_by_embedding", {"query_embedding": [0.1] * 768}),
476
+ ("_query_by_sparse", {"query_sparse_embedding": SparseEmbedding(indices=[0], values=[0.1])}),
477
+ ],
478
+ )
479
+ @pytest.mark.parametrize("scale_score, expected_score", [(True, 0.75), (False, 0.5)])
480
+ def test_query_with_group_by_scale_score(self, method_name, query_kwargs, scale_score, expected_score):
481
+ document_store = QdrantDocumentStore(location=":memory:", similarity="cosine", use_sparse_embeddings=True)
482
+ mock_client = MagicMock()
483
+ point = rest.ScoredPoint(id=1, version=0, score=0.5, payload={"id": "1", "content": "doc"})
484
+ mock_client.query_points_groups.return_value.groups = [rest.PointGroup(id="a", hits=[point])]
485
+ document_store._client = mock_client
486
+
487
+ result = getattr(document_store, method_name)(**query_kwargs, group_by="meta.group", scale_score=scale_score)
488
+
489
+ assert [doc.score for doc in result] == [expected_score]
490
+
491
+ def test_write_documents_progress_bar_counts_documents(self):
492
+ document_store = QdrantDocumentStore(location=":memory:", write_batch_size=2)
493
+ document_store._client = MagicMock()
494
+ with patch("haystack_integrations.document_stores.qdrant.document_store.tqdm") as mock_tqdm:
495
+ document_store.write_documents(
496
+ [Document(content=str(i)) for i in range(3)], policy=DuplicatePolicy.OVERWRITE
497
+ )
498
+
499
+ progress_bar = mock_tqdm.return_value.__enter__.return_value
500
+ assert [c.args[0] for c in progress_bar.update.call_args_list] == [2, 1]
501
+
455
502
 
456
503
  @pytest.mark.integration
457
504
  class TestQdrantDocumentStore(
@@ -1,3 +1,4 @@
1
+ from types import SimpleNamespace
1
2
  from unittest.mock import AsyncMock, MagicMock, patch
2
3
 
3
4
  import pytest
@@ -184,6 +185,22 @@ class TestQdrantDocumentStoreAsyncUnit:
184
185
  await getattr(document_store, method_name)(*args)
185
186
  assert exc_info.value.__cause__ is err
186
187
 
188
+ async def test_get_metadata_fields_info_async_includes_unindexed_fields_when_some_are_indexed(self):
189
+ document_store = QdrantDocumentStore(location=":memory:")
190
+ schema = {"meta.category": rest.PayloadIndexInfo(data_type=rest.PayloadSchemaType.TEXT, points=1)}
191
+ client = AsyncMock()
192
+ client.get_collection.return_value = SimpleNamespace(payload_schema=schema)
193
+ client.scroll.return_value = (
194
+ [SimpleNamespace(payload={"meta": {"category": "news", "priority": 1}})],
195
+ None,
196
+ )
197
+ document_store._async_client = client
198
+
199
+ fields = await document_store.get_metadata_fields_info_async()
200
+
201
+ assert fields == {"category": {"type": "text"}, "priority": {"type": "long"}}
202
+ client.scroll.assert_awaited_once()
203
+
187
204
  async def test_close_async(self):
188
205
  document_store = QdrantDocumentStore(location=":memory:")
189
206
  mock_client = AsyncMock()
@@ -207,6 +224,38 @@ class TestQdrantDocumentStoreAsyncUnit:
207
224
 
208
225
  assert document_store._async_client is None
209
226
 
227
+ @pytest.mark.parametrize(
228
+ "method_name, query_kwargs",
229
+ [
230
+ ("_query_by_embedding_async", {"query_embedding": [0.1] * 768}),
231
+ ("_query_by_sparse_async", {"query_sparse_embedding": SparseEmbedding(indices=[0], values=[0.1])}),
232
+ ],
233
+ )
234
+ @pytest.mark.parametrize("scale_score, expected_score", [(True, 0.75), (False, 0.5)])
235
+ async def test_query_with_group_by_scale_score_async(self, method_name, query_kwargs, scale_score, expected_score):
236
+ document_store = QdrantDocumentStore(location=":memory:", similarity="cosine", use_sparse_embeddings=True)
237
+ mock_client = AsyncMock()
238
+ point = rest.ScoredPoint(id=1, version=0, score=0.5, payload={"id": "1", "content": "doc"})
239
+ mock_client.query_points_groups.return_value = rest.GroupsResult(groups=[rest.PointGroup(id="a", hits=[point])])
240
+ document_store._async_client = mock_client
241
+
242
+ result = await getattr(document_store, method_name)(
243
+ **query_kwargs, group_by="meta.group", scale_score=scale_score
244
+ )
245
+
246
+ assert [doc.score for doc in result] == [expected_score]
247
+
248
+ async def test_write_documents_async_progress_bar_counts_documents(self):
249
+ document_store = QdrantDocumentStore(location=":memory:", write_batch_size=2)
250
+ document_store._async_client = AsyncMock()
251
+ with patch("haystack_integrations.document_stores.qdrant.document_store.tqdm") as mock_tqdm:
252
+ await document_store.write_documents_async(
253
+ [Document(content=str(i)) for i in range(3)], policy=DuplicatePolicy.OVERWRITE
254
+ )
255
+
256
+ progress_bar = mock_tqdm.return_value.__enter__.return_value
257
+ assert [c.args[0] for c in progress_bar.update.call_args_list] == [2, 1]
258
+
210
259
 
211
260
  @pytest.mark.integration
212
261
  @pytest.mark.asyncio
@@ -73,6 +73,46 @@ class TestConvertFiltersToQdrantUnit:
73
73
  assert isinstance(qdrant_filter, models.Filter)
74
74
  assert len(qdrant_filter.must) == 2
75
75
 
76
+ @pytest.mark.parametrize("value", [4, 4.5, -3, 2**53])
77
+ def test_eq_numeric_uses_closed_range(self, value):
78
+ qdrant_filter = convert_filters_to_qdrant({"operator": "==", "field": "meta.x", "value": value})
79
+ condition = qdrant_filter.must[0]
80
+ assert condition.match is None
81
+ assert condition.range == models.Range(gte=value, lte=value)
82
+
83
+ @pytest.mark.parametrize("value", [True, False, "a", 2**53 + 1, -(2**60)])
84
+ def test_eq_non_numeric_or_unsafe_int_keeps_match_value(self, value):
85
+ qdrant_filter = convert_filters_to_qdrant({"operator": "==", "field": "meta.x", "value": value})
86
+ condition = qdrant_filter.must[0]
87
+ assert condition.range is None
88
+ assert condition.match == models.MatchValue(value=value)
89
+ assert type(condition.match.value) is type(value)
90
+
91
+ def test_in_and_not_in_mixed_numerics(self):
92
+ values = [1, 2.5, "a", True]
93
+ in_filter = convert_filters_to_qdrant({"operator": "in", "field": "meta.x", "value": values})
94
+ nin_filter = convert_filters_to_qdrant({"operator": "not in", "field": "meta.x", "value": values})
95
+ for conditions in (in_filter.should, nin_filter.must_not):
96
+ assert [(c.range, c.match) for c in conditions] == [
97
+ (models.Range(gte=1, lte=1), None),
98
+ (models.Range(gte=2.5, lte=2.5), None),
99
+ (None, models.MatchValue(value="a")),
100
+ (None, models.MatchValue(value=True)),
101
+ ]
102
+
103
+ def test_ne_float(self):
104
+ qdrant_filter = convert_filters_to_qdrant({"operator": "!=", "field": "meta.x", "value": 4.5})
105
+ assert qdrant_filter.must_not[0].range == models.Range(gte=4.5, lte=4.5)
106
+
107
+ def test_not_negates_the_conjunction_of_its_conditions(self):
108
+ a = {"operator": "==", "field": "meta.a", "value": 1}
109
+ b = {"operator": "==", "field": "meta.b", "value": 2}
110
+ qdrant_filter = convert_filters_to_qdrant({"operator": "NOT", "conditions": [a, b]})
111
+ # `must_not` alone would exclude points matching either condition
112
+ assert qdrant_filter == models.Filter(
113
+ must_not=[models.Filter(must=[convert_filters_to_qdrant(a).must[0], convert_filters_to_qdrant(b).must[0]])]
114
+ )
115
+
76
116
 
77
117
  def test_is_datetime_string():
78
118
  assert is_datetime_string("2024-01-01T00:00:00") is True
@@ -165,22 +205,6 @@ class TestQdrantFilters(FilterDocumentsTest):
165
205
  # Check that the sets are equal, meaning the content and IDs match regardless of order
166
206
  assert {doc.id for doc in received} == {doc.id for doc in expected}
167
207
 
168
- def test_not_operator(self, document_store, filterable_docs):
169
- document_store.write_documents(filterable_docs)
170
- result = document_store.filter_documents(
171
- filters={
172
- "operator": "NOT",
173
- "conditions": [
174
- {"field": "meta.number", "operator": "==", "value": 100},
175
- {"field": "meta.name", "operator": "==", "value": "name_0"},
176
- ],
177
- }
178
- )
179
- self.assert_documents_are_equal(
180
- result,
181
- [d for d in filterable_docs if (d.meta.get("number") != 100 and d.meta.get("name") != "name_0")],
182
- )
183
-
184
208
  def test_filter_criteria(self, document_store):
185
209
  documents = [
186
210
  Document(
@@ -329,3 +353,39 @@ class TestQdrantFilters(FilterDocumentsTest):
329
353
 
330
354
  @pytest.mark.skip(reason="Cannot distinguish errors yet")
331
355
  def test_missing_top_level_operator_key(self, document_store, filterable_docs): ...
356
+
357
+ @pytest.mark.parametrize(
358
+ ("operator", "value", "expected"),
359
+ [
360
+ ("==", 4, [4, 4.0]),
361
+ ("==", 4.5, [4.5]),
362
+ ("==", 4.0, [4, 4.0]),
363
+ ("!=", 4.5, [3, 4, 4.0]),
364
+ ("!=", 4, [3, 4.5]),
365
+ ("in", [3, 4.5], [3, 4.5]),
366
+ ("in", [4.0, 9], [4, 4.0]),
367
+ ("not in", [3, 4.5], [4, 4.0]),
368
+ ("not in", [4, 9.5], [3, 4.5]),
369
+ ],
370
+ )
371
+ def test_numeric_filters_match_int_and_float_payloads(self, document_store, operator, value, expected):
372
+ ratings = [3, 4, 4.0, 4.5]
373
+ document_store.write_documents(
374
+ [Document(content=str(r), meta={"rating": r}, embedding=[0.1] * 768) for r in ratings]
375
+ )
376
+
377
+ result = document_store.filter_documents({"field": "meta.rating", "operator": operator, "value": value})
378
+
379
+ assert sorted(d.meta["rating"] for d in result) == sorted(expected)
380
+
381
+ def test_eq_bool_does_not_match_numeric_payload(self, document_store):
382
+ document_store.write_documents(
383
+ [
384
+ Document(content="int", meta={"flag": 1}, embedding=[0.1] * 768),
385
+ Document(content="bool", meta={"flag": True}, embedding=[0.1] * 768),
386
+ ]
387
+ )
388
+
389
+ result = document_store.filter_documents({"field": "meta.flag", "operator": "==", "value": True})
390
+
391
+ assert [d.content for d in result] == ["bool"]