How Lazy Index Initialization Works in Turbovec (and When to Use It)
Lazy index initialization lets you create a TurboQuantIndex before runtime knows the vector dimension, deferring the dimensional lock until the first add_2d call and building heavy caches only when the first search executes.
Lazy index initialization in the RyanCodrai/turbovec crate solves a common vector-search pipeline problem: the application must instantiate an index before it discovers the embedding size. Instead of requiring dim at construction, TurboQuantIndex::new_lazy creates an uncommitted index that validates and locks the dimension on first ingest, safely handles pre-data queries, and materializes its SIMD structures on demand.
How Lazy Index Initialization Works
Creating an Uncommitted Index with new_lazy
The entry point is TurboQuantIndex::new_lazy. This constructor accepts only a bit_width and performs two critical setup steps.
First, it validates that bit_width falls in the supported 2–4 range. Second, it creates an index whose dim field is None and whose internal storage vectors begin empty. All heavyweight caches—including rotation, boundaries, centroids, and blocked—are instantiated as std::sync::OnceLock::new() rather than being populated immediately.
This design keeps start-up memory and CPU usage minimal. You can see this construction logic in turbovec/src/lib.rs around lines 87–103.
pub fn new_lazy(bit_width: usize) -> Result<Self, ConstructError> { … }
Locking the Dimension on the First add_2d Call
When the first 2-D batch arrives, the index commits its dimension inside the add_2d method in turbovec/src/lib.rs (lines 124–152). The code matches on self.dim:
- If a dimension already exists and matches the input, processing continues.
- If a dimension exists and differs, the method returns
AddError::DimMismatch. - If
dimisNone, the method validates that the new dimension is a multiple of 8, then commits it toself.dim.
The commit happens only after all input checks succeed, so an invalid first batch cannot accidentally lock the index into a bad state. Once set, the dimension is immutable for the lifetime of the index.
pub fn add_2d(&mut self, vectors: &[f32], dim: usize) -> Result<(), AddError> {
match self.dim {
Some(existing) if existing != dim => Err(AddError::DimMismatch { … }),
Some(_) => {}
None => {
if dim % 8 != 0 { return Err(AddError::DimNotMultipleOf8(dim)); }
}
}
if self.dim.is_none() {
self.dim = Some(dim);
}
self.add(vectors);
Ok(())
}
Querying an Empty Index Without Panics
A major safety feature of Turbovec’s lazy index initialization is that search can be called before any vector has been added. In turbovec/src/lib.rs (lines 99–107), the search implementation short-circuits on an uncommitted index by pattern matching on self.dim.
If dim is still None, the method returns an empty SearchResults set with zero scores and zero indices rather than panicking. This makes the uncommitted state safe for downstream services that may issue health-check or warm-up queries before data is loaded. After the first successful add_2d, search proceeds normally.
let Some(dim) = self.dim else {
return SearchResults { scores: Vec::new(), indices: Vec::new(), nq: 0, k: 0 };
};
On-Demand Cache Materialization
All expensive SIMD structures are stored behind OnceLock pointers. The first call to search—or an explicit call to prepare()—triggers their one-time creation from &self, which allows concurrent access without external locking.
The lazy materialization builds three primary caches:
- Rotation matrix – generated by
rotation::make_rotation_matrix(dim). - Centroids – computed from the current quantized data.
- Blocked layout – produced by
pack::repackto create theBlockedCache.
Because these structures are built on demand, memory usage stays low until a query actually arrives. If you prefer to pay the setup cost ahead of time, prepare() forces initialization early. This cache logic appears in the search path of turbovec/src/lib.rs.
let rotation = self.rotation.get_or_init(|| rotation::make_rotation_matrix(dim));
let centroids = self.centroids.get_or_init(|| { … });
let blocked = self.blocked.get_or_init(|| {
let (data, n_blocks) = pack::repack(&self.packed_codes, self.n_vectors, self.bit_width, dim);
BlockedCache { data, n_blocks }
});
Persistence and Round-Trip Safety
Lazy state is fully round-trippable. When write serializes an index whose dimension is still uncommitted, the header stores dim = 0. During load, that zero is translated back to dim_opt = None, restoring the exact lazy state with all caches empty.
This means you can persist an index before ingesting a single vector and later resume in the same uncommitted condition. The persistence logic is handled in turbovec/src/lib.rs around lines 129–135.
let dim_opt = if dim == 0 { None } else { Some(dim) };
When to Use Lazy Index Initialization
Lazy index initialization is the right choice in several real-world scenarios:
- Dynamic data pipelines – You discover the vector dimension only after reading the first batch, such as when loading a CSV or NumPy array whose shape is unknown at compile time.
- Zero-vector start-up – Services that boot before any embeddings are available can safely call
searchand receive empty results instead of crashing. - Memory-constrained warm-up – You can defer expensive rotation-matrix and centroid generation until the first query arrives, keeping start-up resource usage minimal.
- Language bindings – Python wrappers like the one in
turbovec-python/python/turbovec/llama_index.pycan callnew_lazy(bit_width)and pass the 2-D array later without exposing a separate eager constructor. - Unit testing – Tests in
turbovec/tests/lazy_init.rsinstantiate lazy indexes to verify no-panic behavior before any data is added.
The IdMapIndex wrapper in turbovec/src/id_map.rs mirrors this behavior with its own new_lazy constructor, so the same guidance applies when you need ID mapping.
Rust Code Example
The following workflow demonstrates a full lazy initialization cycle: create, search empty, add vectors, prepare caches, and query.
use turbovec::TurboQuantIndex;
// Create a lazy index (no dim known yet)
let mut idx = TurboQuantIndex::new_lazy(4).expect("valid bit width");
// Search before any vectors – gets an empty result
let empty = idx.search(&[], 10);
assert!(empty.scores.is_empty() && empty.indices.is_empty());
// First add – dimension is locked here (must be a multiple of 8)
let dim = 1536; // e.g. embeddings from a model
let vectors = vec![0.0_f32; dim * 5]; // 5 vectors
idx.add_2d(&vectors, dim).expect("first add");
// Subsequent add – dim is already committed
let more = vec![0.1_f32; dim * 3];
idx.add_2d(&more, dim).expect("second add");
// Prepare caches up-front (optional)
idx.prepare(); // now `search` will not pay the one-time cost
// Perform a search
let queries = vec![0.0_f32; dim * 2];
let results = idx.search(&queries, 3);
println!("Top-3 scores: {:?}", results.scores);
Summary
- Lazy index initialization in Turbovec starts with
TurboQuantIndex::new_lazy, which needs only abit_widthand leavesdimuncommitted. - The dimension is validated and locked on the first successful call to
add_2d; mismatches afterward raiseAddError::DimMismatch. - Searching a lazy, empty index returns an empty result set instead of panicking, making uninitialized indexes safe to query.
- Heavy caches—rotation matrix, centroids, and blocked layout—are materialized on demand via
OnceLockand can be warmed early withprepare(). - Uncommitted state survives serialization because the persistence layer writes
dim = 0and restoresdim_opt = Noneon load.
Frequently Asked Questions
What happens if I search a lazy index before adding any vectors?
Turbovec returns an empty SearchResults structure with zero scores and zero indices. The search method in turbovec/src/lib.rs checks self.dim and short-circuits when the dimension is still uncommitted, so your application will not panic.
Can I change the vector dimension after the first add_2d call?
No. Once the first batch commits the dimension via add_2d, the value is immutable. Any subsequent call with a different dim returns AddError::DimMismatch. If you need to support multiple dimensions, you must create separate index instances.
Does lazy initialization affect index saving and loading?
It does not limit persistence. When an uncommitted index is saved, write encodes dim = 0 in the header. Loading restores dim_opt = None and leaves all caches empty, so the index resumes in the exact same lazy state.
Is there a performance penalty for using new_lazy instead of new?
There is no runtime penalty for search or add operations after the dimension is committed. The only difference is deferred work: caches are built on first use rather than at construction. If you want to front-load that cost, call prepare() before serving queries.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →