vink 0.1.0a1__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.
Potentially problematic release.
This version of vink might be problematic. Click here for more details.
- vink/__init__.py +52 -0
- vink/core.py +519 -0
- vink/exceptions.py +40 -0
- vink/filter_parser.py +181 -0
- vink/latency_predictor.py +132 -0
- vink/models.py +119 -0
- vink/sql_wrapper.py +225 -0
- vink/strategies/__init__.py +0 -0
- vink/strategies/approximate_search.py +499 -0
- vink/strategies/base.py +167 -0
- vink/strategies/exact_search.py +300 -0
- vink/utils/__init__.py +0 -0
- vink/utils/id_generation.py +22 -0
- vink/utils/input_validation.py +171 -0
- vink/utils/logging.py +22 -0
- vink-0.1.0a1.dist-info/METADATA +408 -0
- vink-0.1.0a1.dist-info/RECORD +20 -0
- vink-0.1.0a1.dist-info/WHEEL +5 -0
- vink-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- vink-0.1.0a1.dist-info/top_level.txt +1 -0
vink/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vink: Vector Incremental Nano Kit
|
|
3
|
+
|
|
4
|
+
A lightweight vector database that incrementally switches from exact to
|
|
5
|
+
approximate search as your data grows — without full index rebuilds.
|
|
6
|
+
|
|
7
|
+
Note:
|
|
8
|
+
ANN switching is one-way — once switched to approximate search,
|
|
9
|
+
the system will never switch back to exact search.
|
|
10
|
+
|
|
11
|
+
Key differentiators:
|
|
12
|
+
- **Incremental inserts**: Add vectors anytime — no rebuild per insert.
|
|
13
|
+
- **Automatic strategy switching**: No manual tuning — exact for small datasets, ANN for large.
|
|
14
|
+
- **Thread-safe**: Background ANN building doesn't block new operations.
|
|
15
|
+
- **Soft deletes + compact**: Efficient deletion with explicit storage reclamation.
|
|
16
|
+
|
|
17
|
+
Features:
|
|
18
|
+
- ~100x faster using RII and Product Quantization for large datasets.
|
|
19
|
+
- Pure Python: No external services or dependencies required.
|
|
20
|
+
- Supports Euclidean (L2) and cosine (dot) product similarity.
|
|
21
|
+
- SQLite-backed persistent storage.
|
|
22
|
+
|
|
23
|
+
Technical Background:
|
|
24
|
+
Vink uses Reconfigurable Inverted Index (RII) with Product Quantization (PQ)
|
|
25
|
+
for approximate nearest neighbor search. The switch from exact to approximate
|
|
26
|
+
happens when the normalized power-law complexity reaches 1.0:
|
|
27
|
+
(dim * vectors / 1M) ^ switch_exp >= 1.0. Default switch_exp is 1.0.
|
|
28
|
+
|
|
29
|
+
References:
|
|
30
|
+
.. [Matsui18] Matsui et al., "Reconfigurable Inverted Index", ACM MM 2018.
|
|
31
|
+
|
|
32
|
+
.. [Jegou11] Jegou et al., "Product Quantization for Nearest Neighbor Search",
|
|
33
|
+
IEEE TPAMI 2011.
|
|
34
|
+
|
|
35
|
+
.. [Matsui15] Matsui et al., "Optimized Product Quantization for Nearest
|
|
36
|
+
Neighbor Search", CVPR 2015.
|
|
37
|
+
|
|
38
|
+
See Also:
|
|
39
|
+
- RII: https://github.com/matsui528/rii
|
|
40
|
+
- nanopq: https://github.com/matsui528/nanopq
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
44
|
+
|
|
45
|
+
from vink.core import VinkDB
|
|
46
|
+
from vink.exceptions import *
|
|
47
|
+
from vink.models import AnnConfig
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
__version__ = version("vink")
|
|
51
|
+
except PackageNotFoundError:
|
|
52
|
+
__version__ = "0.0.0"
|
vink/core.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from threading import Thread
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated, Callable, Literal
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from vink.latency_predictor import LatencyPredictor
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import regex as re
|
|
13
|
+
from pydantic import Field, ValidationError
|
|
14
|
+
from readerwriterlock import rwlock
|
|
15
|
+
|
|
16
|
+
from vink.exceptions import InvalidInputError, VectorDimensionError
|
|
17
|
+
from vink.models import AnnConfig, VectorRecords
|
|
18
|
+
from vink.sql_wrapper import SQLiteWrapper
|
|
19
|
+
|
|
20
|
+
# The strategies and the latency predictor are lazy imported
|
|
21
|
+
from vink.strategies.base import BaseStrategy
|
|
22
|
+
from vink.utils.input_validation import (
|
|
23
|
+
pretty_errors,
|
|
24
|
+
validate_arguments,
|
|
25
|
+
validate_embedding,
|
|
26
|
+
validate_id,
|
|
27
|
+
)
|
|
28
|
+
from vink.utils.logging import log_info, logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VinkDB:
|
|
32
|
+
"""
|
|
33
|
+
Vector database with hybrid exact/approximate nearest neighbor search.
|
|
34
|
+
|
|
35
|
+
VinkDB automatically switches from exact brute-force search to approximate
|
|
36
|
+
nearest neighbor (ANN) search based on dataset size, using Reconfigurable Inverted
|
|
37
|
+
Index (RII) and Product Quantization (PQ) for efficient ANN.
|
|
38
|
+
|
|
39
|
+
Note:
|
|
40
|
+
ANN switching is one-way — once switched, the system never switches back to exact search.
|
|
41
|
+
|
|
42
|
+
Features:
|
|
43
|
+
|
|
44
|
+
- Hybrid search: exact for small datasets, ANN for large datasets.
|
|
45
|
+
- Automatic strategy switching based on runtime-calibrated latency prediction.
|
|
46
|
+
- Normalized embeddings for consistent distance metrics.
|
|
47
|
+
- Supports Euclidean (L2) and cosine (dot) product similarity.
|
|
48
|
+
- Soft deletes: efficient deletion without data reorganization.
|
|
49
|
+
|
|
50
|
+
Getting ANNConfig:
|
|
51
|
+
|
|
52
|
+
To customize ANN behavior, create an ANNConfig instance:
|
|
53
|
+
|
|
54
|
+
>>> from vink import AnnConfig
|
|
55
|
+
>>> config = AnnConfig(
|
|
56
|
+
... num_subspaces=16,
|
|
57
|
+
... codebook_size=128,
|
|
58
|
+
... switch_latency_ms=300,
|
|
59
|
+
... quantizer="pq"
|
|
60
|
+
... )
|
|
61
|
+
>>> db = VinkDB(dir_path="./data", dim=384, ann_config=config)
|
|
62
|
+
|
|
63
|
+
For help with AnnConfig parameters, call AnnConfig.help()
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@validate_arguments
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
dir_path: str | Path,
|
|
70
|
+
dim: Annotated[int, Field(ge=16)],
|
|
71
|
+
metric: Literal["euclidean", "cosine"] = "euclidean",
|
|
72
|
+
force_exact: bool = False,
|
|
73
|
+
ann_config: AnnConfig | None = None,
|
|
74
|
+
embedding_callback: Callable | None = None,
|
|
75
|
+
overwrite: bool = False,
|
|
76
|
+
verbose: bool = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Initialize a VinkDB instance.
|
|
80
|
+
|
|
81
|
+
Note:
|
|
82
|
+
The only editable attributes after initialization are:
|
|
83
|
+
- ann_config
|
|
84
|
+
- embedding_callback
|
|
85
|
+
- verbose
|
|
86
|
+
Everything else is read-only properties.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
dir_path (str | Path): Directory path to store vector data. Contains the pickled index
|
|
90
|
+
and SQLite database for vector records.
|
|
91
|
+
Use ":memory:" for volatile in-memory storage.
|
|
92
|
+
dim (int): Dimension of the vectors. Must be higher than 16.
|
|
93
|
+
metric (Literal["euclidean", "cosine"], optional): Distance metric to use.
|
|
94
|
+
Defaults to "euclidean".
|
|
95
|
+
force_exact (bool, optional): If True, only exact calculation is used.
|
|
96
|
+
If False, switches between exact and ANN based on runtime calibration.
|
|
97
|
+
Defaults to False.
|
|
98
|
+
ann_config (AnnConfig, optional): Configuration for approximate nearest neighbor search.
|
|
99
|
+
Used during switching and compacting. Defaults to ANNConfig with standard settings.
|
|
100
|
+
Only applicable when force_exact is False.
|
|
101
|
+
embedding_callback (Callable, optional): Callback function to generate embeddings
|
|
102
|
+
from content. If provided, 'embedding' key is optional in
|
|
103
|
+
vector records as it will be generated via this callback. Defaults to None.
|
|
104
|
+
overwrite (bool, optional): Overwrite existing index if exists. Defaults to False.
|
|
105
|
+
verbose (bool, optional): Enable verbose output. Defaults to False.
|
|
106
|
+
"""
|
|
107
|
+
# Determine if in-memory mode
|
|
108
|
+
self._in_memory = isinstance(dir_path, str) and dir_path.strip() == ":memory:"
|
|
109
|
+
|
|
110
|
+
self._dir_path = Path(dir_path)
|
|
111
|
+
self._dim = dim
|
|
112
|
+
self._metric = metric
|
|
113
|
+
self._ann_config = ann_config
|
|
114
|
+
self._force_exact = force_exact
|
|
115
|
+
self.embedding_callback = embedding_callback
|
|
116
|
+
self.verbose = verbose
|
|
117
|
+
|
|
118
|
+
self._strategy: BaseStrategy | None = None
|
|
119
|
+
self._latency_predictor: "LatencyPredictor" | None = None
|
|
120
|
+
|
|
121
|
+
# Default the config with standard settings if force_exact is not true
|
|
122
|
+
if not (self._force_exact or self._ann_config):
|
|
123
|
+
self._ann_config = AnnConfig()
|
|
124
|
+
|
|
125
|
+
self._validate_config()
|
|
126
|
+
|
|
127
|
+
if not self._in_memory:
|
|
128
|
+
if overwrite and self.dir_path.exists():
|
|
129
|
+
shutil.rmtree(self.dir_path)
|
|
130
|
+
|
|
131
|
+
self.dir_path.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
self._records_db_path = str(self.dir_path / "records.sqlite")
|
|
133
|
+
else:
|
|
134
|
+
self._records_db_path = ":memory:"
|
|
135
|
+
|
|
136
|
+
self._records_db = SQLiteWrapper(
|
|
137
|
+
self._records_db_path,
|
|
138
|
+
index_config={
|
|
139
|
+
"dimension": str(self.dim),
|
|
140
|
+
"metric": self.metric,
|
|
141
|
+
"strategy": "exact",
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Threading components for ANN auto-switch
|
|
146
|
+
self._ann_building = False
|
|
147
|
+
self._rwlock = rwlock.RWLockFair()
|
|
148
|
+
|
|
149
|
+
self.load()
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def dir_path(self) -> Path:
|
|
153
|
+
return self._dir_path
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def dim(self) -> int:
|
|
157
|
+
return self._dim
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def metric(self) -> str:
|
|
161
|
+
return self._metric
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def force_exact(self) -> bool:
|
|
165
|
+
return self._force_exact
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def in_memory(self) -> bool:
|
|
169
|
+
return self._in_memory
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def strategy(self) -> str:
|
|
173
|
+
"""The internal indexing strategy currently active, formatted in snake_case."""
|
|
174
|
+
if self._strategy is None:
|
|
175
|
+
return "exact_search"
|
|
176
|
+
|
|
177
|
+
strategy_name = self._strategy.__class__.__name__
|
|
178
|
+
parts = re.split(r"(?<!^)(?=\p{LU})", strategy_name)
|
|
179
|
+
return "_".join([p.lower() for p in parts])
|
|
180
|
+
|
|
181
|
+
def count(self, status: Literal["active", "deleted"] | None = None) -> int:
|
|
182
|
+
"""Count vectors in the database.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
status (Literal["active", "deleted"], optional): Which vectors to count.
|
|
186
|
+
Count all if not provided.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
int: Count of vectors.
|
|
190
|
+
"""
|
|
191
|
+
return self._records_db.count(status)
|
|
192
|
+
|
|
193
|
+
def stats(self) -> dict: # pragma: no cover
|
|
194
|
+
"""Return database statistics and metadata.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
dict: Database metadata including version, dimension, metric, strategy,
|
|
198
|
+
last_saved_at, last_deleted_at, active_count, deleted_count,
|
|
199
|
+
and other stored metadata.
|
|
200
|
+
"""
|
|
201
|
+
return {
|
|
202
|
+
"version": self._records_db["version"],
|
|
203
|
+
"dimension": self._records_db["dimension"],
|
|
204
|
+
"metric": self._records_db["metric"],
|
|
205
|
+
"strategy": self._records_db["strategy"],
|
|
206
|
+
"last_saved_at": self._records_db["last_saved_at"],
|
|
207
|
+
"last_deleted_at": self._records_db["last_deleted_at"],
|
|
208
|
+
"active_count": self.count("active"),
|
|
209
|
+
"deleted_count": self.count("deleted"),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def _validate_config(self) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Internal handshake to verify embedding dimensions and PQ constraints.
|
|
215
|
+
"""
|
|
216
|
+
# Callback Handshake validation
|
|
217
|
+
if self.embedding_callback:
|
|
218
|
+
try:
|
|
219
|
+
raw_vec = self.embedding_callback("vink_warmup_test")
|
|
220
|
+
|
|
221
|
+
# This handles casting, shape normalization (1, d), and L2 projection
|
|
222
|
+
validated_vec = validate_embedding(raw_vec, dim=self.dim, metric=self.metric)
|
|
223
|
+
|
|
224
|
+
if validated_vec.shape[-1] != self._dim:
|
|
225
|
+
raise VectorDimensionError(
|
|
226
|
+
f"Embedding callback output dimension ({validated_vec.shape[-1]}) "
|
|
227
|
+
f"does not match VinkDB dimension ({self._dim})."
|
|
228
|
+
)
|
|
229
|
+
except (VectorDimensionError, InvalidInputError):
|
|
230
|
+
# Let these specific errors bubble up for the test/user
|
|
231
|
+
raise
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise InvalidInputError("Embedding callback crashed during handshake") from e
|
|
234
|
+
|
|
235
|
+
if not self._force_exact:
|
|
236
|
+
self._ann_config.validate_vector_dim(self._dim)
|
|
237
|
+
|
|
238
|
+
@validate_arguments
|
|
239
|
+
def add(self, vector_records: list[dict]) -> list[str]:
|
|
240
|
+
"""Add vectors to the index.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
vector_records (list[dict]): List of dicts with 'content', 'metadata',
|
|
244
|
+
and 'embedding' keys. 'id' is optional
|
|
245
|
+
If not provided, a UUIDv7 will be auto-generated.
|
|
246
|
+
|
|
247
|
+
Note:
|
|
248
|
+
The first batch (when database is empty) is limited to 10,000 vectors to avoid
|
|
249
|
+
expensive initial index operations. This constraint only applies to the first add()
|
|
250
|
+
call. Subsequent batches can be any size.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
list[str]: List of assigned UUIDv7 IDs.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
InvalidInputError: If validation fails or if the first batch exceeds 10,000 vectors.
|
|
257
|
+
"""
|
|
258
|
+
if not vector_records:
|
|
259
|
+
log_info(self.verbose, "Input is empty, returning empty list.")
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
records = VectorRecords(
|
|
264
|
+
dim=self.dim,
|
|
265
|
+
metric=self._metric,
|
|
266
|
+
records=vector_records,
|
|
267
|
+
embedding_callback=self.embedding_callback,
|
|
268
|
+
)
|
|
269
|
+
except ValidationError as e:
|
|
270
|
+
raise InvalidInputError(f"Invalid vector records: {pretty_errors(e)}") from None
|
|
271
|
+
|
|
272
|
+
log_info(self.verbose, "Adding {} vector records to index.", len(vector_records))
|
|
273
|
+
|
|
274
|
+
if self._ann_building:
|
|
275
|
+
assigned_ids = [r.id for r in records.records]
|
|
276
|
+
self._records_db.insert(records, is_buffer=True)
|
|
277
|
+
log_info(
|
|
278
|
+
self.verbose, "Successfully added {} records to buffer.", len(assigned_ids),
|
|
279
|
+
)
|
|
280
|
+
return assigned_ids
|
|
281
|
+
|
|
282
|
+
assigned_ids = self._strategy.add(records)
|
|
283
|
+
|
|
284
|
+
if self.strategy == "exact_search":
|
|
285
|
+
# Check if switch should be triggered based on new count
|
|
286
|
+
if self._should_switch():
|
|
287
|
+
self._ann_building = True
|
|
288
|
+
Thread(target=self._prepare_approx_strategy, daemon=True).start()
|
|
289
|
+
|
|
290
|
+
log_info(
|
|
291
|
+
self.verbose, "Successfully added {} records to index.", len(assigned_ids)
|
|
292
|
+
)
|
|
293
|
+
return assigned_ids
|
|
294
|
+
|
|
295
|
+
@validate_arguments
|
|
296
|
+
def soft_delete(self, ids: list[str]) -> None:
|
|
297
|
+
"""Soft-delete vectors from the index by their IDs (marks as deleted).
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
ids (list[str]): List of UUIDv7 IDs to soft-delete.
|
|
301
|
+
"""
|
|
302
|
+
log_info(self.verbose, "Soft-deleting {} vectors from index.", len(ids))
|
|
303
|
+
|
|
304
|
+
id_bytes = [validate_id(id_str) for id_str in ids]
|
|
305
|
+
|
|
306
|
+
# If ANN is building, write to buffer for replay after switch
|
|
307
|
+
if self.strategy != "approximate_search" and self._ann_building:
|
|
308
|
+
self._records_db.soft_delete(id_bytes)
|
|
309
|
+
self._records_db["last_deleted_at"] = datetime.now(timezone.utc).isoformat()
|
|
310
|
+
log_info(
|
|
311
|
+
self.verbose, "Marked {} vectors for soft-deletion in buffer.", len(ids)
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
self._strategy.soft_delete(id_bytes)
|
|
316
|
+
self._records_db["last_deleted_at"] = datetime.now(timezone.utc).isoformat()
|
|
317
|
+
|
|
318
|
+
def compact(self) -> None:
|
|
319
|
+
"""Hard-delete soft-deleted records and rebuild the index.
|
|
320
|
+
|
|
321
|
+
Note:
|
|
322
|
+
For ApproximateSearch, the ANN index is rebuilt from scratch which can take
|
|
323
|
+
20-200+ seconds depending on data size. This operation should be called
|
|
324
|
+
during maintenance windows or off-peak hours.
|
|
325
|
+
If not enough vectors remain to retrain the codec, rebuild is skipped.
|
|
326
|
+
"""
|
|
327
|
+
log_info(self.verbose, "Compacting database...")
|
|
328
|
+
self._strategy.compact()
|
|
329
|
+
log_info(self.verbose, "Compaction complete.")
|
|
330
|
+
|
|
331
|
+
def save(self) -> None:
|
|
332
|
+
"""Save the index to disk."""
|
|
333
|
+
log_info(self.verbose, "Saving index to {}.", self._dir_path)
|
|
334
|
+
self._strategy.save()
|
|
335
|
+
self._records_db["last_saved_at"] = datetime.now(timezone.utc).isoformat()
|
|
336
|
+
log_info(self.verbose, "Index saved successfully.")
|
|
337
|
+
|
|
338
|
+
def load(self, overwrite: bool = False) -> None:
|
|
339
|
+
"""Load the index from disk.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
overwrite (bool): If True, replace in-memory state with loaded data.
|
|
343
|
+
Defaults to False.
|
|
344
|
+
"""
|
|
345
|
+
log_info(self.verbose, "Loading index from {}.", self._dir_path)
|
|
346
|
+
|
|
347
|
+
if self._strategy is None:
|
|
348
|
+
params = {
|
|
349
|
+
"db": self._records_db,
|
|
350
|
+
"dir_path": self._dir_path if not self._in_memory else None,
|
|
351
|
+
"dim": self._dim,
|
|
352
|
+
"in_memory": self._in_memory,
|
|
353
|
+
"metric": self.metric,
|
|
354
|
+
"verbose": self.verbose,
|
|
355
|
+
}
|
|
356
|
+
if self.strategy == "exact_search":
|
|
357
|
+
from vink.strategies.exact_search import ExactSearch
|
|
358
|
+
strategy_class = ExactSearch
|
|
359
|
+
else:
|
|
360
|
+
from vink.strategies.approximate_search import ApproximateSearch
|
|
361
|
+
strategy_class = ApproximateSearch
|
|
362
|
+
params["ann_config"] = self._ann_config
|
|
363
|
+
|
|
364
|
+
self._strategy = strategy_class(**params)
|
|
365
|
+
|
|
366
|
+
self._strategy.load(overwrite=overwrite)
|
|
367
|
+
|
|
368
|
+
# Lazy init predictor only if strategy is exact
|
|
369
|
+
if self.strategy == "exact_search":
|
|
370
|
+
from vink.latency_predictor import LatencyPredictor
|
|
371
|
+
self._latency_predictor = LatencyPredictor(dim=self._dim)
|
|
372
|
+
|
|
373
|
+
log_info(self.verbose, "Index loaded successfully.")
|
|
374
|
+
|
|
375
|
+
@validate_arguments
|
|
376
|
+
def search(
|
|
377
|
+
self,
|
|
378
|
+
query_vec: list[float] | np.ndarray,
|
|
379
|
+
top_k: int = 10,
|
|
380
|
+
include_vectors: bool = False,
|
|
381
|
+
filters: list[str] | None = None,
|
|
382
|
+
) -> list[dict]:
|
|
383
|
+
"""Search for k nearest neighbors using the configured metric.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
query_vec (list[float] | np.ndarray): The query vector as a list of floats,
|
|
387
|
+
1D numpy array (d,), or 2D numpy array (1, d).
|
|
388
|
+
top_k (int, optional): Number of nearest neighbors to return. Defaults to 10.
|
|
389
|
+
include_vectors (bool, optional): If True, include 'embedding' key in results.
|
|
390
|
+
Defaults to False.
|
|
391
|
+
filters (list[str] | None, optional): Filter expressions to apply before scoring.
|
|
392
|
+
E.g., ["category == 'science'", "price >= 10"].
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
list[dict]: List of dicts with 'id', 'content', 'metadata', 'distance',
|
|
396
|
+
and optionally 'embedding' (if include_vectors is True).
|
|
397
|
+
"""
|
|
398
|
+
log_info(
|
|
399
|
+
self.verbose,
|
|
400
|
+
"Searching for {} nearest neighbors using {}.",
|
|
401
|
+
top_k,
|
|
402
|
+
self.strategy,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
start = time.perf_counter()
|
|
406
|
+
|
|
407
|
+
validated_query = validate_embedding(query_vec, dim=self.dim, metric=self.metric,)
|
|
408
|
+
results = self._strategy.search(
|
|
409
|
+
validated_query, top_k=top_k, include_vectors=include_vectors, filters=filters
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
413
|
+
|
|
414
|
+
# Tune predictor with actual latency
|
|
415
|
+
if self._latency_predictor is not None:
|
|
416
|
+
self._latency_predictor.tune(self.count(), elapsed_ms)
|
|
417
|
+
|
|
418
|
+
log_info(
|
|
419
|
+
self.verbose,
|
|
420
|
+
"Found {} results for query in {} ms.",
|
|
421
|
+
len(results),
|
|
422
|
+
round(elapsed_ms, 2)
|
|
423
|
+
)
|
|
424
|
+
return results
|
|
425
|
+
|
|
426
|
+
def _should_switch(self) -> bool:
|
|
427
|
+
"""
|
|
428
|
+
Check if ANN switch should be triggered based on dual conditions:
|
|
429
|
+
|
|
430
|
+
1. Sufficiency: num_vectors >= min_required (num_subspaces * codebook_size)
|
|
431
|
+
2. latency: predicted latency exceeds this threshold
|
|
432
|
+
"""
|
|
433
|
+
if self._force_exact:
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
n_vecs = self.count()
|
|
437
|
+
cfg = self._ann_config
|
|
438
|
+
min_required = cfg.num_subspaces * cfg.codebook_size
|
|
439
|
+
|
|
440
|
+
if n_vecs < min_required:
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
# Use predictor for proactive switching
|
|
444
|
+
predicted_latency = self._latency_predictor.predict(n_vecs)
|
|
445
|
+
return predicted_latency >= cfg.switch_latency_ms
|
|
446
|
+
|
|
447
|
+
def _prepare_approx_strategy(self) -> None:
|
|
448
|
+
"""
|
|
449
|
+
Build ANN strategy in a background daemon thread.
|
|
450
|
+
|
|
451
|
+
Runs in a daemon thread so add()/search() remain unblocked.
|
|
452
|
+
Replays buffered records after the strategy switch completes.
|
|
453
|
+
"""
|
|
454
|
+
self._strategy._ensure_cache()
|
|
455
|
+
vectors = self._strategy.active_vectors_arr
|
|
456
|
+
ids = self._strategy.active_ids_arr
|
|
457
|
+
|
|
458
|
+
from vink.strategies.approximate_search import ApproximateSearch
|
|
459
|
+
|
|
460
|
+
approx_strategy = ApproximateSearch(
|
|
461
|
+
db=self._records_db,
|
|
462
|
+
dir_path=self._dir_path if not self._in_memory else None,
|
|
463
|
+
dim=self._dim,
|
|
464
|
+
in_memory=self._in_memory,
|
|
465
|
+
metric=self.metric,
|
|
466
|
+
verbose=self.verbose,
|
|
467
|
+
ann_config=self._ann_config,
|
|
468
|
+
)
|
|
469
|
+
approx_strategy.fit(vectors, ids)
|
|
470
|
+
|
|
471
|
+
log_info(self.verbose, "ANN index fit complete, switching strategy.")
|
|
472
|
+
|
|
473
|
+
# Automatically switch after successful build
|
|
474
|
+
self._switch_to_approx_strategy(approx_strategy)
|
|
475
|
+
|
|
476
|
+
def _switch_to_approx_strategy(self, strategy) -> None:
|
|
477
|
+
"""Switch to approximate search and auto dumps buffer."""
|
|
478
|
+
with self._rwlock.gen_wlock():
|
|
479
|
+
self._strategy = strategy
|
|
480
|
+
|
|
481
|
+
self._ann_building = False
|
|
482
|
+
|
|
483
|
+
cursor = self._records_db.conn.cursor()
|
|
484
|
+
buffer_rows = cursor.execute("""
|
|
485
|
+
SELECT id, embedding FROM vec_records
|
|
486
|
+
WHERE buffered = TRUE AND deleted = FALSE
|
|
487
|
+
""").fetchall()
|
|
488
|
+
|
|
489
|
+
if not buffer_rows:
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
records = [
|
|
493
|
+
{
|
|
494
|
+
"id": row[0],
|
|
495
|
+
"embedding": np.frombuffer(row[1], dtype=np.float32),
|
|
496
|
+
|
|
497
|
+
# Not used, kept for validation
|
|
498
|
+
"content": "",
|
|
499
|
+
"metadata": {},
|
|
500
|
+
}
|
|
501
|
+
for row in buffer_rows
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
strategy.add(VectorRecords(dim=self.dim, metric=self.metric, records=records), is_buffer=True)
|
|
505
|
+
self._records_db.clear_buffer()
|
|
506
|
+
log_info(
|
|
507
|
+
self.verbose,
|
|
508
|
+
"Buffer dump: added {} vectors to ANN index.",
|
|
509
|
+
len(records),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def __enter__(self) -> "VinkDB":
|
|
513
|
+
return self
|
|
514
|
+
|
|
515
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
516
|
+
if exc_type:
|
|
517
|
+
logger.error(f"Transaction failed: {exc_val}")
|
|
518
|
+
return False # Tell python to reraise it
|
|
519
|
+
self.save()
|
vink/exceptions.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class VinkDBError(Exception):
|
|
2
|
+
"""Base exception for all Vink errors."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvalidInputError(VinkDBError):
|
|
8
|
+
"""Raised when one or multiple invalid input(s) are encountered."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VectorDimensionError(InvalidInputError):
|
|
14
|
+
"""Raised when vector dimensions don't match the index configuration."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidIdError(InvalidInputError):
|
|
20
|
+
"""Raised when an invalid UUIDv7 is provided."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IndexNotFittedError(Exception):
|
|
26
|
+
"""Raised when an operation requiring learned quantization is called on an unitialized index."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FilterError(InvalidInputError):
|
|
32
|
+
"""Raised when a filter expression fails to parse."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DatabaseCorruptedError(VinkDBError):
|
|
38
|
+
"""Raised when database files are corrupted."""
|
|
39
|
+
|
|
40
|
+
pass
|