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 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