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