ZeusDB Vector Database is a high-performance, Rust-powered vector database designed for fast similarity search across high-dimensional data. It enables efficient approximate nearest neighbor (ANN) search, ideal for use cases like document retrieval, semantic search, recommendation systems, and AI-powered assistants.
ZeusDB leverages the HNSW (Hierarchical Navigable Small World) algorithm for speed and accuracy, with native Python bindings for easy integration into data science and machine learning workflows. Whether you're indexing millions of vectors or running low-latency queries in production, ZeusDB offers a lightweight, extensible foundation for scalable vector search.
π User-friendly Python API for adding vectors and running similarity searches
π₯ High-performance Rust backend optimized for speed and concurrency
π Approximate Nearest Neighbor (ANN) search using HNSW for fast, accurate results
π¦ Product Quantization (PQ) for compact storage and faster distance computations
π₯ Flexible input formats, including native Python types and NumPy arrays
ποΈ Metadata-aware filtering for precise and contextual querying
πΎ Save and load complete indexes to disk
ZeusDB Vector Database supports the following metrics for vector similarity search. All metric names are case-insensitive, so "cosine", "COSINE", and "Cosine" are treated identically.
| Metric | Description | Accepted Values (case-insensitive) |
|---|---|---|
| cosine | Cosine Distance (1 - Cosine Similarity) | "cosine", "COSINE", "Cosine" |
| l1 | Manhattan distance | "l1", "L1" |
| l2 | Euclidean distance | "l2", "L2" |
All distance metrics in ZeusDB Vector Database return distance values, not similarity scores:
- Lower values = more similar
- A vector identical to the query scores 0.0, or a value within floating point error of it
This applies to all distance types, including cosine.
Under cosine, vectors are normalized to unit length when they are stored. A vector you read back with return_vector=True or get_records() is therefore the normalized form, not the values you supplied. Under l1 and l2 the values are stored unchanged.
A zero vector has no direction, so under cosine it sits at distance 1.0 from everything, including itself.
You can install ZeusDB Vector Database with 'uv' or alternatively using 'pip'.
uv pip install zeusdb-vector-databasepip install zeusdb-vector-database# Import the vector database module
from zeusdb_vector_database import VectorDatabase
# Instantiate the VectorDatabase class
vdb = VectorDatabase()
# Initialize and set up the database resources
index = vdb.create(index_type="hnsw", dim=8)
# Vector embeddings with accompanying ID's and Metadata
records = [
{"id": "doc_001", "values": [0.1, 0.2, 0.3, 0.1, 0.4, 0.2, 0.6, 0.7], "metadata": {"author": "Alice"}},
{"id": "doc_002", "values": [0.9, 0.1, 0.4, 0.2, 0.8, 0.5, 0.3, 0.9], "metadata": {"author": "Bob"}},
{"id": "doc_003", "values": [0.11, 0.21, 0.31, 0.15, 0.41, 0.22, 0.61, 0.72], "metadata": {"author": "Alice"}},
{"id": "doc_004", "values": [0.85, 0.15, 0.42, 0.27, 0.83, 0.52, 0.33, 0.95], "metadata": {"author": "Bob"}},
{"id": "doc_005", "values": [0.12, 0.22, 0.33, 0.13, 0.45, 0.23, 0.65, 0.71], "metadata": {"author": "Alice"}},
]
# Upload records using the `add()` method
add_result = index.add(records)
print(add_result.summary())
# Perform a similarity search and print the top 2 results
query_vector = [0.1, 0.2, 0.3, 0.1, 0.4, 0.2, 0.6, 0.7]
results = index.search(vector=query_vector, filter=None, top_k=2)
for i, res in enumerate(results, 1):
print(f"{i}. ID: {res['id']}, Score: {res['score']:.6f}, Metadata: {res['metadata']}")Results Output:
5 inserted, 0 errors
1. ID: doc_001, Score: 0.000000, Metadata: {'author': 'Alice'}
2. ID: doc_003, Score: 0.000988, Metadata: {'author': 'Alice'}
add_result.summary() returns a plain ASCII string, so it prints on any console encoding. The same counts are on add_result.total_inserted and add_result.total_errors if you want the numbers rather than the sentence.
ZeusDB Vector Database makes it easy to work with high-dimensional vector data using a fast, memory-efficient HNSW index. Whether you're building semantic search, recommendation engines, or embedding-based clustering, the workflow is simple and intuitive.
Three simple steps
- Create an index using
.create() - Add data using
.add(...) - Conduct a similarity search using
.search(...)
Each step is covered below.
To get started, first initialize a VectorDatabase and create an HNSWIndex. You can configure the vector dimension, distance metric, and graph construction parameters.
# Import the vector database module
from zeusdb_vector_database import VectorDatabase
# Instantiate the VectorDatabase class
vdb = VectorDatabase()
# Initialize and set up the database resources
index = vdb.create(
index_type="hnsw",
dim=8,
space="cosine",
m=16,
ef_construction=200,
expected_size=5,
)
print(index.info())Output
HNSWIndex(dim=8, space=cosine, m=16, ef_construction=200, expected_size=5, vectors=0, quantization=none)
| Parameter | Type | Default | Description |
|---|---|---|---|
index_type |
str |
"hnsw" |
The type of vector index to create. Currently only "hnsw" is supported. Case-insensitive. |
dim |
int |
1536 |
Dimensionality of the vectors to be indexed. Each vector must have this length. Must be positive. The default of 1536 matches the output dimensionality of OpenAI's text-embedding-3-small and text-embedding-ada-002 models. |
space |
str |
"cosine" |
Distance metric used for similarity search. One of "cosine", "l1", "l2". Case-insensitive. |
m |
int |
16 or 32, see below |
Number of bi-directional connections created for each new node, from 2 to 256. Higher m improves recall but increases index size and build time. |
ef_construction |
int |
200 |
Size of the dynamic list used during index construction. Must be positive. Larger values increase indexing time and memory, but improve quality. |
expected_size |
int |
10000 |
Estimated number of records to be inserted, from 1 to 100,000,000. Used for preallocating internal data structures and for choosing the default m. Not a hard limit, see below. |
quantization_config |
dict |
None |
Product Quantization configuration for memory-efficient vector compression. See Product Quantization. |
The default m depends on expected_size. It is 16 for an expected_size of 25,000 or less, and 32 above that. A graph too sparse for the number of records loses recall that no search width recovers, and m is fixed once the index is created, so declare expected_size honestly or set m yourself. Passing m explicitly always wins.
vdb.create("hnsw", dim=8, expected_size=25_000).get_stats()["m"] # '16'
vdb.create("hnsw", dim=8, expected_size=25_001).get_stats()["m"] # '32'expected_size is a hint and not a limit. An index accepts more records than it declared, and the graph grows to fit them. What it does not change is m, which is chosen at creation from the declaration and fixed there, so an index that has badly outgrown its expected_size is running at a degree meant for a smaller one. Passing twice the declared size logs a warning once, on the add() that crosses it.
The upper bound of 100,000,000 exists because the graph reserves one slot per declared record at creation, 8 bytes each, and that allocation aborts the process rather than raising if it fails. The bound turns an abort into a ValueError. Declaring less than the truth is safe.
m starts at 2, not 1. Layer assignment samples from a scale of 1 / ln(m), which is infinity at m of 1, so every point is redispatched uniformly across all 16 layers rather than following the exponential distribution the graph depends on. On 3,000 records of 32 dimensions, recall at 10 measured 0.0220 at m of 1 against 0.6880 at 2 and 1.0000 at 16.
ZeusDB provides a flexible .add(...) method that supports multiple input formats for inserting or updating vectors in the index. Whether you're adding a single record, a list of documents, or structured arrays, the API is designed to be both intuitive and robust. Each record can include optional metadata for filtering or downstream use.
All formats return an AddResult containing total_inserted, total_errors, errors and vector_shape.
index = vdb.create("hnsw", dim=2)
add_result = index.add({
"id": "doc1",
"values": [0.1, 0.2],
"metadata": {"text": "hello"}
})
print(add_result.total_inserted, add_result.total_errors)
print(add_result.is_success())Output
1 0
True
index = vdb.create("hnsw", dim=2)
add_result = index.add([
{"id": "doc1", "values": [0.1, 0.2], "metadata": {"text": "hello"}},
{"id": "doc2", "values": [0.3, 0.4], "metadata": {"text": "world"}},
])
print(add_result.total_inserted, add_result.total_errors)
print(add_result.vector_shape)
print(add_result.errors)Output
2 0
(2, 2)
[]
index = vdb.create("hnsw", dim=2)
add_result = index.add({
"ids": ["doc1", "doc2"],
"embeddings": [[0.1, 0.2], [0.3, 0.4]],
"metadatas": [{"text": "hello"}, {"text": "world"}],
})
print(add_result)Output
AddResult(inserted=2, errors=0, shape=Some((2, 2)))
The Some(...) wrapper appears only in the printed form. add_result.vector_shape is the plain tuple (2, 2).
ZeusDB also supports NumPy arrays as input for seamless integration with scientific and ML workflows.
import numpy as np
index = vdb.create("hnsw", dim=4)
data = [
{"id": "doc2", "values": np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32), "metadata": {"type": "blog"}},
{"id": "doc3", "values": np.array([0.5, 0.6, 0.7, 0.8], dtype=np.float32), "metadata": {"type": "news"}},
]
result = index.add(data)
print(result.total_inserted, result.total_errors)Output
2 0
index = vdb.create("hnsw", dim=2)
add_result = index.add({
"ids": ["doc1", "doc2"],
"embeddings": np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32),
"metadatas": [{"text": "hello"}, {"text": "world"}],
})
print(add_result)Output
AddResult(inserted=2, errors=0, shape=Some((2, 2)))
Each format is parsed and validated automatically. Invalid records are skipped rather than aborting the call, and the reason for each is returned in errors. A record whose vector contains NaN or an infinity is rejected this way.
add() upserts by default. Re-adding an existing ID replaces the whole record, metadata included. Metadata is not merged, so a key you leave out of the new record is gone.
index = vdb.create("hnsw", dim=2)
index.add({"id": "doc1", "values": [0.1, 0.2], "metadata": {"text": "hello", "lang": "en"}})
# "lang" is not carried over
index.add({"id": "doc1", "values": [0.3, 0.4], "metadata": {"text": "goodbye"}})
print(index.get_records("doc1", return_vector=False))
# overwrite=False rejects the record instead, and counts it as an error
rejected = index.add({"id": "doc1", "values": [0.5, 0.6]}, overwrite=False)
print(rejected.total_inserted, rejected.total_errors)
print(rejected.errors)Output
[{'id': 'doc1', 'metadata': {'text': 'goodbye'}}]
0 1
["Vector doc1: ValueError: Vector with ID 'doc1' already exists"]
A rejected record is reported in the AddResult. It does not raise. The rejection is also logged at WARNING level, which is visible on stderr under the default development settings.
Every overwrite leaves a node behind in the graph. See compact().
The add() method inserts or replaces one or more vectors in the index.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
dict, list[dict], dict of arrays, or np.ndarray |
required | Input records to upsert into the index. Supports the five formats above. |
overwrite |
bool |
True |
Whether an ID already in the index is replaced. With False, a colliding record is skipped and counted as an error. |
Returns:
AddResult with:
total_inserted: number of records successfully inserted or replacedtotal_errors: number of failed recordserrors: list of error messagesvector_shape: the shape of the processed batch, as(rows, dim)is_success():Truewhentotal_errorsis zerosummary(): a one-line string of the two counts
Query the index using a new vector and retrieve the top-k nearest neighbors. You can also filter by metadata or return the stored vectors.
The examples below all run against this index:
index = vdb.create(index_type="hnsw", dim=8)
index.add([
{"id": "doc_001", "values": [0.1, 0.2, 0.3, 0.1, 0.4, 0.2, 0.6, 0.7], "metadata": {"author": "Alice"}},
{"id": "doc_002", "values": [0.9, 0.1, 0.4, 0.2, 0.8, 0.5, 0.3, 0.9], "metadata": {"author": "Bob"}},
{"id": "doc_003", "values": [0.11, 0.21, 0.31, 0.15, 0.41, 0.22, 0.61, 0.72], "metadata": {"author": "Alice"}},
{"id": "doc_004", "values": [0.85, 0.15, 0.42, 0.27, 0.83, 0.52, 0.33, 0.95], "metadata": {"author": "Bob"}},
{"id": "doc_005", "values": [0.12, 0.22, 0.33, 0.13, 0.45, 0.23, 0.65, 0.71], "metadata": {"author": "Alice"}},
])
query_vector = [0.1, 0.2, 0.3, 0.1, 0.4, 0.2, 0.6, 0.7]results = index.search(vector=query_vector, top_k=2)
for res in results:
print(res["id"], round(res["score"], 6), res["metadata"])Output
doc_001 0.0 {'author': 'Alice'}
doc_003 0.000988 {'author': 'Alice'}
results = index.search(vector=query_vector, filter={"author": "Alice"}, top_k=5)
for res in results:
print(res["id"], round(res["score"], 6), res["metadata"])Output
doc_001 0.0 {'author': 'Alice'}
doc_003 0.000988 {'author': 'Alice'}
doc_005 0.001143 {'author': 'Alice'}
The filter is applied after the graph search, not during it. The index finds the top_k nearest vectors first and then discards the ones the filter rejects, so a selective filter can return fewer than top_k results, or none at all. Raise top_k when you filter. See Metadata Filtering for a worked example.
Set return_vector=True to get the stored embedding alongside the metadata and score. Under cosine this is the normalized vector, not the values you supplied.
results = index.search(vector=query_vector, top_k=1, return_vector=True)
print(results[0]["id"], round(results[0]["score"], 6))
print([round(v, 4) for v in results[0]["vector"]])Output
doc_001 0.0
[0.0913, 0.1826, 0.2739, 0.0913, 0.3651, 0.1826, 0.5477, 0.639]
Perform a similarity search on multiple query vectors at once. The result is a list of result lists, one per query, in the order the queries were given.
batch = [
[0.1, 0.2, 0.3, 0.1, 0.4, 0.2, 0.6, 0.7],
[0.9, 0.1, 0.4, 0.2, 0.8, 0.5, 0.3, 0.9],
]
results = index.search(vector=batch, top_k=2)
for q, hits in enumerate(results):
print(f"query {q}:", [(h["id"], round(h["score"], 6)) for h in hits])Output
query 0: [('doc_001', 0.0), ('doc_003', 0.000988)]
query 1: [('doc_002', 0.0), ('doc_004', 0.002238)]
query_batch = np.array(batch, dtype=np.float32)
results = index.search(vector=query_batch, top_k=2)
for q, hits in enumerate(results):
print(f"query {q}:", [h["id"] for h in hits])Output
query 0: ['doc_001', 'doc_003']
query 1: ['doc_002', 'doc_004']
The same filter is applied to every query in the batch. The second query below returns nothing, because both of its two nearest neighbours are Bob's.
results = index.search(batch, filter={"author": "Alice"}, top_k=2)
for q, hits in enumerate(results):
print(f"query {q}:", [h["id"] for h in hits])Output
query 0: ['doc_001', 'doc_003']
query 1: []
The search() method retrieves the top-k most similar vectors from the index given an input query vector. Results include the vector ID, distance score, metadata, and optionally the stored vector.
| Parameter | Type | Default | Description |
|---|---|---|---|
vector |
List[float], List[List[float]], or np.ndarray |
required | The query vector (single: List[float] or 1D np.ndarray) or batch of query vectors (List[List[float]] or 2D np.ndarray). Must match the index dimension and contain only finite values. |
filter |
Dict[str, Any] | None |
None |
Optional metadata filter. Values may be a plain value for equality or a dict of operators. See Filter Operators. |
top_k |
int |
10 |
Number of nearest neighbors to return. |
ef_search |
int | None |
see below | Search complexity parameter. Higher values improve accuracy at the cost of speed. |
return_vector |
bool |
False |
If True, each result includes the stored embedding vector under a vector key. |
rerank |
int | None |
20 |
Candidates fetched per requested result before rescoring against raw vectors. Only applies to a quantized index whose storage_mode is quantized_with_raw. See Quantized search accuracy. |
The default ef_search depends on the distance metric. It is max(2 Γ top_k, 100) for cosine and max(2 Γ top_k, 150) for l1 and l2.
A query vector containing NaN or an infinity raises ValueError rather than returning meaningless distances.
ZeusDB Vector Database includes a suite of utility functions to help you inspect, manage, and maintain your index. You can view index configuration, attach custom metadata, list stored records, and remove vectors by ID.
print(index.info())Output
HNSWIndex(dim=8, space=cosine, m=16, ef_construction=200, expected_size=10000, vectors=5, quantization=none)
The vectors= field is the live record count, in every storage mode. get_vector_count() returns the same number. get_stats()["raw_vectors_stored"] is the one that counts raw vectors specifically, and on a quantized_only index it is lower.
Other single-value accessors: index.dim, index.get_space(), index.get_vector_count(), index.has_quantization(), index.can_use_quantization(), and VectorDatabase.available_index_types().
Index level metadata is a flat str to str map, separate from the per-record metadata used for filtering. It is preserved by save() and load().
index.add_metadata({
"creator": "John Smith",
"version": "0.1",
"created_at": "2024-01-28T11:35:55Z",
"embedding_model": "openai/text-embedding-ada-002",
"environment": "production",
})
# View index level metadata by key
print(index.get_metadata("creator"))
# View all index level metadata
for key, value in sorted(index.get_all_metadata().items()):
print(f"{key}: {value}")Output
John Smith
created_at: 2024-01-28T11:35:55Z
creator: John Smith
embedding_model: openai/text-embedding-ada-002
environment: production
version: 0.1
get_all_metadata() returns a dict whose iteration order is not stable, which is why the example sorts it.
for record_id, metadata in sorted(index.list(number=5)):
print(record_id, metadata)Output
doc_001 {'author': 'Alice'}
doc_002 {'author': 'Bob'}
doc_003 {'author': 'Alice'}
doc_004 {'author': 'Bob'}
doc_005 {'author': 'Alice'}
list() returns (id, metadata) tuples in no particular order, so the example sorts them. It is not a paging API: number takes the first N in whatever order internal storage yields, and the same N are not guaranteed across calls. It lists every record, in every storage mode.
stats = index.get_stats()
for key in ["total_vectors", "graph_nodes", "stranded_graph_nodes", "storage_mode_description"]:
print(f"{key}: {stats[key]}")Output
total_vectors: 5
graph_nodes: 5
stranded_graph_nodes: 0
storage_mode_description: raw_only
get_stats() returns a str to str map. It also carries dimension, space, m, ef_construction, expected_size, index_type, raw_vectors_stored, quantized_codes_stored and storage_mode, plus training and compression fields once quantization is configured.
Remove a vector and its metadata with .remove_point(id). This performs a logical deletion:
- The vector is deleted from internal storage.
- The metadata is removed.
- The vector ID is no longer returned by
.contains(),.get_records(), or.search().
index.remove_point("doc_001")
print("doc_001 present:", index.contains("doc_001"))
print("records remaining:", index.get_vector_count())Output
doc_001 present: False
records remaining: 4
compact() reclaims those nodes.
Both remove_point() and an overwriting add() leave a node behind in the graph. compact() rebuilds the graph in memory and returns the number of nodes it reclaimed. Nothing else changes: IDs, metadata, stored vectors, quantized codes and PQ training state all survive, so every ID resolves to the same record before and after.
print("stranded graph nodes:", index.get_stats()["stranded_graph_nodes"])
print("reclaimed:", index.compact())
print("stranded graph nodes:", index.get_stats()["stranded_graph_nodes"])Output
stranded graph nodes: 1
reclaimed: 1
stranded graph nodes: 0
compact() costs a full rebuild, proportional to the number of live records rather than to the amount of debris, and it holds both graphs in memory while it runs. It returns 0 and does nothing when there is nothing to reclaim. It is never automatic, so schedule it when your workload has accumulated deletions.
Use get_records() to fetch one or more records by ID, with optional vector inclusion. It returns a list of dicts with id, metadata, and, when return_vector is true, vector.
# Single record
print(index.get_records("doc_002", return_vector=False))
# Multiple records
print(index.get_records(["doc_002", "doc_003"], return_vector=False))
# Missing IDs are silently skipped
print(index.get_records(["doc_002", "missing_id"], return_vector=False))
# Vectors are included by default
record = index.get_records("doc_002")[0]
print(sorted(record.keys()), len(record["vector"]))Output
[{'id': 'doc_002', 'metadata': {'author': 'Bob'}}]
[{'id': 'doc_002', 'metadata': {'author': 'Bob'}}, {'id': 'doc_003', 'metadata': {'author': 'Alice'}}]
[{'id': 'doc_002', 'metadata': {'author': 'Bob'}}]
['id', 'metadata', 'vector'] 8
get_records() only returns results for IDs that exist in the index. Missing IDs are silently skipped, so a shorter list than you asked for is how a missing ID is reported.
Product Quantization (PQ) is a vector compression technique that reduces memory usage by dividing each vector into subvectors and quantizing them independently. A record's compressed form is one byte per subvector, whatever the dimension, so an index over 1536-dimensional vectors with 8 subvectors stores 8 bytes per code in place of 6144 bytes of float32.
ZeusDB Vector Database's PQ implementation features:
β
Automatic training, triggered on the add() call that reaches the configured threshold
β Compact codes, one byte per subvector per record
β Asymmetric Distance Computation (ADC) for fast search against the codes
β Automatic switch from raw to quantized storage once training completes
Compression is not free, and the accuracy cost is much larger than the memory saving suggests. Read Quantized search accuracy before choosing a storage mode.
To enable PQ, pass a quantization_config dictionary to the .create() index method:
| Parameter | Type | Description | Valid Range | Default |
|---|---|---|---|---|
type |
str |
Quantization algorithm type | "pq" |
required |
subvectors |
int |
Number of vector subspaces. Must divide dim evenly |
1 to dim |
8 |
bits |
int |
Bits per quantized code, which sets the centroids per subvector to 2^bits | 1 to 8 | 8 |
training_size |
int |
Records collected before training is triggered | β₯ 1000 | 10000 |
max_training_vectors |
int | None |
Maximum records used during training | β₯ training_size |
None |
storage_mode |
str |
"quantized_only" or "quantized_with_raw" |
see below | "quantized_only" |
Compression ratio is dim Γ 4 / subvectors. More subvectors means a longer code, so it lowers the compression ratio and raises accuracy. Fewer subvectors means the opposite. At dim=1536, 8 subvectors gives 768x and 16 subvectors gives 384x.
bits does not change the size of a record's code, which is always one byte per subvector. It sets the number of centroids in the codebook, so it trades codebook memory and training time against quantization accuracy.
create() emits a UserWarning when the configuration looks unbalanced, for example when the compression ratio exceeds 50x, and another when storage_mode is quantized_with_raw.
from zeusdb_vector_database import VectorDatabase
import numpy as np
vdb = VectorDatabase()
quantization_config = {
"type": "pq", # `pq` for Product Quantization
"subvectors": 8, # 8 subvectors of 192 dims each
"bits": 8, # 256 centroids per subvector (2^8)
"training_size": 1000, # Train once 1,000 records are collected
"storage_mode": "quantized_with_raw" # Keep raw vectors so results can be reranked
}
index = vdb.create(
index_type="hnsw",
dim=1536, # OpenAI `text-embedding-3-small` dimension
expected_size=2500,
quantization_config=quantization_config
)
# Add vectors. Training triggers automatically at the threshold.
rng = np.random.default_rng(0)
documents = {
"ids": [f"doc_{i}" for i in range(2500)],
"embeddings": rng.random((2500, 1536), dtype=np.float32),
"metadatas": [{"category": "tech", "year": 2026} for _ in range(2500)],
}
result = index.add(documents)
print("inserted:", result.total_inserted)
# Check quantization status
print("training progress:", f"{index.get_training_progress():.1f}%")
print("storage mode:", index.get_storage_mode())
print("is quantized:", index.is_quantized())
# Get compression statistics
quant_info = index.get_quantization_info()
print("compression ratio:", f"{quant_info['compression_ratio']:.1f}x")
print("codebook memory:", f"{quant_info['memory_mb']:.1f} MB")
# Search works the same way on a quantized index
query_vector = rng.random(1536, dtype=np.float32)
results = index.search(vector=query_vector, top_k=3)
print("results:", len(results), "| keys:", sorted(results[0].keys()))Output
inserted: 2500
training progress: 100.0%
storage mode: quantized_active
is quantized: True
compression ratio: 768.0x
codebook memory: 1.5 MB
results: 3 | keys: ['id', 'metadata', 'score']
The result IDs and scores depend on the data, so they are not shown. Production indexes use a much larger training_size; 1,000 is the minimum the validator accepts and keeps this example quick.
index.info() reports the quantization state as well:
print(index.info())Output
HNSWIndex(dim=1536, space=cosine, m=16, ef_construction=200, expected_size=2500, vectors=2500, quantization=pq(subvectors=8, bits=8, trained, active, compression=768.0x))
from zeusdb_vector_database import VectorDatabase
vdb = VectorDatabase()
quantization_config = {
"type": "pq",
"subvectors": 8,
"bits": 8,
"training_size": 10000,
"max_training_vectors": 50000,
"storage_mode": "quantized_only" # Drop raw vectors once training completes
}
index = vdb.create(
index_type="hnsw",
dim=3072, # OpenAI `text-embedding-3-large` dimension
expected_size=100000,
quantization_config=quantization_config
)| Mode | What it stores | Rerank available | Memory |
|---|---|---|---|
quantized_only |
Codes for every record, plus the raw vectors of the records collected before training | No | Lowest |
quantized_with_raw |
Codes and raw vectors for every record | Yes | Higher than raw storage for the records, lower for the graph |
Two consequences of quantized_only are worth knowing before you pick it.
The training records keep their raw vectors. Records collected before the training threshold is reached are stored at full width and stay that way, so an index whose training_size is a large fraction of its total size saves much less than the compression ratio suggests.
A record added after training exists only as a code, so the vector you read back is an approximation. Every accessor sees the record. get_records(..., return_vector=True) and search(..., return_vector=True) reconstruct its vector from the code, so what they hand back is close to the value supplied rather than equal to it. A record collected before training still holds its raw vector and reads back exactly. get_stats()["raw_vectors_stored"] is what tells you how many of each you have.
only = vdb.create("hnsw", dim=1536, expected_size=2500, quantization_config={
"type": "pq",
"subvectors": 8,
"bits": 8,
"training_size": 1000,
"storage_mode": "quantized_only",
})
only.add(documents) # the same 2,500 records used in Usage Example 1
print("storage mode:", only.get_storage_mode())
stats = only.get_stats()
print("raw vectors kept:", stats["raw_vectors_stored"])
print("quantized codes:", stats["quantized_codes_stored"])
print("records:", only.get_vector_count())
print("contains doc_0 (added before training):", only.contains("doc_0"))
print("contains doc_2000 (added after training):", only.contains("doc_2000"))
print("get_records doc_2000 returns:", len(only.get_records("doc_2000")), "record")Output
storage mode: quantized_active
raw vectors kept: 1000
quantized codes: 2500
records: 2500
contains doc_0 (added before training): True
contains doc_2000 (added after training): True
get_records doc_2000 returns: 1 record
Quantized search is far less accurate than raw search, and quantized_only cannot be repaired by tuning. ADC scores candidates against the codes, and a code discards most of the information in a vector. Rerank fixes this by over-fetching candidates and rescoring them against raw vectors, which is only possible when the raw vectors are still there.
Measured on 6,000 clustered 128-dimensional vectors with 8 subvectors and 8 bits, recall at 10 against exact cosine search:
| Configuration | Recall@10 |
|---|---|
| No quantization | 1.00 |
quantized_only |
0.16 |
quantized_with_raw, rerank=0 |
0.15 |
quantized_with_raw, default rerank |
1.00 |
The exact figures depend on your data, but the shape does not. If you need quantization and you need accuracy, use quantized_with_raw and leave rerank on.
rerankdefaults to 20, meaning 20 candidates are fetched per requested result and the page is reordered by raw distance. The over-fetch is capped at the number of live records.rerank=0turns reranking off and returns the ADC scores and ordering.- The fetch is
top_k Γ rerankcandidates, so a largetop_kmultiplies the cost. Lowerrerankwhen you ask for a large page. rerankhas no effect on an unquantized index or on aquantized_onlyone. Both ignore it.- With rerank on, the scores you get back are raw-vector distances. With it off, they are ADC estimates. The two are not comparable.
ef_search still applies to the quantized traversal, but it moves recall very little compared with rerank.
- Training: happens once, on the
add()call that reachestraining_size. That call takes noticeably longer than the others. - Memory: a record's code is
subvectorsbytes againstdim Γ 4for a raw vector. The graph shrinks by the same factor, because it holds codes rather than vectors. - Search speed: quantized search is faster than raw search. Measured over 20,000 256-dimensional vectors, 0.93 ms per query against 1.52 ms.
- Accuracy: see the table above. Treat quantization as a memory decision that costs accuracy, not as a free win.
ZeusDB Vector Database can save and restore complete indexes on disk, which lets you preserve your work, move indexes between systems, and back up production deployments.
The persistence system supports:
β Complete state preservation for vectors, per-record metadata, index level metadata, ID mappings and quantization models β Hybrid storage format, binary encoding for vectors with human-readable JSON for metadata β Quantization support, both raw and quantized storage modes, including the trained codebook β Training state recovery, so an index saved mid-collection resumes collecting β Format versioning, so a directory this build cannot interpret is refused rather than misread
save() and load() print progress to stdout. Every step writes a line. This is not configurable, so redirect stdout if it is a problem in your application.
Use the .save() method to persist your index to a .zdb directory:
from zeusdb_vector_database import VectorDatabase
import numpy as np
import os
vdb = VectorDatabase()
index = vdb.create("hnsw", dim=1536, space="cosine", expected_size=1000)
rng = np.random.default_rng(1)
vectors = rng.random((1000, 1536), dtype=np.float32)
index.add({
"ids": [f"doc_{i}" for i in range(1000)],
"embeddings": vectors,
"metadatas": [{"category": f"cat_{i % 5}", "index": i} for i in range(1000)],
})
index.save("my_index.zdb")
print("saved:", sorted(os.listdir("my_index.zdb")))Output, with the progress lines omitted
saved: ['config.json', 'hnsw_index.hnsw.data', 'hnsw_index.hnsw.graph', 'manifest.json', 'mappings.bin', 'metadata.json', 'vectors.bin']
Use the .load() method to restore a previously saved index:
vdb = VectorDatabase()
loaded_index = vdb.load("my_index.zdb")
print("vectors:", loaded_index.get_vector_count())
print(loaded_index.info())
results = loaded_index.search(vectors[0].tolist(), top_k=3)
print("top hit:", results[0]["id"])Output, with the progress lines omitted
vectors: 1000
HNSWIndex(dim=1536, space=cosine, m=16, ef_construction=200, expected_size=1000, vectors=1000, quantization=none)
top hit: doc_0
Loading rebuilds the graph rather than reading it back, so load time is proportional to the number of records, not to the size of the directory. The saved graph files are written but not consumed on load.
A quantized index comes back quantized, with its codebook and training state intact:
quantization_config = {
"type": "pq",
"subvectors": 8,
"bits": 8,
"training_size": 1000,
"storage_mode": "quantized_with_raw",
}
vdb = VectorDatabase()
index = vdb.create("hnsw", dim=1536, expected_size=2000,
quantization_config=quantization_config)
rng = np.random.default_rng(2)
index.add({
"ids": [f"vec_{i}" for i in range(2000)],
"embeddings": rng.random((2000, 1536), dtype=np.float32),
})
print("quantization active:", index.is_quantized())
index.save("quantized_index.zdb")
loaded_index = vdb.load("quantized_index.zdb")
print("quantization active after load:", loaded_index.is_quantized())
print("storage mode after load:", loaded_index.get_storage_mode())
print("saved:", sorted(os.listdir("quantized_index.zdb")))Output, with the progress lines omitted
quantization active: True
quantization active after load: True
storage mode after load: quantized_active
saved: ['config.json', 'hnsw_index.hnsw.data', 'hnsw_index.hnsw.graph', 'manifest.json', 'mappings.bin', 'metadata.json', 'pq_centroids.bin', 'pq_codes.bin', 'quantization.json', 'vectors.bin']
The .save() method creates a directory containing all index components:
my_index.zdb/
βββ manifest.json # Index metadata and file inventory
βββ config.json # HNSW configuration and index level metadata
βββ mappings.bin # ID mappings (binary format)
βββ metadata.json # Per-record metadata (JSON format)
βββ vectors.bin # Raw vectors (whenever the index holds any)
βββ quantization.json # PQ configuration (if enabled)
βββ pq_centroids.bin # Trained centroids (if PQ trained)
βββ pq_codes.bin # Quantized codes (if PQ active)
βββ hnsw_index.hnsw.graph # HNSW graph structure
βββ hnsw_index.hnsw.data # HNSW graph payload
manifest.json lists hnsw_index.hnsw.data under files_excluded because the load path does not read it, but the file is written for every non-empty index.
A full persistence lifecycle with integrity checks:
from zeusdb_vector_database import VectorDatabase
import numpy as np
# === PHASE 1: CREATE AND POPULATE INDEX ===
vdb = VectorDatabase()
original_index = vdb.create("hnsw", dim=1536, space="cosine", expected_size=500)
rng = np.random.default_rng(42)
vectors = rng.random((500, 1536), dtype=np.float32)
original_index.add({
"ids": [f"doc_{i:03d}" for i in range(500)],
"embeddings": vectors,
"metadatas": [
{
"category": ["science", "tech", "health", "finance"][i % 4],
"priority": i % 10,
"published": i % 2 == 0,
"tags": ["important", "featured"] if i % 5 == 0 else ["standard"],
}
for i in range(500)
],
})
original_index.add_metadata({
"dataset": "demo_collection",
"created_by": "data_team",
"version": "1.0",
})
query_vector = vectors[0].tolist()
original_results = original_index.search(query_vector, top_k=3)
# === PHASE 2: SAVE, THEN LOAD ===
original_index.save("demo_index.zdb")
loaded_index = vdb.load("demo_index.zdb")
# === PHASE 3: VERIFY INTEGRITY ===
assert loaded_index.get_vector_count() == original_index.get_vector_count()
assert loaded_index.info() == original_index.info()
assert loaded_index.get_all_metadata() == original_index.get_all_metadata()
loaded_results = loaded_index.search(query_vector, top_k=3)
assert [r["id"] for r in loaded_results] == [r["id"] for r in original_results]
filtered = loaded_index.search(
query_vector,
filter={"category": "science", "published": True},
top_k=20,
)
print("records:", loaded_index.get_vector_count())
print("index metadata fields:", len(loaded_index.get_all_metadata()))
print("filtered hits:", len(filtered))
print("all checks passed")Output, with the progress lines omitted
records: 500
index metadata fields: 3
filtered hits: 5
all checks passed
-
Directory, not a file.
.save()creates a directory. You need write permission for the target location. -
Not atomic. Files are written one at a time into the target directory. An interrupted save leaves a partial directory behind, and a later
load()of it fails rather than returning a truncated index. Save to a new path and move it into place if you need an atomic swap. -
Overwriting is not clean either. Saving over an existing directory replaces files individually and does not remove ones that no longer apply. Save to a fresh directory.
-
Version compatibility. The manifest records a format version. This build writes 1.1.0 and reads any 1.x. A different major version is refused.
-
Integrity check on load. The restored record count is checked against the count in
config.json. A missing or truncated data file fails the load with a message naming what disagreed.
ZeusDB supports rich metadata with full type fidelity. Your metadata preserves the original Python data types, so integers stay integers and floats stay floats.
| Type | Python Example | Notes |
|---|---|---|
| String | "Alice" |
Text data, IDs, categories |
| Integer | 42, 2024 |
Counts, years, IDs |
| Float | 4.5, 29.99 |
Ratings, prices, scores |
| Boolean | True, False |
Flags, status indicators |
| Null | None |
Missing or empty values |
| Array | ["ai", "science"] |
Tags, categories, lists |
| Nested Object | {"key": "value"} |
Structured data |
Integers and floats compare by magnitude, so a stored integer 10 matches {"eq": 10.0} and {"gte": 10.0} alike. Booleans and strings do not cross into numbers.
A filter is a dict of field names. A field maps either to a plain value, which means equality, or to a dict of operators, all of which must hold.
| Operator | Usage | Example | Description |
|---|---|---|---|
| Direct equality | {"field": value} |
{"author": "Alice"} |
Equality for strings, numbers, booleans, null and arrays |
eq |
{"eq": value} |
{"source": {"eq": {"kind": "web"}}} |
Equality, including for nested objects |
ne |
{"ne": value} |
{"author": {"ne": "Alice"}} |
Not equal |
gt |
{"gt": value} |
{"rating": {"gt": 4.0}} |
Greater than (numeric) |
gte |
{"gte": value} |
{"year": {"gte": 2024}} |
Greater than or equal (numeric) |
lt |
{"lt": value} |
{"price": {"lt": 30}} |
Less than (numeric) |
lte |
{"lte": value} |
{"pages": {"lte": 100}} |
Less than or equal (numeric) |
contains |
{"contains": value} |
{"tags": {"contains": "ai"}} |
String contains substring, or array contains value |
startswith |
{"startswith": value} |
{"title": {"startswith": "The"}} |
String starts with substring |
endswith |
{"endswith": value} |
{"file": {"endswith": ".pdf"}} |
String ends with substring |
in |
{"in": [values]} |
{"lang": {"in": ["en", "es"]}} |
Value is in the provided array |
Three behaviours are worth knowing.
A record that lacks the field never matches, whatever the operator. That includes ne. {"lang": {"ne": "en"}} does not match a record with no lang at all.
A dict value is always read as operators. Direct equality against a nested object has no plain form, because the two would be indistinguishable, so write it as {"source": {"eq": {"kind": "web"}}}. Writing {"source": {"kind": "web"}} raises ValueError: Unknown filter operation: kind.
An unrecognised operator raises ValueError before the search runs, rather than quietly matching nothing.
The examples below all run against this index:
from zeusdb_vector_database import VectorDatabase
vdb = VectorDatabase()
index = vdb.create("hnsw", dim=4, space="l2")
index.add([
{"id": "doc_1", "values": [0.1, 0.1, 0.1, 0.1], "metadata": {
"author": "Alice", "rating": 4.5, "year": 2024, "price": 29.99,
"published": True, "tags": ["ai", "science"], "title": "The Guide",
"filename": "report.pdf", "lang": "en"}},
{"id": "doc_2", "values": [0.2, 0.2, 0.2, 0.2], "metadata": {
"author": "Bob", "rating": 3.0, "year": 2023, "price": 45.00,
"published": False, "tags": ["cooking"], "title": "A Book",
"filename": "notes.txt", "lang": "es"}},
{"id": "doc_3", "values": [0.3, 0.3, 0.3, 0.3], "metadata": {
"author": "Charlie", "rating": 5.0, "year": 2026, "price": 25.00,
"published": True, "tags": ["ai"], "title": "Theory",
"filename": "paper.pdf", "lang": "fr"}},
])
query_embedding = [0.1, 0.1, 0.1, 0.1]def matched(filter, top_k=10):
return [hit["id"] for hit in index.search(vector=query_embedding, filter=filter, top_k=top_k)]
# doc_3 is the furthest of the three from the query, so a top_k of 1 finds
# nothing once the filter is applied
print(matched({"author": "Charlie"}, top_k=1))
print(matched({"author": "Charlie"}, top_k=10))Output
[]
['doc_3']
# Find high-quality recent documents
print(matched({"published": True, "rating": {"gte": 4.0}, "year": {"gte": 2024}}))
# Find documents by specific authors
print(matched({"author": {"in": ["Alice", "Bob"]}}))
# Find AI-related content
print(matched({"tags": {"contains": "ai"}}))
# Find documents in a price range
print(matched({"price": {"gte": 20.0, "lte": 40.0}}))
# Find documents with a specific file type
print(matched({"filename": {"endswith": ".pdf"}}))
# Match on a title prefix
print(matched({"title": {"startswith": "The"}}))
# Exclude an author
print(matched({"author": {"ne": "Alice"}}))
# Match a whole array
print(matched({"tags": ["ai"]}))Output
['doc_1', 'doc_3']
['doc_1', 'doc_2']
['doc_1', 'doc_3']
['doc_1', 'doc_3']
['doc_1', 'doc_3']
['doc_1', 'doc_3']
['doc_2', 'doc_3']
['doc_3']
ZeusDB Vector Database includes structured logging that works automatically out of the box while providing customization for advanced users.
For most users, logging works automatically with sensible defaults:
from zeusdb_vector_database import VectorDatabase
# Logging is automatically configured, no setup required
vdb = VectorDatabase()
index = vdb.create("hnsw", dim=1536)
# Operations are automatically logged with structured data
result = index.add({"ids": ids, "embeddings": vectors})
results = index.search(query_vector, top_k=5)What you get automatically:
- β Quiet by default, only warnings and errors outside development
- β Environment detection, appropriate defaults for dev, prod, testing, CI and notebooks
- β Structured JSON logs in production environments
- β Human-readable logs in development environments
- β Operation timing on index creation, additions, searches and saves
- β Cross-platform compatibility
Note that save() and load() print progress directly to stdout. That output is not part of the logging system and is not affected by any of the settings below.
Control logging behavior with environment variables:
export ZEUSDB_LOG_LEVEL=debug
python your_app.pyexport ZEUSDB_LOG_LEVEL=error
export ZEUSDB_LOG_FORMAT=json
export ZEUSDB_LOG_TARGET=file
export ZEUSDB_LOG_FILE=/var/log/zeusdb/app.log
python your_app.py| Variable | Options | Default | Description |
|---|---|---|---|
ZEUSDB_LOG_LEVEL |
trace, debug, info, error |
warning (dev), error (prod) |
Controls log verbosity |
ZEUSDB_LOG_FORMAT |
human, json |
human (dev), json (prod) |
Output format |
ZEUSDB_LOG_TARGET |
stdout, stderr, file |
stderr |
Where logs go |
ZEUSDB_LOG_FILE |
/path/to/file.log |
zeusdb.log |
Log file path, written exactly as given (if target=file) |
ZEUSDB_LOG_ROTATION |
daily, never |
never |
With daily, a UTC date is appended to the file name |
ZEUSDB_LOG_CONSOLE |
true, false |
Auto-detected | Force console output |
ZEUSDB_DISABLE_AUTO_LOGGING |
true, 1, yes |
unset | Skip automatic configuration entirely |
RUST_LOG |
standard env_logger syntax |
unset | Overrides ZEUSDB_LOG_LEVEL for the Rust layer |
warning and critical are not accepted level names. The Python layer accepts them, but the Rust layer rejects them and prints ignoring 'zeusdb_vector_database=warning': invalid filter directive. The bare warn is the opposite, accepted by Rust and rejected by Python. Use trace, debug, info or error, which both layers accept.
Under ZEUSDB_LOG_ROTATION=daily with ZEUSDB_LOG_FILE=logs/app.log, two files appear: logs/app.log and a dated logs/app.log.2026-08-05. Rotation applies to the Rust layer, which writes the dated one.
The system detects your environment and applies appropriate defaults:
- π Production (
ENVIRONMENT=production, or Kubernetes or Docker markers): ERROR level, JSON format, file output - π» Development (default): WARNING level, human format, console output
- π§ͺ Testing (
ENVIRONMENT=testing,PYTEST_CURRENT_TEST, orpytestimported): CRITICAL level, minimal output - π Jupyter (
JUPYTER_SERVER_ROOT,JPY_PARENT_PID, or IPython imported): INFO level, human format - π CI/CD (
CI,GITHUB_ACTIONS,GITLAB_CI): WARNING level, human format for readability
Environment variables always override the detected defaults.
For enterprise environments with existing logging infrastructure:
import os
os.environ["ZEUSDB_DISABLE_AUTO_LOGGING"] = "1"
# Now configure your own logging before importing ZeusDB
import logging
logging.basicConfig(level=logging.INFO, format='%(message)s')
from zeusdb_vector_database import VectorDatabase # Will respect your existing logging setupimport os
os.environ["ZEUSDB_DISABLE_AUTO_LOGGING"] = "1"
import zeusdb_vector_database
# JSON to stdout
success = zeusdb_vector_database.init_logging(level="info")
# OR JSON to a directory of daily rotating files. Pick one, not both.
# success = zeusdb_vector_database.init_file_logging(
# log_dir="/var/log/myapp",
# level="debug",
# file_prefix="zeusdb"
# )
print("initialized:", success)
vdb = zeusdb_vector_database.VectorDatabase()Only the first initializer to run takes effect. Both functions return True if they installed the subscriber and False if one was already installed, so calling both leaves the second with no effect and a False return. zeusdb_vector_database.is_logging_initialized() reports whether either has run.
import logging
import os
# Disable auto-configuration
os.environ["ZEUSDB_DISABLE_AUTO_LOGGING"] = "1"
# Set up your own logger first
logger = logging.getLogger("myapp.zeusdb")
logger.setLevel(logging.INFO)
# Configure Rust logging to match
os.environ["ZEUSDB_LOG_LEVEL"] = "info"
os.environ["ZEUSDB_LOG_FORMAT"] = "json"
from zeusdb_vector_database import VectorDatabase
# ZeusDB will integrate with your logging setup2026-08-05T12:19:39.261318Z INFO build: HNSW index created successfully operation="index_creation_complete" dim=8 space=cosine m=16 ef_construction=200 expected_size=10000 has_quantization=false duration_ms=0
2026-08-05T12:19:39.3491294Z INFO add: Vector addition completed operation="add_vectors_complete" total_inserted=2 total_errors=0 success_rate=100.0 duration_ms=87 overwrite_mode=true final_storage_mode="raw_only"
{"timestamp":"2026-08-05T12:19:39.4853862Z","level":"INFO","fields":{"message":"HNSW index created successfully","operation":"index_creation_complete","dim":8,"space":"cosine","m":16,"ef_construction":200,"expected_size":10000,"has_quantization":false,"duration_ms":"0"},"target":"zeusdb_vector_database::hnsw_index","filename":"src\\hnsw_index.rs","line_number":1068,"threadId":"ThreadId(1)"}operation: the operation name, for exampleindex_creation_complete,add_vectors_complete,search_complete,pq_training_complete,save_complete,compact_completeduration_ms: timing on index creation, additions, searches, saves and compactiontotal_inserted,total_errors,success_rate: outcome of eachadd()final_storage_mode: whether an index is serving raw or quantized resultsresults_count: results returned by a search
# Monitor error rates
grep '"level":"ERROR"' /var/log/zeusdb/app.log | wc -l
# Track search latency
grep '"operation":"search_complete"' /var/log/zeusdb/app.log | jq '.fields.duration_ms'
# Watch quantization training
grep '"operation":"pq_training' /var/log/zeusdb/app.logLogs not appearing?
# Check if auto-logging is disabled
echo $ZEUSDB_DISABLE_AUTO_LOGGING
# Verify the level is one both layers accept
ZEUSDB_LOG_LEVEL=debug python -c "import zeusdb_vector_database as z; print(z.is_logging_initialized())"File logging not working?
# Check permissions
ls -la /path/to/log/directory
# Test with console first
ZEUSDB_LOG_TARGET=stderr ZEUSDB_LOG_LEVEL=info python your_app.pyWant to see Rust logs specifically?
# Enable trace level to see all Rust operations
ZEUSDB_LOG_LEVEL=trace python your_app.py- File logging is non-blocking: records are handed to a background writer rather than written on the calling thread.
traceanddebugare verbose enough to dominate runtime on a hot loop. Leave production aterror.
export ZEUSDB_LOG_LEVEL=debug
export ZEUSDB_LOG_FORMAT=humanexport ZEUSDB_LOG_LEVEL=info
export ZEUSDB_LOG_FORMAT=json
export ZEUSDB_LOG_TARGET=file
export ZEUSDB_LOG_FILE=logs/zeusdb-staging.log
export ZEUSDB_LOG_ROTATION=dailyexport ENVIRONMENT=production
export ZEUSDB_LOG_LEVEL=error
export ZEUSDB_LOG_FORMAT=json
export ZEUSDB_LOG_TARGET=file
export ZEUSDB_LOG_FILE=/var/log/zeusdb/production.log
export ZEUSDB_LOG_ROTATION=dailyThis project is licensed under the Apache License 2.0.