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 ADDED
@@ -0,0 +1,50 @@
1
+ """One Python API for Milvus, Qdrant, and other vector databases."""
2
+
3
+ from vecspan.capabilities import Capability
4
+ from vecspan.client import VectorClient
5
+ from vecspan.errors import (
6
+ BackendError,
7
+ CollectionAlreadyExistsError,
8
+ CollectionNotFoundError,
9
+ UnsupportedOperation,
10
+ ValidationError,
11
+ VecspanError,
12
+ )
13
+ from vecspan.filters import Filter
14
+ from vecspan.registry import register_backend
15
+ from vecspan.types import (
16
+ CollectionSpec,
17
+ Distance,
18
+ IdType,
19
+ PayloadField,
20
+ PayloadType,
21
+ Point,
22
+ ScrollPage,
23
+ SearchHit,
24
+ VectorField,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "BackendError",
31
+ "Capability",
32
+ "CollectionAlreadyExistsError",
33
+ "CollectionNotFoundError",
34
+ "CollectionSpec",
35
+ "Distance",
36
+ "Filter",
37
+ "IdType",
38
+ "PayloadField",
39
+ "PayloadType",
40
+ "Point",
41
+ "ScrollPage",
42
+ "SearchHit",
43
+ "UnsupportedOperation",
44
+ "ValidationError",
45
+ "VecspanError",
46
+ "VectorClient",
47
+ "VectorField",
48
+ "__version__",
49
+ "register_backend",
50
+ ]
@@ -0,0 +1,6 @@
1
+ """Built-in backends. Milvus and Qdrant stay lazy so importing vecspan is cheap."""
2
+
3
+ from vecspan.backends.base import VectorBackend
4
+ from vecspan.backends.memory import MemoryBackend
5
+
6
+ __all__ = ["MemoryBackend", "VectorBackend"]
@@ -0,0 +1,107 @@
1
+ """Backend interface implemented by Milvus, Qdrant, and the in-memory store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Sequence
7
+
8
+ from vecspan.capabilities import ALL_CAPABILITIES, Capability
9
+ from vecspan.filters import Filter
10
+ from vecspan.types import CollectionSpec, Point, ScrollPage, SearchHit
11
+
12
+
13
+ class VectorBackend(ABC):
14
+ """Storage operations behind `VectorClient`.
15
+
16
+ `VectorClient` validates arguments and checks capabilities before it calls
17
+ these methods. A backend still raises `VecspanError` for conditions only
18
+ the database can see, such as a missing collection or a missing point id.
19
+ """
20
+
21
+ name = "backend"
22
+ capabilities: frozenset[Capability] = ALL_CAPABILITIES
23
+
24
+ @abstractmethod
25
+ def create_collection(self, spec: CollectionSpec) -> None:
26
+ """Create a collection from `spec`."""
27
+
28
+ @abstractmethod
29
+ def drop_collection(self, name: str) -> None:
30
+ """Drop a collection."""
31
+
32
+ @abstractmethod
33
+ def has_collection(self, name: str) -> bool:
34
+ """Return whether the collection exists."""
35
+
36
+ @abstractmethod
37
+ def describe_collection(self, name: str) -> CollectionSpec:
38
+ """Return the schema stored for a collection."""
39
+
40
+ @abstractmethod
41
+ def list_collections(self) -> list[str]:
42
+ """Return collection names visible to callers."""
43
+
44
+ @abstractmethod
45
+ def upsert(self, name: str, points: Sequence[Point]) -> None:
46
+ """Insert points or replace them completely when the id already exists."""
47
+
48
+ @abstractmethod
49
+ def update_payload(self, name: str, ids: Sequence[int | str], payload: dict) -> None:
50
+ """Merge `payload` into existing points. Missing ids are an error."""
51
+
52
+ @abstractmethod
53
+ def update_vectors(
54
+ self,
55
+ name: str,
56
+ ids: Sequence[int | str],
57
+ vectors: Sequence[dict[str, list[float]]],
58
+ ) -> None:
59
+ """Replace the given vector fields on existing points and leave the rest."""
60
+
61
+ @abstractmethod
62
+ def delete(
63
+ self,
64
+ name: str,
65
+ *,
66
+ ids: Sequence[int | str] | None = None,
67
+ filter: Filter | None = None,
68
+ ) -> None:
69
+ """Delete by id or by filter. Exactly one of the two is set."""
70
+
71
+ @abstractmethod
72
+ def get(self, name: str, ids: Sequence[int | str], *, with_vectors: bool = False) -> list[Point]:
73
+ """Return the points that exist, in the same order as `ids`."""
74
+
75
+ @abstractmethod
76
+ def count(self, name: str, *, filter: Filter | None = None) -> int:
77
+ """Count points, optionally restricted by a filter."""
78
+
79
+ @abstractmethod
80
+ def scroll(
81
+ self,
82
+ name: str,
83
+ *,
84
+ filter: Filter | None = None,
85
+ limit: int = 100,
86
+ offset: int | str | None = None,
87
+ with_vectors: bool = False,
88
+ ) -> ScrollPage:
89
+ """Read a page of points. `offset` comes from a previous page."""
90
+
91
+ @abstractmethod
92
+ def search(
93
+ self,
94
+ name: str,
95
+ vector: Sequence[float],
96
+ *,
97
+ vector_name: str,
98
+ filter: Filter | None = None,
99
+ limit: int = 10,
100
+ with_vectors: bool = False,
101
+ backend_options: dict | None = None,
102
+ ) -> list[SearchHit]:
103
+ """Return the nearest points for one named vector field."""
104
+
105
+ def close(self) -> None:
106
+ """Release the underlying connection. The default does nothing."""
107
+ return None
@@ -0,0 +1,289 @@
1
+ """In-process backend used by tests and local development."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import math
7
+ import threading
8
+ from dataclasses import dataclass
9
+ from typing import Sequence
10
+
11
+ from vecspan.backends.base import VectorBackend
12
+ from vecspan.errors import (
13
+ BackendError,
14
+ CollectionAlreadyExistsError,
15
+ CollectionNotFoundError,
16
+ ValidationError,
17
+ )
18
+ from vecspan.filters import Filter, matches
19
+ from vecspan.types import CollectionSpec, Distance, Point, ScrollPage, SearchHit
20
+ from vecspan.validate import (
21
+ check_limit,
22
+ check_with_vectors,
23
+ prepare_filter,
24
+ prepare_ids,
25
+ prepare_payload,
26
+ prepare_points,
27
+ prepare_query_vector,
28
+ prepare_search_options,
29
+ prepare_vector_updates,
30
+ )
31
+
32
+ PointId = int | str
33
+
34
+
35
+ @dataclass
36
+ class _Record:
37
+ id: PointId
38
+ vectors: dict[str, list[float]]
39
+ payload: dict
40
+
41
+
42
+ class MemoryBackend(VectorBackend):
43
+ """Brute-force vector store kept in the current process."""
44
+
45
+ name = "memory"
46
+
47
+ def __init__(self) -> None:
48
+ self._lock = threading.RLock()
49
+ self._collections: dict[str, tuple[CollectionSpec, dict[PointId, _Record]]] = {}
50
+ self._closed = False
51
+
52
+ def create_collection(self, spec: CollectionSpec) -> None:
53
+ with self._lock:
54
+ self._check_open()
55
+ if not isinstance(spec, CollectionSpec):
56
+ raise ValidationError("spec must be a CollectionSpec")
57
+ if spec.name in self._collections:
58
+ raise CollectionAlreadyExistsError(f"collection {spec.name!r} already exists")
59
+ self._collections[spec.name] = (spec, {})
60
+
61
+ def drop_collection(self, name: str) -> None:
62
+ with self._lock:
63
+ self._check_open()
64
+ if name not in self._collections:
65
+ raise CollectionNotFoundError(f"collection {name!r} does not exist")
66
+ del self._collections[name]
67
+
68
+ def has_collection(self, name: str) -> bool:
69
+ with self._lock:
70
+ self._check_open()
71
+ return name in self._collections
72
+
73
+ def describe_collection(self, name: str) -> CollectionSpec:
74
+ with self._lock:
75
+ self._check_open()
76
+ return self._spec(name)
77
+
78
+ def list_collections(self) -> list[str]:
79
+ with self._lock:
80
+ self._check_open()
81
+ return list(self._collections)
82
+
83
+ def upsert(self, name: str, points: Sequence[Point]) -> None:
84
+ with self._lock:
85
+ self._check_open()
86
+ spec, store = self._collection(name)
87
+ for point in prepare_points(spec, points):
88
+ store[point.id] = _Record(
89
+ point.id,
90
+ {key: list(value) for key, value in point.vectors.items()},
91
+ copy.deepcopy(point.payload),
92
+ )
93
+
94
+ def update_payload(self, name: str, ids: Sequence[PointId], payload: dict) -> None:
95
+ with self._lock:
96
+ self._check_open()
97
+ spec, store = self._collection(name)
98
+ prepared_ids = prepare_ids(spec, ids)
99
+ patch = prepare_payload(spec, payload, allow_empty=False)
100
+ records = self._require(store, prepared_ids)
101
+ for record in records:
102
+ record.payload.update(copy.deepcopy(patch))
103
+
104
+ def update_vectors(
105
+ self,
106
+ name: str,
107
+ ids: Sequence[PointId],
108
+ vectors: Sequence[dict[str, list[float]]],
109
+ ) -> None:
110
+ with self._lock:
111
+ self._check_open()
112
+ spec, store = self._collection(name)
113
+ updates = prepare_vector_updates(spec, ids, vectors)
114
+ records = {record.id: record for record in self._require(store, [point_id for point_id, _ in updates])}
115
+ for point_id, vector_update in updates:
116
+ records[point_id].vectors.update(
117
+ {key: list(value) for key, value in vector_update.items()}
118
+ )
119
+
120
+ def delete(
121
+ self,
122
+ name: str,
123
+ *,
124
+ ids: Sequence[PointId] | None = None,
125
+ filter: Filter | None = None,
126
+ ) -> None:
127
+ with self._lock:
128
+ self._check_open()
129
+ spec, store = self._collection(name)
130
+ if (ids is None) == (filter is None):
131
+ raise ValidationError("pass exactly one of ids or filter")
132
+ if ids is not None:
133
+ for point_id in prepare_ids(spec, ids):
134
+ store.pop(point_id, None)
135
+ return
136
+ prepared = prepare_filter(spec, filter)
137
+ assert prepared is not None
138
+ doomed = [point_id for point_id, record in store.items() if matches(record.payload, prepared)]
139
+ for point_id in doomed:
140
+ del store[point_id]
141
+
142
+ def get(self, name: str, ids: Sequence[PointId], *, with_vectors: bool = False) -> list[Point]:
143
+ with self._lock:
144
+ self._check_open()
145
+ spec, store = self._collection(name)
146
+ include = check_with_vectors(with_vectors)
147
+ found: list[Point] = []
148
+ for point_id in prepare_ids(spec, ids):
149
+ record = store.get(point_id)
150
+ if record is not None:
151
+ found.append(self._point(record, include))
152
+ return found
153
+
154
+ def count(self, name: str, *, filter: Filter | None = None) -> int:
155
+ with self._lock:
156
+ self._check_open()
157
+ spec, store = self._collection(name)
158
+ prepared = prepare_filter(spec, filter)
159
+ if prepared is None:
160
+ return len(store)
161
+ return sum(1 for record in store.values() if matches(record.payload, prepared))
162
+
163
+ def scroll(
164
+ self,
165
+ name: str,
166
+ *,
167
+ filter: Filter | None = None,
168
+ limit: int = 100,
169
+ offset: int | str | None = None,
170
+ with_vectors: bool = False,
171
+ ) -> ScrollPage:
172
+ with self._lock:
173
+ self._check_open()
174
+ spec, store = self._collection(name)
175
+ prepared = prepare_filter(spec, filter)
176
+ size = check_limit(limit)
177
+ include = check_with_vectors(with_vectors)
178
+ start = 0 if offset is None else offset
179
+ if isinstance(start, bool) or not isinstance(start, int) or start < 0:
180
+ raise ValidationError("scroll offset must be an integer from a previous page")
181
+ ordered = [
182
+ record
183
+ for _, record in sorted(store.items(), key=lambda item: _id_sort_key(item[0]))
184
+ if prepared is None or matches(record.payload, prepared)
185
+ ]
186
+ page = ordered[start : start + size]
187
+ next_offset = None if start + size >= len(ordered) else start + size
188
+ return ScrollPage(
189
+ points=[self._point(record, include) for record in page],
190
+ next_offset=next_offset,
191
+ )
192
+
193
+ def search(
194
+ self,
195
+ name: str,
196
+ vector: Sequence[float],
197
+ *,
198
+ vector_name: str,
199
+ filter: Filter | None = None,
200
+ limit: int = 10,
201
+ with_vectors: bool = False,
202
+ backend_options: dict | None = None,
203
+ ) -> list[SearchHit]:
204
+ with self._lock:
205
+ self._check_open()
206
+ spec, store = self._collection(name)
207
+ field, query = prepare_query_vector(spec, vector, vector_name)
208
+ prepared = prepare_filter(spec, filter)
209
+ size = check_limit(limit)
210
+ include = check_with_vectors(with_vectors)
211
+ prepare_search_options(backend_options, allow_nprobe=False)
212
+ ranked: list[tuple[float, PointId, _Record]] = []
213
+ for record in store.values():
214
+ if prepared is not None and not matches(record.payload, prepared):
215
+ continue
216
+ score = _score(field.distance, query, record.vectors[field.name])
217
+ ranked.append((score, record.id, record))
218
+ ranked.sort(key=lambda item: _rank_key(field.distance, item[0], item[1]))
219
+ hits: list[SearchHit] = []
220
+ for score, _, record in ranked[:size]:
221
+ point = self._point(record, include)
222
+ hits.append(
223
+ SearchHit(
224
+ id=point.id,
225
+ score=score,
226
+ distance=field.distance,
227
+ payload=point.payload,
228
+ vectors=point.vectors if include else None,
229
+ )
230
+ )
231
+ return hits
232
+
233
+ def close(self) -> None:
234
+ with self._lock:
235
+ self._closed = True
236
+ self._collections.clear()
237
+
238
+ def _check_open(self) -> None:
239
+ if self._closed:
240
+ raise BackendError("client is closed")
241
+
242
+ def _collection(self, name: str) -> tuple[CollectionSpec, dict[PointId, _Record]]:
243
+ try:
244
+ return self._collections[name]
245
+ except KeyError:
246
+ raise CollectionNotFoundError(f"collection {name!r} does not exist") from None
247
+
248
+ def _spec(self, name: str) -> CollectionSpec:
249
+ return self._collection(name)[0]
250
+
251
+ def _require(self, store: dict[PointId, _Record], ids: Sequence[PointId]) -> list[_Record]:
252
+ records: list[_Record] = []
253
+ for point_id in ids:
254
+ record = store.get(point_id)
255
+ if record is None:
256
+ raise ValidationError(f"unknown point id: {point_id!r}")
257
+ records.append(record)
258
+ return records
259
+
260
+ @staticmethod
261
+ def _point(record: _Record, with_vectors: bool) -> Point:
262
+ vectors = {key: list(value) for key, value in record.vectors.items()} if with_vectors else {}
263
+ return Point(id=record.id, vectors=vectors, payload=copy.deepcopy(record.payload))
264
+
265
+
266
+ def _id_sort_key(point_id: PointId) -> tuple:
267
+ if isinstance(point_id, int):
268
+ return (0, point_id)
269
+ return (1, point_id)
270
+
271
+
272
+ def _rank_key(distance: Distance, score: float, point_id: PointId) -> tuple:
273
+ primary = score if distance == Distance.L2 else -score
274
+ return (primary, _id_sort_key(point_id))
275
+
276
+
277
+ def _score(distance: Distance, query: Sequence[float], stored: Sequence[float]) -> float:
278
+ if distance == Distance.DOT:
279
+ return _dot(query, stored)
280
+ if distance == Distance.COSINE:
281
+ denom = math.sqrt(_dot(query, query) * _dot(stored, stored))
282
+ if denom == 0:
283
+ return 0.0
284
+ return _dot(query, stored) / denom
285
+ return math.sqrt(sum((left - right) ** 2 for left, right in zip(query, stored)))
286
+
287
+
288
+ def _dot(left: Sequence[float], right: Sequence[float]) -> float:
289
+ return sum(left_item * right_item for left_item, right_item in zip(left, right))