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.
@@ -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