qdrant-haystack 13.0.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.0.0 → qdrant_haystack-13.1.1}/CHANGELOG.md +18 -0
  2. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/PKG-INFO +1 -1
  3. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/document_store.py +59 -48
  4. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/filters.py +15 -1
  5. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_dict_converters.py +3 -0
  6. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_document_store.py +73 -0
  7. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_document_store_async.py +66 -0
  8. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_embedding_retriever.py +1 -0
  9. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_filters.py +76 -16
  10. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_hybrid_retriever.py +1 -0
  11. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_sparse_embedding_retriever.py +1 -0
  12. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/.gitignore +0 -0
  13. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/LICENSE.txt +0 -0
  14. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/README.md +0 -0
  15. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/examples/embedding_retrieval.py +0 -0
  16. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/pydoc/config_docusaurus.yml +0 -0
  17. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/pyproject.toml +0 -0
  18. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  19. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/qdrant/__init__.py +0 -0
  20. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/qdrant/retriever.py +0 -0
  21. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/py.typed +0 -0
  22. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/__init__.py +0 -0
  23. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/converters.py +0 -0
  24. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/migrate_to_sparse.py +0 -0
  25. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/__init__.py +0 -0
  26. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/conftest.py +0 -0
  27. {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_converters.py +0 -0
@@ -1,5 +1,23 @@
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
+
10
+ ## [integrations/qdrant-v13.0.0] - 2026-10-16
11
+
12
+ ### 🧪 Testing
13
+
14
+ - Test compatible integrations with Python 3.15; update pyproject (#4104)
15
+
16
+ ### 🧹 Chores
17
+
18
+ - [**breaking**] Drop Python 3.10 support (#4105)
19
+
20
+
3
21
  ## [integrations/qdrant-v12.0.0] - 2026-10-09
4
22
 
5
23
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qdrant-haystack
3
- Version: 13.0.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
@@ -150,6 +150,7 @@ class QdrantDocumentStore:
150
150
  write_batch_size: int = 100,
151
151
  scroll_size: int = 10_000,
152
152
  payload_fields_to_index: list[dict] | None = None,
153
+ client_kwargs: dict[str, Any] | None = None,
153
154
  ) -> None:
154
155
  """
155
156
  Initializes a QdrantDocumentStore.
@@ -236,6 +237,14 @@ class QdrantDocumentStore:
236
237
  The scroll size for reading documents.
237
238
  :param payload_fields_to_index:
238
239
  List of payload fields to index.
240
+ :param client_kwargs:
241
+ Additional keyword arguments passed to both `QdrantClient` and `AsyncQdrantClient`.
242
+ These arguments take precedence over named client parameters in case of overlap.
243
+ Serialized as-is by `to_dict()`: callbacks and `httpx.Limits` may not serialize, and
244
+ authentication headers will appear in plain text in pipeline YAML.
245
+ The same arguments are used for sync and async clients. For example, an async
246
+ `auth_token_provider` is also passed to `QdrantClient`, where it may not work with
247
+ synchronous requests.
239
248
  """
240
249
 
241
250
  self._client: qdrant_client.QdrantClient | None = None
@@ -255,6 +264,7 @@ class QdrantDocumentStore:
255
264
  self.path = path
256
265
  self.force_disable_check_same_thread = force_disable_check_same_thread
257
266
  self.metadata = metadata or {}
267
+ self.client_kwargs = dict(client_kwargs or {})
258
268
 
259
269
  # Store the Qdrant collection specific attributes
260
270
  self.shard_number = shard_number
@@ -457,7 +467,7 @@ class QdrantDocumentStore:
457
467
  wait=self.wait_result_from_api,
458
468
  )
459
469
 
460
- progress_bar.update(self.write_batch_size)
470
+ progress_bar.update(len(document_batch))
461
471
  return len(document_objects)
462
472
 
463
473
  async def write_documents_async(
@@ -512,7 +522,7 @@ class QdrantDocumentStore:
512
522
  wait=self.wait_result_from_api,
513
523
  )
514
524
 
515
- progress_bar.update(self.write_batch_size)
525
+ progress_bar.update(len(document_batch))
516
526
  return len(document_objects)
517
527
 
518
528
  def delete_documents(self, document_ids: list[str]) -> None:
@@ -684,8 +694,8 @@ class QdrantDocumentStore:
684
694
  """
685
695
  Build field name -> {type: ...} dict from Qdrant payload_schema.
686
696
 
687
- Used when payload_schema has indexed metadata fields (e.g. meta.category).
688
- 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.
689
699
  """
690
700
  fields_info: dict[str, dict[str, str]] = {}
691
701
  for field_name, field_config in payload_schema.items():
@@ -1072,9 +1082,9 @@ class QdrantDocumentStore:
1072
1082
  """
1073
1083
  Returns the information about the metadata fields in the collection.
1074
1084
 
1075
- Since Qdrant may not have a payload schema for unindexed metadata,
1076
- this method scrolls through documents to infer field types from
1077
- 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"].
1078
1088
 
1079
1089
  :returns:
1080
1090
  A dictionary mapping field names to their type information e.g.:
@@ -1091,20 +1101,21 @@ class QdrantDocumentStore:
1091
1101
  payload_schema = collection_info.payload_schema or {}
1092
1102
  fields_info = self._metadata_fields_info_from_schema(payload_schema)
1093
1103
 
1094
- if not fields_info:
1095
- next_offset = None
1096
- while True:
1097
- records, next_offset = self._client.scroll(
1098
- collection_name=self.index,
1099
- scroll_filter=None,
1100
- limit=self.scroll_size,
1101
- offset=next_offset,
1102
- with_payload=True,
1103
- with_vectors=False,
1104
- )
1105
- self._process_records_fields_info(records, fields_info)
1106
- if self._check_stop_scrolling(next_offset):
1107
- 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
1108
1119
 
1109
1120
  return fields_info
1110
1121
  except Exception as e:
@@ -1115,9 +1126,9 @@ class QdrantDocumentStore:
1115
1126
  """
1116
1127
  Asynchronously returns the information about the metadata fields in the collection.
1117
1128
 
1118
- Since Qdrant may not have a payload schema for unindexed metadata,
1119
- this method scrolls through documents to infer field types from
1120
- 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"].
1121
1132
 
1122
1133
  :returns:
1123
1134
  A dictionary mapping field names to their type information e.g.:
@@ -1134,20 +1145,21 @@ class QdrantDocumentStore:
1134
1145
  payload_schema = collection_info.payload_schema or {}
1135
1146
  fields_info = self._metadata_fields_info_from_schema(payload_schema)
1136
1147
 
1137
- if not fields_info:
1138
- next_offset = None
1139
- while True:
1140
- records, next_offset = await self._async_client.scroll(
1141
- collection_name=self.index,
1142
- scroll_filter=None,
1143
- limit=self.scroll_size,
1144
- offset=next_offset,
1145
- with_payload=True,
1146
- with_vectors=False,
1147
- )
1148
- self._process_records_fields_info(records, fields_info)
1149
- if self._check_stop_scrolling(next_offset):
1150
- 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
1151
1163
 
1152
1164
  return fields_info
1153
1165
  except Exception as e:
@@ -1654,7 +1666,7 @@ class QdrantDocumentStore:
1654
1666
  with_vectors=return_embedding,
1655
1667
  score_threshold=score_threshold,
1656
1668
  ).groups
1657
- return self._process_group_results(groups)
1669
+ return self._process_group_results(groups, scale_score=scale_score)
1658
1670
  else:
1659
1671
  points = self._client.query_points(
1660
1672
  collection_name=self.index,
@@ -1716,7 +1728,7 @@ class QdrantDocumentStore:
1716
1728
  with_vectors=return_embedding,
1717
1729
  score_threshold=score_threshold,
1718
1730
  ).groups
1719
- return self._process_group_results(groups)
1731
+ return self._process_group_results(groups, scale_score=scale_score)
1720
1732
 
1721
1733
  else:
1722
1734
  points = self._client.query_points(
@@ -1914,7 +1926,7 @@ class QdrantDocumentStore:
1914
1926
  score_threshold=score_threshold,
1915
1927
  )
1916
1928
  groups = response.groups
1917
- return self._process_group_results(groups)
1929
+ return self._process_group_results(groups, scale_score=scale_score)
1918
1930
  else:
1919
1931
  query_response = await self._async_client.query_points(
1920
1932
  collection_name=self.index,
@@ -1978,7 +1990,7 @@ class QdrantDocumentStore:
1978
1990
  score_threshold=score_threshold,
1979
1991
  )
1980
1992
  groups = response.groups
1981
- return self._process_group_results(groups)
1993
+ return self._process_group_results(groups, scale_score=scale_score)
1982
1994
  else:
1983
1995
  query_response = await self._async_client.query_points(
1984
1996
  collection_name=self.index,
@@ -2485,6 +2497,7 @@ class QdrantDocumentStore:
2485
2497
  # when using a hosted Qdrant service, which means running to_dict() exposes the api key.
2486
2498
  "metadata": {**self.metadata},
2487
2499
  "force_disable_check_same_thread": self.force_disable_check_same_thread,
2500
+ **{key: {**value} if isinstance(value, dict) else value for key, value in self.client_kwargs.items()},
2488
2501
  }
2489
2502
 
2490
2503
  def _prepare_collection_config(
@@ -2568,7 +2581,7 @@ class QdrantDocumentStore:
2568
2581
 
2569
2582
  return documents
2570
2583
 
2571
- 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]:
2572
2585
  """
2573
2586
  Processes grouped query results from Qdrant.
2574
2587
 
@@ -2576,11 +2589,9 @@ class QdrantDocumentStore:
2576
2589
  if not groups:
2577
2590
  return []
2578
2591
 
2579
- return [
2580
- convert_qdrant_point_to_haystack_document(point, use_sparse_embeddings=self.use_sparse_embeddings)
2581
- for group in groups
2582
- for point in group.hits
2583
- ]
2592
+ return self._process_query_point_results(
2593
+ [point for group in groups for point in group.hits], scale_score=scale_score
2594
+ )
2584
2595
 
2585
2596
  def _validate_collection_compatibility(
2586
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
 
@@ -43,6 +43,7 @@ def test_to_dict():
43
43
  "write_batch_size": 100,
44
44
  "scroll_size": 10000,
45
45
  "payload_fields_to_index": None,
46
+ "client_kwargs": {},
46
47
  },
47
48
  }
48
49
 
@@ -73,6 +74,7 @@ def test_from_dict():
73
74
  "write_batch_size": 1000,
74
75
  "scroll_size": 10000,
75
76
  "payload_fields_to_index": None,
77
+ "client_kwargs": {"check_compatibility": False},
76
78
  },
77
79
  }
78
80
  )
@@ -102,5 +104,6 @@ def test_from_dict():
102
104
  document_store.scroll_size == 10000,
103
105
  document_store.api_key == Secret.from_env_var("ENV_VAR", strict=False),
104
106
  document_store.payload_fields_to_index is None,
107
+ document_store.client_kwargs == {"check_compatibility": False},
105
108
  ]
106
109
  )
@@ -49,6 +49,7 @@ class TestQdrantDocumentStoreUnit:
49
49
  wait_result_from_api=True,
50
50
  use_sparse_embeddings=False,
51
51
  api_key=Secret.from_env_var("QDRANT_API_KEY"),
52
+ client_kwargs={"check_compatibility": False},
52
53
  )
53
54
  expected_dict = {
54
55
  "type": "haystack_integrations.document_stores.qdrant.document_store.QdrantDocumentStore",
@@ -91,6 +92,7 @@ class TestQdrantDocumentStoreUnit:
91
92
  "write_batch_size": 100,
92
93
  "scroll_size": 10000,
93
94
  "payload_fields_to_index": None,
95
+ "client_kwargs": {"check_compatibility": False},
94
96
  },
95
97
  }
96
98
  assert doc_store.to_dict() == expected_dict
@@ -347,6 +349,30 @@ class TestQdrantDocumentStoreUnit:
347
349
  params["metadata"]["added"] = "x"
348
350
  assert metadata == {"key": "value"}
349
351
 
352
+ def test_prepare_client_params_does_not_mutate_client_kwargs(self):
353
+ client_kwargs = {"grpc_options": {"custom_option": "value"}}
354
+ document_store = QdrantDocumentStore(location=":memory:", client_kwargs=client_kwargs)
355
+ params = document_store._prepare_client_params()
356
+ params["grpc_options"]["grpc.primary_user_agent"] = "qdrant-client"
357
+
358
+ assert client_kwargs == {"grpc_options": {"custom_option": "value"}}
359
+ assert document_store.to_dict()["init_parameters"]["client_kwargs"] == client_kwargs
360
+
361
+ @pytest.mark.parametrize("port", [6333, 7333])
362
+ def test_client_kwargs_are_forwarded_with_client_kwargs_taking_precedence(self, port):
363
+ document_store = QdrantDocumentStore(
364
+ location=":memory:", port=port, client_kwargs={"check_compatibility": False, "port": 8333}
365
+ )
366
+
367
+ with (
368
+ patch("haystack_integrations.document_stores.qdrant.document_store.qdrant_client.QdrantClient") as client,
369
+ patch.object(document_store, "_set_up_collection"),
370
+ ):
371
+ document_store._initialize_client()
372
+
373
+ assert client.call_args.kwargs["check_compatibility"] is False
374
+ assert client.call_args.kwargs["port"] == 8333
375
+
350
376
  def test_get_batches_from_generator(self):
351
377
  batches = list(get_batches_from_generator([1, 2, 3, 4, 5], 2))
352
378
  assert batches == [(1, 2), (3, 4), (5,)]
@@ -403,6 +429,23 @@ class TestQdrantDocumentStoreUnit:
403
429
  getattr(document_store, method_name)(*args)
404
430
  assert exc_info.value.__cause__ is err
405
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
+
406
449
  def test_close(self):
407
450
  document_store = QdrantDocumentStore(location=":memory:")
408
451
  mock_client = MagicMock()
@@ -426,6 +469,36 @@ class TestQdrantDocumentStoreUnit:
426
469
 
427
470
  assert document_store._client is None
428
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
+
429
502
 
430
503
  @pytest.mark.integration
431
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
@@ -35,6 +36,23 @@ from haystack_integrations.document_stores.qdrant.document_store import (
35
36
 
36
37
  @pytest.mark.asyncio
37
38
  class TestQdrantDocumentStoreAsyncUnit:
39
+ @pytest.mark.parametrize("port", [6333, 7333])
40
+ async def test_client_kwargs_are_forwarded_async(self, port):
41
+ document_store = QdrantDocumentStore(
42
+ location=":memory:", port=port, client_kwargs={"check_compatibility": False, "port": 8333}
43
+ )
44
+
45
+ with (
46
+ patch(
47
+ "haystack_integrations.document_stores.qdrant.document_store.qdrant_client.AsyncQdrantClient"
48
+ ) as client,
49
+ patch.object(document_store, "_set_up_collection_async", new_callable=AsyncMock),
50
+ ):
51
+ await document_store._initialize_async_client()
52
+
53
+ assert client.call_args.kwargs["check_compatibility"] is False
54
+ assert client.call_args.kwargs["port"] == 8333
55
+
38
56
  async def test_query_hybrid_search_batch_failure_async(self):
39
57
  document_store = QdrantDocumentStore(location=":memory:", use_sparse_embeddings=True)
40
58
  await document_store._initialize_async_client()
@@ -167,6 +185,22 @@ class TestQdrantDocumentStoreAsyncUnit:
167
185
  await getattr(document_store, method_name)(*args)
168
186
  assert exc_info.value.__cause__ is err
169
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
+
170
204
  async def test_close_async(self):
171
205
  document_store = QdrantDocumentStore(location=":memory:")
172
206
  mock_client = AsyncMock()
@@ -190,6 +224,38 @@ class TestQdrantDocumentStoreAsyncUnit:
190
224
 
191
225
  assert document_store._async_client is None
192
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
+
193
259
 
194
260
  @pytest.mark.integration
195
261
  @pytest.mark.asyncio
@@ -83,6 +83,7 @@ class TestQdrantRetriever:
83
83
  "write_batch_size": 100,
84
84
  "scroll_size": 10000,
85
85
  "payload_fields_to_index": None,
86
+ "client_kwargs": {},
86
87
  },
87
88
  },
88
89
  "filters": None,
@@ -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"]
@@ -81,6 +81,7 @@ class TestQdrantHybridRetriever:
81
81
  "write_batch_size": 100,
82
82
  "scroll_size": 10000,
83
83
  "payload_fields_to_index": None,
84
+ "client_kwargs": {},
84
85
  },
85
86
  },
86
87
  "filters": None,
@@ -82,6 +82,7 @@ class TestQdrantSparseEmbeddingRetriever:
82
82
  "write_batch_size": 100,
83
83
  "scroll_size": 10000,
84
84
  "payload_fields_to_index": None,
85
+ "client_kwargs": {},
85
86
  },
86
87
  },
87
88
  "filters": None,