Persistence migration guide¶
Version 0.5 completes the persistence lifecycle introduced in 0.4. Creating a store, opening an archive, saving, and refreshing from disk now have separate, explicit operations. Applications that adopted the recommended 0.4 API need no further persistence changes.
The 0.4 compatibility paths are no longer present: constructor file_path=,
instance load(), context-manager persistence, and the unversioned archive
reader have been removed.
API at a glance¶
store = VectorStore(dimensions=1536, normalize=True)
store.add(vectors, metadata)
store.save("vectors.npz")
store.save() # Update the bound archive.
loaded = VectorStore.open("vectors.npz")
loaded.reload() # Deliberately discard memory and reread the archive.
The persistence changes from 0.4 to 0.5 are:
| Area | 0.4 | 0.5 |
|---|---|---|
| Create a store | VectorStore(dimensions, normalize=...) |
Unchanged |
Constructor file_path= |
Works with FutureWarning |
Removed |
| Open an archive | VectorStore.open(path) |
Unchanged |
| Save and bind | save(path) |
Unchanged |
| Save again | save() |
Unchanged |
| Refresh from disk | reload() |
Unchanged |
Instance load() |
Works with FutureWarning |
Removed |
| Context manager | Works with FutureWarning |
Removed |
| Unversioned archive | Temporary migration reader | Reader removed |
| Format version 1 archive | Supported | Supported unchanged |
dimensions, normalize, file_path |
Writable attributes | Read-only properties |
vectors, metadata |
Writable owning arrays | Read-only inspection views |
Vector returned by get() |
View into live storage | Independent float32 copy |
Repeated add() calls |
Recopy all existing rows | Reuse private spare capacity |
| Equal search values | Unspecified order | Lower original store row index first |
| Thread safety | Not formally defined | Concurrent reads only while state is unchanged |
Store-owned state¶
Most code that reads configuration or uses NumPy operations for inspection and prefiltering remains unchanged. The familiar property names are still present:
store.dimensions
store.normalize
store.file_path
store.vectors
store.metadata
The difference is ownership. Configuration cannot be assigned directly, and
the vector and metadata views reject normal row mutation. Their views describe
the rows present when the property was requested, so code should request a
fresh view after add(), clear(), or reload().
The views are a supported inspection boundary, not tamper-proof snapshots.
Deliberately mutating their backing storage through .base, private attributes,
ctypes, or similar escape hatches is unsupported and can corrupt the store.
Call .copy() when code needs an independently mutable full-array snapshot.
For metadata, this copies the outer row array while preserving the opaque
payload objects by reference.
Row retrieval deliberately treats vectors and metadata differently:
row = store.get(0)
if row is not None:
vector, payload = row
vector[0] = 10.0 # Independent copy; the store is unchanged.
payload["reviewed"] = True # Shared application metadata object.
Vectors have one uniform NumPy representation, so copying a single row gives the caller clear ownership at bounded cost. Metadata can be any Python object, so the store preserves payload identity instead of imposing a potentially expensive or invalid deep-copy policy. Applications that need immutable metadata can use frozen application objects or copy payloads themselves.
Repeated additions¶
The add(vectors, metadata) signature and validation rules do not change. In
0.4, every noninitial call concatenated the new rows with the complete store.
In 0.5, the store reserves private row capacity and grows it geometrically when
needed. Repeated small additions therefore copy existing rows only when the
current allocation is full.
This does not add a public capacity setting or change row indexes. len(store),
inspection, search, retrieval, and persistence continue to use only active
rows. clear() discards reserved storage as well as active rows, so it does not
retain metadata payloads through unused capacity. A caller-held inspection view
can still keep its previous NumPy buffer and payload references alive until the
view itself is released.
Search result ordering¶
Version 0.5 makes exact metric ties deterministic. Cosine and dot-product searches still rank larger values first, and Euclidean search still ranks smaller distances first. When computed values are equal, the lower original store row index comes first.
This tie break also decides which rows are returned when equal values cross
the top_k boundary. A shuffled within_rows input does not change the result:
the original store indexes, rather than positions in the filtered input, break
the tie. No call-site change is required, but code that depended on an
incidental NumPy partition order should update its expectations.
Thread safety¶
Version 0.5 defines the existing synchronization boundary without adding
internal locks. Search, get(), and inspection may run concurrently on one
instance only while store state and shared metadata payloads remain unchanged.
Applications must externally synchronize all access to an instance whenever
any thread may call add(), clear(), reload(), or save(), or mutate a
metadata payload shared with the store. Saves must not overlap in-memory
mutation because archive replacement protects the destination path, not the
consistency of vectors and metadata read from a changing instance.
Atomic replacement also does not coordinate separate store instances writing the same path. Applications with multiple writers must serialize them; without that coordination, each complete save may replace another and the last successful replacement wins.
Creating and saving a new store¶
Previously, the destination was supplied while constructing the store:
store = VectorStore(dimensions=1536, file_path="vectors.npz")
store.add(vectors, metadata)
store.save()
Create the in-memory store first, then bind its destination with the first save:
store = VectorStore(dimensions=1536)
store.add(vectors, metadata)
store.save("vectors.npz")
Later save() calls update the bound archive. Supplying another path performs
a Save As operation and binds the new destination after the write succeeds.
Calling save() before a store is bound raises ValueError rather than
silently leaving the data unsaved.
Opening an existing archive¶
The old API required callers to repeat configuration that should belong to the archive:
store = VectorStore(
dimensions=1536,
file_path="vectors.npz",
normalize=True,
)
store.load()
Open a version 1 archive directly:
store = VectorStore.open("vectors.npz")
open() restores dimensions and normalize from the archive, validates its
contents, loads its rows, and binds its path. Applications no longer need to
keep archive configuration separately or risk loading the same vectors with
different semantics.
Paths may be strings or string-valued os.PathLike objects such as
pathlib.Path. Empty paths raise ValueError, while bytes and other non-path
values raise TypeError. A None path is meaningful only for save(), where
it reuses an existing binding. A relative path is anchored to the current
working directory when save(path) or open(path) binds it. The public
file_path is therefore absolute, and later working-directory changes do not
redirect pathless saves or reloads. Symlinks and .. components are not
canonicalized.
The generic parameter still describes application metadata. It can be kept when useful:
from dataclasses import dataclass
@dataclass(frozen=True)
class ChunkMetadata:
source: str
chunk_index: int
store = VectorStore[ChunkMetadata].open("vectors.npz")
ChunkMetadata is an example application type, not a class provided by this
library.
Refreshing from disk¶
Use reload() when another process may have changed the bound archive and the
current in-memory changes should be discarded:
store = VectorStore.open("vectors.npz")
# Later, after the file may have changed:
store.reload()
reload() always attempts to read. It raises if the store is unbound, the file
is missing, or the archive is invalid. A failed reload leaves the current
in-memory vectors and metadata unchanged.
Replacing context-manager persistence¶
The earlier context manager saved automatically on exit:
with VectorStore(dimensions=1536, file_path="vectors.npz") as store:
store.add(vectors, metadata)
Use an explicit save after the work succeeds:
store = VectorStore(dimensions=1536)
store.add(vectors, metadata)
store.save("vectors.npz")
Normal Python control flow already prevents the final line from running if
add() raises. The persistence boundary is visible, and readers do not need to
remember an implicit exit side effect.
There is no replacement autosave context manager. An explicit save() keeps
the persistence boundary visible and lets the application decide whether work
completed successfully enough to persist.
Migrating an archive created before 0.4¶
Older archives contain only vectors and metadata. They do not record their
dimensions or whether vectors use normalized or raw semantics, so open()
cannot construct a correct store from them.
Before upgrading to 0.5, use the 0.4 compatibility API once with the archive's original configuration:
legacy = VectorStore(
dimensions=1536,
file_path="legacy-vectors.npz",
normalize=True,
)
legacy.load()
legacy.save()
This code emits transition warnings in 0.4 by design. The final save()
rewrites the archive as format version 1 with format_version, dimensions,
normalize, vectors, and metadata. It can then be opened by 0.5:
store = VectorStore.open("legacy-vectors.npz")
Applications that can recreate archives from source vectors and metadata may do that instead. NumPy Vector Store 0.5 cannot perform this conversion because the unversioned file does not contain enough information to reconstruct its configuration safely.
Removal schedule¶
| Transitional behavior | 0.4 | 0.5 |
|---|---|---|
Constructor file_path= |
Works with FutureWarning |
Removed |
Instance load() |
Works with FutureWarning |
Removed |
| Direct context-manager persistence | Saves only on successful exit and warns | Removed |
| Unversioned two-array archives | Load with known configuration and warn | Reader removed |
open(), save(path), save(), and reload() |
Preferred | Supported |
Persistence boundaries that do not change¶
Metadata is stored in a pickle-backed NumPy object array. Archives remain trusted input and must not be opened from untrusted or unverifiable sources.
Current releases retain forward-reading support for self-describing format-version-1 archives written by 0.4. The test suite keeps a binary fixture generated by the published 0.4.0 package on Python 3.11 and NumPy 1.23.2. This does not restore the unversioned reader or promise that an older release can read an arbitrary future format.
Read and write failures retain the exception type owned by their source:
filesystem OSError subclasses, schema ValueError, and NumPy, pickle, or
application metadata-loading errors are not collapsed into a package-specific
exception.
Saves use same-directory temporary files and atomic replacement, but the library does not add file locking, coordinate concurrent writers, or promise power-loss durability across every operating system and filesystem.