Skip to content

Repository files navigation

zeusdb-vector-database-logo-cropped

ZeusDB Vector Database

Meta Β  Β  Β  Powered by RustΒ  ZeusDBΒ 

ℹ️ What is ZeusDB Vector Database?

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.


⭐ Features

🐍 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


βœ… Supported Distance Metrics

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"

πŸ“ Scores vs Distances

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.


πŸ“¦ Installation

You can install ZeusDB Vector Database with 'uv' or alternatively using 'pip'.

Recommended (with uv):

uv pip install zeusdb-vector-database

Alternatively (using pip):

pip install zeusdb-vector-database

πŸ”₯ Quick Start Example

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


✨ Usage

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

  1. Create an index using .create()
  2. Add data using .add(...)
  3. Conduct a similarity search using .search(...)

Each step is covered below.


1️⃣ Create an Index

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)

πŸ“˜ Parameters - create()

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.


2️⃣ Add Data to the Index

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.

βœ… Format 1 – Single Object

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

βœ… Format 2 – List of Objects

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)
[]

βœ… Format 3 – Separate Arrays

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

βœ… Format 4 – Using NumPy Arrays

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

βœ… Format 5 – Separate Arrays with NumPy

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.


⚠️ Adding an ID that already exists

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


πŸ“˜ Parameters - add()

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 replaced
  • total_errors: number of failed records
  • errors: list of error messages
  • vector_shape: the shape of the processed batch, as (rows, dim)
  • is_success(): True when total_errors is zero
  • summary(): a one-line string of the two counts

3️⃣ Conduct a Similarity Search

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]

πŸ” Search Example 1 - Basic (Returning Top 2 most similar)

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

πŸ” Search Example 2 - Query with metadata filter

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.

πŸ” Search Example 3 - Search results include vectors

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]

πŸ” Search Example 4 - Batch Search with a list of vectors

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

πŸ” Search Example 5 - Batch Search with NumPy Array

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

πŸ” Search Example 6 - Batch Search with metadata filter

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: []

πŸ“˜ Parameters - search()

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 derived from the record count 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.


🧰 Additional functionality

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.

β˜‘οΈ Check the details of your HNSW index

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 trained quantized_only index it is zero.

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


β˜‘οΈ Add index level metadata

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.


β˜‘οΈ List records in the index

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.


β˜‘οΈ Inspect index statistics

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.

On a quantized index it is also where the memory figures live. raw_vectors_memory_mb and quantized_codes_memory_mb scale with the record count, while codebook_memory_mb and sdc_table_memory_mb are fixed by dim, subvectors and bits and do not move as records arrive. raw_vectors_retained states the mode's policy: none_once_trained for quantized_only, whose raw vectors are released when training completes, and all_records for quantized_with_raw.

It is also where the rerank calibration is reported. rerank_default_fetch is the number of candidates a search at top_k=10 will fetch and rescore at the record count the index holds now, and it is the figure to read if you want to know what a quantized search is paying for. rerank_calibrated is true on a trained quantized_with_raw index, and false on every other index, including one saved before the calibration existed. When it is true, rerank_calibration_fetch, rerank_calibration_fit_fetches, rerank_calibration_exponent, rerank_calibration_records, rerank_calibration_queries, rerank_calibration_target_recall and rerank_calibration_ms report what training measured, on how many records, and what it cost. rerank_calibration_fit_fetches is the fetch measured at each quarter of the training sample, comma separated, and it is what the exponent is fitted from.


β˜‘οΈ Remove Records

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

⚠️ Please Note: Due to the nature of HNSW, the underlying graph node remains in memory after a point is removed. Searches never return it, but it still occupies memory and edge slots. compact() reclaims those nodes.


♻️ Reclaim space left by removals and overwrites

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.


β˜‘οΈ Retrieve records by ID

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

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.


πŸ“˜ Quantization Configuration Parameters

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 derived from dim, see below
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.

subvectors defaults to dim / 32, clamped to between 8 and 192, snapped to a divisor of dim. That holds the compression ratio at 128x rather than holding the code length at 8 bytes, which is what a fixed default did. The ratio is the quantity accuracy follows, because dim / subvectors is the width of a subvector and the ratio is exactly four times that width. Measured on clustered data at 10,000 records, recall at 10 without reranking runs 0.187, 0.182 and 0.184 at 128x for dim 256, 768 and 1536, and 0.405 and 0.406 at 32x for 256 and 768. Two indexes at the same ratio return the same recall at different dimensions, and two at the same subvector count do not.

dim default subvectors compression
64 8 32x
128 8 64x
256 8 128x
768 24 128x
1536 48 128x
3072 96 128x

128x is where the default sits because it is the highest ratio that returns recall at 10 above 0.99 at the fetch the default uses, at every corpus size measured on clustered data. At dim=768 over 200 queries, recall at 10 at the default fetch reads 0.9800 at 384x, 0.9850 at 192x, 0.9925 at 128x and 0.9995 at 64x on 10,000 records, and 0.9935, 0.9980, 0.9980 and 1.0000 on 100,000. The binding size is the smaller one, because the fetch there is the rerank floor rather than 2% of the corpus, and 384x needs 208 candidates to reach 0.99. On data whose groups the codes cannot resolve, no ratio in this range returns 0.99 at any fetch. See Quantized search accuracy.

Going lower than 128x costs memory and build time and returns nothing on recall. At dim=768 and 100,000 records, 32x holds 111 MB more resident memory and builds in 521 s against 170 for the same recall, and 16x holds 160 MB more and builds in 846 s. At 10,000 records 16x holds 94 MB where not quantizing at all holds 90, so quantization has stopped saving anything by then.

Query time is the one axis where a lower ratio can win, and only at the bottom of the range and at scale. A lower ratio puts the true neighbours shallower in the code ordering, so the fetch that reaches them is smaller, and it lengthens the code, so each candidate costs more to score. Between 384x and 32x the two cancel. At 16x the fetch collapses and the first effect wins. See Quantized search accuracy for the table and for when that trade is worth taking.

The default is not free. At dim=768 and 100,000 records it raises resident memory from 463 MB to 585 MB against the old fixed 8, and build time from 79 s to 170 s. Below dim=256 the floor of 8 subvectors binds and the default is the old one, because a code is one byte per subvector and 2 subvectors would give the whole corpus only 65,536 distinct codes. Pass subvectors explicitly for the older, cheaper, less accurate setting.

bits does not change the size of a record's code, which is always one byte per subvector at every value, so lowering it saves no memory per record. It sets the number of centroids per subvector to 2^bits, which sizes the codebook linearly and the centroid distance table quadratically. Both are fixed costs that grow with subvectors, so how much a lower bits saves depends on how many subvectors you have. At dim=768 the two tables hold 3.7 MB at the default 24 subvectors and 12.7 MB at 96, and dropping bits to 6 takes those to 0.4 MB and 0.9 MB.

What it costs is recall. Measured at dim=768 and 10,000 records, dropping bits from 8 to 6 takes recall at 10 without reranking from 0.153 to 0.057 at 8 subvectors, and from 0.408 to 0.346 at 96, and it takes the build of 10,000 records at 96 subvectors from 40 s to 16 s. Leave bits at 8 unless the fixed cost or the build time is the constraint, and do not read a lower value as a per-record saving.

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. The ratio warning does not fire on a subvectors the library derived, only on one you passed.

It also warns when the configuration cannot repay its fixed memory at the expected_size you declared. The codebook and the centroid distance table are held whatever the record count. A record is held twice, once in a storage map and once inside the HNSW graph, and quantization replaces a copy of dim Γ— 4 bytes with a code of subvectors bytes. quantized_only replaces both copies and quantized_with_raw replaces the graph's, so quantization starts saving above

quantized_only       fixed bytes / (dim Γ— 4 βˆ’ subvectors)
quantized_with_raw   fixed bytes / (dim Γ— 4 βˆ’ 2 Γ— subvectors)

records. The warning names that figure. Raise expected_size if your estimate was low, or drop quantization_config.

Both figures are analytic and describe the steady state. The quantized_only figure is deliberately conservative, because it counts only one of the two copies that mode replaces, so it warns above the true crossover rather than below it. The quantized_with_raw figure has no second copy to leave out, and measured against resident memory it runs low, because training leaves an allocator high water mark the arithmetic does not model. At dim=768 with 8 subvectors of 8 bits it names 599 records where the measured crossover is near 1,700.

A separate warning fires when expected_size is below training_size, because an index that never reaches its training threshold never trains, so quantization never engages at the size you declared.


πŸ”§ Usage Example 1

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

πŸ”§ Usage Example 2 - with explicit storage mode

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
)

πŸ“¦ Storage modes

Mode What it stores Rerank available Memory
quantized_only Codes for every record; the raw vectors collected for training are released when training completes No Lowest of the three
quantized_with_raw Codes and raw vectors for every record Yes Between the two. Measured at 0.69x an unquantized index at 10,000 records of dim=768 and 0.59x at 100,000, at the default subvectors

Two consequences of quantized_only are worth knowing before you pick it.

The training records are held at full width only until training completes. Records collected before the training threshold is reached are stored raw so the quantizer has something to train on. The moment training completes they are encoded to codes and their raw copies are released, so a trained index in this mode holds no raw vector for any record.

The gap between the two modes is not the compression ratio. quantized_with_raw holds every raw vector on top of every code, so on the vectors and codes alone it holds close to the compression ratio times more than a trained quantized_only index. The whole resident index differs far less, because the graph, the codebook and the centroid distance table are identical in both modes and at small record counts they dominate. get_stats() reports the figures for your own index.

Both modes hold less than an unquantized index once they clear the fixed cost, quantized_with_raw included. The HNSW graph owns a second copy of every point, separate from the storage map, and that copy is dim Γ— 4 bytes in an unquantized index and subvectors bytes in a quantized one whichever storage mode is set. quantized_only drops both copies and quantized_with_raw drops the graph's, which at dim=768 is 3,072 bytes per record. Measured resident against the same data unquantized at dim=768, quantized_with_raw holds 0.69x at 10,000 records and 0.59x at 100,000 at the default subvectors, and 0.60x and 0.47x at 8 subvectors. quantized_only holds 0.35x and 0.29x at the default subvectors. get_stats() does not report the graph, so its totals understate what quantization saves.

How much quantization saves is set by the dimension, and below dim=256 it is not much. Quantization removes the graph's copy of the vector and puts a code in its place, so what it can save per record is dim Γ— 4 βˆ’ 2 Γ— subvectors bytes against the dim Γ— 8 an unquantized index holds for its two copies plus about 2,740 bytes of graph neighbour lists, id maps and metadata that neither mode touches. Measured resident, one dimension per process, 25,000 records at m=32, an unquantized index and a quantized_with_raw one built over the same records:

dim unquantized quantized_with_raw ratio saving
64 75.7 MiB 73.4 MiB 0.97x 3%
96 83.4 73.8 0.88x 12%
128 89.7 75.0 0.84x 16%
192 102.6 82.7 0.81x 19%
256 115.5 88.2 0.76x 24%
384 140.5 105.8 0.75x 25%
768 216.1 151.5 0.70x 30%
1,536 369.6 236.0 0.64x 36%

create() warns when the saving falls below a fifth of what an unquantized index holds, which the arithmetic above puts at dim=235 for quantized_with_raw and at dim=88 for quantized_only. Below that a quantized search still fetches and rescores hundreds of candidates on every query, for a saving of under 20 percent. The measured column crosses a fifth between dim=192 and dim=256.

Quantization can cost memory rather than save it. The centroid distance table is subvectors Γ— 2^bits Γ— (2^bits βˆ’ 1) / 2 Γ— 4 bytes, being the strict upper triangle of a symmetric matrix per subvector, and it is held whatever the record count. That is 1.0 MB at 8 subvectors of 8 bits and 12 MB at 96. Measured resident against the same data unquantized, quantized_only crosses from costing to saving at roughly 1,800 records at dim=256 and below 1,000 at dim=768, and quantized_with_raw at roughly 2,600 and 1,700 for the same two dimensions, all at 8 subvectors of 8 bits. The crossover no longer depends on training_size, because the training records are released once training completes. Below the crossover quantization costs memory. create() warns when the configuration cannot repay the fixed cost at your expected_size.

Once training completes every record exists only as a code, so the vector you read back is an approximation. Every accessor sees every record. get_records(..., return_vector=True) and search(..., return_vector=True) reconstruct the vector from its code, so what they hand back is close to the value supplied rather than equal to it, for the training records exactly as for the ones added later. Only quantized_with_raw reads back exactly. get_stats()["raw_vectors_stored"] reports zero once a quantized_only index has trained, which is how you can confirm the release happened.

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: 0
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 accuracy

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.

What the fetch has to reach is the group of records the codes cannot tell apart from your query, and how large that group is depends on your data. A 128x code resolves which cluster a record belongs to and very little inside it, so the fetch has to cover the query's own group. Measured on three real datasets at 100,000 records with the default subvectors, the fetch that reaches mean recall at 10 of 0.99:

dataset dim compression fetch for 0.99 share of corpus
dbpedia-openai (ada-002) 1,536 128x 494 0.49%
sift-128 128 64x 426 0.43%
glove-100 100 40x 5,143 5.14%

No formula in the record count fits those three. At the same corpus size one needs 426 candidates and another needs 5,143. That is why the fetch is not a formula.

ZeusDB measures it on your data instead. When a quantized_with_raw index finishes training it holds training_size raw vectors and a codebook fitted to them, so it measures the fetch directly. It takes 512 of the training records as queries, finds their exact nearest neighbours over the training sample, locates each of those neighbours in the code ordering, and takes the 0.99 percentile of the ranks. A query is removed from its own corpus and from its own ordering, so the measurement is leave one out. The training sample is held in a seeded random order rather than the order your records arrived in, so the queries and every subset of it are random draws. get_stats() reports the result under rerank_calibration_fetch and the fetch it produces at your current record count under rerank_default_fetch.

The depth grows with the record count, and how fast it grows is also a property of your data, so the calibration measures that too. It repeats the measurement over a quarter, a half and three quarters of the training sample and fits the exponent the fetch is scaled by as the least squares slope of the log fetch on the log record count. A corpus that keeps a fixed number of topics as it grows puts more records in each of them, and its depth grows linearly. A corpus that gains new topics as it grows puts its depth on a root of the record count. Measured over 10,000 to 100,000 records, the exponent reads 0.48 to 0.58 on sift-128, 0.27 to 0.32 on dbpedia-openai and 0.64 to 0.74 on glove-100, and 0.95 to 0.99 on generators holding a fixed number of clusters at every size.

The result is clamped between 0.40 and 1.00, multiplied by a safety factor of 1.75, floored at 250 candidates and at 5 Γ— top_k, and capped at a quarter of the record count.

What the calibration asks for at 100,000 records, against what the data needs and against the fixed 2%. Every figure is measured on a built index through the ordinary search path, 1,000 queries at top_k=10 against exact ground truth. The requirement is the smallest fetch on that same index reaching recall at 10 of 0.99, read off a sweep of the explicit rerank argument:

dataset calibrated fetch measured requirement the old fixed fetch recall, calibrated recall, fixed
dbpedia-openai 776 750 2,000 0.9905 0.9954
glove-100 7,749 4,500 2,000 0.9962 0.9723
sift-128 596 450 2,000 0.9941 1.0000

The calibration clears the requirement on all three. It does not land on it and it is not meant to, since the measurement is taken on a tenth of the records with queries drawn from the corpus, where your queries will not be.

On data with no resolvable structure no fetch works. Once the group the codes cannot separate is smaller than top_k, the true top ten span groups, the distances between groups differ in the fourth decimal, and nothing reaches them. At 5,000 clusters over 25,000 records, being five records to a cluster, recall at 10 reaches 0.917 at a fetch of half the corpus, and uniform points on the sphere reach 0.859. Measure recall on your own data before you rely on quantization.

  • rerank omitted uses the calibrated fetch above. It is the only setting that holds recall across corpus sizes and across datasets.
  • An index trained before this release, and any index loaded from a directory saved by one, carries no calibration. It falls back to the fixed fetch of 2% of the record count, floored at 250 candidates and at 5 Γ— top_k. get_stats() reports rerank_calibrated: false for it. Rebuild the index to calibrate it.
  • rerank=N for N of 1 or more fetches top_k Γ— N candidates, a fixed multiple of the page that does not move with the corpus. Use it to override the default deliberately, not as the normal path.
  • rerank=0 turns reranking off and returns the ADC scores and ordering.
  • rerank has no effect on an unquantized index or on a quantized_only one. 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.

Above roughly 10,000 records a reranked quantized search is slower than an unquantized one, and the gap widens as the index grows. That is the price of the default holding recall. On dbpedia-openai at dim=1,536, paired against an unquantized index over the same records, 200 queries one each in turn:

records calibrated fetch unquantized quantized, default rerank ratio
10,000 277 1.97 ms 1.89 ms 0.96
25,000 459 2.75 ms 4.29 ms 1.56
50,000 561 3.44 ms 5.30 ms 1.54
100,000 776 3.17 ms 7.65 ms 2.42

Each row is one process building both indexes over the same records, so the ratio is the figure to read. The absolute times move between rows because each row is its own process, and the 100,000 row is unpaired, being the loaded index above against the unquantized figure from the grid.

The crossover is structural rather than a tuning accident. The traversal has to be as wide as the fetch because HNSW cannot return more results than its candidate list holds, an HNSW search costs roughly linear time in that width, and the fetch grows with the record count while an unquantized search grows with its logarithm, so the two cross once. What the calibration changes is where and how steeply. The fixed 2% of the corpus reached a ratio of 6.91 at 100,000 records on this dataset, and a fetch measured on the data reaches 2.42.

What the calibration costs and saves, measured on real data. Both arms on the same loaded index at 100,000 records, in one process, 500 queries each. The before arm names rerank so it fetches exactly the 2,000 candidates the fixed 2% produced, and the after arm names nothing and takes the calibration:

dataset fixed fetch calibrated fetch fixed calibrated ratio fixed recall@10 calibrated recall@10
sift-128 2,000 596 11.99 ms 3.63 ms 0.30 1.0000 0.9938
dbpedia-openai 2,000 776 19.44 ms 8.62 ms 0.44 0.9958 0.9910
glove-100 2,000 7,744 26.22 ms 82.70 ms 3.15 0.9666 0.9960

Read that as three different answers, because the calibration gives each dataset the fetch its own data needs. sift-128 and dbpedia-openai need far less than the fixed fetch gave them and get two thirds and over half of their query time back, for 0.006 and 0.005 of recall. glove-100 needs far more, and buys 0.029 of recall for 3.15 times the query time. If query time matters more to you than the last hundredth of recall, rerank=N overrides the calibration and get_stats()["rerank_default_fetch"] tells you what you are overriding.

Where they cross depends on your data. The table above is 50 Gaussian clusters, where the crossover is between 10,000 and 15,000 records. On the anisotropic embedding-like corpus it is below 10,000, because an unquantized search converges faster on that data while the fetch does not shrink:

data model records unquantized quantized, default rerank ratio quantized recall
50 clusters 25,000 0.90 ms 1.99 ms 2.22 0.996
embedding-like 10,000 0.57 ms 1.03 ms 1.80 0.988
embedding-like 100,000 1.59 ms 9.48 ms 5.97 0.989

Memory goes the other way and is not data dependent. On the embedding-like corpus at 100,000 records the quantized index holds 552 MB against 877 MB unquantized, being 0.63 times.

No subvectors value moves that crossing, and one gets close. A lower compression ratio puts the true neighbours shallower in the code ordering, so the fetch that reaches them is smaller, but each candidate costs more to score because the code is longer. Measured at dim=768 and 100,000 records, the smallest fetch each ratio needs to reach recall at 10 of 0.99 and what that fetch costs:

compression subvectors fetch for 0.99 share of corpus query resident build
384x 8 1,995 1.99% 9.40 ms 463 MB 79 s
192x 16 1,945 1.94% 10.89 ms 576 MB 131 s
128x 24 1,921 1.92% 13.08 ms 585 MB 170 s
64x 48 1,522 1.52% 11.46 ms 617 MB 281 s
32x 96 960 0.96% 9.34 ms 696 MB 521 s
16x 192 222 0.22% 4.32 ms 745 MB 846 s

An unquantized index over the same records holds 994 MB, builds in 313 s and answers in 3.05 ms.

If query time above the crossover is what matters to you, subvectors = dim / 4 is the setting, and set rerank with it. At 16x the fetch collapses and the query falls to 4.32 ms. You pay for it twice: the index holds 745 MB against 585 at the default, and it builds in 846 s against 170 and against 313 for no quantization at all. At 10,000 records that same setting holds 94 MB where not quantizing holds 90, so it is a choice for large indexes only. The default fetch is 2% of the corpus whatever subvectors is, so pass rerank explicitly to take the benefit, for example rerank=30 at 100,000 records with top_k=10.

Quantization remains a memory decision. At 100,000 records of dim=768 the default holds 585 MB against 994 MB unquantized and answers in 13.4 ms against 3.05 ms. Lower rerank explicitly if query time matters more to you than recall, and measure what it costs you.

ef_search does nothing on a reranked quantized search. The graph traversal widens to the number of candidates asked for, so a fetch of 2,000 already searches far wider than any ef_search a caller is likely to set, and setting it smaller is discarded. HNSW cannot return more results than its candidate list holds, so a fetch of 2,000 genuinely requires a traversal 2,000 wide. At the defaults the fetch is at least 250 and ef_search is 100, so raising ef_search alone changes nothing. Change rerank instead. On an unquantized search, and on a quantized search with rerank=0, ef_search applies normally.

Setting ef_search above the fetch does not help either. Measured at dim=768 over 200 queries, quadrupling ef_search at a fixed fetch moves recall at 10 by at most 0.008 and by nothing at all in fourteen of twenty configurations, because the candidates a fetch returns are limited by the code ordering rather than by the traversal. It costs query time in every case.

πŸ“Š Performance Characteristics

  • Training: happens once, on the add() call that reaches training_size. That call takes noticeably longer than the others. On quantized_with_raw it also calibrates the rerank fetch, which is reported in get_stats()["rerank_calibration_ms"] and measured at 3.3 s inside a 103 s training call at dim=1536, and at 0.21 s and 0.28 s at dim=100 and dim=128. The calibration is linear in the dimension and it is paid once.
  • Memory: a record's code is subvectors bytes against dim Γ— 4 for a raw vector. The graph holds a second copy of every point and it shrinks by the same factor, which is why both storage modes hold less than an unquantized index above their break even. How much less is set by the dimension, and the table in Storage modes prices it from dim=64 to dim=1536.
  • Search speed: an unreranked quantized search is faster than a raw search. A reranked one is slower, and the table above prices it.
  • Build speed: a quantized build is faster than an unquantized one, because the graph compares codes rather than vectors. At 100,000 records of dim=768 it is 137 s against 231 s at the default subvectors, and it slows as subvectors rises.
  • Accuracy: see the tables above. Treat quantization as a memory decision that costs accuracy and query time, not as a free win.

πŸ’Ύ Persistence

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.


πŸ’Ύ Saving an Index - .save()

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

πŸ“‚ Loading an Index - .load()

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 reads the saved graph back rather than rebuilding it, so a reloaded index returns the same result pages as the index that was saved, with the same IDs and the same scores. Load time is proportional to the size of the directory rather than to the cost of building the index: 50,000 records at 1,536 dimensions load in 1.1 seconds against a 156 second build.

The graph is rebuilt by re-inserting every record only when the saved graph cannot be used, which covers a directory whose graph files were lost or damaged and one written by a release too old for this build to interpret. Set ZEUSDB_LOAD_REBUILD_GRAPH=1 to ask for that rebuild on a directory whose graph is perfectly readable, which is how an index built by an earlier release picks up graph improvements made since.


πŸ—œοΈ Persistence with Product Quantization

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

πŸ“ Index Directory Structure

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 both graph files under files_included. The load path restores the saved graph rather than rebuilding it, so both are required to reopen a directory holding records.


πŸ”„ Complete Save/Load Workflow

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

⚠️ Important Notes on Persistence

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


🏷️ Metadata Filtering

ZeusDB supports rich metadata with full type fidelity. Your metadata preserves the original Python data types, so integers stay integers and floats stay floats.

πŸ“˜ Supported Types

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.


πŸ“˜ Filter Operators Reference

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.


πŸ’‘ Practical Filter Examples

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]

βœ”οΈ Filtering happens after the search, so raise top_k

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

βœ”οΈ Common filters

# 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']

πŸ“ Logging

ZeusDB Vector Database includes structured logging that works automatically out of the box while providing customization for advanced users.

πŸš€ Basic Usage - it just works!

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.

βš™οΈ Intermediate Usage (Environment Variables)

Control logging behavior with environment variables:

Quick Development Debugging

export ZEUSDB_LOG_LEVEL=debug
python your_app.py

Production JSON Logging

export 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

Environment Variables Reference

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.

Smart Environment Detection

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, or pytest imported): 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.

πŸ”§ Advanced Usage (Programmatic Control)

For enterprise environments with existing logging infrastructure:

Option 1: Disable Auto-Configuration

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 setup

Option 2: Programmatic Initialization

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

Option 3: Custom Logger Integration

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 setup

πŸ“Š Log Output Examples

Human-Readable (Development)

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

Structured JSON (Production)

{"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)"}

πŸ” Monitoring and Observability

Key Fields to Monitor

  • operation: the operation name, for example index_creation_complete, add_vectors_complete, search_complete, pq_training_complete, save_complete, compact_complete
  • duration_ms: timing on index creation, additions, searches, saves and compaction
  • total_inserted, total_errors, success_rate: outcome of each add()
  • final_storage_mode: whether an index is serving raw or quantized results
  • results_count: results returned by a search

Production Alerting Examples

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

πŸ› οΈ Troubleshooting

Common Issues

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

Want to see Rust logs specifically?

# Enable trace level to see all Rust operations
ZEUSDB_LOG_LEVEL=trace python your_app.py

Performance Notes

  • File logging is non-blocking: records are handed to a background writer rather than written on the calling thread.
  • trace and debug are verbose enough to dominate runtime on a hot loop. Leave production at error.

🎯 Best Practices

Development

export ZEUSDB_LOG_LEVEL=debug
export ZEUSDB_LOG_FORMAT=human

Staging

export 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=daily

Production

export 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=daily

πŸ“„ License

This project is licensed under the Apache License 2.0.

About

Blazing-fast vector DB with real-time similarity search and metadata filtering

Resources

Code of conduct

Contributing

Stars

8 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages