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
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
|
+
]
|
vecspan/backends/base.py
ADDED
|
@@ -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))
|