muscles-data-qdrant 0.1.0__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.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-qdrant
3
+ Version: 0.1.0
4
+ Summary: Qdrant VectorSearchPort adapter for muscles-data
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles-data<1.0.0,>=0.1.0
9
+ Requires-Dist: qdrant-client<2.0,>=1.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-qdrant
14
+
15
+ Qdrant adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports, resource runtime and diagnostics, while this package owns the
19
+ Qdrant-backed `VectorSearchPort` implementation.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Redis key-value/lock/stream adapter:
30
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
31
+ - MongoDB document-store adapter:
32
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
33
+ - S3 object-store adapter:
34
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_qdrant_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_qdrant_1)
39
+
40
+ ## Usage
41
+
42
+ Register the factory in the project composition root:
43
+
44
+ ```python
45
+ from muscles_data.catalog import DataAdapterCatalog
46
+ from muscles_data.ports import VectorSearchPort
47
+ from muscles_data.runtime import DataRuntime
48
+ from muscles_data_qdrant import QdrantVectorFactory
49
+
50
+ catalog = DataAdapterCatalog.with_defaults()
51
+ catalog.register(QdrantVectorFactory())
52
+
53
+ runtime = DataRuntime(config=config, catalog=catalog)
54
+ vector = runtime.require_port("vector.docs", VectorSearchPort)
55
+ ```
56
+
57
+ Resource config stays in the project:
58
+
59
+ ```yaml
60
+ data:
61
+ resources:
62
+ vector.docs:
63
+ type: qdrant
64
+ url_env: QDRANT_URL
65
+ api_key: ${QDRANT_API_KEY}
66
+ collection: docs
67
+ vector_size: 1536
68
+ distance: cosine
69
+ timeout: 3
70
+ prefer_grpc: false
71
+ ```
72
+
73
+ The adapter creates the Qdrant client and collection lazily on vector search/write/delete,
74
+ explicit native access or `data.doctor`. Application code should use
75
+ `VectorSearchPort`; direct client access is only an advanced escape hatch with
76
+ `native_client: true`.
77
+
78
+ `vector_size` is required when a missing collection must be created. Request
79
+ dimensions are validated against the configured/existing collection, and
80
+ keyword payload indexes are created for standard AssetForge filter fields.
81
+ Embeddings are not generated by this adapter.
82
+
83
+ See `muscular-example/example_data_qdrant_1` for an executable example.
@@ -0,0 +1,71 @@
1
+ # muscles-data-qdrant
2
+
3
+ Qdrant adapter package for `muscles-data`.
4
+
5
+ This package is intentionally separate from `muscles-data`: the core package
6
+ owns typed ports, resource runtime and diagnostics, while this package owns the
7
+ Qdrant-backed `VectorSearchPort` implementation.
8
+
9
+ ## Related packages
10
+
11
+ - Core runtime and port contracts:
12
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
13
+ - Elasticsearch search adapter:
14
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
15
+ - OpenSearch search adapter:
16
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
17
+ - Redis key-value/lock/stream adapter:
18
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
19
+ - MongoDB document-store adapter:
20
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
21
+ - S3 object-store adapter:
22
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
23
+ - SQLAlchemy direct SQL resource adapter:
24
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
25
+ - Executable example:
26
+ [`example_data_qdrant_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_qdrant_1)
27
+
28
+ ## Usage
29
+
30
+ Register the factory in the project composition root:
31
+
32
+ ```python
33
+ from muscles_data.catalog import DataAdapterCatalog
34
+ from muscles_data.ports import VectorSearchPort
35
+ from muscles_data.runtime import DataRuntime
36
+ from muscles_data_qdrant import QdrantVectorFactory
37
+
38
+ catalog = DataAdapterCatalog.with_defaults()
39
+ catalog.register(QdrantVectorFactory())
40
+
41
+ runtime = DataRuntime(config=config, catalog=catalog)
42
+ vector = runtime.require_port("vector.docs", VectorSearchPort)
43
+ ```
44
+
45
+ Resource config stays in the project:
46
+
47
+ ```yaml
48
+ data:
49
+ resources:
50
+ vector.docs:
51
+ type: qdrant
52
+ url_env: QDRANT_URL
53
+ api_key: ${QDRANT_API_KEY}
54
+ collection: docs
55
+ vector_size: 1536
56
+ distance: cosine
57
+ timeout: 3
58
+ prefer_grpc: false
59
+ ```
60
+
61
+ The adapter creates the Qdrant client and collection lazily on vector search/write/delete,
62
+ explicit native access or `data.doctor`. Application code should use
63
+ `VectorSearchPort`; direct client access is only an advanced escape hatch with
64
+ `native_client: true`.
65
+
66
+ `vector_size` is required when a missing collection must be created. Request
67
+ dimensions are validated against the configured/existing collection, and
68
+ keyword payload indexes are created for standard AssetForge filter fields.
69
+ Embeddings are not generated by this adapter.
70
+
71
+ See `muscular-example/example_data_qdrant_1` for an executable example.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "muscles-data-qdrant"
7
+ version = "0.1.0"
8
+ description = "Qdrant VectorSearchPort adapter for muscles-data"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Denis B.", email = "denis@butko.info" }]
12
+ dependencies = [
13
+ "muscles-data>=0.1.0,<1.0.0",
14
+ "qdrant-client>=1.10,<2.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0,<9.0",
20
+ ]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ markers = [
25
+ "integration: tests that require the local backend integration stack",
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ package-dir = {"" = "src"}
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from .adapter import (
4
+ QdrantAdapterError,
5
+ QdrantClientMissingError,
6
+ QdrantConfigError,
7
+ QdrantConnectionError,
8
+ QdrantDimensionError,
9
+ QdrantFilterError,
10
+ QdrantVectorAdapter,
11
+ QdrantVectorFactory,
12
+ qdrant_filter_from_mapping,
13
+ )
14
+
15
+
16
+ __all__ = [
17
+ "QdrantAdapterError",
18
+ "QdrantClientMissingError",
19
+ "QdrantConfigError",
20
+ "QdrantConnectionError",
21
+ "QdrantDimensionError",
22
+ "QdrantFilterError",
23
+ "QdrantVectorAdapter",
24
+ "QdrantVectorFactory",
25
+ "qdrant_filter_from_mapping",
26
+ ]
@@ -0,0 +1,509 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import threading
5
+ import uuid
6
+ from collections.abc import Callable
7
+ from dataclasses import asdict
8
+ from typing import Any, Mapping
9
+
10
+ from muscles_data.config import DataResourceConfig
11
+ from muscles_data.errors import (
12
+ DataConfigurationError,
13
+ DataConnectionError,
14
+ DataError,
15
+ DataSchemaMismatchError,
16
+ DataUnsupportedOperationError,
17
+ DataVectorDimensionError,
18
+ )
19
+ from muscles_data.models import DataCapability, HealthResult, InspectResult, VectorHit, WriteResult
20
+
21
+
22
+ _RANGE_OPERATORS = {"gt", "gte", "lt", "lte"}
23
+ _CLIENT_UNSET = object()
24
+ _MODELS_UNSET = object()
25
+ _PUBLIC_ID_FIELD = "_muscles_id"
26
+
27
+
28
+ class QdrantAdapterError(DataError):
29
+ """Base error for Qdrant vector adapter failures."""
30
+
31
+
32
+ class QdrantConfigError(DataConfigurationError, QdrantAdapterError):
33
+ """Raised when Qdrant resource configuration is incomplete or invalid."""
34
+
35
+
36
+ class QdrantClientMissingError(DataConfigurationError, QdrantAdapterError):
37
+ """Raised when Qdrant adapter is used without an available client."""
38
+
39
+
40
+ class QdrantConnectionError(DataConnectionError, QdrantAdapterError):
41
+ """Raised when Qdrant operation cannot reach or use the backend."""
42
+
43
+
44
+ class QdrantFilterError(DataUnsupportedOperationError, QdrantAdapterError):
45
+ """Raised when a data filter cannot be translated to Qdrant."""
46
+
47
+
48
+ class QdrantDimensionError(DataVectorDimensionError, QdrantAdapterError):
49
+ """Raised when vectors are empty or incompatible with the Qdrant collection."""
50
+
51
+
52
+ class QdrantVectorAdapter:
53
+ resource_type = "qdrant"
54
+
55
+ def __init__(
56
+ self,
57
+ config: DataResourceConfig,
58
+ *,
59
+ client_factory: Callable[[DataResourceConfig], Any] | None = None,
60
+ models_provider: Callable[[], Any] | None = None,
61
+ ) -> None:
62
+ self.config = config
63
+ self._client_factory = client_factory
64
+ self._models_provider = models_provider
65
+ self._client: Any = _CLIENT_UNSET
66
+ self._models: Any = _MODELS_UNSET
67
+ self._lock = threading.RLock()
68
+ self._collection_initialized = False
69
+ self.closed = False
70
+
71
+ def search_vectors(
72
+ self,
73
+ vector: list[float],
74
+ filters: Mapping[str, Any] | None = None,
75
+ limit: int = 10,
76
+ options: Mapping[str, Any] | None = None,
77
+ ) -> list[VectorHit]:
78
+ options = dict(options or {})
79
+ normalized_vector = _normalize_vector(vector)
80
+ self._validate_dimension(normalized_vector)
81
+ self._ensure_collection(len(normalized_vector))
82
+ query_filter = self._filter(filters)
83
+ kwargs: dict[str, Any] = {
84
+ "collection_name": self.collection_name(),
85
+ "query": normalized_vector,
86
+ "limit": max(0, int(limit)),
87
+ "with_payload": options.pop("with_payload", True),
88
+ "with_vectors": options.pop("with_vectors", False),
89
+ }
90
+ if query_filter is not None:
91
+ kwargs["query_filter"] = query_filter
92
+ for key in ("score_threshold", "using", "search_params", "params", "consistency", "shard_key_selector"):
93
+ if key in options:
94
+ kwargs[key] = options[key]
95
+ try:
96
+ result = self._client_instance().query_points(**kwargs)
97
+ except (QdrantClientMissingError, QdrantDimensionError, QdrantFilterError):
98
+ raise
99
+ except Exception as exc:
100
+ _raise_qdrant_operation_error(exc)
101
+ points = getattr(result, "points", result)
102
+ return [_hit_from_point(point) for point in points]
103
+
104
+ def upsert_vectors(self, items: list[Mapping[str, Any]], options: Mapping[str, Any] | None = None) -> WriteResult:
105
+ options = dict(options or {})
106
+ models = self._models_instance()
107
+ try:
108
+ normalized_items = []
109
+ for item in items:
110
+ vector = _normalize_vector(item["vector"])
111
+ self._validate_dimension(vector)
112
+ normalized_items.append((item, vector))
113
+ points = [
114
+ models.PointStruct(
115
+ id=_qdrant_point_id(str(item["id"])),
116
+ vector=vector,
117
+ payload=_payload_with_public_id(item),
118
+ )
119
+ for item, vector in normalized_items
120
+ ]
121
+ except KeyError as exc:
122
+ raise QdrantDimensionError(f"Vector item requires {exc.args[0]}") from exc
123
+ if normalized_items:
124
+ self._ensure_collection(len(normalized_items[0][1]))
125
+ kwargs: dict[str, Any] = {"collection_name": self.collection_name(), "points": points}
126
+ if "wait" in options:
127
+ kwargs["wait"] = bool(options["wait"])
128
+ try:
129
+ self._client_instance().upsert(**kwargs)
130
+ except (QdrantClientMissingError, QdrantDimensionError, QdrantFilterError):
131
+ raise
132
+ except Exception as exc:
133
+ _raise_qdrant_operation_error(exc)
134
+ return WriteResult(written=len(items), matched=len(items))
135
+
136
+ def delete_vectors(
137
+ self,
138
+ ids: list[str] | None = None,
139
+ filters: Mapping[str, Any] | None = None,
140
+ options: Mapping[str, Any] | None = None,
141
+ ) -> WriteResult:
142
+ options = dict(options or {})
143
+ if ids is None and not filters:
144
+ return WriteResult()
145
+ models = self._models_instance()
146
+ self._ensure_collection()
147
+ if ids is not None:
148
+ selector = models.PointIdsList(points=[_qdrant_point_id(str(item_id)) for item_id in ids])
149
+ deleted = len(ids)
150
+ else:
151
+ selector = models.FilterSelector(filter=self._filter(filters))
152
+ deleted = 0
153
+ kwargs: dict[str, Any] = {"collection_name": self.collection_name(), "points_selector": selector}
154
+ if "wait" in options:
155
+ kwargs["wait"] = bool(options["wait"])
156
+ try:
157
+ self._client_instance().delete(**kwargs)
158
+ except (QdrantClientMissingError, QdrantDimensionError, QdrantFilterError):
159
+ raise
160
+ except Exception as exc:
161
+ _raise_qdrant_operation_error(exc)
162
+ return WriteResult(deleted=deleted, matched=deleted)
163
+
164
+ def inspect(self) -> dict[str, Any]:
165
+ return asdict(
166
+ InspectResult(
167
+ name=self.config.name,
168
+ type=self.config.type,
169
+ capabilities=[],
170
+ initialized=self._client is not _CLIENT_UNSET,
171
+ status="ok",
172
+ options=self.config.safe_options(),
173
+ details={"backend": "qdrant", "collection": self.collection_name()},
174
+ )
175
+ )
176
+
177
+ def health(self) -> HealthResult:
178
+ try:
179
+ self._ensure_collection()
180
+ client = self._client_instance()
181
+ collection = self.collection_name()
182
+ exists = self._collection_initialized
183
+ if not exists:
184
+ if hasattr(client, "collection_exists"):
185
+ exists = bool(client.collection_exists(collection_name=collection))
186
+ else:
187
+ client.get_collection(collection_name=collection)
188
+ exists = True
189
+ except DataConfigurationError as exc:
190
+ return HealthResult(status="failed", code="configuration_error", message=str(exc), details={"collection": self.collection_name()})
191
+ except DataSchemaMismatchError as exc:
192
+ return HealthResult(status="failed", code="schema_mismatch", message=str(exc), details={"collection": self.collection_name()})
193
+ except Exception as exc:
194
+ return HealthResult(status="failed", code="connection_failed", message=self._safe_error(exc), details={"collection": self.collection_name()})
195
+ if not exists:
196
+ return HealthResult(
197
+ status="failed",
198
+ code="resource_missing",
199
+ message=f"Qdrant collection '{self.collection_name()}' is not available",
200
+ details={"collection": self.collection_name()},
201
+ )
202
+ return HealthResult(status="ok", code="ok", message="Qdrant collection is available", details={"collection": self.collection_name()})
203
+
204
+ def close(self) -> None:
205
+ if self._client is _CLIENT_UNSET:
206
+ self.closed = True
207
+ return
208
+ close = getattr(self._client, "close", None)
209
+ if callable(close):
210
+ close()
211
+ self.closed = True
212
+
213
+ def native_client(self):
214
+ return self._client_instance()
215
+
216
+ def collection_name(self) -> str:
217
+ try:
218
+ return str(self.config.options["collection"])
219
+ except KeyError as exc:
220
+ raise QdrantConfigError("Qdrant resource requires collection") from exc
221
+
222
+ def vector_size(self) -> int | None:
223
+ value = self.config.options.get("vector_size")
224
+ if value is None:
225
+ return None
226
+ try:
227
+ normalized = int(value)
228
+ except (TypeError, ValueError) as exc:
229
+ raise QdrantConfigError("Qdrant vector_size must be a positive integer") from exc
230
+ if normalized <= 0:
231
+ raise QdrantConfigError("Qdrant vector_size must be a positive integer")
232
+ return normalized
233
+
234
+ def distance(self) -> str:
235
+ value = str(self.config.options.get("distance", "cosine")).lower()
236
+ if value not in {"cosine", "dot", "euclid"}:
237
+ raise QdrantConfigError("Qdrant distance must be cosine, dot or euclid")
238
+ return value
239
+
240
+ def payload_indexes(self) -> list[str]:
241
+ configured = self.config.options.get(
242
+ "payload_indexes",
243
+ ["workspace_uid", "resource_type", "section_uid", "category", "status"],
244
+ )
245
+ if not isinstance(configured, (list, tuple, set)):
246
+ raise QdrantConfigError("Qdrant payload_indexes must be a list")
247
+ return [str(field) for field in configured]
248
+
249
+ def _validate_dimension(self, vector: list[float]) -> None:
250
+ expected = self.vector_size()
251
+ if expected is not None and len(vector) != expected:
252
+ raise QdrantDimensionError(
253
+ f"Qdrant vector dimension mismatch: expected {expected}, got {len(vector)}"
254
+ )
255
+
256
+ def _ensure_collection(self, vector_size: int | None = None) -> None:
257
+ if self._collection_initialized:
258
+ return
259
+ client = self._client_instance()
260
+ collection = self.collection_name()
261
+ try:
262
+ if hasattr(client, "collection_exists"):
263
+ exists = bool(client.collection_exists(collection_name=collection))
264
+ else:
265
+ client.get_collection(collection_name=collection)
266
+ exists = True
267
+ if not exists:
268
+ size = self.vector_size() or vector_size
269
+ if size is None:
270
+ raise QdrantConfigError("Qdrant vector_size is required to create a collection")
271
+ models = self._models_instance()
272
+ distance_name = self.distance().upper()
273
+ distance = getattr(getattr(models, "Distance", None), distance_name, distance_name.title())
274
+ client.create_collection(
275
+ collection_name=collection,
276
+ vectors_config=models.VectorParams(size=size, distance=distance),
277
+ )
278
+ else:
279
+ self._validate_existing_collection(client)
280
+ self._ensure_payload_indexes(client)
281
+ self._collection_initialized = True
282
+ except (QdrantConfigError, QdrantDimensionError):
283
+ raise
284
+ except Exception as exc:
285
+ _raise_qdrant_operation_error(exc)
286
+
287
+ def _ensure_payload_indexes(self, client: Any) -> None:
288
+ create = getattr(client, "create_payload_index", None)
289
+ if not callable(create):
290
+ return
291
+ models = self._models_instance()
292
+ schema_type = getattr(getattr(models, "PayloadSchemaType", None), "KEYWORD", "keyword")
293
+ for field in self.payload_indexes():
294
+ try:
295
+ create(
296
+ collection_name=self.collection_name(),
297
+ field_name=field,
298
+ field_schema=schema_type,
299
+ )
300
+ except Exception as exc:
301
+ if "already exists" not in str(exc).lower():
302
+ raise
303
+
304
+ def _validate_existing_collection(self, client: Any) -> None:
305
+ expected = self.vector_size()
306
+ get_collection = getattr(client, "get_collection", None)
307
+ if expected is None or not callable(get_collection):
308
+ return
309
+ info = get_collection(collection_name=self.collection_name())
310
+ vectors = getattr(getattr(getattr(info, "config", None), "params", None), "vectors", None)
311
+ remote_size = getattr(vectors, "size", None)
312
+ if remote_size is not None and int(remote_size) != expected:
313
+ raise QdrantDimensionError(
314
+ f"Qdrant collection dimension mismatch: expected {expected}, got {remote_size}"
315
+ )
316
+
317
+ def _client_instance(self):
318
+ if self._client is _CLIENT_UNSET:
319
+ with self._lock:
320
+ if self._client is _CLIENT_UNSET:
321
+ client = self._client_factory(self.config) if self._client_factory else _default_qdrant_client(self.config)
322
+ if client is None:
323
+ raise QdrantClientMissingError("Qdrant client is not available")
324
+ self._client = client
325
+ return self._client
326
+
327
+ def _models_instance(self):
328
+ if self._models is _MODELS_UNSET:
329
+ with self._lock:
330
+ if self._models is _MODELS_UNSET:
331
+ self._models = self._models_provider() if self._models_provider else _default_qdrant_models()
332
+ return self._models
333
+
334
+ def _filter(self, filters: Mapping[str, Any] | None):
335
+ if not filters:
336
+ return None
337
+ return qdrant_filter_from_mapping(filters, models=self._models_instance())
338
+
339
+ def _safe_error(self, exc: Exception) -> str:
340
+ message = str(exc)
341
+ try:
342
+ options = self.config.resolved_options()
343
+ except DataConfigurationError:
344
+ options = self.config.options
345
+ for key, value in options.items():
346
+ if key in {"url", "api_key", "token", "password"} and value:
347
+ message = message.replace(str(value), "***")
348
+ return message
349
+
350
+
351
+ class QdrantVectorFactory:
352
+ resource_type = "qdrant"
353
+
354
+ def __init__(
355
+ self,
356
+ *,
357
+ client_factory: Callable[[DataResourceConfig], Any] | None = None,
358
+ models_provider: Callable[[], Any] | None = None,
359
+ ) -> None:
360
+ self._client_factory = client_factory
361
+ self._models_provider = models_provider
362
+
363
+ def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
364
+ native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
365
+ return {DataCapability.VECTOR_SEARCH, DataCapability.VECTOR_WRITE, DataCapability.HEALTHCHECK} | native
366
+
367
+ def create(self, config: DataResourceConfig) -> QdrantVectorAdapter:
368
+ return QdrantVectorAdapter(
369
+ config,
370
+ client_factory=self._client_factory,
371
+ models_provider=self._models_provider,
372
+ )
373
+
374
+
375
+ def qdrant_filter_from_mapping(filters: Mapping[str, Any], *, models=None):
376
+ models = models or _default_qdrant_models()
377
+ must: list[Any] = []
378
+ should: list[Any] = []
379
+ must_not: list[Any] = []
380
+ for key in sorted(filters, key=str):
381
+ value = filters[key]
382
+ if key == "$and":
383
+ must.extend(_logical_conditions(value, models=models))
384
+ elif key == "$or":
385
+ should.extend(_logical_conditions(value, models=models))
386
+ elif key == "$not":
387
+ must_not.extend(_logical_conditions(value, models=models))
388
+ else:
389
+ must.append(_field_condition(str(key), value, models=models))
390
+ return models.Filter(must=must, should=should, must_not=must_not)
391
+
392
+
393
+ def _logical_conditions(value: Any, *, models) -> list[Any]:
394
+ if isinstance(value, Mapping):
395
+ return _conditions_from_mapping(value, models=models)
396
+ if isinstance(value, list):
397
+ conditions: list[Any] = []
398
+ for item in value:
399
+ if not isinstance(item, Mapping):
400
+ raise QdrantFilterError("Qdrant logical filter items must be mappings")
401
+ conditions.extend(_conditions_from_mapping(item, models=models))
402
+ return conditions
403
+ raise QdrantFilterError("Qdrant logical filters must be mappings or lists of mappings")
404
+
405
+
406
+ def _conditions_from_mapping(filters: Mapping[str, Any], *, models) -> list[Any]:
407
+ conditions: list[Any] = []
408
+ for key in sorted(filters, key=str):
409
+ value = filters[key]
410
+ if key in {"$and", "$or", "$not"}:
411
+ conditions.append(qdrant_filter_from_mapping({key: value}, models=models))
412
+ else:
413
+ conditions.append(_field_condition(str(key), value, models=models))
414
+ return conditions
415
+
416
+
417
+ def _field_condition(key: str, value: Any, *, models):
418
+ if isinstance(value, Mapping):
419
+ operators = set(value)
420
+ if operators <= _RANGE_OPERATORS:
421
+ return models.FieldCondition(key=key, range=models.Range(**dict(value)))
422
+ if operators == {"eq"}:
423
+ return models.FieldCondition(key=key, match=models.MatchValue(value=value["eq"]))
424
+ if operators == {"any"}:
425
+ return models.FieldCondition(key=key, match=models.MatchAny(any=list(value["any"])))
426
+ unsupported = ", ".join(sorted(str(operator) for operator in operators))
427
+ raise QdrantFilterError(f"Unsupported Qdrant filter operator for '{key}': {unsupported}")
428
+ if isinstance(value, (list, tuple, set)):
429
+ return models.FieldCondition(key=key, match=models.MatchAny(any=list(value)))
430
+ return models.FieldCondition(key=key, match=models.MatchValue(value=value))
431
+
432
+
433
+ def _default_qdrant_client(config: DataResourceConfig):
434
+ try:
435
+ client_module = importlib.import_module("qdrant_client")
436
+ except Exception as exc:
437
+ raise QdrantClientMissingError("Install qdrant-client or muscles-data-qdrant to use type=qdrant") from exc
438
+ options = config.resolved_options()
439
+ kwargs = {
440
+ "url": options["url"],
441
+ "api_key": options.get("api_key"),
442
+ "timeout": options.get("timeout"),
443
+ "prefer_grpc": options.get("prefer_grpc"),
444
+ }
445
+ kwargs = {key: value for key, value in kwargs.items() if value is not None}
446
+ try:
447
+ return client_module.QdrantClient(**kwargs)
448
+ except Exception as exc:
449
+ raise QdrantConnectionError(str(exc)) from exc
450
+
451
+
452
+ def _default_qdrant_models():
453
+ try:
454
+ return importlib.import_module("qdrant_client.models")
455
+ except Exception as exc:
456
+ raise QdrantClientMissingError("Install qdrant-client or muscles-data-qdrant to use type=qdrant") from exc
457
+
458
+
459
+ def _normalize_vector(vector: Any) -> list[float]:
460
+ try:
461
+ normalized = [float(value) for value in vector]
462
+ except TypeError as exc:
463
+ raise QdrantDimensionError("Vector must be an iterable of numbers") from exc
464
+ if not normalized:
465
+ raise QdrantDimensionError("Vector must not be empty")
466
+ return normalized
467
+
468
+
469
+ def _hit_from_point(point: Any) -> VectorHit:
470
+ payload = dict(getattr(point, "payload", {}) or {})
471
+ public_id = str(payload.pop(_PUBLIC_ID_FIELD, getattr(point, "id")))
472
+ metadata = {"backend": "qdrant"}
473
+ version = getattr(point, "version", None)
474
+ if version is not None:
475
+ metadata["version"] = version
476
+ return VectorHit(
477
+ id=public_id,
478
+ score=float(getattr(point, "score", 0.0)),
479
+ payload=payload,
480
+ metadata=metadata,
481
+ text=payload.get("text"),
482
+ title=payload.get("title"),
483
+ )
484
+
485
+
486
+ def _payload_with_public_id(item: Mapping[str, Any]) -> dict[str, Any]:
487
+ payload = dict(item.get("payload", {}) or {})
488
+ item_id = str(item["id"])
489
+ existing_id = payload.get(_PUBLIC_ID_FIELD)
490
+ if existing_id is not None and str(existing_id) != item_id:
491
+ raise QdrantConfigError(f"Qdrant payload field '{_PUBLIC_ID_FIELD}' is reserved")
492
+ payload[_PUBLIC_ID_FIELD] = item_id
493
+ return payload
494
+
495
+
496
+ def _qdrant_point_id(value: str) -> str | int:
497
+ """Map the string port id to a Qdrant-supported stable point id."""
498
+ try:
499
+ return int(value) if value.isdigit() else str(uuid.UUID(value))
500
+ except (ValueError, AttributeError):
501
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, f"muscles-data:qdrant:{value}"))
502
+
503
+
504
+ def _raise_qdrant_operation_error(exc: Exception):
505
+ message = str(exc)
506
+ lowered = message.lower()
507
+ if "dimension" in lowered or "vector size" in lowered or "vector dimension" in lowered:
508
+ raise QdrantDimensionError(message) from exc
509
+ raise QdrantConnectionError(message) from exc
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-qdrant
3
+ Version: 0.1.0
4
+ Summary: Qdrant VectorSearchPort adapter for muscles-data
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles-data<1.0.0,>=0.1.0
9
+ Requires-Dist: qdrant-client<2.0,>=1.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-qdrant
14
+
15
+ Qdrant adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports, resource runtime and diagnostics, while this package owns the
19
+ Qdrant-backed `VectorSearchPort` implementation.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Redis key-value/lock/stream adapter:
30
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
31
+ - MongoDB document-store adapter:
32
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
33
+ - S3 object-store adapter:
34
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_qdrant_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_qdrant_1)
39
+
40
+ ## Usage
41
+
42
+ Register the factory in the project composition root:
43
+
44
+ ```python
45
+ from muscles_data.catalog import DataAdapterCatalog
46
+ from muscles_data.ports import VectorSearchPort
47
+ from muscles_data.runtime import DataRuntime
48
+ from muscles_data_qdrant import QdrantVectorFactory
49
+
50
+ catalog = DataAdapterCatalog.with_defaults()
51
+ catalog.register(QdrantVectorFactory())
52
+
53
+ runtime = DataRuntime(config=config, catalog=catalog)
54
+ vector = runtime.require_port("vector.docs", VectorSearchPort)
55
+ ```
56
+
57
+ Resource config stays in the project:
58
+
59
+ ```yaml
60
+ data:
61
+ resources:
62
+ vector.docs:
63
+ type: qdrant
64
+ url_env: QDRANT_URL
65
+ api_key: ${QDRANT_API_KEY}
66
+ collection: docs
67
+ vector_size: 1536
68
+ distance: cosine
69
+ timeout: 3
70
+ prefer_grpc: false
71
+ ```
72
+
73
+ The adapter creates the Qdrant client and collection lazily on vector search/write/delete,
74
+ explicit native access or `data.doctor`. Application code should use
75
+ `VectorSearchPort`; direct client access is only an advanced escape hatch with
76
+ `native_client: true`.
77
+
78
+ `vector_size` is required when a missing collection must be created. Request
79
+ dimensions are validated against the configured/existing collection, and
80
+ keyword payload indexes are created for standard AssetForge filter fields.
81
+ Embeddings are not generated by this adapter.
82
+
83
+ See `muscular-example/example_data_qdrant_1` for an executable example.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/muscles_data_qdrant/__init__.py
4
+ src/muscles_data_qdrant/adapter.py
5
+ src/muscles_data_qdrant.egg-info/PKG-INFO
6
+ src/muscles_data_qdrant.egg-info/SOURCES.txt
7
+ src/muscles_data_qdrant.egg-info/dependency_links.txt
8
+ src/muscles_data_qdrant.egg-info/requires.txt
9
+ src/muscles_data_qdrant.egg-info/top_level.txt
10
+ tests/test_integration.py
11
+ tests/test_qdrant_adapter.py
@@ -0,0 +1,5 @@
1
+ muscles-data<1.0.0,>=0.1.0
2
+ qdrant-client<2.0,>=1.10
3
+
4
+ [dev]
5
+ pytest<9.0,>=8.0
@@ -0,0 +1 @@
1
+ muscles_data_qdrant
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import inspect
5
+ from uuid import uuid4
6
+
7
+ import pytest
8
+ from muscles_data.catalog import DataAdapterCatalog
9
+ from muscles_data.config import DataConfig
10
+ from muscles_data.models import DataCapability
11
+ from muscles_data.ports import VectorSearchPort
12
+ from muscles_data.runtime import DataRuntime
13
+
14
+ from muscles_data_qdrant import QdrantVectorFactory
15
+
16
+
17
+ pytestmark = [
18
+ pytest.mark.integration,
19
+ pytest.mark.skipif(not os.getenv("MUSCLES_DATA_INTEGRATION"), reason="backend integration is disabled"),
20
+ ]
21
+
22
+
23
+ def test_qdrant_real_collection_vector_lifecycle():
24
+ collection = f"muscles_data_it_{uuid4().hex[:12]}"
25
+ config = DataConfig.from_raw(
26
+ {
27
+ "data": {
28
+ "resources": {
29
+ "vector.qdrant": {
30
+ "type": "qdrant",
31
+ "url_env": "QDRANT_URL",
32
+ "collection": collection,
33
+ "vector_size": 3,
34
+ "distance": "cosine",
35
+ "payload_indexes": ["status"],
36
+ "native_client": True,
37
+ }
38
+ }
39
+ }
40
+ }
41
+ )
42
+ catalog = DataAdapterCatalog.with_defaults()
43
+ catalog.register(QdrantVectorFactory())
44
+ runtime = DataRuntime(config=config, catalog=catalog)
45
+
46
+ client = None
47
+ try:
48
+ vector = runtime.require_port("vector.qdrant", VectorSearchPort)
49
+ assert vector.upsert_vectors(
50
+ [
51
+ {"id": "alpha", "vector": [1.0, 0.0, 0.0], "payload": {"status": "ready"}},
52
+ {"id": "beta", "vector": [0.0, 1.0, 0.0], "payload": {"status": "draft"}},
53
+ ],
54
+ options={"wait": True},
55
+ ).written == 2
56
+ hits = vector.search_vectors([1.0, 0.0, 0.0], filters={"status": "ready"}, limit=10)
57
+ assert [hit.id for hit in hits] == ["alpha"]
58
+ assert hits[0].payload["status"] == "ready"
59
+ assert runtime.doctor()["status"] == "ok"
60
+ assert vector.delete_vectors(ids=["alpha"], options={"wait": True}).deleted == 1
61
+ contracts = pytest.importorskip("muscles_data.contracts")
62
+ contract = getattr(contracts, "assert_vector_search_contract", None)
63
+ if contract is not None:
64
+ if "dimension" in inspect.signature(contract).parameters:
65
+ contract(lambda: vector, dimension=3)
66
+ client = runtime.require_resource("vector.qdrant", DataCapability.NATIVE_CLIENT).native_client()
67
+ client.delete_collection(collection_name=collection)
68
+ finally:
69
+ if client is not None:
70
+ try:
71
+ client.delete_collection(collection_name=collection)
72
+ except Exception:
73
+ pass
74
+ runtime.close()
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from typing import Any
5
+ from uuid import NAMESPACE_URL, UUID, uuid5
6
+
7
+ import pytest
8
+ from muscles_data.catalog import DataAdapterCatalog
9
+ from muscles_data.config import DataConfig
10
+ from muscles_data.models import DataCapability
11
+ from muscles_data.ports import VectorSearchPort
12
+ from muscles_data.runtime import DataRuntime
13
+
14
+ from muscles_data_qdrant import (
15
+ QdrantClientMissingError,
16
+ QdrantConnectionError,
17
+ QdrantDimensionError,
18
+ QdrantFilterError,
19
+ QdrantVectorFactory,
20
+ qdrant_filter_from_mapping,
21
+ )
22
+
23
+
24
+ class FakePoint:
25
+ def __init__(self, point_id: str, score: float, payload: dict[str, Any]) -> None:
26
+ self.id = point_id
27
+ self.score = score
28
+ self.payload = payload
29
+ self.version = 7
30
+
31
+
32
+ class FakeQueryResult:
33
+ def __init__(self, points: list[FakePoint]) -> None:
34
+ self.points = points
35
+
36
+
37
+ class FakeQdrantClient:
38
+ def __init__(self, *, fail_health: bool = False) -> None:
39
+ self.fail_health = fail_health
40
+ self.queries: list[dict[str, Any]] = []
41
+ self.upserts: list[dict[str, Any]] = []
42
+ self.deletes: list[dict[str, Any]] = []
43
+ self.collection_checks: list[str] = []
44
+ self.collections_created: list[dict[str, Any]] = []
45
+ self.payload_indexes: list[dict[str, Any]] = []
46
+ self.closed = False
47
+
48
+ def query_points(self, **kwargs):
49
+ self.queries.append(kwargs)
50
+ return FakeQueryResult([
51
+ FakePoint("doc-1", 0.91, {"section": "docs"}),
52
+ FakePoint("doc-2", 0.42, {"section": "other"}),
53
+ ])
54
+
55
+ def upsert(self, **kwargs):
56
+ self.upserts.append(kwargs)
57
+ return SimpleNamespace(status="completed")
58
+
59
+ def delete(self, **kwargs):
60
+ self.deletes.append(kwargs)
61
+ return SimpleNamespace(status="completed")
62
+
63
+ def collection_exists(self, collection_name: str) -> bool:
64
+ self.collection_checks.append(collection_name)
65
+ if self.fail_health:
66
+ raise TimeoutError("qdrant api_key=qdrant-secret timed out")
67
+ return collection_name == "docs"
68
+
69
+ def create_collection(self, **kwargs):
70
+ self.collections_created.append(kwargs)
71
+
72
+ def create_payload_index(self, **kwargs):
73
+ self.payload_indexes.append(kwargs)
74
+
75
+ def close(self) -> None:
76
+ self.closed = True
77
+
78
+
79
+ class FakeModels:
80
+ class Distance:
81
+ COSINE = "Cosine"
82
+ DOT = "Dot"
83
+ EUCLID = "Euclid"
84
+
85
+ class PayloadSchemaType:
86
+ KEYWORD = "keyword"
87
+
88
+ class VectorParams:
89
+ def __init__(self, *, size: int, distance) -> None:
90
+ self.size = size
91
+ self.distance = distance
92
+
93
+ class MatchValue:
94
+ def __init__(self, value) -> None:
95
+ self.value = value
96
+
97
+ class MatchAny:
98
+ def __init__(self, any) -> None:
99
+ self.any = any
100
+
101
+ class Range:
102
+ def __init__(self, **kwargs) -> None:
103
+ self.values = kwargs
104
+
105
+ class FieldCondition:
106
+ def __init__(self, *, key: str, match=None, range=None) -> None:
107
+ self.key = key
108
+ self.match = match
109
+ self.range = range
110
+
111
+ class Filter:
112
+ def __init__(self, *, must=None, should=None, must_not=None) -> None:
113
+ self.must = must or []
114
+ self.should = should or []
115
+ self.must_not = must_not or []
116
+
117
+ class PointStruct:
118
+ def __init__(self, *, id, vector, payload=None) -> None:
119
+ self.id = id
120
+ self.vector = vector
121
+ self.payload = payload or {}
122
+
123
+ class PointIdsList:
124
+ def __init__(self, *, points) -> None:
125
+ self.points = points
126
+
127
+ class FilterSelector:
128
+ def __init__(self, *, filter) -> None:
129
+ self.filter = filter
130
+
131
+
132
+ def _config() -> dict:
133
+ return {
134
+ "data": {
135
+ "resources": {
136
+ "vector.qdrant": {
137
+ "type": "qdrant",
138
+ "url": "https://qdrant.example",
139
+ "api_key": "qdrant-secret",
140
+ "collection": "docs",
141
+ "vector_size": 2,
142
+ "distance": "cosine",
143
+ "native_client": True,
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+
150
+ def _runtime(client: FakeQdrantClient | None):
151
+ catalog = DataAdapterCatalog.with_defaults()
152
+ catalog.register(QdrantVectorFactory(client_factory=lambda _config: client, models_provider=lambda: FakeModels))
153
+ return DataRuntime(config=DataConfig.from_raw(_config()), catalog=catalog)
154
+
155
+
156
+ def test_qdrant_external_adapter_maps_vector_operations_and_native_access():
157
+ client = FakeQdrantClient()
158
+ runtime = _runtime(client)
159
+
160
+ listed = runtime.list_resources()[0]
161
+ assert listed["type"] == "qdrant"
162
+ assert {"vector_search", "vector_write"} <= set(listed["capabilities"])
163
+ assert listed["initialized"] is False
164
+
165
+ vector = runtime.require_port("vector.qdrant", VectorSearchPort)
166
+ hits = vector.search_vectors([0.1, 0.9], filters={"section": "docs"}, limit=2)
167
+ write = vector.upsert_vectors([{"id": "doc-1", "vector": [0.1, 0.9], "payload": {"section": "docs"}}])
168
+ deleted = vector.delete_vectors(ids=["doc-1"])
169
+
170
+ assert [hit.id for hit in hits] == ["doc-1", "doc-2"]
171
+ assert client.queries[0]["query_filter"].must[0].key == "section"
172
+ assert write.written == 1
173
+ assert client.upserts[0]["points"][0].payload == {"section": "docs", "_muscles_id": "doc-1"}
174
+ assert deleted.deleted == 1
175
+ assert client.deletes[0]["points_selector"].points == [str(uuid5(NAMESPACE_URL, "muscles-data:qdrant:doc-1"))]
176
+ assert runtime.require_resource("vector.qdrant", DataCapability.NATIVE_CLIENT).native_client() is client
177
+ assert runtime.doctor()["status"] == "ok"
178
+ assert client.collection_checks == ["docs"]
179
+ assert runtime.close()["status"] == "ok"
180
+ assert client.closed is True
181
+
182
+
183
+ def test_qdrant_external_adapter_filters_dimensions_and_safe_failures():
184
+ translated = qdrant_filter_from_mapping({"score": {"gte": 0.5}, "$not": {"archived": True}}, models=FakeModels)
185
+ assert translated.must[0].key == "score"
186
+ assert translated.must[0].range.values == {"gte": 0.5}
187
+ assert translated.must_not[0].key == "archived"
188
+
189
+ with pytest.raises(QdrantFilterError):
190
+ qdrant_filter_from_mapping({"score": {"near": 1.0}}, models=FakeModels)
191
+ with pytest.raises(QdrantDimensionError):
192
+ _runtime(FakeQdrantClient()).require_port("vector.qdrant", VectorSearchPort).search_vectors([])
193
+ with pytest.raises(QdrantDimensionError):
194
+ _runtime(FakeQdrantClient()).require_port("vector.qdrant", VectorSearchPort).search_vectors([1.0])
195
+ with pytest.raises(QdrantClientMissingError):
196
+ _runtime(None).require_port("vector.qdrant", VectorSearchPort).search_vectors([1.0, 0.0])
197
+
198
+ failing = _runtime(FakeQdrantClient(fail_health=True)).doctor()
199
+ assert failing["status"] == "failed"
200
+ assert "qdrant-secret" not in repr(failing)
201
+
202
+ bad_client = FakeQdrantClient()
203
+ bad_client.query_points = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("network unavailable"))
204
+ with pytest.raises(QdrantConnectionError):
205
+ _runtime(bad_client).require_port("vector.qdrant", VectorSearchPort).search_vectors([1.0, 0.0])
206
+
207
+
208
+ def test_qdrant_creates_collection_indexes_payload_and_resolves_url_env(monkeypatch):
209
+ monkeypatch.setenv("QDRANT_URL", "https://qdrant.example")
210
+ client = FakeQdrantClient()
211
+ client.collection_exists = lambda collection_name: False
212
+ captured: list[dict] = []
213
+ catalog = DataAdapterCatalog.with_defaults()
214
+ catalog.register(
215
+ QdrantVectorFactory(
216
+ client_factory=lambda config: captured.append(config.resolved_options()) or client,
217
+ models_provider=lambda: FakeModels,
218
+ )
219
+ )
220
+ runtime = DataRuntime(
221
+ config=DataConfig.from_raw(
222
+ {
223
+ "data": {
224
+ "resources": {
225
+ "vector.qdrant": {
226
+ "type": "qdrant",
227
+ "url_env": "QDRANT_URL",
228
+ "collection": "assetforge_rag",
229
+ "vector_size": 2,
230
+ "distance": "cosine",
231
+ "payload_indexes": ["workspace_uid", "status"],
232
+ }
233
+ }
234
+ }
235
+ }
236
+ ),
237
+ catalog=catalog,
238
+ )
239
+
240
+ runtime.require_port("vector.qdrant", VectorSearchPort).upsert_vectors(
241
+ [{"id": "doc-1", "vector": [1.0, 0.0], "payload": {"status": "ready"}}]
242
+ )
243
+
244
+ assert captured[0]["url"] == "https://qdrant.example"
245
+ assert client.collections_created[0]["collection_name"] == "assetforge_rag"
246
+ assert client.collections_created[0]["vectors_config"].size == 2
247
+ assert {item["field_name"] for item in client.payload_indexes} == {"workspace_uid", "status"}
248
+
249
+
250
+ def test_qdrant_normalizes_arbitrary_string_ids_for_backend_and_keeps_public_id():
251
+ client = FakeQdrantClient()
252
+ runtime = _runtime(client)
253
+ vector = runtime.require_port("vector.qdrant", VectorSearchPort)
254
+
255
+ vector.upsert_vectors([{"id": "document-alpha", "vector": [1.0, 0.0], "payload": {"kind": "doc"}}])
256
+
257
+ point = client.upserts[-1]["points"][0]
258
+ UUID(str(point.id))
259
+ assert point.payload["_muscles_id"] == "document-alpha"
260
+
261
+
262
+ def test_qdrant_rejects_configured_dimension_mismatch_with_existing_collection():
263
+ client = FakeQdrantClient()
264
+ client.get_collection = lambda **_kwargs: SimpleNamespace(
265
+ config=SimpleNamespace(params=SimpleNamespace(vectors=SimpleNamespace(size=3)))
266
+ )
267
+ with pytest.raises(QdrantDimensionError, match="expected 2, got 3"):
268
+ _runtime(client).require_port("vector.qdrant", VectorSearchPort).search_vectors([1.0, 0.0])