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.
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/CHANGELOG.md +18 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/PKG-INFO +1 -1
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/document_store.py +59 -48
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/filters.py +15 -1
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_dict_converters.py +3 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_document_store.py +73 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_document_store_async.py +66 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_embedding_retriever.py +1 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_filters.py +76 -16
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_hybrid_retriever.py +1 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/test_sparse_embedding_retriever.py +1 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/.gitignore +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/LICENSE.txt +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/README.md +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/examples/embedding_retrieval.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/pydoc/config_docusaurus.yml +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/pyproject.toml +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/qdrant/__init__.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/components/retrievers/qdrant/retriever.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/py.typed +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/__init__.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/converters.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/qdrant/migrate_to_sparse.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/__init__.py +0 -0
- {qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/tests/conftest.py +0 -0
- {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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
2581
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qdrant_haystack-13.0.0 → qdrant_haystack-13.1.1}/src/haystack_integrations/document_stores/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|