fromscratch-pyvecdb 0.3.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.
- fromscratch_pyvecdb-0.3.0.dist-info/METADATA +147 -0
- fromscratch_pyvecdb-0.3.0.dist-info/RECORD +16 -0
- fromscratch_pyvecdb-0.3.0.dist-info/WHEEL +4 -0
- fromscratch_pyvecdb-0.3.0.dist-info/licenses/LICENSE +21 -0
- pyvecdb/__init__.py +68 -0
- pyvecdb/_version.py +24 -0
- pyvecdb/atomic.py +66 -0
- pyvecdb/distance.py +138 -0
- pyvecdb/errors.py +61 -0
- pyvecdb/filtering.py +128 -0
- pyvecdb/persistence.py +149 -0
- pyvecdb/py.typed +0 -0
- pyvecdb/record.py +94 -0
- pyvecdb/search.py +165 -0
- pyvecdb/store.py +250 -0
- pyvecdb/vector.py +180 -0
pyvecdb/persistence.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Save and load vector stores to/from JSON files.
|
|
2
|
+
|
|
3
|
+
Think of persistence like saving your game -- you can close the program,
|
|
4
|
+
come back later, and pick up right where you left off. Without persistence,
|
|
5
|
+
all your vectors would vanish when the program stops!
|
|
6
|
+
|
|
7
|
+
The JSON format looks like this:
|
|
8
|
+
|
|
9
|
+
.. code-block:: json
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"dimensions": 3,
|
|
13
|
+
"records": [
|
|
14
|
+
{
|
|
15
|
+
"record_id": "song-1",
|
|
16
|
+
"vector": [0.8, 0.2, 0.5],
|
|
17
|
+
"metadata": {"genre": "rock"}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> from pyvecdb.store import VectorStore
|
|
24
|
+
>>> from pyvecdb.persistence import save_store, load_store
|
|
25
|
+
>>> store = VectorStore(dimensions=2)
|
|
26
|
+
>>> store.insert("a", [1.0, 2.0])
|
|
27
|
+
>>> save_store(store, "/tmp/my_vectors.json")
|
|
28
|
+
>>> loaded = load_store("/tmp/my_vectors.json")
|
|
29
|
+
>>> loaded.count
|
|
30
|
+
1
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import cast
|
|
37
|
+
|
|
38
|
+
from pyvecdb.atomic import atomic_write_text
|
|
39
|
+
from pyvecdb.errors import PersistenceError, VecDBError
|
|
40
|
+
from pyvecdb.record import VectorRecord
|
|
41
|
+
from pyvecdb.store import VectorStore
|
|
42
|
+
|
|
43
|
+
# Every real save file has these top-level keys; anything else isn't one of ours.
|
|
44
|
+
_REQUIRED_KEYS = frozenset({"dimensions", "records"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def store_to_dict(
|
|
48
|
+
store: VectorStore,
|
|
49
|
+
) -> dict[str, int | list[dict[str, str | list[float] | dict[str, str | int | float | bool]]]]:
|
|
50
|
+
"""Convert a VectorStore to a plain dictionary.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
store: The vector store to serialize.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A dictionary with 'dimensions' and 'records' keys.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
return {
|
|
60
|
+
"dimensions": store.dimensions,
|
|
61
|
+
"records": [r.to_dict() for r in store.list_records()],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def dict_to_store(data: dict[str, object]) -> VectorStore:
|
|
66
|
+
"""Create a VectorStore from a dictionary.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
data: A dictionary with 'dimensions' and 'records' keys.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A new VectorStore populated with the records.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
dimensions = int(data["dimensions"]) # type: ignore[arg-type]
|
|
76
|
+
store = VectorStore(dimensions=dimensions)
|
|
77
|
+
raw_records = data.get("records", [])
|
|
78
|
+
if not isinstance(raw_records, list):
|
|
79
|
+
msg = "Records must be a list"
|
|
80
|
+
raise TypeError(msg)
|
|
81
|
+
for raw in raw_records: # pyright: ignore[reportUnknownVariableType]
|
|
82
|
+
if not isinstance(raw, dict):
|
|
83
|
+
msg = "Each record must be a dict"
|
|
84
|
+
raise TypeError(msg)
|
|
85
|
+
rec_data: dict[str, object] = {str(k): v for k, v in raw.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
|
|
86
|
+
record = VectorRecord.from_dict(rec_data)
|
|
87
|
+
store.insert(record.record_id, record.vector, record.metadata)
|
|
88
|
+
return store
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def save_store(store: VectorStore, path: str | Path) -> None:
|
|
92
|
+
"""Save a vector store to a JSON file.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
store: The vector store to save.
|
|
96
|
+
path: The file path to write to.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
file_path = Path(path)
|
|
100
|
+
data = store_to_dict(store)
|
|
101
|
+
atomic_write_text(file_path, json.dumps(data, indent=2))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_store(path: str | Path) -> VectorStore:
|
|
105
|
+
"""Load a vector store from a JSON file.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: The file path to read from.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A new VectorStore populated with the saved records.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
PersistenceError: If the file is missing, unreadable, not valid JSON,
|
|
115
|
+
or not a valid save file.
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
file_path = Path(path)
|
|
119
|
+
try:
|
|
120
|
+
raw = file_path.read_text(encoding="utf-8")
|
|
121
|
+
# A file that isn't UTF-8 text (an image, a zip, a corrupted save) fails
|
|
122
|
+
# while decoding, not while reading, so it needs catching too.
|
|
123
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
124
|
+
msg = f"Could not read {file_path}: {exc}"
|
|
125
|
+
raise PersistenceError(msg) from exc
|
|
126
|
+
try:
|
|
127
|
+
data: object = json.loads(raw)
|
|
128
|
+
# JSONDecodeError is a kind of ValueError; a plain ValueError also means a
|
|
129
|
+
# number too long for Python to read, and RecursionError means lists or
|
|
130
|
+
# objects nested too deep. Each one is a file we can't load.
|
|
131
|
+
except (ValueError, RecursionError) as exc:
|
|
132
|
+
msg = f"Invalid JSON in {file_path}: {exc}"
|
|
133
|
+
raise PersistenceError(msg) from exc
|
|
134
|
+
if not isinstance(data, dict):
|
|
135
|
+
msg = f"{file_path} is not a valid save file: the top level must be an object"
|
|
136
|
+
raise PersistenceError(msg)
|
|
137
|
+
saved: dict[str, object] = {str(k): v for k, v in cast("dict[object, object]", data).items()}
|
|
138
|
+
missing = _REQUIRED_KEYS - saved.keys()
|
|
139
|
+
if missing:
|
|
140
|
+
msg = f"{file_path} is not a valid save file: missing {sorted(missing)}"
|
|
141
|
+
raise PersistenceError(msg)
|
|
142
|
+
try:
|
|
143
|
+
return dict_to_store(saved)
|
|
144
|
+
# OverflowError: a number too big to be a float, or Infinity dimensions.
|
|
145
|
+
# VecDBError: a record the store itself refuses -- an empty vector, the
|
|
146
|
+
# wrong number of dimensions, or the same ID twice.
|
|
147
|
+
except (KeyError, TypeError, ValueError, AttributeError, OverflowError, VecDBError) as exc:
|
|
148
|
+
msg = f"{file_path} is not a valid save file: {exc}"
|
|
149
|
+
raise PersistenceError(msg) from exc
|
pyvecdb/py.typed
ADDED
|
File without changes
|
pyvecdb/record.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Vector record -- a vector with an ID and extra info.
|
|
2
|
+
|
|
3
|
+
Think of a ``VectorRecord`` like a trading card:
|
|
4
|
+
|
|
5
|
+
- The **ID** is the card number (unique identifier)
|
|
6
|
+
- The **vector** is the card's stats (a list of numbers)
|
|
7
|
+
- The **metadata** is the extra info printed on the card (name, team, year)
|
|
8
|
+
|
|
9
|
+
When you search a vector database, you get back records -- so you know
|
|
10
|
+
*which* item matched, not just *where* it is in space.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> from pyvecdb.record import VectorRecord
|
|
14
|
+
>>> card = VectorRecord(
|
|
15
|
+
... record_id="card-42",
|
|
16
|
+
... vector=[0.8, 0.2, 0.5],
|
|
17
|
+
... metadata={"name": "Pikachu", "type": "electric"},
|
|
18
|
+
... )
|
|
19
|
+
>>> card.record_id
|
|
20
|
+
'card-42'
|
|
21
|
+
>>> card.dimensions
|
|
22
|
+
3
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
from pyvecdb.errors import EmptyVectorError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class VectorRecord:
|
|
33
|
+
"""A vector paired with an ID and optional metadata.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
record_id: A unique string identifier for this record.
|
|
37
|
+
vector: The list of floats representing this record's position in space.
|
|
38
|
+
metadata: Optional key-value pairs with extra info about this record.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
record_id: str
|
|
43
|
+
vector: list[float]
|
|
44
|
+
metadata: dict[str, str | int | float | bool] = field(default_factory=lambda: {})
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
"""Validate the record after creation."""
|
|
48
|
+
if not self.vector:
|
|
49
|
+
msg = "Vector must have at least one component"
|
|
50
|
+
raise EmptyVectorError(msg)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def dimensions(self) -> int:
|
|
54
|
+
"""Return the number of dimensions in this record's vector."""
|
|
55
|
+
return len(self.vector)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, str | list[float] | dict[str, str | int | float | bool]]:
|
|
58
|
+
"""Convert this record to a plain dictionary for serialization.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A dictionary with 'record_id', 'vector', and 'metadata' keys.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
return {
|
|
65
|
+
"record_id": self.record_id,
|
|
66
|
+
"vector": list(self.vector),
|
|
67
|
+
"metadata": dict(self.metadata),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: dict[str, object]) -> VectorRecord:
|
|
72
|
+
"""Create a VectorRecord from a dictionary.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
data: A dictionary with 'record_id', 'vector', and optionally 'metadata'.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A new VectorRecord instance.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
record_id = str(data["record_id"])
|
|
82
|
+
raw_vector = data["vector"]
|
|
83
|
+
if not isinstance(raw_vector, list):
|
|
84
|
+
msg = "Vector must be a list"
|
|
85
|
+
raise TypeError(msg)
|
|
86
|
+
vector = [float(x) for x in raw_vector] # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
|
|
87
|
+
raw_metadata = data.get("metadata")
|
|
88
|
+
if not isinstance(raw_metadata, dict):
|
|
89
|
+
raw_metadata = {}
|
|
90
|
+
metadata: dict[str, str | int | float | bool] = {}
|
|
91
|
+
for k, v in raw_metadata.items(): # pyright: ignore[reportUnknownVariableType]
|
|
92
|
+
if isinstance(v, (str, int, float, bool)):
|
|
93
|
+
metadata[str(k)] = v # pyright: ignore[reportUnknownArgumentType]
|
|
94
|
+
return cls(record_id=record_id, vector=vector, metadata=metadata)
|
pyvecdb/search.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""K-Nearest Neighbors (KNN) search for finding similar vectors.
|
|
2
|
+
|
|
3
|
+
Imagine you're standing in a huge room full of people, and you want to find
|
|
4
|
+
the 5 people standing closest to you. That's KNN search! Instead of people,
|
|
5
|
+
we have vectors (lists of numbers), and "close" means "similar."
|
|
6
|
+
|
|
7
|
+
This module provides the core search function that:
|
|
8
|
+
|
|
9
|
+
1. Computes the distance from your query vector to every vector in the store
|
|
10
|
+
2. Sorts by distance (closest first)
|
|
11
|
+
3. Returns the top K results
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from pyvecdb.store import VectorStore
|
|
15
|
+
>>> from pyvecdb.search import knn_search
|
|
16
|
+
>>> store = VectorStore(dimensions=2)
|
|
17
|
+
>>> store.insert("a", [1.0, 0.0])
|
|
18
|
+
>>> store.insert("b", [0.0, 1.0])
|
|
19
|
+
>>> store.insert("c", [0.9, 0.1])
|
|
20
|
+
>>> results = knn_search(store, query=[1.0, 0.0], k=2)
|
|
21
|
+
>>> [r.record_id for r, _ in results]
|
|
22
|
+
['a', 'c']
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from pyvecdb.distance import (
|
|
27
|
+
cosine_similarity,
|
|
28
|
+
dot_product,
|
|
29
|
+
euclidean_distance,
|
|
30
|
+
manhattan_distance,
|
|
31
|
+
)
|
|
32
|
+
from pyvecdb.errors import DimensionMismatchError, EmptyStoreError
|
|
33
|
+
from pyvecdb.filtering import MetadataFilter, filter_records
|
|
34
|
+
from pyvecdb.record import VectorRecord
|
|
35
|
+
from pyvecdb.store import VectorStore
|
|
36
|
+
|
|
37
|
+
# Supported metric names
|
|
38
|
+
METRIC_COSINE = "cosine"
|
|
39
|
+
METRIC_EUCLIDEAN = "euclidean"
|
|
40
|
+
METRIC_DOT = "dot"
|
|
41
|
+
METRIC_MANHATTAN = "manhattan"
|
|
42
|
+
|
|
43
|
+
VALID_METRICS = {METRIC_COSINE, METRIC_EUCLIDEAN, METRIC_DOT, METRIC_MANHATTAN}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _compute_score(query: list[float], candidate: list[float], metric: str) -> float:
|
|
47
|
+
"""Compute a similarity/distance score between two vectors.
|
|
48
|
+
|
|
49
|
+
For cosine and dot product, higher is better (more similar).
|
|
50
|
+
For euclidean and manhattan, lower is better (closer together).
|
|
51
|
+
|
|
52
|
+
We negate distance metrics so that sorting by descending score
|
|
53
|
+
always puts the best matches first.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
query: The query vector.
|
|
57
|
+
candidate: The candidate vector to compare against.
|
|
58
|
+
metric: One of 'cosine', 'euclidean', 'dot', or 'manhattan'.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A score where higher means more similar.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
if metric == METRIC_COSINE:
|
|
65
|
+
return cosine_similarity(query, candidate)
|
|
66
|
+
if metric == METRIC_EUCLIDEAN:
|
|
67
|
+
return -euclidean_distance(query, candidate)
|
|
68
|
+
if metric == METRIC_MANHATTAN:
|
|
69
|
+
return -manhattan_distance(query, candidate)
|
|
70
|
+
# dot product -- delegate so the same dimension check applies as the other metrics
|
|
71
|
+
return dot_product(query, candidate)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def knn_search(
|
|
75
|
+
store: VectorStore,
|
|
76
|
+
query: list[float],
|
|
77
|
+
k: int = 5,
|
|
78
|
+
metric: str = METRIC_COSINE,
|
|
79
|
+
metadata_filter: MetadataFilter | None = None,
|
|
80
|
+
) -> list[tuple[VectorRecord, float]]:
|
|
81
|
+
"""Find the K most similar vectors to a query vector.
|
|
82
|
+
|
|
83
|
+
This is the heart of a vector database! It checks every vector in the
|
|
84
|
+
store, computes how similar it is to your query, and returns the top K.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
store: The vector store to search.
|
|
88
|
+
query: The query vector to find neighbors for.
|
|
89
|
+
k: How many results to return (default 5).
|
|
90
|
+
metric: Distance metric -- 'cosine', 'euclidean', 'dot', or 'manhattan'.
|
|
91
|
+
metadata_filter: Optional filter to narrow results by metadata.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A list of (record, score) tuples, sorted by similarity (best first).
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
EmptyStoreError: If the store has no records.
|
|
98
|
+
DimensionMismatchError: If the query has wrong dimensions.
|
|
99
|
+
ValueError: If the metric is not recognized or k < 1.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
if store.count == 0:
|
|
103
|
+
msg = "Cannot search an empty store"
|
|
104
|
+
raise EmptyStoreError(msg)
|
|
105
|
+
|
|
106
|
+
if len(query) != store.dimensions:
|
|
107
|
+
msg = f"Query has {len(query)} dimensions, store expects {store.dimensions}"
|
|
108
|
+
raise DimensionMismatchError(msg)
|
|
109
|
+
|
|
110
|
+
if metric not in VALID_METRICS:
|
|
111
|
+
msg = f"Unknown metric '{metric}'. Choose from: {', '.join(sorted(VALID_METRICS))}"
|
|
112
|
+
raise ValueError(msg)
|
|
113
|
+
|
|
114
|
+
min_k = 1
|
|
115
|
+
if k < min_k:
|
|
116
|
+
msg = f"k must be at least {min_k}, got {k}"
|
|
117
|
+
raise ValueError(msg)
|
|
118
|
+
|
|
119
|
+
# Get all records, optionally filtered
|
|
120
|
+
candidates = store.list_records()
|
|
121
|
+
if metadata_filter is not None:
|
|
122
|
+
candidates = filter_records(candidates, metadata_filter)
|
|
123
|
+
|
|
124
|
+
if not candidates:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
# Score each candidate
|
|
128
|
+
scored: list[tuple[VectorRecord, float]] = []
|
|
129
|
+
for record in candidates:
|
|
130
|
+
score = _compute_score(query, record.vector, metric)
|
|
131
|
+
scored.append((record, score))
|
|
132
|
+
|
|
133
|
+
# Sort by score descending (higher = better match)
|
|
134
|
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
|
135
|
+
|
|
136
|
+
return scored[:k]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def batch_search(
|
|
140
|
+
store: VectorStore,
|
|
141
|
+
queries: list[list[float]],
|
|
142
|
+
k: int = 5,
|
|
143
|
+
metric: str = METRIC_COSINE,
|
|
144
|
+
metadata_filter: MetadataFilter | None = None,
|
|
145
|
+
) -> list[list[tuple[VectorRecord, float]]]:
|
|
146
|
+
"""Search for multiple query vectors at once.
|
|
147
|
+
|
|
148
|
+
Like asking "find the 5 closest pizza shops" for each of your friends
|
|
149
|
+
at the same time!
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
store: The vector store to search.
|
|
153
|
+
queries: A list of query vectors.
|
|
154
|
+
k: How many results per query (default 5).
|
|
155
|
+
metric: Distance metric to use.
|
|
156
|
+
metadata_filter: Optional metadata filter applied to all queries.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A list of result lists, one per query.
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
return [
|
|
163
|
+
knn_search(store, query=q, k=k, metric=metric, metadata_filter=metadata_filter)
|
|
164
|
+
for q in queries
|
|
165
|
+
]
|
pyvecdb/store.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Vector store -- a container for vector records.
|
|
2
|
+
|
|
3
|
+
Think of a ``VectorStore`` like a filing cabinet where every folder
|
|
4
|
+
(record) contains a list of numbers (vector) and a label (ID).
|
|
5
|
+
The cabinet has one rule: every folder must contain the same number
|
|
6
|
+
of pages (dimensions).
|
|
7
|
+
|
|
8
|
+
The store provides CRUD operations:
|
|
9
|
+
|
|
10
|
+
- **Create**: Insert a new vector record
|
|
11
|
+
- **Read**: Get a record by its ID
|
|
12
|
+
- **Update**: Replace a record's vector or metadata
|
|
13
|
+
- **Delete**: Remove a record by its ID
|
|
14
|
+
- **List**: Get all records in the store
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from pyvecdb.store import VectorStore
|
|
18
|
+
>>> store = VectorStore(dimensions=3)
|
|
19
|
+
>>> store.insert("song-1", [0.8, 0.2, 0.5], metadata={"genre": "rock"})
|
|
20
|
+
>>> store.get("song-1").metadata["genre"]
|
|
21
|
+
'rock'
|
|
22
|
+
>>> store.count
|
|
23
|
+
1
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from pyvecdb.errors import (
|
|
28
|
+
DimensionMismatchError,
|
|
29
|
+
DuplicateIDError,
|
|
30
|
+
RecordNotFoundError,
|
|
31
|
+
)
|
|
32
|
+
from pyvecdb.record import VectorRecord
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _copy(record: VectorRecord) -> VectorRecord:
|
|
36
|
+
"""Return a copy of *record* with its own vector and metadata.
|
|
37
|
+
|
|
38
|
+
The store only ever hands out copies, like a library that lends you a
|
|
39
|
+
photocopy: scribble on it all you like, the original stays clean.
|
|
40
|
+
"""
|
|
41
|
+
return VectorRecord(
|
|
42
|
+
record_id=record.record_id,
|
|
43
|
+
vector=list(record.vector),
|
|
44
|
+
metadata=dict(record.metadata),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VectorStore:
|
|
49
|
+
"""A container that stores vector records with the same dimensionality.
|
|
50
|
+
|
|
51
|
+
All vectors in a store must have the same number of dimensions.
|
|
52
|
+
Think of it like a spreadsheet where every row has the same number of columns.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
dimensions: The number of dimensions each vector must have.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, dimensions: int) -> None:
|
|
60
|
+
"""Create a new empty vector store.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
dimensions: The number of dimensions for vectors in this store.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If dimensions is less than 1.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
min_dims = 1
|
|
70
|
+
if dimensions < min_dims:
|
|
71
|
+
msg = f"Dimensions must be at least {min_dims}, got {dimensions}"
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
self._dimensions = dimensions
|
|
74
|
+
self._records: dict[str, VectorRecord] = {}
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def dimensions(self) -> int:
|
|
78
|
+
"""Return the dimensionality of this store."""
|
|
79
|
+
return self._dimensions
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def count(self) -> int:
|
|
83
|
+
"""Return the number of records in this store."""
|
|
84
|
+
return len(self._records)
|
|
85
|
+
|
|
86
|
+
def _check_dimensions(self, vector: list[float]) -> None:
|
|
87
|
+
"""Raise DimensionMismatchError if the vector has the wrong length."""
|
|
88
|
+
if len(vector) != self._dimensions:
|
|
89
|
+
msg = f"Expected {self._dimensions} dimensions, got {len(vector)}"
|
|
90
|
+
raise DimensionMismatchError(msg)
|
|
91
|
+
|
|
92
|
+
def insert(
|
|
93
|
+
self,
|
|
94
|
+
record_id: str,
|
|
95
|
+
vector: list[float],
|
|
96
|
+
metadata: dict[str, str | int | float | bool] | None = None,
|
|
97
|
+
) -> VectorRecord:
|
|
98
|
+
"""Insert a new vector record into the store.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
record_id: A unique identifier for this record.
|
|
102
|
+
vector: The vector (list of floats) to store.
|
|
103
|
+
metadata: Optional key-value pairs of extra information.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A copy of the newly created VectorRecord.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
DuplicateIDError: If a record with this ID already exists.
|
|
110
|
+
DimensionMismatchError: If the vector has the wrong number of dimensions.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
if record_id in self._records:
|
|
114
|
+
msg = f"Record with ID '{record_id}' already exists"
|
|
115
|
+
raise DuplicateIDError(msg)
|
|
116
|
+
self._check_dimensions(vector)
|
|
117
|
+
record = VectorRecord(
|
|
118
|
+
record_id=record_id,
|
|
119
|
+
vector=list(vector),
|
|
120
|
+
metadata=dict(metadata) if metadata is not None else {},
|
|
121
|
+
)
|
|
122
|
+
self._records[record_id] = record
|
|
123
|
+
return _copy(record)
|
|
124
|
+
|
|
125
|
+
def get(self, record_id: str) -> VectorRecord:
|
|
126
|
+
"""Get a record by its ID.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
record_id: The ID of the record to retrieve.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
A copy of the matching VectorRecord. Changing the copy doesn't
|
|
133
|
+
change the store; use ``update`` for that.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
RecordNotFoundError: If no record with this ID exists.
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
if record_id not in self._records:
|
|
140
|
+
msg = f"No record found with ID '{record_id}'"
|
|
141
|
+
raise RecordNotFoundError(msg)
|
|
142
|
+
return _copy(self._records[record_id])
|
|
143
|
+
|
|
144
|
+
def update(
|
|
145
|
+
self,
|
|
146
|
+
record_id: str,
|
|
147
|
+
vector: list[float] | None = None,
|
|
148
|
+
metadata: dict[str, str | int | float | bool] | None = None,
|
|
149
|
+
) -> VectorRecord:
|
|
150
|
+
"""Update an existing record's vector and/or metadata.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
record_id: The ID of the record to update.
|
|
154
|
+
vector: New vector (optional -- keeps old if not provided).
|
|
155
|
+
metadata: New metadata (optional -- keeps old if not provided).
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A copy of the updated VectorRecord.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
RecordNotFoundError: If no record with this ID exists.
|
|
162
|
+
DimensionMismatchError: If the new vector has wrong dimensions.
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
if record_id not in self._records:
|
|
166
|
+
msg = f"No record found with ID '{record_id}'"
|
|
167
|
+
raise RecordNotFoundError(msg)
|
|
168
|
+
record = self._records[record_id]
|
|
169
|
+
if vector is not None:
|
|
170
|
+
self._check_dimensions(vector)
|
|
171
|
+
record.vector = list(vector)
|
|
172
|
+
if metadata is not None:
|
|
173
|
+
record.metadata = dict(metadata)
|
|
174
|
+
return _copy(record)
|
|
175
|
+
|
|
176
|
+
def delete(self, record_id: str) -> None:
|
|
177
|
+
"""Remove a record from the store.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
record_id: The ID of the record to delete.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
RecordNotFoundError: If no record with this ID exists.
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
if record_id not in self._records:
|
|
187
|
+
msg = f"No record found with ID '{record_id}'"
|
|
188
|
+
raise RecordNotFoundError(msg)
|
|
189
|
+
del self._records[record_id]
|
|
190
|
+
|
|
191
|
+
def list_records(self) -> list[VectorRecord]:
|
|
192
|
+
"""Return all records in the store as a list.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Copies of all the VectorRecord objects, in insertion order.
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
return [_copy(record) for record in self._records.values()]
|
|
199
|
+
|
|
200
|
+
def contains(self, record_id: str) -> bool:
|
|
201
|
+
"""Check whether a record with the given ID exists.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
record_id: The ID to check for.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if the record exists, False otherwise.
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
return record_id in self._records
|
|
211
|
+
|
|
212
|
+
def clear(self) -> None:
|
|
213
|
+
"""Remove all records from the store."""
|
|
214
|
+
self._records.clear()
|
|
215
|
+
|
|
216
|
+
def batch_insert(
|
|
217
|
+
self,
|
|
218
|
+
records: list[tuple[str, list[float], dict[str, str | int | float | bool] | None]],
|
|
219
|
+
) -> list[VectorRecord]:
|
|
220
|
+
"""Insert multiple records at once.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
records: A list of (record_id, vector, metadata) tuples.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Copies of the newly created VectorRecord objects.
|
|
227
|
+
|
|
228
|
+
This is all-or-nothing: if *any* record is invalid, nothing is inserted
|
|
229
|
+
and the store is left exactly as it was -- like a shopping cart that
|
|
230
|
+
refuses to check out unless every item scans correctly.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
DuplicateIDError: If any ID already exists or is duplicated in the batch.
|
|
234
|
+
DimensionMismatchError: If any vector has wrong dimensions.
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
# Validate the whole batch first so a bad record never leaves a half-done insert.
|
|
238
|
+
seen_ids: set[str] = set()
|
|
239
|
+
for record_id, vector, _metadata in records:
|
|
240
|
+
if record_id in self._records or record_id in seen_ids:
|
|
241
|
+
msg = f"Record with ID '{record_id}' already exists"
|
|
242
|
+
raise DuplicateIDError(msg)
|
|
243
|
+
seen_ids.add(record_id)
|
|
244
|
+
self._check_dimensions(vector)
|
|
245
|
+
|
|
246
|
+
# Everything checks out -- now it is safe to insert.
|
|
247
|
+
results: list[VectorRecord] = []
|
|
248
|
+
for record_id, vector, metadata in records:
|
|
249
|
+
results.append(self.insert(record_id, vector, metadata))
|
|
250
|
+
return results
|