# How Lazy Index Initialization Works in Turbovec (and When to Use It)

> Discover lazy index initialization in Turbovec. Defer vector dimension locking until first add_2d and build heavy caches only when needed for efficient vector index creation.

- Repository: [Ryan Codrai/turbovec](https://github.com/RyanCodrai/turbovec)
- Tags: internals
- Published: 2026-06-08

---

**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`](https://github.com/RyanCodrai/turbovec/blob/main/turbovec/src/lib.rs) around lines 87–103.

```rust
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`](https://github.com/RyanCodrai/turbovec/blob/main/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 `dim` is `None`, the method validates that the new dimension is a multiple of 8, then commits it to `self.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.

```rust
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`](https://github.com/RyanCodrai/turbovec/blob/main/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.

```rust
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::repack` to create the `BlockedCache`.

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`](https://github.com/RyanCodrai/turbovec/blob/main/turbovec/src/lib.rs).

```rust
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`](https://github.com/RyanCodrai/turbovec/blob/main/turbovec/src/lib.rs) around lines 129–135.

```rust
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 `search` and 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.py`](https://github.com/RyanCodrai/turbovec/blob/main/turbovec-python/python/turbovec/llama_index.py) can call `new_lazy(bit_width)` and pass the 2-D array later without exposing a separate eager constructor.
- **Unit testing** – Tests in [`turbovec/tests/lazy_init.rs`](https://github.com/RyanCodrai/turbovec/blob/main/turbovec/tests/lazy_init.rs) instantiate lazy indexes to verify no-panic behavior before any data is added.

The `IdMapIndex` wrapper in [`turbovec/src/id_map.rs`](https://github.com/RyanCodrai/turbovec/blob/main/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.

```rust
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 a `bit_width` and leaves `dim` uncommitted.
- The dimension is validated and locked on the first successful call to `add_2d`; mismatches afterward raise `AddError::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 `OnceLock` and can be warmed early with `prepare()`.
- Uncommitted state survives serialization because the persistence layer writes `dim = 0` and restores `dim_opt = None` on 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`](https://github.com/RyanCodrai/turbovec/blob/main/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.