Nitrite Vector
Approximate-nearest-neighbour (ANN) vector index and RAG store for the Nitrite embedded database.
It plugs into Nitrite as a standard index extension (like nitrite-spatial /
nitrite-tantivy-fts): load a module, create a vector index on a field, and
query it through the normal collection API — or use the higher-level RagStore.
- Two backends, chosen per database or per index:
- HNSW (default) — in-memory Malkov–Yashunin graph, persisted to Nitrite's
own KV store (
NitriteMap): every mutation is written through as one atomic batch, loading tolerates torn state, and a damaged index is rebuilt automatically from the collection. Fastest when the index fits in RAM. - DiskANN — disk-resident Vamana graph + full vectors in a
memory-mapped flat file, with product-quantized (PQ) codes resident in
RAM for traversal and exact re-ranking from the on-disk vectors.
Resident memory is bounded by the OS page cache (hot pages stay, cold pages
are reclaimed under pressure — the index cannot OOM from its vector data), so
it serves indexes larger than RAM, e.g. on mobile. Requires a
persistent
db_path(refuses in-memory databases).
- HNSW (default) — in-memory Malkov–Yashunin graph, persisted to Nitrite's
own KV store (
- Metrics: cosine, Euclidean (L2), and dot product.
- Configurable precision: store vectors as
F32,F16, orI8(scalar quantized) to trade size for exactness. - Deletes + background consolidation (DiskANN): deletes are correct immediately; a FreshDiskANN-style pass later repairs the graph and reclaims space, off the caller's thread.
- Tuned for throughput: portable SIMD, fast integer hashing, allocation-free hot path, and lock-free concurrent queries (see Performance).
- Bring-your-own embeddings — this crate stores and searches vectors; it does not generate them (pass in vectors, as with sqlite-vec / usearch / lance).
Install
[]
= "0.4"
= "0.4"
# a storage backend for the DiskANN path / durability:
= "0.4"
Quick start — collection API
use Nitrite;
use PersistentCollection;
use doc;
use ;
// Load the vector module (in-memory HNSW here) at build time.
let db = builder
.load_module
.open_or_create?;
let docs = db.collection?;
docs.create_index?;
// Each document's `embedding` field is a numeric array.
docs.insert?;
docs.insert?;
// kNN query via the fluent filter — returns a normal document cursor.
let filter = vector_field
.nearest // query vector, k
.ef // optional: search width (recall/latency)
.min_score // optional: similarity cutoff
.build;
let cursor = docs.find?;
RAG store
RagStore is a thin layer over a collection: it stores text + embedding +
arbitrary metadata, does kNN, and combines the result with normal Nitrite
metadata filters, returning documents with scores.
use ;
use doc;
use field;
// db must be built with a VectorModule (any backend) using the same metric.
let store = create?;
let id = store.add?;
let hits = store
.search // top-5
.filter // combine with metadata (post-filter)
.min_score // drop dissimilar hits
.ef // search width
.run?; // Vec<SearchHit { id, text, score, document }>
store.delete?;
RagStore also exposes add_many, get, len, is_empty, and collection()
for advanced use.
Choosing & configuring a backend
Everything is on VectorModule::builder(dim, metric):
use ;
// Disk-resident DiskANN, sized to the device:
let module = builder
.backend
.precision // stored-vector precision
.degree // Vamana out-degree R
.build_beam // construction search width L
.search_beam // default query search width L
.alpha // RobustPrune diversity slack
.pq_subvectors // PQ bytes/code (0 disables PQ)
.pq_train_threshold // train PQ once this many vectors exist
.consolidate_threshold // background delete-consolidation trigger
.cache_bytes // advisory (OS page cache bounds RAM)
.build;
// In-memory HNSW (default backend):
let module = builder
.m // graph connectivity
.ef_construction
.ef_search // default query search width
.build;
// Different dimensions / metrics / backends per index in one database:
let module = builder // default for all indexes
.index_config // override for images.clip
.build;
Configuration reference
| Knob | Backend | Meaning | Default |
|---|---|---|---|
backend |
both | Hnsw or DiskAnn |
Hnsw |
precision |
both | F32 / F16 / I8 stored-vector encoding |
F32 |
m |
HNSW | graph connectivity M |
16 |
ef_construction |
HNSW | build search width | 200 |
ef_search |
HNSW | default query search width | 64 |
degree |
DiskANN | Vamana out-degree R |
64 |
build_beam |
DiskANN | construction search width L |
100 |
search_beam |
DiskANN | default query search width L |
100 |
alpha |
DiskANN | RobustPrune slack (≥ 1.0) | 1.2 |
pq_subvectors |
DiskANN | PQ bytes per code; 0 = exact traversal |
16 |
pq_train_threshold |
DiskANN | train PQ once N vectors are indexed (runs in the background; queries fall back to exact distances for not-yet-encoded nodes) | 10 000 |
consolidate_threshold |
DiskANN | run background consolidation past N deletes; 0 = manual |
1000 |
cache_bytes |
DiskANN | advisory RAM budget (see below) | 64 MiB |
Per-query, .ef(n) on the fluent filter overrides ef_search (HNSW) or
search_beam (DiskANN).
Resolved parameters are persisted in each index's header, so a reopened index keeps the settings it was built with.
Precision
Precision selects the on-disk / stored-vector codec, trading size for exactness:
| Precision | Bytes/dim | Notes |
|---|---|---|
F32 |
4 | exact (default) |
F16 |
2 | IEEE half; ~exact for normalized embeddings |
I8 |
1 | per-vector scalar quantization; ~4× smaller, approximate |
For DiskANN, PQ codes (used only to guide traversal) are separate; final ranking is always an exact re-rank against the stored vectors at the chosen precision.
Deletes & consolidation
- HNSW: a delete unlinks the node exactly and reconnects the orphaned neighborhood through the deleted node's other neighbors (diversity-pruned), so sustained insert/delete churn does not fragment the graph.
- DiskANN: a delete is correct immediately: the freed slot is held aside (never reused until cleaned), and stale in-edges resolve to a dead sentinel that queries skip.
- Once
consolidate_thresholddeletes accumulate, a background thread (single-flight, gated per chunk so writers and queries interleave) runs a FreshDiskANN-style pass: it drops references to deleted nodes, reconnects through their surviving neighbors, re-prunes todegree, and reclaims the slots. Writers and the consolidation pass serialize on a per-index write gate, so concurrent mutations cannot lose updates. - On close (
flush), a final synchronous consolidation runs so the persisted state is clean. You can also callDiskAnnIndex::consolidate()manually.
Persistence & durability
| Backend | Storage | Durability |
|---|---|---|
| HNSW | Nitrite NitriteMap (fjall) |
per-write: each mutation persists as one atomic batch |
| DiskANN | memory-mapped flat file + checksummed sidecar next to the DB | checkpointed: on close, and automatically every ~8k mutations |
Both backends treat the index as derived data and fail safe, never silently wrong:
- HNSW batches every touched record + header into a single atomic
put_all; loading sanitizes the graph (dangling links pruned, bad records dropped), and an unreadable header wipes the index and rebuilds it automatically from the collection on open. - DiskANN marks its data file dirty on the first mutation after a checkpoint and clears the flag only after the checksummed sidecar has been atomically replaced (tmp + rename) under a matching generation. On open, a dirty flag, checksum mismatch, or generation skew is detected, the files are wiped, and the index is rebuilt automatically from the collection. A crash therefore costs a bounded re-index, silent corruption never.
Note that Nitrite writes documents and index entries as separate store
operations; a crash exactly between them can leave one document unindexed.
collection.rebuild_index(...) heals this; the automatic rebuild above covers
all index-side damage.
cache_bytes is advisory for DiskANN: because the store is memory-mapped,
the OS page cache bounds resident memory and reclaims cold pages under pressure
(the index can't OOM from its vector data). The knob is reserved for future
madvise hinting.
Security & privacy
- Vectors are stored in plaintext by both backends. Embeddings are generally invertible back to their source content — treat them with the same sensitivity as the documents themselves.
- DiskANN files live next to the database (named after a sanitized + hashed form of the collection/field, so hostile names cannot escape the directory) and are not covered by any encryption or at-rest features a storage adapter might provide.
- The DiskANN backend refuses in-memory databases rather than writing embeddings into a shared temp directory.
- The sidecar is checksummed and structurally validated on load; corrupt or tampered files are wiped and rebuilt, not trusted.
Performance
Tuning that closes most of the gap to hand-optimized ANN libraries:
- Portable SIMD distance kernels via
wide(f32x8) — pure Rust, no C dependency, cross-compiles cleanly to ARM NEON for mobile (unlike simsimd). rustc-hash(FxHash) on the query hot path — SipHash is ~5–10× slower for the internal integer lookups done thousands of times per query.- Allocation-free vector decode into reused buffers on the hot loop.
- Lock-free per-query read view (DiskANN): one read lock per query instead of per node, so concurrent queries scale instead of contending on the lock's cache line.
Indicative release-build numbers on a 10-core laptop, 384-dim, 2k index:
| Benchmark | Latency |
|---|---|
| HNSW query (k=10) | ~0.10 ms |
| DiskANN query (k=10, PQ + re-rank) | ~0.19 ms |
| distance kernel (128-dim, SIMD) | ~12 ns |
| DiskANN 128 queries, 8 threads | ~4.6× faster than single-threaded |
Numbers vary with dataset, dimension, recall target, and hardware; treat them as same-class-as-embedded-ANN-libraries, not a head-to-head claim. Run them yourself:
Benchmarks (criterion) cover the distance kernels, build/query throughput for
both backends, and multi-threaded query scaling
(benches/vector_bench.rs).
Testing
Coverage includes distance/precision/PQ math, HNSW recall vs brute force, DiskANN recall + disk-residency + persistence + delete + consolidation, and parity of both backends through the collection / RAG APIs.
Known limitations
- DiskANN durability is checkpoint-based, not per-write (see above); a crash costs an automatic rebuild of that index on next open.
- PQ codebooks are trained once (in the background, off the insert path) and
not retrained; under heavy distribution drift, traversal quality can degrade
(final ranking stays exact via re-rank). PQ's ADC guide assumes squared-L2
ordering, which is monotone for Cosine/Euclidean; for
Metric::Dotit is a heuristic guide only. - Querying a vector filter without a vector index is an error (a kNN filter cannot be evaluated as a per-document predicate).
- With
min_score, the index over-fetches 4×kbefore applying the cutoff; extremely selective cutoffs can still return fewer thankhits. - SIMD is portable (f32x8), not AVX-512-tuned; on AVX-512 servers a hand-tuned kernel would still be faster.
- DiskANN traversal maps external ids ↔ dense slots and allocates a small neighbor list per node; a fully dense-slot, zero-copy traversal is the next step for low-dimensional / very-high-QPS workloads.
License
Apache-2.0