muscles-data-qdrant 0.1.0__py3-none-any.whl

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,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,6 @@
1
+ muscles_data_qdrant/__init__.py,sha256=KaIeNxLJ1uK-V4JK2hUGnLIqoo2jYiUeeIrNqEy30fs,565
2
+ muscles_data_qdrant/adapter.py,sha256=spVzKF0hsh7O_20OrgnDgEL-MY0GP-DjAAWENktWGo0,20752
3
+ muscles_data_qdrant-0.1.0.dist-info/METADATA,sha256=7yNsMvVTdfJcOeO7GjF3TWj4dmAwyXFz5sqaeEjveZ8,2943
4
+ muscles_data_qdrant-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ muscles_data_qdrant-0.1.0.dist-info/top_level.txt,sha256=6FzG8bDwwrS2Yv1JEm4HWIUYERpGTGQ_kzLs6TV_V7M,20
6
+ muscles_data_qdrant-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ muscles_data_qdrant