Useful Utilities#
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 and page through stored records, inspect statistics, count and test membership, remove vectors one at a time or in bulk, change a record’s metadata, reclaim the space removals leave behind, empty the index, and rebuild the graph at a new configuration. These tools make it easy to monitor and evolve your index over time, whether you are experimenting locally or deploying in production.
Examples#
The examples below all run against this index:
from zeusdb import VectorDatabase
vdb = VectorDatabase()
index = vdb.create(index_type="hnsw", dim=8, expected_size=5)
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"}},
])
Example 1 - Check the details of your HNSW index
print(index.info())
Output
HNSWIndex(dim=8, space=cosine, m=16, ef_construction=200, expected_size=5, vectors=5, quantization=none)
The vectors= field is the live record count, in every storage mode. get_vector_count() and len(index) return 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.
index.dim, index.space, index.m, index.ef_construction, index.expected_size and index.indexed_fields are read-only properties. get_space() is the same value as index.space as a method and is kept for callers already using it. Other single-value accessors: index.contains(id), index.has_quantization(), index.can_use_quantization(), and VectorDatabase.available_index_types().
print(index.dim, index.space, index.m, index.ef_construction, index.expected_size, index.indexed_fields)
Output
8 cosine 16 200 5 []
Example 2 - Add index level metadata
Index level metadata is a flat string-to-string 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.
Example 3 - List and page through the records in the index
for record_id, metadata in 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 the order the records were added, and offset pages through them. number defaults to 10. It lists every record, in every storage mode, and the order survives save() and load().
print(index.list(number=2, offset=0))
print(index.list(number=2, offset=2))
print(index.list(number=2, offset=4))
print(index.list(number=2, offset=99))
Output
[('doc_001', {'author': 'Alice'}), ('doc_002', {'author': 'Bob'})]
[('doc_003', {'author': 'Alice'}), ('doc_004', {'author': 'Bob'})]
[('doc_005', {'author': 'Alice'})]
[]
An offset past the end returns an empty list rather than raising.
Deleting while you page shifts the pages under offset. Removing a record ahead of your cursor moves everything behind it up by one, so the next page skips one. Page with after instead, which names the last ID you saw.
paged = vdb.create("hnsw", dim=2)
paged.add({"ids": [f"p{n}" for n in range(5)], "embeddings": [[n, 0.0] for n in range(5)]})
first = paged.list(number=2)
print([record_id for record_id, _ in first])
paged.remove_point("p0")
print([record_id for record_id, _ in paged.list(number=2, after=first[-1][0])])
print([record_id for record_id, _ in paged.list(number=2, offset=2)])
Output
['p0', 'p1']
['p2', 'p3']
['p3', 'p4']
offset skipped p2 because a record ahead of it was removed. after did not, because it names a position rather than a count.
after and offset cannot both be given, and passing both raises ValueError. If the record after names has itself been removed there is no position to resume from, and the call raises KeyError rather than returning a page from somewhere else.
Example 4 - Remove Records
ZeusDB allows you to remove a vector and its associated metadata from the index using the .remove_point(id) method. This performs a logical deletion, meaning:
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. Removed and overwritten records no longer consume result slots, so top_k does not degrade with churn, and compact() reclaims the stranded nodes.
remove_points(ids), remove_where(filter) and delete() remove a batch and a filtered set; see Examples 10 and 11 below.
Example 5 - 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.
Example 6 - 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: 4
graph_nodes: 4
stranded_graph_nodes: 0
storage_mode_description: raw_only
get_stats() returns a string-to-string map. Every key it carries:
Key |
Holds |
|---|---|
|
The configuration the index was created with |
|
The live record count |
|
Nodes in the HNSW graph, and how many of them no record uses |
|
Records held at full width, and records held as codes |
|
What the index is storing and serving |
|
The locking the index uses |
|
The HNSW graph, being the neighbour lists and, on a quantized index, the codes it scores against. It holds no raw vector |
|
The raw vectors, which are held once |
|
The codes, which grow with the record count |
|
The trained tables, fixed by |
|
The hash tables that find a record, including the per-record metadata map |
|
The sum of the seven figures above |
|
|
On a quantized index it also carries quantization_active, quantization_trained, quantization_compression_ratio, quantization_training_size, training_progress, training_threshold_reached, training_vectors_needed, raw_vectors_retained and the rerank calibration keys described on the Product Quantization page.
total_memory_mb is what the index asked the allocator for, not what the process holds. Either can be the larger. An arena reserved and not yet written is asked for and not resident, and the allocator’s own bookkeeping is resident and not asked for. Measured on 50,000 real 1,536-dimensional embeddings, one index per interpreter, against the resident set delta across the build:
mode |
reported |
resident |
reported / resident |
|---|---|---|---|
no quantization |
347.90 MiB |
334.43 MiB |
1.04 |
|
348.01 |
361.70 |
0.96 |
|
55.04 |
69.12 |
0.80 |
Size infrastructure from the resident figure rather than from this one. The gap is widest under quantized_only, where the fixed tables and the hash tables that find a record are most of what is left.
Example 7 - Retrieve records by ID
Use get_records() to fetch one or more records by ID. It returns a list of dicts with id, metadata, and, unless return_vector=False, vector, which is a list of Python floats.
# 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 skipped by default, so a shorter list than you asked for is how a missing ID is reported, and the result does not say which one. strict=True raises KeyError instead, naming every ID the index does not hold.
try:
index.get_records(["doc_002", "missing_id"], strict=True)
except KeyError as error:
print(error)
Output
'get_records(strict=True) was asked for 1 id the index does not hold: missing_id. Call it without strict=True to receive the records that are present, or test an id with contains(id) first.'
Under cosine the returned vector is the normalized form, and on a trained quantized_only index it is reconstructed from the code rather than stored raw; see Product Quantization.
Example 8 - Count and test membership
len(index) is the live record count. id in index tests membership. count(filter) counts the records a metadata filter matches, using the same filter language as search(), and count() with no filter is len(index).
print(len(index))
print("doc_002" in index, "doc_001" in index)
print(index.count())
print(index.count({"author": "Alice"}))
print(index.count({"author": "Nobody"}))
Output
4
True False
4
2
0
count() is exact and therefore reads every record’s metadata, so it costs what a filtered search costs. contains(id) is the same test as in and is kept for callers already using it.
Example 9 - Change a record’s metadata
update_metadata(id, metadata) replaces one record’s metadata without resupplying its vector. The record keeps its vector, its quantized codes and its graph node, and no node is stranded.
print(index.get_records("doc_002", return_vector=False)[0]["metadata"])
print(index.update_metadata("doc_002", {"author": "Bob", "status": "reviewed"}))
print(sorted(index.get_records("doc_002", return_vector=False)[0]["metadata"].items()))
print(index.update_metadata("no_such_id", {"author": "Nobody"}))
print(index.get_stats()["stranded_graph_nodes"])
Output
{'author': 'Bob'}
True
[('author', 'Bob'), ('status', 'reviewed')]
False
0
The example sorts the second result because a record’s metadata comes back as a dict whose key order is not stable between processes. Read metadata by key rather than by position.
The replacement is wholesale, not a merge. Any key you leave out is gone, which is what add(overwrite=True) already does. It returns False for an ID the index does not hold, and writes nothing in that case. Use it rather than reading a record back with get_records() and adding it again, which strands a graph node per update.
Example 10 - Remove several records at once
remove_points(ids) takes the lock once for the whole batch instead of once per ID. It returns the IDs that were not in the index, so an empty list means every one was removed. A repeated ID is removed on its first occurrence and is never reported missing.
print(index.remove_points(["doc_004", "no_such_id"]))
print(len(index))
Output
['no_such_id']
3
remove_where(filter) removes every record a metadata filter matches, using the same filter language as search(), and returns how many it removed.
print(index.remove_where({"author": "Alice"}))
print(len(index), index.count({"author": "Alice"}))
print(index.remove_where({"author": "Nobody"}))
Output
2
1 0
0
An unrecognised operator raises ValueError before any record is removed. A filter matching nothing removes nothing and returns 0.
remove_where({}) is refused. An empty filter matches every record everywhere else in this language, and here that would destroy the index. Name the records with remove_points(ids) if that is what you want, or use clear().
Both leave one stranded graph node per record removed, exactly as remove_point() does, and neither calls compact().
Example 11 - delete(), the shorter name for both
delete(ids=...) dispatches to remove_points and delete(where=...) to remove_where. Both of those stay.
deletable = vdb.create("hnsw", dim=2, expected_size=10)
deletable.add({
"ids": ["doc_1", "doc_2", "doc_3", "doc_4"],
"embeddings": [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]],
"metadatas": [{"author": "Alice"}, {"author": "Bob"},
{"author": "Bob"}, {"author": "Alice"}],
})
print(deletable.delete(ids="doc_1"))
print(deletable.delete(ids=["doc_2", "no_such_id"]))
print(deletable.delete(where={"author": "Bob"}))
print(len(deletable))
Output
1
1
1
1
It returns the number of records removed, an int, whichever argument was given. ids takes a string or a list of strings. A repeated ID counts once. An ID that was not there counts zero rather than raising.
remove_points still returns the IDs it could not find, which is more than a count, so keep calling it where you need that.
Passing both arguments raises ValueError, and so does passing neither. Use clear() when emptying the index is what you mean.
Example 12 - Empty the index with clear()
clear() drops every record and returns how many went. It replaces the graph rather than removing records one at a time, so stranded_graph_nodes reads 0 afterwards.
clearable = vdb.create("hnsw", dim=4, expected_size=10)
clearable.add({
"ids": ["a", "b", "c", "d", "e"],
"embeddings": [[1.0, 0, 0, 0], [0, 1.0, 0, 0], [0, 0, 1.0, 0],
[0, 0, 0, 1.0], [1.0, 1.0, 0, 0]],
})
clearable.remove_point("a")
print(clearable.clear())
print(len(clearable), clearable.get_stats()["stranded_graph_nodes"])
print(clearable.clear())
Output
4
0 0
0
It keeps the index and drops the records. dim, space, m, ef_construction, expected_size, indexed_fields, the index level metadata and the quantization configuration all survive, and a fitted PQ codebook survives with them, so a trained quantized index can be refilled and searched without retraining. An index still collecting for training starts collecting again.
Clearing an empty index returns 0 and is not an error. The generated ID counter is not reset, so ids generated after a clear continue the sequence rather than starting again from vec_1.
Example 13 - Change m after the fact with rebuild()
m is chosen from expected_size when the index is created, so an index declared for far fewer records than it received runs at a degree meant for the smaller one. rebuild(m=..., expected_size=..., ef_construction=...) builds the graph again at a new configuration, in place, and every record keeps its vector, its metadata and its id. A quantized index is rebuilt from its stored codes rather than re-encoded. Pass any of the three.
sized_wrong = vdb.create("hnsw", dim=8, expected_size=100, m=4)
sized_wrong.add({
"ids": [f"v{i}" for i in range(400)],
"embeddings": [[float(i % 7) + j * 0.1 for j in range(8)] for i in range(400)],
})
print(sized_wrong.m, sized_wrong.expected_size, len(sized_wrong))
print(sized_wrong.rebuild(m=16, expected_size=400))
print(sized_wrong.m, sized_wrong.expected_size, len(sized_wrong))
Output
4 100 400
400
16 400 400
It returns the node count of the graph it built, which is the live record count. Passing none of the three raises ValueError, because rebuilding the graph as it stands is compact(). The three arguments are held to the rules create() applies, so an invalid value raises the message create() raises for it.
Raise m where an index outgrew its declaration, and schedule it. It costs a full rebuild, 27.0 seconds at 100,000 real 128 dimensional vectors, and it holds both graphs in memory while it runs. Nothing outside the graph is touched, so every filter returns what it returned and a save afterwards carries the new m.
Example 14 - Return the graph’s spare capacity
An index built by inserting grows its graph buffers geometrically, so the last growth leaves the largest of them holding close to twice what they use. shrink_to_fit() returns that slack to the allocator and reports the bytes it released. compact() calls it.
fresh = vdb.create("hnsw", dim=8, expected_size=300)
fresh.add({
"ids": [f"v{i}" for i in range(500)],
"embeddings": [[float(i % 7) + j * 0.1 for j in range(8)] for i in range(500)],
})
before = float(fresh.get_stats()["graph_memory_mb"])
freed = fresh.shrink_to_fit()
after = float(fresh.get_stats()["graph_memory_mb"])
print(freed > 0, after < before)
print(fresh.shrink_to_fit())
Output
True True
0
The index above declared 300 records and was given 500, so its graph grew and left slack behind. A second call finds nothing to release.
No node, edge or distance is touched, so every search returns the same page with the same scores. Call it on an index that holds its records, not on one about to receive them. On an empty index it hands back the whole creation reservation that expected_size bought, so every later insertion regrows the arenas from nothing. The index stays writable, and the buffers grow again on the next add(), which costs one reallocation. That is why it is never automatic.
Example 15 - Quantization status and performance reporting
Five further accessors report state that get_stats() also carries.
Method |
Returns |
|---|---|
|
Whether the training threshold has been reached. |
|
Records still to collect before training triggers. |
|
Rebuilds the graph against the quantized codes and returns whether it did. Training and |
|
A string-to-string map describing the search and insertion paths |
|
Times sequential against threaded searches over random queries, returning |
ready = vdb.create("hnsw", dim=8, expected_size=1200, quantization_config={
"type": "pq", "subvectors": 8, "bits": 8, "training_size": 1000,
})
print(ready.is_training_ready(), ready.training_vectors_needed())
print(sorted(ready.get_performance_info()))
Output
False 1000
['benefits', 'insertion_path', 'quantization_accuracy_impact', 'quantization_compression', 'search_bottleneck', 'search_speedup_expected']
Concurrency#
Concurrent search from multiple threads is supported and scales, measured at roughly seven times the single-thread throughput at sixteen threads. add() and search() can also run concurrently from different threads. benchmark_concurrent_reads() measures concurrent search throughput on your own index and data.