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.
- muscles_data_qdrant-0.1.0/PKG-INFO +83 -0
- muscles_data_qdrant-0.1.0/README.md +71 -0
- muscles_data_qdrant-0.1.0/pyproject.toml +32 -0
- muscles_data_qdrant-0.1.0/setup.cfg +4 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant/__init__.py +26 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant/adapter.py +509 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant.egg-info/PKG-INFO +83 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant.egg-info/SOURCES.txt +11 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant.egg-info/dependency_links.txt +1 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant.egg-info/requires.txt +5 -0
- muscles_data_qdrant-0.1.0/src/muscles_data_qdrant.egg-info/top_level.txt +1 -0
- muscles_data_qdrant-0.1.0/tests/test_integration.py +74 -0
- muscles_data_qdrant-0.1.0/tests/test_qdrant_adapter.py +268 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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])
|