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/client.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Facade that validates calls and delegates them to a backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from vecspan.backends.base import VectorBackend
|
|
8
|
+
from vecspan.capabilities import Capability
|
|
9
|
+
from vecspan.errors import UnsupportedOperation, ValidationError
|
|
10
|
+
from vecspan.filters import Filter
|
|
11
|
+
from vecspan.naming import check_collection_name
|
|
12
|
+
from vecspan.registry import create_backend
|
|
13
|
+
from vecspan.types import CollectionSpec, Point, ScrollPage, SearchHit
|
|
14
|
+
from vecspan.validate import (
|
|
15
|
+
check_limit,
|
|
16
|
+
check_with_vectors,
|
|
17
|
+
prepare_filter,
|
|
18
|
+
prepare_ids,
|
|
19
|
+
prepare_payload,
|
|
20
|
+
prepare_points,
|
|
21
|
+
prepare_query_vector,
|
|
22
|
+
prepare_vector_updates,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PointId = int | str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class VectorClient:
|
|
29
|
+
"""Synchronous client for one vector database.
|
|
30
|
+
|
|
31
|
+
Connect with a URL, or pass a backend you already built:
|
|
32
|
+
|
|
33
|
+
client = VectorClient.connect("memory://")
|
|
34
|
+
client = VectorClient.connect("milvus://localhost:19530")
|
|
35
|
+
client = VectorClient.connect("qdrant://localhost:6333", api_key="...")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, backend: VectorBackend) -> None:
|
|
39
|
+
self.backend = backend
|
|
40
|
+
self._specs: dict[str, CollectionSpec] = {}
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def connect(cls, target: str | VectorBackend, **kwargs: Any) -> VectorClient:
|
|
44
|
+
"""Connect to `target`. A string is a URL. A backend is used as-is."""
|
|
45
|
+
if isinstance(target, str):
|
|
46
|
+
return cls(create_backend(target, **kwargs))
|
|
47
|
+
if kwargs:
|
|
48
|
+
raise ValidationError("options are only accepted when connecting with a URL")
|
|
49
|
+
return cls(target)
|
|
50
|
+
|
|
51
|
+
def create_collection(self, spec: CollectionSpec) -> None:
|
|
52
|
+
"""Create a collection. The id type and vector fields stay fixed afterwards."""
|
|
53
|
+
if not isinstance(spec, CollectionSpec):
|
|
54
|
+
raise ValidationError("spec must be a CollectionSpec")
|
|
55
|
+
self.backend.create_collection(spec)
|
|
56
|
+
self._specs[spec.name] = spec
|
|
57
|
+
|
|
58
|
+
def drop_collection(self, name: str) -> None:
|
|
59
|
+
"""Drop a collection."""
|
|
60
|
+
checked = check_collection_name(name)
|
|
61
|
+
self.backend.drop_collection(checked)
|
|
62
|
+
self._specs.pop(checked, None)
|
|
63
|
+
|
|
64
|
+
def has_collection(self, name: str) -> bool:
|
|
65
|
+
"""Return whether a collection exists."""
|
|
66
|
+
return self.backend.has_collection(check_collection_name(name))
|
|
67
|
+
|
|
68
|
+
def describe_collection(self, name: str) -> CollectionSpec:
|
|
69
|
+
"""Return the schema of a collection."""
|
|
70
|
+
checked = check_collection_name(name)
|
|
71
|
+
spec = self.backend.describe_collection(checked)
|
|
72
|
+
self._specs[checked] = spec
|
|
73
|
+
return spec
|
|
74
|
+
|
|
75
|
+
def list_collections(self) -> list[str]:
|
|
76
|
+
"""Return collection names in sorted order."""
|
|
77
|
+
return sorted(self.backend.list_collections())
|
|
78
|
+
|
|
79
|
+
def upsert(self, name: str, points: Point | Mapping[str, Any] | Sequence[object]) -> None:
|
|
80
|
+
"""Insert points, or replace the vector and payload of an existing id."""
|
|
81
|
+
spec = self._spec(name)
|
|
82
|
+
prepared = prepare_points(spec, points)
|
|
83
|
+
if prepared:
|
|
84
|
+
self.backend.upsert(spec.name, prepared)
|
|
85
|
+
|
|
86
|
+
def update_payload(self, name: str, ids: object, payload: Mapping[str, Any]) -> None:
|
|
87
|
+
"""Merge payload fields into existing points. Other fields stay as they are."""
|
|
88
|
+
self._need(Capability.UPDATE_PAYLOAD)
|
|
89
|
+
spec = self._spec(name)
|
|
90
|
+
prepared_ids = prepare_ids(spec, ids)
|
|
91
|
+
patch = prepare_payload(spec, payload, allow_empty=False)
|
|
92
|
+
self.backend.update_payload(spec.name, prepared_ids, patch)
|
|
93
|
+
|
|
94
|
+
def update_vectors(self, name: str, ids: object, vectors: Sequence[Mapping[str, Sequence[float]]]) -> None:
|
|
95
|
+
"""Replace the named vectors and leave every other vector field untouched."""
|
|
96
|
+
self._need(Capability.UPDATE_VECTORS)
|
|
97
|
+
spec = self._spec(name)
|
|
98
|
+
updates = prepare_vector_updates(spec, ids, vectors)
|
|
99
|
+
self.backend.update_vectors(
|
|
100
|
+
spec.name,
|
|
101
|
+
[point_id for point_id, _ in updates],
|
|
102
|
+
[vector for _, vector in updates],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def delete(
|
|
106
|
+
self,
|
|
107
|
+
name: str,
|
|
108
|
+
*,
|
|
109
|
+
ids: object = None,
|
|
110
|
+
filter: Filter | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Delete points by id or by filter. Pass one of the two, not both."""
|
|
113
|
+
spec = self._spec(name)
|
|
114
|
+
if (ids is None) == (filter is None):
|
|
115
|
+
raise ValidationError("pass exactly one of ids or filter")
|
|
116
|
+
if filter is not None:
|
|
117
|
+
self._need(Capability.DELETE_BY_FILTER)
|
|
118
|
+
prepared = prepare_filter(spec, filter)
|
|
119
|
+
self.backend.delete(spec.name, filter=prepared)
|
|
120
|
+
return
|
|
121
|
+
prepared_ids = prepare_ids(spec, ids)
|
|
122
|
+
self.backend.delete(spec.name, ids=prepared_ids)
|
|
123
|
+
|
|
124
|
+
def get(self, name: str, ids: object, *, with_vectors: bool = False) -> list[Point]:
|
|
125
|
+
"""Return existing points in the requested id order."""
|
|
126
|
+
spec = self._spec(name)
|
|
127
|
+
prepared_ids = prepare_ids(spec, ids)
|
|
128
|
+
include = check_with_vectors(with_vectors)
|
|
129
|
+
return self.backend.get(spec.name, prepared_ids, with_vectors=include)
|
|
130
|
+
|
|
131
|
+
def count(self, name: str, *, filter: Filter | None = None) -> int:
|
|
132
|
+
"""Count points that match `filter`, or every point when it is omitted."""
|
|
133
|
+
spec = self._spec(name)
|
|
134
|
+
return self.backend.count(spec.name, filter=prepare_filter(spec, filter))
|
|
135
|
+
|
|
136
|
+
def scroll(
|
|
137
|
+
self,
|
|
138
|
+
name: str,
|
|
139
|
+
*,
|
|
140
|
+
filter: Filter | None = None,
|
|
141
|
+
limit: int = 100,
|
|
142
|
+
offset: int | str | None = None,
|
|
143
|
+
with_vectors: bool = False,
|
|
144
|
+
) -> ScrollPage:
|
|
145
|
+
"""Read one page of points. Pass `next_offset` back in as `offset`."""
|
|
146
|
+
spec = self._spec(name)
|
|
147
|
+
return self.backend.scroll(
|
|
148
|
+
spec.name,
|
|
149
|
+
filter=prepare_filter(spec, filter),
|
|
150
|
+
limit=check_limit(limit),
|
|
151
|
+
offset=offset,
|
|
152
|
+
with_vectors=check_with_vectors(with_vectors),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def search(
|
|
156
|
+
self,
|
|
157
|
+
name: str,
|
|
158
|
+
vector: Sequence[float],
|
|
159
|
+
*,
|
|
160
|
+
vector_name: str | None = None,
|
|
161
|
+
filter: Filter | None = None,
|
|
162
|
+
limit: int = 10,
|
|
163
|
+
with_vectors: bool = False,
|
|
164
|
+
backend_options: Mapping[str, Any] | None = None,
|
|
165
|
+
) -> list[SearchHit]:
|
|
166
|
+
"""Return the nearest points.
|
|
167
|
+
|
|
168
|
+
`score` is the backend's own value. Cosine and dot are larger when
|
|
169
|
+
closer. L2 is smaller when closer. Memory and Qdrant L2 values are
|
|
170
|
+
Euclidean distance. Milvus L2 values are squared Euclidean distance.
|
|
171
|
+
"""
|
|
172
|
+
spec = self._spec(name)
|
|
173
|
+
field, query = prepare_query_vector(spec, vector, vector_name)
|
|
174
|
+
return self.backend.search(
|
|
175
|
+
spec.name,
|
|
176
|
+
query,
|
|
177
|
+
vector_name=field.name,
|
|
178
|
+
filter=prepare_filter(spec, filter),
|
|
179
|
+
limit=check_limit(limit),
|
|
180
|
+
with_vectors=check_with_vectors(with_vectors),
|
|
181
|
+
backend_options=None if backend_options is None else dict(backend_options),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
"""Close the underlying connection."""
|
|
186
|
+
self._specs.clear()
|
|
187
|
+
self.backend.close()
|
|
188
|
+
|
|
189
|
+
def __enter__(self) -> VectorClient:
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *exc: object) -> None:
|
|
193
|
+
self.close()
|
|
194
|
+
|
|
195
|
+
def _spec(self, name: str) -> CollectionSpec:
|
|
196
|
+
checked = check_collection_name(name)
|
|
197
|
+
spec = self._specs.get(checked)
|
|
198
|
+
if spec is None:
|
|
199
|
+
spec = self.backend.describe_collection(checked)
|
|
200
|
+
self._specs[checked] = spec
|
|
201
|
+
return spec
|
|
202
|
+
|
|
203
|
+
def _need(self, capability: Capability) -> None:
|
|
204
|
+
if capability not in self.backend.capabilities:
|
|
205
|
+
raise UnsupportedOperation(f"{self.backend.name} does not support {capability.value}")
|
vecspan/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Errors raised by vecspan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VecspanError(Exception):
|
|
7
|
+
"""Base class for errors raised by this library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ValidationError(VecspanError):
|
|
11
|
+
"""The call does not match the collection schema or the public API."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsupportedOperation(VecspanError):
|
|
15
|
+
"""The connected backend does not implement this operation."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CollectionNotFoundError(VecspanError):
|
|
19
|
+
"""No collection with this name exists."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CollectionAlreadyExistsError(VecspanError):
|
|
23
|
+
"""A collection with this name already exists."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BackendError(VecspanError):
|
|
27
|
+
"""The underlying database rejected the call or could not be reached."""
|
vecspan/filters.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Filter expression tree, in-memory matcher, and Milvus compiler.
|
|
2
|
+
|
|
3
|
+
Qdrant compilation lives next to that backend because it builds Qdrant models.
|
|
4
|
+
The tree itself has no database dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from vecspan.errors import ValidationError
|
|
14
|
+
from vecspan.naming import check_field_name
|
|
15
|
+
|
|
16
|
+
Expr = "Eq | Cmp | In | IsNull | Not | And | Or"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Eq:
|
|
21
|
+
field: str
|
|
22
|
+
value: object
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Cmp:
|
|
27
|
+
field: str
|
|
28
|
+
op: str
|
|
29
|
+
value: object
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class In:
|
|
34
|
+
field: str
|
|
35
|
+
values: tuple[object, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class IsNull:
|
|
40
|
+
field: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Not:
|
|
45
|
+
inner: Expr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class And:
|
|
50
|
+
children: tuple[Expr, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Or:
|
|
55
|
+
children: tuple[Expr, ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_CMP_OPS = {
|
|
59
|
+
"gt": ">",
|
|
60
|
+
"gte": ">=",
|
|
61
|
+
"lt": "<",
|
|
62
|
+
"lte": "<=",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Filter:
|
|
67
|
+
"""A boolean expression over payload fields.
|
|
68
|
+
|
|
69
|
+
Build it with the class methods and combine pieces with `&`, `|`, and `~`.
|
|
70
|
+
Field names are identifiers. Values are scalars, so a value cannot change
|
|
71
|
+
the shape of the compiled expression.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, expr: Expr) -> None:
|
|
75
|
+
self.expr = expr
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def eq(cls, field: str, value: object) -> Filter:
|
|
79
|
+
"""Match a payload field equal to `value`."""
|
|
80
|
+
return cls(Eq(check_field_name(field), _equality_value(value)))
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def gt(cls, field: str, value: object) -> Filter:
|
|
84
|
+
return cls._cmp("gt", field, value)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def gte(cls, field: str, value: object) -> Filter:
|
|
88
|
+
return cls._cmp("gte", field, value)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def lt(cls, field: str, value: object) -> Filter:
|
|
92
|
+
return cls._cmp("lt", field, value)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def lte(cls, field: str, value: object) -> Filter:
|
|
96
|
+
return cls._cmp("lte", field, value)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def in_(cls, field: str, values: Sequence[object]) -> Filter:
|
|
100
|
+
"""Match a field whose value is one of `values`.
|
|
101
|
+
|
|
102
|
+
The method is named `in_` because `in` is a Python keyword.
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
|
|
105
|
+
raise ValidationError("in values must be a sequence")
|
|
106
|
+
items = tuple(_equality_value(item) for item in values)
|
|
107
|
+
if not items:
|
|
108
|
+
raise ValidationError("in values must not be empty")
|
|
109
|
+
kinds = {_value_kind(item) for item in items}
|
|
110
|
+
if len(kinds) > 1:
|
|
111
|
+
raise ValidationError("in values must share one type")
|
|
112
|
+
return cls(In(check_field_name(field), items))
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def is_null(cls, field: str) -> Filter:
|
|
116
|
+
"""Match a missing payload field or a field explicitly set to null."""
|
|
117
|
+
return cls(IsNull(check_field_name(field)))
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def _cmp(cls, op: str, field: str, value: object) -> Filter:
|
|
121
|
+
return cls(Cmp(check_field_name(field), op, _number_value(value)))
|
|
122
|
+
|
|
123
|
+
def __and__(self, other: object) -> Filter:
|
|
124
|
+
if not isinstance(other, Filter):
|
|
125
|
+
return NotImplemented
|
|
126
|
+
return Filter(And((self.expr, other.expr)))
|
|
127
|
+
|
|
128
|
+
def __or__(self, other: object) -> Filter:
|
|
129
|
+
if not isinstance(other, Filter):
|
|
130
|
+
return NotImplemented
|
|
131
|
+
return Filter(Or((self.expr, other.expr)))
|
|
132
|
+
|
|
133
|
+
def __invert__(self) -> Filter:
|
|
134
|
+
return Filter(Not(self.expr))
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"Filter({self.expr!r})"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def matches(payload: Mapping[str, object], filt: Filter) -> bool:
|
|
141
|
+
"""Return whether `payload` satisfies `filt`."""
|
|
142
|
+
return _matches(payload, filt.expr)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def compile_milvus(filt: Filter) -> str:
|
|
146
|
+
"""Compile a filter to a Milvus boolean expression."""
|
|
147
|
+
return _compile_milvus(filt.expr)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _matches(payload: Mapping[str, object], expr: Expr) -> bool:
|
|
151
|
+
if isinstance(expr, Eq):
|
|
152
|
+
if expr.field not in payload:
|
|
153
|
+
return False
|
|
154
|
+
return _same(payload[expr.field], expr.value)
|
|
155
|
+
if isinstance(expr, Cmp):
|
|
156
|
+
if expr.field not in payload:
|
|
157
|
+
return False
|
|
158
|
+
left = payload[expr.field]
|
|
159
|
+
if not _is_number(left) or not _is_number(expr.value):
|
|
160
|
+
return False
|
|
161
|
+
if expr.op == "gt":
|
|
162
|
+
return left > expr.value
|
|
163
|
+
if expr.op == "gte":
|
|
164
|
+
return left >= expr.value
|
|
165
|
+
if expr.op == "lt":
|
|
166
|
+
return left < expr.value
|
|
167
|
+
return left <= expr.value
|
|
168
|
+
if isinstance(expr, In):
|
|
169
|
+
if expr.field not in payload:
|
|
170
|
+
return False
|
|
171
|
+
return any(_same(payload[expr.field], item) for item in expr.values)
|
|
172
|
+
if isinstance(expr, IsNull):
|
|
173
|
+
return expr.field not in payload or payload[expr.field] is None
|
|
174
|
+
if isinstance(expr, Not):
|
|
175
|
+
return not _matches(payload, expr.inner)
|
|
176
|
+
if isinstance(expr, And):
|
|
177
|
+
return all(_matches(payload, child) for child in expr.children)
|
|
178
|
+
if isinstance(expr, Or):
|
|
179
|
+
return any(_matches(payload, child) for child in expr.children)
|
|
180
|
+
raise ValidationError(f"unsupported filter node: {type(expr).__name__}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _compile_milvus(expr: Expr) -> str:
|
|
184
|
+
if isinstance(expr, Eq):
|
|
185
|
+
return f"{expr.field} == {_literal(expr.value)}"
|
|
186
|
+
if isinstance(expr, Cmp):
|
|
187
|
+
return f"{expr.field} {_CMP_OPS[expr.op]} {_literal(expr.value)}"
|
|
188
|
+
if isinstance(expr, In):
|
|
189
|
+
rendered = ", ".join(_literal(item) for item in expr.values)
|
|
190
|
+
return f"{expr.field} in [{rendered}]"
|
|
191
|
+
if isinstance(expr, IsNull):
|
|
192
|
+
return f"{expr.field} is null"
|
|
193
|
+
if isinstance(expr, Not):
|
|
194
|
+
return f"not ({_compile_milvus(expr.inner)})"
|
|
195
|
+
if isinstance(expr, And):
|
|
196
|
+
return " and ".join(f"({_compile_milvus(child)})" for child in expr.children)
|
|
197
|
+
if isinstance(expr, Or):
|
|
198
|
+
return " or ".join(f"({_compile_milvus(child)})" for child in expr.children)
|
|
199
|
+
raise ValidationError(f"unsupported filter node: {type(expr).__name__}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _equality_value(value: object) -> object:
|
|
203
|
+
if isinstance(value, bool):
|
|
204
|
+
return value
|
|
205
|
+
if isinstance(value, int):
|
|
206
|
+
return value
|
|
207
|
+
if isinstance(value, float):
|
|
208
|
+
if not math.isfinite(value):
|
|
209
|
+
raise ValidationError("filter value must be finite")
|
|
210
|
+
return value
|
|
211
|
+
if isinstance(value, str):
|
|
212
|
+
return value
|
|
213
|
+
raise ValidationError(f"filter value must be a string, number, or bool, not {type(value).__name__}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _number_value(value: object) -> int | float:
|
|
217
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
218
|
+
raise ValidationError("comparison value must be a number")
|
|
219
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
220
|
+
raise ValidationError("filter value must be finite")
|
|
221
|
+
return value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _value_kind(value: object) -> str:
|
|
225
|
+
if isinstance(value, bool):
|
|
226
|
+
return "bool"
|
|
227
|
+
if isinstance(value, (int, float)):
|
|
228
|
+
return "number"
|
|
229
|
+
if isinstance(value, str):
|
|
230
|
+
return "string"
|
|
231
|
+
return type(value).__name__
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _is_number(value: object) -> bool:
|
|
235
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _same(left: object, right: object) -> bool:
|
|
239
|
+
if left is None or right is None:
|
|
240
|
+
return False
|
|
241
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
242
|
+
return isinstance(left, bool) and isinstance(right, bool) and left is right
|
|
243
|
+
if _is_number(left) and _is_number(right):
|
|
244
|
+
return left == right
|
|
245
|
+
return type(left) is type(right) and left == right
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _literal(value: object) -> str:
|
|
249
|
+
if isinstance(value, bool):
|
|
250
|
+
return "true" if value else "false"
|
|
251
|
+
if isinstance(value, int):
|
|
252
|
+
return str(value)
|
|
253
|
+
if isinstance(value, float):
|
|
254
|
+
return repr(value)
|
|
255
|
+
if isinstance(value, str):
|
|
256
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
257
|
+
return f'"{escaped}"'
|
|
258
|
+
raise ValidationError(f"filter value must be a string, number, or bool, not {type(value).__name__}")
|
vecspan/naming.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared rules for collection and field names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from vecspan.errors import ValidationError
|
|
8
|
+
|
|
9
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
10
|
+
META_COLLECTION = "_vecspan_schemas"
|
|
11
|
+
RESERVED_COLLECTIONS = frozenset({META_COLLECTION})
|
|
12
|
+
_MAX_NAME_LENGTH = 255
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_identifier(name: object, label: str) -> str:
|
|
16
|
+
"""Return `name` when it is a short ASCII identifier."""
|
|
17
|
+
if not isinstance(name, str) or not _IDENTIFIER.match(name) or len(name) > _MAX_NAME_LENGTH:
|
|
18
|
+
raise ValidationError(f"invalid {label}: {name!r}")
|
|
19
|
+
return name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_field_name(name: object) -> str:
|
|
23
|
+
"""Return a field name that is safe to compile into a filter."""
|
|
24
|
+
checked = check_identifier(name, "field name")
|
|
25
|
+
if checked == "id" or checked.startswith("$"):
|
|
26
|
+
raise ValidationError(f"reserved field name: {checked!r}")
|
|
27
|
+
return checked
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_collection_name(name: object) -> str:
|
|
31
|
+
"""Return a collection name callers are allowed to create."""
|
|
32
|
+
checked = check_identifier(name, "collection name")
|
|
33
|
+
if checked in RESERVED_COLLECTIONS:
|
|
34
|
+
raise ValidationError(f"reserved collection name: {checked!r}")
|
|
35
|
+
return checked
|
vecspan/py.typed
ADDED
|
File without changes
|
vecspan/registry.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Resolve a connection URL to a backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from vecspan.backends.memory import MemoryBackend
|
|
9
|
+
from vecspan.backends.base import VectorBackend
|
|
10
|
+
from vecspan.errors import ValidationError
|
|
11
|
+
|
|
12
|
+
_BUILTINS = frozenset({"memory", "milvus", "qdrant", "qdrant+https"})
|
|
13
|
+
_EXTRA: dict[str, Callable[..., VectorBackend]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_backend(scheme: str) -> Callable[[Callable[..., VectorBackend]], Callable[..., VectorBackend]]:
|
|
17
|
+
"""Register a factory for a URL scheme.
|
|
18
|
+
|
|
19
|
+
The factory is called as `factory(parsed_url, **kwargs)` and returns a
|
|
20
|
+
`VectorBackend`. Built-in schemes cannot be replaced.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def decorator(factory: Callable[..., VectorBackend]) -> Callable[..., VectorBackend]:
|
|
24
|
+
key = scheme.lower()
|
|
25
|
+
if not key or any(character in key for character in ":/"):
|
|
26
|
+
raise ValidationError(f"invalid backend scheme: {scheme!r}")
|
|
27
|
+
if key in _BUILTINS or key in _EXTRA:
|
|
28
|
+
raise ValidationError(f"backend {key!r} is already registered")
|
|
29
|
+
_EXTRA[key] = factory
|
|
30
|
+
return factory
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def unregister_backend(scheme: str) -> None:
|
|
36
|
+
"""Remove a backend registered with `register_backend`."""
|
|
37
|
+
_EXTRA.pop(scheme.lower(), None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_backend(url: str, **kwargs: object) -> VectorBackend:
|
|
41
|
+
"""Build the backend addressed by `url`."""
|
|
42
|
+
if not isinstance(url, str) or "://" not in url:
|
|
43
|
+
raise ValidationError("URL must look like memory://, milvus://host:19530, or qdrant://host:6333")
|
|
44
|
+
parsed = urlsplit(url)
|
|
45
|
+
scheme = parsed.scheme.lower()
|
|
46
|
+
if scheme == "memory":
|
|
47
|
+
if kwargs:
|
|
48
|
+
raise ValidationError(f"unknown option: {sorted(kwargs)[0]}")
|
|
49
|
+
_check_memory_url(parsed)
|
|
50
|
+
return MemoryBackend()
|
|
51
|
+
if scheme == "milvus":
|
|
52
|
+
from vecspan.backends.milvus import MilvusBackend
|
|
53
|
+
|
|
54
|
+
return MilvusBackend.from_url(url, **kwargs)
|
|
55
|
+
if scheme in {"qdrant", "qdrant+https"}:
|
|
56
|
+
from vecspan.backends.qdrant import QdrantBackend
|
|
57
|
+
|
|
58
|
+
return QdrantBackend.from_url(url, **kwargs)
|
|
59
|
+
factory = _EXTRA.get(scheme)
|
|
60
|
+
if factory is None:
|
|
61
|
+
raise ValidationError(f"unknown backend scheme: {scheme!r}")
|
|
62
|
+
return factory(parsed, **kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_memory_url(parsed: object) -> None:
|
|
66
|
+
if parsed.netloc or parsed.path not in ("", "/") or parsed.query or parsed.fragment or parsed.username:
|
|
67
|
+
raise ValidationError("memory URL does not take a host, path, or options")
|