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,532 @@
1
+ """Qdrant adapter built on `qdrant_client.QdrantClient`.
2
+
3
+ Collection specs are stored in a private `_vecspan_schemas` collection because
4
+ Qdrant does not keep the id type or unindexed payload fields.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from typing import Any, Mapping, Sequence
11
+ from urllib.parse import urlsplit
12
+
13
+ from vecspan.backends.base import VectorBackend
14
+ from vecspan.errors import (
15
+ BackendError,
16
+ CollectionAlreadyExistsError,
17
+ CollectionNotFoundError,
18
+ ValidationError,
19
+ VecspanError,
20
+ )
21
+ from vecspan.filters import And, Cmp, Eq, Expr, Filter, In, IsNull, Not, Or
22
+ from vecspan.naming import META_COLLECTION
23
+ from vecspan.types import (
24
+ CollectionSpec,
25
+ PayloadType,
26
+ Point,
27
+ ScrollPage,
28
+ SearchHit,
29
+ as_plain,
30
+ as_vector,
31
+ spec_from_dict,
32
+ spec_to_dict,
33
+ )
34
+ from vecspan.validate import coerce_stored_id, prepare_search_options
35
+
36
+ _META_NAMESPACE = uuid.UUID("8c1c6b2e-9a1d-4f0e-9b7a-6e5d4c3b2a19")
37
+ _OPTIONS = {"api_key", "timeout", "prefer_grpc"}
38
+ _SCHEMA = {
39
+ PayloadType.KEYWORD: "keyword",
40
+ PayloadType.INT: "integer",
41
+ PayloadType.FLOAT: "float",
42
+ PayloadType.BOOL: "bool",
43
+ }
44
+
45
+
46
+ class QdrantBackend(VectorBackend):
47
+ """Qdrant collections with named dense vectors."""
48
+
49
+ name = "qdrant"
50
+
51
+ def __init__(self, client: Any) -> None:
52
+ self._client = client
53
+ self._specs: dict[str, CollectionSpec] = {}
54
+ self._closed = False
55
+
56
+ @classmethod
57
+ def from_url(cls, url: str, **kwargs: Any) -> QdrantBackend:
58
+ """Open a client from a `qdrant://` or `qdrant+https://` URL."""
59
+ params = parse_qdrant_target(url, **kwargs)
60
+ try:
61
+ from qdrant_client import QdrantClient
62
+ except ImportError as exc:
63
+ raise BackendError("Qdrant support requires the qdrant extra: pip install 'vecspan[qdrant]'") from exc
64
+ try:
65
+ client = QdrantClient(**params)
66
+ except Exception as exc:
67
+ raise BackendError(f"could not connect to Qdrant: {exc}") from exc
68
+ return cls(client)
69
+
70
+ def create_collection(self, spec: CollectionSpec) -> None:
71
+ def run() -> None:
72
+ if self._exists(spec.name):
73
+ raise CollectionAlreadyExistsError(f"collection {spec.name!r} already exists")
74
+ plan = qdrant_plan(spec)
75
+ models = _models()
76
+ vectors = {
77
+ name: models.VectorParams(
78
+ size=item["size"],
79
+ distance=getattr(models.Distance, item["distance"]),
80
+ hnsw_config=models.HnswConfigDiff(m=item["m"], ef_construct=item["ef_construct"]),
81
+ )
82
+ for name, item in plan["vectors"].items()
83
+ }
84
+ self._client.create_collection(collection_name=spec.name, vectors_config=vectors)
85
+ try:
86
+ for index in plan["payload_indexes"]:
87
+ self._client.create_payload_index(
88
+ collection_name=spec.name,
89
+ field_name=index["name"],
90
+ field_schema=getattr(models.PayloadSchemaType, index["schema"].upper()),
91
+ )
92
+ self._write_meta(spec)
93
+ except Exception:
94
+ self._client.delete_collection(collection_name=spec.name)
95
+ raise
96
+ self._specs[spec.name] = spec
97
+
98
+ self._run(run)
99
+
100
+ def drop_collection(self, name: str) -> None:
101
+ def run() -> None:
102
+ if name == META_COLLECTION:
103
+ raise ValidationError(f"reserved collection name: {name!r}")
104
+ if not self._exists(name):
105
+ raise CollectionNotFoundError(f"collection {name!r} does not exist")
106
+ self._client.delete_collection(collection_name=name)
107
+ self._delete_meta(name)
108
+ self._specs.pop(name, None)
109
+
110
+ self._run(run)
111
+
112
+ def has_collection(self, name: str) -> bool:
113
+ return self._run(lambda: self._exists(name) and name != META_COLLECTION)
114
+
115
+ def describe_collection(self, name: str) -> CollectionSpec:
116
+ return self._run(lambda: self._describe(name))
117
+
118
+ def list_collections(self) -> list[str]:
119
+ def run() -> list[str]:
120
+ listed = self._client.get_collections().collections
121
+ return [item.name for item in listed if item.name != META_COLLECTION]
122
+
123
+ return self._run(run)
124
+
125
+ def upsert(self, name: str, points: Sequence[Point]) -> None:
126
+ def run() -> None:
127
+ self._cached(name)
128
+ if not points:
129
+ return
130
+ models = _models()
131
+ structs = [
132
+ models.PointStruct(id=point.id, vector=dict(point.vectors), payload=dict(point.payload))
133
+ for point in points
134
+ ]
135
+ self._client.upsert(collection_name=name, points=structs, wait=True)
136
+
137
+ self._run(run)
138
+
139
+ def update_payload(self, name: str, ids: Sequence[int | str], payload: dict) -> None:
140
+ def run() -> None:
141
+ spec = self._cached(name)
142
+ self._require_existing(name, spec, ids)
143
+ self._client.set_payload(collection_name=name, payload=dict(payload), points=list(ids), wait=True)
144
+
145
+ self._run(run)
146
+
147
+ def update_vectors(
148
+ self,
149
+ name: str,
150
+ ids: Sequence[int | str],
151
+ vectors: Sequence[dict[str, list[float]]],
152
+ ) -> None:
153
+ def run() -> None:
154
+ spec = self._cached(name)
155
+ self._require_existing(name, spec, ids)
156
+ models = _models()
157
+ points = [
158
+ models.PointVectors(id=point_id, vector=dict(vector))
159
+ for point_id, vector in zip(ids, vectors)
160
+ ]
161
+ self._client.update_vectors(collection_name=name, points=points, wait=True)
162
+
163
+ self._run(run)
164
+
165
+ def delete(
166
+ self,
167
+ name: str,
168
+ *,
169
+ ids: Sequence[int | str] | None = None,
170
+ filter: Filter | None = None,
171
+ ) -> None:
172
+ def run() -> None:
173
+ self._cached(name)
174
+ models = _models()
175
+ if ids is not None:
176
+ selector = models.PointIdsList(points=list(ids))
177
+ elif filter is not None:
178
+ selector = models.FilterSelector(filter=compile_filter(filter))
179
+ else:
180
+ raise ValidationError("pass exactly one of ids or filter")
181
+ self._client.delete(collection_name=name, points_selector=selector, wait=True)
182
+
183
+ self._run(run)
184
+
185
+ def get(self, name: str, ids: Sequence[int | str], *, with_vectors: bool = False) -> list[Point]:
186
+ def run() -> list[Point]:
187
+ spec = self._cached(name)
188
+ records = self._client.retrieve(
189
+ collection_name=name,
190
+ ids=list(ids),
191
+ with_payload=True,
192
+ with_vectors=with_vectors,
193
+ )
194
+ by_id = {
195
+ point.id: point
196
+ for point in (
197
+ record_to_point(record, spec, with_vectors=with_vectors) for record in records
198
+ )
199
+ }
200
+ return [by_id[point_id] for point_id in ids if point_id in by_id]
201
+
202
+ return self._run(run)
203
+
204
+ def count(self, name: str, *, filter: Filter | None = None) -> int:
205
+ def run() -> int:
206
+ self._cached(name)
207
+ result = self._client.count(
208
+ collection_name=name,
209
+ count_filter=None if filter is None else compile_filter(filter),
210
+ exact=True,
211
+ )
212
+ return int(result.count)
213
+
214
+ return self._run(run)
215
+
216
+ def scroll(
217
+ self,
218
+ name: str,
219
+ *,
220
+ filter: Filter | None = None,
221
+ limit: int = 100,
222
+ offset: int | str | None = None,
223
+ with_vectors: bool = False,
224
+ ) -> ScrollPage:
225
+ def run() -> ScrollPage:
226
+ spec = self._cached(name)
227
+ if offset is not None and not _valid_offset(offset):
228
+ raise ValidationError("Qdrant scroll offset must come from a previous page")
229
+ records, next_offset = self._client.scroll(
230
+ collection_name=name,
231
+ scroll_filter=None if filter is None else compile_filter(filter),
232
+ limit=limit,
233
+ offset=offset,
234
+ with_payload=True,
235
+ with_vectors=with_vectors,
236
+ )
237
+ points = [record_to_point(record, spec, with_vectors=with_vectors) for record in records]
238
+ return ScrollPage(points=points, next_offset=_offset_token(next_offset))
239
+
240
+ return self._run(run)
241
+
242
+ def search(
243
+ self,
244
+ name: str,
245
+ vector: Sequence[float],
246
+ *,
247
+ vector_name: str,
248
+ filter: Filter | None = None,
249
+ limit: int = 10,
250
+ with_vectors: bool = False,
251
+ backend_options: dict | None = None,
252
+ ) -> list[SearchHit]:
253
+ def run() -> list[SearchHit]:
254
+ spec = self._cached(name)
255
+ options = prepare_search_options(backend_options, allow_nprobe=False)
256
+ models = _models()
257
+ response = self._client.query_points(
258
+ collection_name=name,
259
+ query=list(vector),
260
+ using=vector_name,
261
+ query_filter=None if filter is None else compile_filter(filter),
262
+ limit=limit,
263
+ with_payload=True,
264
+ with_vectors=with_vectors,
265
+ search_params=models.SearchParams(hnsw_ef=options.get("ef", max(64, limit))),
266
+ )
267
+ return [
268
+ scored_to_hit(point, spec, vector_name, with_vectors=with_vectors)
269
+ for point in response.points
270
+ ]
271
+
272
+ return self._run(run)
273
+
274
+ def close(self) -> None:
275
+ if self._closed:
276
+ return
277
+ self._closed = True
278
+ closer = getattr(self._client, "close", None)
279
+ if not callable(closer):
280
+ return
281
+ try:
282
+ closer()
283
+ except Exception as exc:
284
+ raise BackendError(f"qdrant close failed: {exc}") from exc
285
+
286
+ def _exists(self, name: str) -> bool:
287
+ exists = getattr(self._client, "collection_exists", None)
288
+ if callable(exists):
289
+ return bool(exists(collection_name=name) if _accepts_collection_name(exists) else exists(name))
290
+ listed = self._client.get_collections().collections
291
+ return any(item.name == name for item in listed)
292
+
293
+ def _describe(self, name: str) -> CollectionSpec:
294
+ if name == META_COLLECTION or not self._exists(name):
295
+ raise CollectionNotFoundError(f"collection {name!r} does not exist")
296
+ if not self._exists(META_COLLECTION):
297
+ raise BackendError(f"collection {name!r} was not created by vecspan")
298
+ records = self._client.retrieve(
299
+ collection_name=META_COLLECTION,
300
+ ids=[_meta_id(name)],
301
+ with_payload=True,
302
+ with_vectors=False,
303
+ )
304
+ if not records:
305
+ raise BackendError(f"collection {name!r} was not created by vecspan")
306
+ payload = _field(records[0], "payload") or {}
307
+ spec = spec_from_dict(payload["spec"])
308
+ self._specs[name] = spec
309
+ return spec
310
+
311
+ def _cached(self, name: str) -> CollectionSpec:
312
+ spec = self._specs.get(name)
313
+ if spec is None:
314
+ return self._describe(name)
315
+ return spec
316
+
317
+ def _require_existing(self, name: str, spec: CollectionSpec, ids: Sequence[int | str]) -> None:
318
+ records = self._client.retrieve(
319
+ collection_name=name,
320
+ ids=list(ids),
321
+ with_payload=False,
322
+ with_vectors=False,
323
+ )
324
+ found = {coerce_stored_id(spec, _field(record, "id")) for record in records}
325
+ for point_id in ids:
326
+ if point_id not in found:
327
+ raise ValidationError(f"unknown point id: {point_id!r}")
328
+
329
+ def _write_meta(self, spec: CollectionSpec) -> None:
330
+ self._ensure_meta()
331
+ models = _models()
332
+ self._client.upsert(
333
+ collection_name=META_COLLECTION,
334
+ points=[
335
+ models.PointStruct(
336
+ id=_meta_id(spec.name),
337
+ vector={"_": [0.0]},
338
+ payload={"spec": spec_to_dict(spec)},
339
+ )
340
+ ],
341
+ wait=True,
342
+ )
343
+
344
+ def _ensure_meta(self) -> None:
345
+ if self._exists(META_COLLECTION):
346
+ return
347
+ models = _models()
348
+ self._client.create_collection(
349
+ collection_name=META_COLLECTION,
350
+ vectors_config={"_": models.VectorParams(size=1, distance=models.Distance.DOT)},
351
+ )
352
+
353
+ def _delete_meta(self, name: str) -> None:
354
+ if not self._exists(META_COLLECTION):
355
+ return
356
+ models = _models()
357
+ self._client.delete(
358
+ collection_name=META_COLLECTION,
359
+ points_selector=models.PointIdsList(points=[_meta_id(name)]),
360
+ wait=True,
361
+ )
362
+
363
+ def _run(self, action):
364
+ if self._closed:
365
+ raise BackendError("client is closed")
366
+ try:
367
+ return action()
368
+ except VecspanError:
369
+ raise
370
+ except Exception as exc:
371
+ raise BackendError(f"qdrant request failed: {exc}") from exc
372
+
373
+
374
+ def parse_qdrant_target(url: str, **kwargs: Any) -> dict[str, Any]:
375
+ """Return `QdrantClient` keyword arguments for a qdrant URL."""
376
+ unknown = sorted(set(kwargs) - _OPTIONS)
377
+ if unknown:
378
+ raise ValidationError(f"unknown option: {unknown[0]}")
379
+ parsed = urlsplit(url)
380
+ scheme = parsed.scheme.lower()
381
+ if scheme not in {"qdrant", "qdrant+https"}:
382
+ raise ValidationError("Qdrant URL must start with qdrant:// or qdrant+https://")
383
+ if parsed.username or parsed.password:
384
+ raise ValidationError("pass api_key= instead of putting credentials in the Qdrant URL")
385
+ if parsed.query or parsed.fragment or parsed.path not in ("", "/"):
386
+ raise ValidationError("qdrant URL does not take a path or query string")
387
+ host = parsed.hostname
388
+ if not host:
389
+ raise ValidationError("qdrant URL requires a host")
390
+ https = scheme == "qdrant+https"
391
+ port = parsed.port or (443 if https else 6333)
392
+ params: dict[str, Any] = {"url": f"{'https' if https else 'http'}://{host}:{port}"}
393
+ api_key = kwargs.get("api_key")
394
+ if api_key is not None:
395
+ if not isinstance(api_key, str):
396
+ raise ValidationError("api_key must be a string")
397
+ params["api_key"] = api_key
398
+ timeout = kwargs.get("timeout")
399
+ if timeout is not None:
400
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout < 0:
401
+ raise ValidationError("timeout must be a non-negative number")
402
+ params["timeout"] = float(timeout)
403
+ if "prefer_grpc" in kwargs:
404
+ if not isinstance(kwargs["prefer_grpc"], bool):
405
+ raise ValidationError("prefer_grpc must be a bool")
406
+ params["prefer_grpc"] = kwargs["prefer_grpc"]
407
+ return params
408
+
409
+
410
+ def qdrant_plan(spec: CollectionSpec) -> dict[str, Any]:
411
+ """Describe named vectors and payload indexes for `spec`."""
412
+ metric = {"cosine": "COSINE", "dot": "DOT", "l2": "EUCLID"}
413
+ return {
414
+ "vectors": {
415
+ item.name: {
416
+ "size": item.size,
417
+ "distance": metric[item.distance.value],
418
+ "m": item.m,
419
+ "ef_construct": item.ef_construct,
420
+ }
421
+ for item in spec.vectors
422
+ },
423
+ "payload_indexes": [
424
+ {"name": item.name, "schema": _SCHEMA[item.type]}
425
+ for item in spec.payload_fields
426
+ if item.indexed
427
+ ],
428
+ }
429
+
430
+
431
+ def compile_filter(filt: Filter) -> Any:
432
+ """Compile a filter into a `qdrant_client.models.Filter` or condition."""
433
+ return _compile(filt.expr, _models())
434
+
435
+
436
+ def record_to_point(record: Any, spec: CollectionSpec, *, with_vectors: bool) -> Point:
437
+ """Convert a Qdrant record into a point."""
438
+ payload = as_plain(_field(record, "payload") or {})
439
+ if not isinstance(payload, dict):
440
+ payload = {}
441
+ vectors: dict[str, list[float]] = {}
442
+ if with_vectors:
443
+ raw = _field(record, "vector")
444
+ if raw is None:
445
+ raw = _field(record, "vectors")
446
+ if isinstance(raw, Mapping):
447
+ for field in spec.vectors:
448
+ if field.name in raw:
449
+ vectors[field.name] = as_vector(raw[field.name])
450
+ elif raw is not None and spec.vectors:
451
+ vectors[spec.vectors[0].name] = as_vector(raw)
452
+ return Point(
453
+ id=coerce_stored_id(spec, _field(record, "id")),
454
+ vectors=vectors,
455
+ payload=payload,
456
+ )
457
+
458
+
459
+ def scored_to_hit(record: Any, spec: CollectionSpec, vector_name: str, *, with_vectors: bool) -> SearchHit:
460
+ """Convert a scored Qdrant point into a search hit."""
461
+ point = record_to_point(record, spec, with_vectors=with_vectors)
462
+ score = _field(record, "score")
463
+ if score is None:
464
+ raise BackendError("Qdrant search hit is missing a score")
465
+ return SearchHit(
466
+ id=point.id,
467
+ score=float(score),
468
+ distance=spec.vector(vector_name).distance,
469
+ payload=dict(point.payload),
470
+ vectors=dict(point.vectors) if with_vectors else None,
471
+ )
472
+
473
+
474
+ def _compile(expr: Expr, models: Any) -> Any:
475
+ if isinstance(expr, Eq):
476
+ return models.FieldCondition(key=expr.field, match=models.MatchValue(value=expr.value))
477
+ if isinstance(expr, Cmp):
478
+ return models.FieldCondition(key=expr.field, range=models.Range(**{expr.op: expr.value}))
479
+ if isinstance(expr, In):
480
+ return models.FieldCondition(key=expr.field, match=models.MatchAny(any=list(expr.values)))
481
+ if isinstance(expr, IsNull):
482
+ return models.IsNullCondition(is_null=models.PayloadField(key=expr.field))
483
+ if isinstance(expr, Not):
484
+ return models.Filter(must_not=[_compile(expr.inner, models)])
485
+ if isinstance(expr, And):
486
+ return models.Filter(must=[_compile(child, models) for child in expr.children])
487
+ if isinstance(expr, Or):
488
+ return models.Filter(should=[_compile(child, models) for child in expr.children])
489
+ raise ValidationError(f"unsupported filter node: {type(expr).__name__}")
490
+
491
+
492
+ def _models() -> Any:
493
+ try:
494
+ from qdrant_client import models
495
+ except ImportError as exc:
496
+ raise BackendError("Qdrant support requires the qdrant extra: pip install 'vecspan[qdrant]'") from exc
497
+ return models
498
+
499
+
500
+ def _meta_id(name: str) -> str:
501
+ return str(uuid.uuid5(_META_NAMESPACE, name))
502
+
503
+
504
+ def _field(record: Any, name: str) -> Any:
505
+ if isinstance(record, Mapping):
506
+ return record.get(name)
507
+ return getattr(record, name, None)
508
+
509
+
510
+ def _valid_offset(offset: object) -> bool:
511
+ if isinstance(offset, bool):
512
+ return False
513
+ return isinstance(offset, (int, str))
514
+
515
+
516
+ def _offset_token(value: object) -> int | str | None:
517
+ if value is None:
518
+ return None
519
+ if isinstance(value, bool):
520
+ raise BackendError("unexpected Qdrant scroll offset")
521
+ if isinstance(value, int):
522
+ return value
523
+ return str(value)
524
+
525
+
526
+ def _accepts_collection_name(func: Any) -> bool:
527
+ try:
528
+ import inspect
529
+
530
+ return "collection_name" in inspect.signature(func).parameters
531
+ except (TypeError, ValueError):
532
+ return False
@@ -0,0 +1,16 @@
1
+ """Operations a backend may or may not implement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class Capability(str, Enum):
9
+ """A portable operation that some backends might not support."""
10
+
11
+ DELETE_BY_FILTER = "delete_by_filter"
12
+ UPDATE_PAYLOAD = "update_payload"
13
+ UPDATE_VECTORS = "update_vectors"
14
+
15
+
16
+ ALL_CAPABILITIES = frozenset(Capability)