vecspan 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.
- vecspan/__init__.py +50 -0
- vecspan/backends/__init__.py +6 -0
- vecspan/backends/base.py +107 -0
- vecspan/backends/memory.py +289 -0
- vecspan/backends/milvus.py +696 -0
- vecspan/backends/qdrant.py +532 -0
- vecspan/capabilities.py +16 -0
- vecspan/client.py +205 -0
- vecspan/errors.py +27 -0
- vecspan/filters.py +258 -0
- vecspan/naming.py +35 -0
- vecspan/py.typed +0 -0
- vecspan/registry.py +67 -0
- vecspan/types.py +282 -0
- vecspan/validate.py +348 -0
- vecspan-0.1.0.dist-info/METADATA +137 -0
- vecspan-0.1.0.dist-info/RECORD +19 -0
- vecspan-0.1.0.dist-info/WHEEL +5 -0
- vecspan-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
"""Milvus adapter built on `pymilvus.MilvusClient`.
|
|
2
|
+
|
|
3
|
+
Partial payload and vector updates use Milvus `upsert(partial_update=True)`
|
|
4
|
+
when that argument exists. Otherwise the adapter reads the full point, merges
|
|
5
|
+
the change, and upserts it. That read-modify-write path can lose a concurrent
|
|
6
|
+
update that lands between the read and the write.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Mapping, Sequence
|
|
14
|
+
from urllib.parse import unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
from vecspan.backends.base import VectorBackend
|
|
17
|
+
from vecspan.errors import (
|
|
18
|
+
BackendError,
|
|
19
|
+
CollectionAlreadyExistsError,
|
|
20
|
+
CollectionNotFoundError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
VecspanError,
|
|
23
|
+
)
|
|
24
|
+
from vecspan.filters import Filter, compile_milvus
|
|
25
|
+
from vecspan.naming import check_identifier
|
|
26
|
+
from vecspan.types import (
|
|
27
|
+
CollectionSpec,
|
|
28
|
+
Distance,
|
|
29
|
+
IdType,
|
|
30
|
+
PayloadType,
|
|
31
|
+
Point,
|
|
32
|
+
ScrollPage,
|
|
33
|
+
SearchHit,
|
|
34
|
+
as_plain,
|
|
35
|
+
as_vector,
|
|
36
|
+
spec_from_dict,
|
|
37
|
+
)
|
|
38
|
+
from vecspan.validate import coerce_stored_id, prepare_search_options
|
|
39
|
+
|
|
40
|
+
_METRIC = {
|
|
41
|
+
Distance.COSINE: "COSINE",
|
|
42
|
+
Distance.DOT: "IP",
|
|
43
|
+
Distance.L2: "L2",
|
|
44
|
+
}
|
|
45
|
+
_METRIC_TO_DISTANCE = {value: key for key, value in _METRIC.items()}
|
|
46
|
+
_PAYLOAD_TYPE = {
|
|
47
|
+
PayloadType.KEYWORD: "VARCHAR",
|
|
48
|
+
PayloadType.INT: "INT64",
|
|
49
|
+
PayloadType.FLOAT: "DOUBLE",
|
|
50
|
+
PayloadType.BOOL: "BOOL",
|
|
51
|
+
}
|
|
52
|
+
_PAYLOAD_FROM_MILVUS = {
|
|
53
|
+
"VARCHAR": PayloadType.KEYWORD,
|
|
54
|
+
"INT64": PayloadType.INT,
|
|
55
|
+
"INT32": PayloadType.INT,
|
|
56
|
+
"INT16": PayloadType.INT,
|
|
57
|
+
"INT8": PayloadType.INT,
|
|
58
|
+
"FLOAT": PayloadType.FLOAT,
|
|
59
|
+
"DOUBLE": PayloadType.FLOAT,
|
|
60
|
+
"BOOL": PayloadType.BOOL,
|
|
61
|
+
}
|
|
62
|
+
_OPTIONS = {"token", "db_name", "timeout"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MilvusBackend(VectorBackend):
|
|
66
|
+
"""Milvus collections addressed through the MilvusClient RPC API."""
|
|
67
|
+
|
|
68
|
+
name = "milvus"
|
|
69
|
+
|
|
70
|
+
def __init__(self, client: Any) -> None:
|
|
71
|
+
self._client = client
|
|
72
|
+
self._specs: dict[str, CollectionSpec] = {}
|
|
73
|
+
self._closed = False
|
|
74
|
+
self._partial: bool | None = None
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_url(cls, url: str, **kwargs: Any) -> MilvusBackend:
|
|
78
|
+
"""Open a client from a `milvus://` URL."""
|
|
79
|
+
params = parse_milvus_target(url, **kwargs)
|
|
80
|
+
try:
|
|
81
|
+
from pymilvus import MilvusClient
|
|
82
|
+
except ImportError as exc:
|
|
83
|
+
raise BackendError("Milvus support requires the milvus extra: pip install 'vecspan[milvus]'") from exc
|
|
84
|
+
try:
|
|
85
|
+
client = MilvusClient(**params)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise BackendError(f"could not connect to Milvus: {exc}") from exc
|
|
88
|
+
return cls(client)
|
|
89
|
+
|
|
90
|
+
def create_collection(self, spec: CollectionSpec) -> None:
|
|
91
|
+
def run() -> None:
|
|
92
|
+
if self._client.has_collection(spec.name):
|
|
93
|
+
raise CollectionAlreadyExistsError(f"collection {spec.name!r} already exists")
|
|
94
|
+
_apply_plan(self._client, collection_plan(spec))
|
|
95
|
+
self._specs[spec.name] = spec
|
|
96
|
+
|
|
97
|
+
self._run(run)
|
|
98
|
+
|
|
99
|
+
def drop_collection(self, name: str) -> None:
|
|
100
|
+
def run() -> None:
|
|
101
|
+
if not self._client.has_collection(name):
|
|
102
|
+
raise CollectionNotFoundError(f"collection {name!r} does not exist")
|
|
103
|
+
self._client.drop_collection(name)
|
|
104
|
+
self._specs.pop(name, None)
|
|
105
|
+
|
|
106
|
+
self._run(run)
|
|
107
|
+
|
|
108
|
+
def has_collection(self, name: str) -> bool:
|
|
109
|
+
return self._run(lambda: bool(self._client.has_collection(name)))
|
|
110
|
+
|
|
111
|
+
def describe_collection(self, name: str) -> CollectionSpec:
|
|
112
|
+
return self._run(lambda: self._describe(name))
|
|
113
|
+
|
|
114
|
+
def list_collections(self) -> list[str]:
|
|
115
|
+
return self._run(lambda: [str(name) for name in self._client.list_collections()])
|
|
116
|
+
|
|
117
|
+
def upsert(self, name: str, points: Sequence[Point]) -> None:
|
|
118
|
+
def run() -> None:
|
|
119
|
+
self._cached(name)
|
|
120
|
+
rows = [point_to_row(point) for point in points]
|
|
121
|
+
if rows:
|
|
122
|
+
self._client.upsert(collection_name=name, data=rows)
|
|
123
|
+
|
|
124
|
+
self._run(run)
|
|
125
|
+
|
|
126
|
+
def update_payload(self, name: str, ids: Sequence[int | str], payload: dict) -> None:
|
|
127
|
+
def run() -> None:
|
|
128
|
+
spec = self._cached(name)
|
|
129
|
+
if self._supports_partial_update():
|
|
130
|
+
self._require_existing(name, spec, ids)
|
|
131
|
+
rows = [{"id": point_id, **payload} for point_id in ids]
|
|
132
|
+
self._client.upsert(collection_name=name, data=rows, partial_update=True)
|
|
133
|
+
return
|
|
134
|
+
self._read_modify_write(name, spec, ids, payload_patch=payload, vectors_by_id={})
|
|
135
|
+
|
|
136
|
+
self._run(run)
|
|
137
|
+
|
|
138
|
+
def update_vectors(
|
|
139
|
+
self,
|
|
140
|
+
name: str,
|
|
141
|
+
ids: Sequence[int | str],
|
|
142
|
+
vectors: Sequence[dict[str, list[float]]],
|
|
143
|
+
) -> None:
|
|
144
|
+
def run() -> None:
|
|
145
|
+
spec = self._cached(name)
|
|
146
|
+
pairs = list(zip(ids, vectors))
|
|
147
|
+
if self._supports_partial_update():
|
|
148
|
+
self._require_existing(name, spec, ids)
|
|
149
|
+
rows = [{"id": point_id, **vector} for point_id, vector in pairs]
|
|
150
|
+
self._client.upsert(collection_name=name, data=rows, partial_update=True)
|
|
151
|
+
return
|
|
152
|
+
self._read_modify_write(
|
|
153
|
+
name,
|
|
154
|
+
spec,
|
|
155
|
+
ids,
|
|
156
|
+
payload_patch={},
|
|
157
|
+
vectors_by_id={point_id: vector for point_id, vector in pairs},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self._run(run)
|
|
161
|
+
|
|
162
|
+
def delete(
|
|
163
|
+
self,
|
|
164
|
+
name: str,
|
|
165
|
+
*,
|
|
166
|
+
ids: Sequence[int | str] | None = None,
|
|
167
|
+
filter: Filter | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
def run() -> None:
|
|
170
|
+
self._cached(name)
|
|
171
|
+
if ids is not None:
|
|
172
|
+
self._client.delete(collection_name=name, ids=list(ids))
|
|
173
|
+
return
|
|
174
|
+
if filter is None:
|
|
175
|
+
raise ValidationError("pass exactly one of ids or filter")
|
|
176
|
+
self._client.delete(collection_name=name, filter=compile_milvus(filter))
|
|
177
|
+
|
|
178
|
+
self._run(run)
|
|
179
|
+
|
|
180
|
+
def get(self, name: str, ids: Sequence[int | str], *, with_vectors: bool = False) -> list[Point]:
|
|
181
|
+
def run() -> list[Point]:
|
|
182
|
+
return self._load(name, ids, with_vectors=with_vectors, ordered=True)
|
|
183
|
+
|
|
184
|
+
return self._run(run)
|
|
185
|
+
|
|
186
|
+
def count(self, name: str, *, filter: Filter | None = None) -> int:
|
|
187
|
+
def run() -> int:
|
|
188
|
+
spec = self._cached(name)
|
|
189
|
+
expr = compile_milvus(filter) if filter is not None else match_all_expr(spec)
|
|
190
|
+
rows = self._client.query(collection_name=name, filter=expr, output_fields=["count(*)"])
|
|
191
|
+
if not rows:
|
|
192
|
+
return 0
|
|
193
|
+
row = rows[0]
|
|
194
|
+
if isinstance(row, Mapping):
|
|
195
|
+
for key, value in row.items():
|
|
196
|
+
if "count" in str(key):
|
|
197
|
+
return int(value)
|
|
198
|
+
raise BackendError("Milvus count response has no count")
|
|
199
|
+
|
|
200
|
+
return self._run(run)
|
|
201
|
+
|
|
202
|
+
def scroll(
|
|
203
|
+
self,
|
|
204
|
+
name: str,
|
|
205
|
+
*,
|
|
206
|
+
filter: Filter | None = None,
|
|
207
|
+
limit: int = 100,
|
|
208
|
+
offset: int | str | None = None,
|
|
209
|
+
with_vectors: bool = False,
|
|
210
|
+
) -> ScrollPage:
|
|
211
|
+
def run() -> ScrollPage:
|
|
212
|
+
spec = self._cached(name)
|
|
213
|
+
start = 0 if offset is None else offset
|
|
214
|
+
if isinstance(start, bool) or not isinstance(start, int) or start < 0:
|
|
215
|
+
raise ValidationError("Milvus scroll offset must be an integer from a previous page")
|
|
216
|
+
expr = compile_milvus(filter) if filter is not None else match_all_expr(spec)
|
|
217
|
+
rows = self._client.query(
|
|
218
|
+
collection_name=name,
|
|
219
|
+
filter=expr,
|
|
220
|
+
limit=limit,
|
|
221
|
+
offset=start,
|
|
222
|
+
output_fields=_output_fields(spec, with_vectors),
|
|
223
|
+
)
|
|
224
|
+
points = [row_to_point(row, spec, with_vectors=with_vectors) for row in rows]
|
|
225
|
+
next_offset = None if len(points) < limit else start + len(points)
|
|
226
|
+
return ScrollPage(points=points, next_offset=next_offset)
|
|
227
|
+
|
|
228
|
+
return self._run(run)
|
|
229
|
+
|
|
230
|
+
def search(
|
|
231
|
+
self,
|
|
232
|
+
name: str,
|
|
233
|
+
vector: Sequence[float],
|
|
234
|
+
*,
|
|
235
|
+
vector_name: str,
|
|
236
|
+
filter: Filter | None = None,
|
|
237
|
+
limit: int = 10,
|
|
238
|
+
with_vectors: bool = False,
|
|
239
|
+
backend_options: dict | None = None,
|
|
240
|
+
) -> list[SearchHit]:
|
|
241
|
+
def run() -> list[SearchHit]:
|
|
242
|
+
spec = self._cached(name)
|
|
243
|
+
field = spec.vector(vector_name)
|
|
244
|
+
options = prepare_search_options(backend_options, allow_nprobe=True)
|
|
245
|
+
arguments: dict[str, Any] = {
|
|
246
|
+
"collection_name": name,
|
|
247
|
+
"data": [list(vector)],
|
|
248
|
+
"anns_field": field.name,
|
|
249
|
+
"search_params": milvus_search_params(field.distance, limit, options),
|
|
250
|
+
"limit": limit,
|
|
251
|
+
"output_fields": _output_fields(spec, with_vectors),
|
|
252
|
+
}
|
|
253
|
+
if filter is not None:
|
|
254
|
+
arguments["filter"] = compile_milvus(filter)
|
|
255
|
+
results = self._client.search(**arguments)
|
|
256
|
+
return parse_search_hits(results, spec, field.name, with_vectors=with_vectors)
|
|
257
|
+
|
|
258
|
+
return self._run(run)
|
|
259
|
+
|
|
260
|
+
def close(self) -> None:
|
|
261
|
+
if self._closed:
|
|
262
|
+
return
|
|
263
|
+
self._closed = True
|
|
264
|
+
closer = getattr(self._client, "close", None)
|
|
265
|
+
if not callable(closer):
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
closer()
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
raise BackendError(f"milvus close failed: {exc}") from exc
|
|
271
|
+
|
|
272
|
+
def _describe(self, name: str) -> CollectionSpec:
|
|
273
|
+
if not self._client.has_collection(name):
|
|
274
|
+
raise CollectionNotFoundError(f"collection {name!r} does not exist")
|
|
275
|
+
description = dict(self._client.describe_collection(name))
|
|
276
|
+
description.setdefault("collection_name", name)
|
|
277
|
+
indexes: dict[str, Mapping[str, Any]] = {}
|
|
278
|
+
for index_name in self._client.list_indexes(name):
|
|
279
|
+
info = self._client.describe_index(name, index_name)
|
|
280
|
+
if not isinstance(info, Mapping):
|
|
281
|
+
info = dict(info)
|
|
282
|
+
field_name = str(info.get("field_name") or index_name)
|
|
283
|
+
indexes[field_name] = info
|
|
284
|
+
spec = spec_from_milvus(description, indexes)
|
|
285
|
+
self._specs[name] = spec
|
|
286
|
+
return spec
|
|
287
|
+
|
|
288
|
+
def _cached(self, name: str) -> CollectionSpec:
|
|
289
|
+
spec = self._specs.get(name)
|
|
290
|
+
if spec is None:
|
|
291
|
+
return self._describe(name)
|
|
292
|
+
return spec
|
|
293
|
+
|
|
294
|
+
def _supports_partial_update(self) -> bool:
|
|
295
|
+
if self._partial is None:
|
|
296
|
+
try:
|
|
297
|
+
parameters = inspect.signature(self._client.upsert).parameters
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
self._partial = False
|
|
300
|
+
else:
|
|
301
|
+
# pymilvus documents partial_update as a **kwargs option.
|
|
302
|
+
self._partial = "partial_update" in parameters or any(
|
|
303
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
|
|
304
|
+
)
|
|
305
|
+
return self._partial
|
|
306
|
+
|
|
307
|
+
def _require_existing(self, name: str, spec: CollectionSpec, ids: Sequence[int | str]) -> None:
|
|
308
|
+
found = {point.id for point in self._load(name, ids, with_vectors=False, ordered=False)}
|
|
309
|
+
for point_id in ids:
|
|
310
|
+
if point_id not in found:
|
|
311
|
+
raise ValidationError(f"unknown point id: {point_id!r}")
|
|
312
|
+
|
|
313
|
+
def _load(
|
|
314
|
+
self,
|
|
315
|
+
name: str,
|
|
316
|
+
ids: Sequence[int | str],
|
|
317
|
+
*,
|
|
318
|
+
with_vectors: bool,
|
|
319
|
+
ordered: bool,
|
|
320
|
+
) -> list[Point]:
|
|
321
|
+
spec = self._cached(name)
|
|
322
|
+
rows = self._client.get(
|
|
323
|
+
collection_name=name,
|
|
324
|
+
ids=list(ids),
|
|
325
|
+
output_fields=_output_fields(spec, with_vectors),
|
|
326
|
+
)
|
|
327
|
+
by_id: dict[int | str, Point] = {}
|
|
328
|
+
for row in rows:
|
|
329
|
+
point = row_to_point(row, spec, with_vectors=with_vectors)
|
|
330
|
+
by_id[point.id] = point
|
|
331
|
+
if not ordered:
|
|
332
|
+
return list(by_id.values())
|
|
333
|
+
return [by_id[point_id] for point_id in ids if point_id in by_id]
|
|
334
|
+
|
|
335
|
+
def _read_modify_write(
|
|
336
|
+
self,
|
|
337
|
+
name: str,
|
|
338
|
+
spec: CollectionSpec,
|
|
339
|
+
ids: Sequence[int | str],
|
|
340
|
+
*,
|
|
341
|
+
payload_patch: Mapping[str, Any],
|
|
342
|
+
vectors_by_id: Mapping[int | str, Mapping[str, Sequence[float]]],
|
|
343
|
+
) -> None:
|
|
344
|
+
current = {
|
|
345
|
+
point.id: point
|
|
346
|
+
for point in self._load(name, ids, with_vectors=True, ordered=False)
|
|
347
|
+
}
|
|
348
|
+
rows = []
|
|
349
|
+
for point_id in ids:
|
|
350
|
+
point = current.get(point_id)
|
|
351
|
+
if point is None:
|
|
352
|
+
raise ValidationError(f"unknown point id: {point_id!r}")
|
|
353
|
+
payload = dict(point.payload)
|
|
354
|
+
payload.update(payload_patch)
|
|
355
|
+
vectors = {key: list(value) for key, value in point.vectors.items()}
|
|
356
|
+
extra = vectors_by_id.get(point_id)
|
|
357
|
+
if extra:
|
|
358
|
+
vectors.update({key: list(value) for key, value in extra.items()})
|
|
359
|
+
rows.append(point_to_row(Point(id=point_id, vectors=vectors, payload=payload)))
|
|
360
|
+
self._client.upsert(collection_name=name, data=rows)
|
|
361
|
+
|
|
362
|
+
def _run(self, action):
|
|
363
|
+
if self._closed:
|
|
364
|
+
raise BackendError("client is closed")
|
|
365
|
+
try:
|
|
366
|
+
return action()
|
|
367
|
+
except VecspanError:
|
|
368
|
+
raise
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
raise BackendError(f"milvus request failed: {exc}") from exc
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def parse_milvus_target(url: str, **kwargs: Any) -> dict[str, Any]:
|
|
374
|
+
"""Return `MilvusClient` keyword arguments for a `milvus://` URL."""
|
|
375
|
+
unknown = sorted(set(kwargs) - _OPTIONS)
|
|
376
|
+
if unknown:
|
|
377
|
+
raise ValidationError(f"unknown option: {unknown[0]}")
|
|
378
|
+
parsed = urlsplit(url)
|
|
379
|
+
if parsed.scheme.lower() != "milvus":
|
|
380
|
+
raise ValidationError("Milvus URL must start with milvus://")
|
|
381
|
+
if parsed.query or parsed.fragment:
|
|
382
|
+
raise ValidationError("milvus URL does not take a query string")
|
|
383
|
+
host = parsed.hostname
|
|
384
|
+
if not host:
|
|
385
|
+
raise ValidationError("milvus URL requires a host")
|
|
386
|
+
port = parsed.port or 19530
|
|
387
|
+
token = _milvus_token(parsed, kwargs.get("token"))
|
|
388
|
+
db_name = _milvus_db(parsed.path, kwargs.get("db_name"))
|
|
389
|
+
params: dict[str, Any] = {"uri": f"http://{host}:{port}"}
|
|
390
|
+
if token is not None:
|
|
391
|
+
params["token"] = token
|
|
392
|
+
if db_name is not None:
|
|
393
|
+
params["db_name"] = db_name
|
|
394
|
+
timeout = kwargs.get("timeout")
|
|
395
|
+
if timeout is not None:
|
|
396
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout < 0:
|
|
397
|
+
raise ValidationError("timeout must be a non-negative number")
|
|
398
|
+
params["timeout"] = float(timeout)
|
|
399
|
+
return params
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def collection_plan(spec: CollectionSpec) -> dict[str, Any]:
|
|
403
|
+
"""Describe the Milvus schema and HNSW indexes for `spec`."""
|
|
404
|
+
return {
|
|
405
|
+
"collection_name": spec.name,
|
|
406
|
+
"primary": {
|
|
407
|
+
"name": "id",
|
|
408
|
+
"type": "INT64" if spec.id_type == IdType.INT else "VARCHAR",
|
|
409
|
+
"max_length": 36 if spec.id_type == IdType.UUID else None,
|
|
410
|
+
},
|
|
411
|
+
"vectors": [
|
|
412
|
+
{
|
|
413
|
+
"name": item.name,
|
|
414
|
+
"dim": item.size,
|
|
415
|
+
"metric": _METRIC[item.distance],
|
|
416
|
+
"m": item.m,
|
|
417
|
+
"ef_construct": item.ef_construct,
|
|
418
|
+
}
|
|
419
|
+
for item in spec.vectors
|
|
420
|
+
],
|
|
421
|
+
"payload": [
|
|
422
|
+
{
|
|
423
|
+
"name": item.name,
|
|
424
|
+
"type": _PAYLOAD_TYPE[item.type],
|
|
425
|
+
"max_length": item.max_length if item.type == PayloadType.KEYWORD else None,
|
|
426
|
+
"indexed": item.indexed,
|
|
427
|
+
}
|
|
428
|
+
for item in spec.payload_fields
|
|
429
|
+
],
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def match_all_expr(spec: CollectionSpec) -> str:
|
|
434
|
+
"""Milvus expression that matches every id this library writes."""
|
|
435
|
+
if spec.id_type == IdType.INT:
|
|
436
|
+
return "id >= 0"
|
|
437
|
+
return 'id != ""'
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def milvus_search_params(distance: Distance, limit: int, options: Mapping[str, int]) -> dict[str, Any]:
|
|
441
|
+
"""Build the HNSW search params passed to MilvusClient.search."""
|
|
442
|
+
params: dict[str, int] = {"ef": options.get("ef", max(64, limit))}
|
|
443
|
+
if "nprobe" in options:
|
|
444
|
+
params["nprobe"] = options["nprobe"]
|
|
445
|
+
return {"metric_type": _METRIC[distance], "params": params}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def point_to_row(point: Point) -> dict[str, Any]:
|
|
449
|
+
"""Flatten a point into the dict MilvusClient upsert expects."""
|
|
450
|
+
row: dict[str, Any] = {"id": point.id}
|
|
451
|
+
row.update(point.vectors)
|
|
452
|
+
row.update(point.payload)
|
|
453
|
+
return row
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def row_to_point(row: Mapping[str, Any] | Any, spec: CollectionSpec, *, with_vectors: bool) -> Point:
|
|
457
|
+
"""Split a Milvus row or search hit into a point."""
|
|
458
|
+
data = _as_mapping(row)
|
|
459
|
+
entity = data.pop("entity", None)
|
|
460
|
+
if isinstance(entity, Mapping):
|
|
461
|
+
for key, value in entity.items():
|
|
462
|
+
data.setdefault(key, value)
|
|
463
|
+
meta = data.pop("$meta", None)
|
|
464
|
+
if isinstance(meta, Mapping):
|
|
465
|
+
for key, value in meta.items():
|
|
466
|
+
data.setdefault(key, value)
|
|
467
|
+
data = as_plain(data)
|
|
468
|
+
if "id" not in data:
|
|
469
|
+
raise BackendError("Milvus row is missing id")
|
|
470
|
+
point_id = coerce_stored_id(spec, data.pop("id"))
|
|
471
|
+
data.pop("distance", None)
|
|
472
|
+
data.pop("score", None)
|
|
473
|
+
vectors: dict[str, list[float]] = {}
|
|
474
|
+
for field in spec.vectors:
|
|
475
|
+
if field.name not in data:
|
|
476
|
+
continue
|
|
477
|
+
raw = data.pop(field.name)
|
|
478
|
+
if with_vectors:
|
|
479
|
+
vectors[field.name] = as_vector(raw)
|
|
480
|
+
return Point(id=point_id, vectors=vectors if with_vectors else {}, payload=dict(data))
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def parse_search_hits(
|
|
484
|
+
results: object,
|
|
485
|
+
spec: CollectionSpec,
|
|
486
|
+
vector_name: str,
|
|
487
|
+
*,
|
|
488
|
+
with_vectors: bool,
|
|
489
|
+
) -> list[SearchHit]:
|
|
490
|
+
"""Convert a MilvusClient.search result into search hits."""
|
|
491
|
+
field = spec.vector(vector_name)
|
|
492
|
+
if isinstance(results, list) and results and isinstance(results[0], list):
|
|
493
|
+
hits = results[0]
|
|
494
|
+
elif isinstance(results, list):
|
|
495
|
+
hits = results
|
|
496
|
+
else:
|
|
497
|
+
raise BackendError("unexpected Milvus search response")
|
|
498
|
+
parsed: list[SearchHit] = []
|
|
499
|
+
for hit in hits:
|
|
500
|
+
data = _as_mapping(hit)
|
|
501
|
+
score = data.get("distance", data.get("score"))
|
|
502
|
+
if score is None:
|
|
503
|
+
raise BackendError("Milvus search hit is missing a score")
|
|
504
|
+
point = row_to_point(data, spec, with_vectors=with_vectors)
|
|
505
|
+
parsed.append(
|
|
506
|
+
SearchHit(
|
|
507
|
+
id=point.id,
|
|
508
|
+
score=float(score),
|
|
509
|
+
distance=field.distance,
|
|
510
|
+
payload=dict(point.payload),
|
|
511
|
+
vectors=dict(point.vectors) if with_vectors else None,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
return parsed
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def spec_from_milvus(description: Mapping[str, Any], indexes: Mapping[str, Mapping[str, Any]]) -> CollectionSpec:
|
|
518
|
+
"""Rebuild a collection spec from Milvus describe and index metadata."""
|
|
519
|
+
primary: tuple[str, str] | None = None
|
|
520
|
+
vectors = []
|
|
521
|
+
payload = []
|
|
522
|
+
for raw in description.get("fields") or []:
|
|
523
|
+
if not isinstance(raw, Mapping):
|
|
524
|
+
raw = dict(raw)
|
|
525
|
+
name = str(raw.get("name"))
|
|
526
|
+
type_name = _type_name(raw.get("type", raw.get("dtype")))
|
|
527
|
+
params = _params(raw.get("params"))
|
|
528
|
+
if raw.get("is_dynamic") or name.startswith("$") or type_name == "JSON":
|
|
529
|
+
continue
|
|
530
|
+
is_primary = bool(raw.get("is_primary")) or name == description.get("primary_field")
|
|
531
|
+
if is_primary:
|
|
532
|
+
primary = (name, type_name)
|
|
533
|
+
continue
|
|
534
|
+
if type_name in {"FLOAT_VECTOR", "FLOAT16_VECTOR", "BFLOAT16_VECTOR"}:
|
|
535
|
+
if type_name != "FLOAT_VECTOR":
|
|
536
|
+
raise BackendError(f"unsupported vector type {type_name} on {name!r}")
|
|
537
|
+
index = dict(indexes.get(name) or {})
|
|
538
|
+
index_params = _params(index.get("params"))
|
|
539
|
+
for key in ("M", "efConstruction", "ef_construct"):
|
|
540
|
+
if key in index and key not in index_params:
|
|
541
|
+
index_params[key] = index[key]
|
|
542
|
+
metric = str(index.get("metric_type") or index_params.get("metric_type") or "").upper()
|
|
543
|
+
if metric not in _METRIC_TO_DISTANCE:
|
|
544
|
+
raise BackendError(f"vector field {name!r} has no supported metric")
|
|
545
|
+
vectors.append(
|
|
546
|
+
{
|
|
547
|
+
"name": name,
|
|
548
|
+
"size": int(params.get("dim")),
|
|
549
|
+
"distance": _METRIC_TO_DISTANCE[metric],
|
|
550
|
+
"m": int(index_params.get("M", 16)),
|
|
551
|
+
"ef_construct": int(index_params.get("efConstruction", index_params.get("ef_construct", 200))),
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
continue
|
|
555
|
+
payload_type = _PAYLOAD_FROM_MILVUS.get(type_name)
|
|
556
|
+
if payload_type is None:
|
|
557
|
+
raise BackendError(f"unsupported field type {type_name} on {name!r}")
|
|
558
|
+
payload.append(
|
|
559
|
+
{
|
|
560
|
+
"name": name,
|
|
561
|
+
"type": payload_type,
|
|
562
|
+
"indexed": name in indexes,
|
|
563
|
+
"max_length": int(params.get("max_length", 2048)),
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
if primary is None:
|
|
567
|
+
raise BackendError("Milvus collection has no primary key")
|
|
568
|
+
id_name, id_type_name = primary
|
|
569
|
+
if id_name != "id":
|
|
570
|
+
raise BackendError(f"primary key must be named id, got {id_name!r}")
|
|
571
|
+
if id_type_name == "INT64":
|
|
572
|
+
id_type = IdType.INT
|
|
573
|
+
elif id_type_name == "VARCHAR":
|
|
574
|
+
id_type = IdType.UUID
|
|
575
|
+
else:
|
|
576
|
+
raise BackendError(f"unsupported primary key type {id_type_name}")
|
|
577
|
+
return spec_from_dict(
|
|
578
|
+
{
|
|
579
|
+
"name": description.get("collection_name") or description.get("name"),
|
|
580
|
+
"id_type": id_type.value,
|
|
581
|
+
"vectors": vectors,
|
|
582
|
+
"payload_fields": payload,
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _apply_plan(client: Any, plan: Mapping[str, Any]) -> None:
|
|
588
|
+
from pymilvus import DataType
|
|
589
|
+
|
|
590
|
+
schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
|
|
591
|
+
primary = plan["primary"]
|
|
592
|
+
if primary["type"] == "VARCHAR":
|
|
593
|
+
schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=primary["max_length"])
|
|
594
|
+
else:
|
|
595
|
+
schema.add_field("id", DataType.INT64, is_primary=True)
|
|
596
|
+
for vector in plan["vectors"]:
|
|
597
|
+
schema.add_field(vector["name"], DataType.FLOAT_VECTOR, dim=vector["dim"])
|
|
598
|
+
type_map = {
|
|
599
|
+
"VARCHAR": DataType.VARCHAR,
|
|
600
|
+
"INT64": DataType.INT64,
|
|
601
|
+
"DOUBLE": DataType.DOUBLE,
|
|
602
|
+
"BOOL": DataType.BOOL,
|
|
603
|
+
}
|
|
604
|
+
for payload in plan["payload"]:
|
|
605
|
+
extra = {"max_length": payload["max_length"]} if payload["max_length"] else {}
|
|
606
|
+
schema.add_field(payload["name"], type_map[payload["type"]], **extra)
|
|
607
|
+
index_params = client.prepare_index_params()
|
|
608
|
+
for vector in plan["vectors"]:
|
|
609
|
+
index_params.add_index(
|
|
610
|
+
field_name=vector["name"],
|
|
611
|
+
index_name=vector["name"],
|
|
612
|
+
index_type="HNSW",
|
|
613
|
+
metric_type=vector["metric"],
|
|
614
|
+
params={"M": vector["m"], "efConstruction": vector["ef_construct"]},
|
|
615
|
+
)
|
|
616
|
+
for payload in plan["payload"]:
|
|
617
|
+
if payload["indexed"]:
|
|
618
|
+
index_params.add_index(
|
|
619
|
+
field_name=payload["name"],
|
|
620
|
+
index_name=payload["name"],
|
|
621
|
+
index_type="INVERTED",
|
|
622
|
+
)
|
|
623
|
+
client.create_collection(
|
|
624
|
+
collection_name=plan["collection_name"],
|
|
625
|
+
schema=schema,
|
|
626
|
+
index_params=index_params,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _output_fields(spec: CollectionSpec, with_vectors: bool) -> list[str]:
|
|
631
|
+
fields = ["*"]
|
|
632
|
+
if with_vectors:
|
|
633
|
+
fields.extend(field.name for field in spec.vectors)
|
|
634
|
+
return fields
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _milvus_token(parsed: Any, explicit: object) -> str | None:
|
|
638
|
+
if parsed.username or parsed.password:
|
|
639
|
+
if explicit not in (None, ""):
|
|
640
|
+
raise ValidationError("pass the Milvus token either in the URL or as token=")
|
|
641
|
+
user = unquote(parsed.username or "")
|
|
642
|
+
password = unquote(parsed.password or "")
|
|
643
|
+
return f"{user}:{password}"
|
|
644
|
+
if explicit is None:
|
|
645
|
+
return None
|
|
646
|
+
if not isinstance(explicit, str):
|
|
647
|
+
raise ValidationError("token must be a string")
|
|
648
|
+
return explicit
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _milvus_db(path: str, explicit: object) -> str | None:
|
|
652
|
+
parts = [unquote(part) for part in path.split("/") if part]
|
|
653
|
+
if explicit is not None and not isinstance(explicit, str):
|
|
654
|
+
raise ValidationError("db_name must be a string")
|
|
655
|
+
if not parts:
|
|
656
|
+
if explicit is None:
|
|
657
|
+
return None
|
|
658
|
+
return check_identifier(explicit, "database name")
|
|
659
|
+
if len(parts) != 1:
|
|
660
|
+
raise ValidationError("milvus URL path must be a single database name")
|
|
661
|
+
if explicit is not None and explicit != parts[0]:
|
|
662
|
+
raise ValidationError("database is set both in the URL and in db_name")
|
|
663
|
+
return check_identifier(parts[0], "database name")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _type_name(value: object) -> str:
|
|
667
|
+
if isinstance(value, str):
|
|
668
|
+
text = value
|
|
669
|
+
else:
|
|
670
|
+
named = getattr(value, "name", None)
|
|
671
|
+
text = named if isinstance(named, str) else str(value)
|
|
672
|
+
return text.split(".")[-1].upper()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _params(value: object) -> dict[str, Any]:
|
|
676
|
+
if isinstance(value, str):
|
|
677
|
+
loaded = json.loads(value)
|
|
678
|
+
return dict(loaded) if isinstance(loaded, dict) else {}
|
|
679
|
+
if isinstance(value, Mapping):
|
|
680
|
+
return dict(value)
|
|
681
|
+
return {}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _as_mapping(value: object) -> dict[str, Any]:
|
|
685
|
+
if isinstance(value, Mapping):
|
|
686
|
+
return dict(value)
|
|
687
|
+
entity = getattr(value, "entity", None)
|
|
688
|
+
data = {
|
|
689
|
+
"id": getattr(value, "id", None),
|
|
690
|
+
"distance": getattr(value, "distance", getattr(value, "score", None)),
|
|
691
|
+
}
|
|
692
|
+
if isinstance(entity, Mapping):
|
|
693
|
+
data["entity"] = dict(entity)
|
|
694
|
+
elif entity is not None:
|
|
695
|
+
data["entity"] = dict(entity)
|
|
696
|
+
return data
|