Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API Reference

Core Facade

VectorIndex

The primary entry point for Chassis. VectorIndex orchestrates the storage layer, graph topology, and search index into a single, crash-consistent unit.

Opening an Index

#![allow(unused)]
fn main() {
use chassis_core::{VectorIndex, IndexOptions};

// Open or create a persistent index
let mut index = VectorIndex::open(
    "embeddings.chassis", 
    768, 
    IndexOptions::default()
)?;
}

Parameters:

  • path: Path to the backing file (created if missing).
  • dims: Vector dimensionality (must be static for the file’s lifetime).
  • options: Configuration for graph construction and search.

Returns:

  • Ok(VectorIndex): Handle to the index.
  • Err: If file is locked, corrupted, or dimensions mismatch.

Adding Vectors

#![allow(unused)]
fn main() {
let vector = vec![0.5; 768];
let id = index.add(&vector)?;
}

Behavior:

  • Crash behavior: If the process dies before the next flush(), this vector is gone on reopen. The index stays usable.
  • Ids: add returns one past the largest id used so far (0, 1, 2… unless you chose ids yourself). It never reuses an id, even after a delete.
  • Durability: Data is written to memory-mapped pages immediately but requires flush() for persistence guarantees.

Errors:

  • Dimension mismatch.
  • Storage write failure (e.g., disk full).

Your Own Ids

#![allow(unused)]
fn main() {
index.add_with_id(42, &vector)?;
}

Search results report 42. Fails if a live vector already has that id; delete it first to replace it. u64::MAX is reserved. Indexes that use their own ids build an id table in memory on the first lookup in a process, which scans the index once (ADR-0007).

Adding Many Vectors

#![allow(unused)]
fn main() {
let vectors: Vec<f32> = rows.concat(); // count × dims floats, back to back
let ids = index.add_batch(&vectors)?;  // or index.add_batch_with_ids(&my_ids, &vectors)?
}

Links the batch on every core, so a large batch builds many times faster than add in a loop (ADR-0010). Ids are assigned as add assigns them. A batch is added whole or not at all. The graph depends on thread timing, so two builds of the same data differ slightly, as sequential builds already do through their random layers.

Deleting

#![allow(unused)]
fn main() {
let deleted = index.delete(42)?; // false if no live vector has id 42
}

Search stops returning the vector immediately, and len() drops by one. The delete is durable after the next flush(); a crash before then rolls it back. A delete and an add in the same flush are all-or-nothing, so delete(id) followed by add_with_id(id, new_vector) replaces a vector safely. Deleted vectors keep their disk space until compact().

Compacting

#![allow(unused)]
fn main() {
index.compact()?;
}

Rewrites the index without its deleted vectors and with a newly built graph, then replaces the file with the copy (ADR-0011). Ids don’t change, and add still never reuses one. Like flush(), it makes every add and delete so far durable. It takes as long as building the index, on every core, and needs free disk for a second copy of the live vectors. Readers in other processes keep searching and move to the new file by themselves. On Windows it fails, leaving the index as it was, while another process has the index open.

Searching

#![allow(unused)]
fn main() {
let query = vec![0.5; 768];
let k = 10; // Neighbors to retrieve

let results = index.search(&query, k)?;

for match in results {
    println!("ID: {}, Distance: {}", match.id, match.distance);
}
}

Returns: Vec<SearchResult>, sorted by distance (nearest first), identified by the ids from add/add_with_id. Deleted vectors are never returned.

#![allow(unused)]
fn main() {
// The ids your application allows, e.g. from `SELECT id FROM docs WHERE owner = ?`
let allowed: HashSet<u64> = owner_doc_ids();
let results = index.search_filtered(&query, 10, |id| allowed.contains(&id))?;
}

Only vectors whose id the filter accepts are returned. The filter is called with ids from add/add_with_id, many times per search, so it should be cheap. When the filter passes many vectors, the search walks the graph as usual; when it passes few, or its matches lie far from the query, walking the graph would visit most of the index to find them, so the search checks every vector instead and returns the exact nearest. Measured, a filtered search takes at most about twice an exact scan of the matching vectors (ADR-0009). IndexReader has the same method.

Persistence

#![allow(unused)]
fn main() {
// Flush all pending writes to physical disk (fsync)
index.flush()?;
}

Recommendation: flush() is an expensive syscall. Call it after a batch of insertions (e.g., every 1,000 vectors) or before shutting down.

Metadata

#![allow(unused)]
fn main() {
let len = index.len();           // Live vectors (deleted ones excluded)
let dim = index.dimensions();    // Vector size
let metric = index.metric();     // DistanceMetric::Euclidean or ::Cosine
let precision = index.precision(); // Precision::Full or ::Half
let empty = index.is_empty();    // True if count == 0
}

IndexReader

Searches an index while a VectorIndex in another process writes it. It takes no lock, so any number of processes can open readers on one file next to its single writer (ADR-0008, decision 5).

#![allow(unused)]
fn main() {
use chassis_core::{IndexOptions, IndexReader};

let mut reader = IndexReader::open("embeddings.chassis", 768, IndexOptions::default())?;
let results = reader.search(&query, 10)?;
}

Every search first takes a new snapshot: it returns what the writer’s newest flush() committed, and nothing that flush deleted, while routing through vectors added since, so a long unflushed batch costs readers little recall. A flush becomes visible just before its final fsync, so one that then fails, or is lost to power loss, may already have been seen. len() reports the last snapshot; refresh() takes a new one without searching, and snapshot() identifies it, for keying a cache. search takes &mut self: open one reader per thread. The file must exist in the current format; open it once with VectorIndex::open to create or migrate it.

Errors

VectorIndex and IndexReader return chassis_core::Result<T>, whose error says what was wrong, with the value, and on a line starting help: what to do instead. Error::kind() is an ErrorKind to act on (Errors lists them, with causes and fixes):

#![allow(unused)]
fn main() {
use chassis_core::{ErrorKind, IndexOptions, IndexReader, VectorIndex};

match VectorIndex::open("embeddings.chassis", 768, IndexOptions::default()) {
    Ok(index) => { /* write */ }
    // One writer at a time; a reader searches while it writes.
    Err(e) if e.kind() == ErrorKind::Locked => {
        let reader = IndexReader::open("embeddings.chassis", 768, IndexOptions::default())?;
    }
    Err(e) => return Err(e.into()),
}
}

chassis_core::Error converts into anyhow::Error and Box<dyn std::error::Error>, so ? works in functions that return those.

Configuration

IndexOptions

Parameters tuning the HNSW graph trade-offs between recall, speed, and memory.

#![allow(unused)]
fn main() {
pub struct IndexOptions {
    /// Max connections per node (M). Default: 16
    pub max_connections: u16,
    
    /// Size of the dynamic candidate list during construction. Default: 200
    /// Higher = Better graph quality, slower inserts.
    pub ef_construction: usize,
    
    /// Size of the dynamic candidate list during search. Default: 50
    /// Higher = Better recall, slower search.
    pub ef_search: usize,

    /// `DistanceMetric::Euclidean` (default) or `DistanceMetric::Cosine`.
    pub metric: DistanceMetric,

    /// `Precision::Full` (default) or `Precision::Half`.
    pub precision: Precision,

    /// Ask the operating system to keep the vectors on huge pages. Default: false
    pub huge_pages: bool,

    /// Read the index into memory on another thread from the moment it is opened. Default: false
    pub warm: bool,
}
}

The metric is fixed when the index is created: reopening with another one is an error, an IndexReader uses the file’s, and metric() on either reports it. A cosine index stores vectors scaled to unit length, rejects zero vectors and vectors with NaN or infinite components, and reports 1 - cosine similarity, from 0 to 2. Search speed is the same for both.

precision is fixed when the index is created, as the metric is: reopening with another one is an error, an IndexReader uses the file’s, and precision() on either reports it. Precision::Half keeps each component of a vector as a 16-bit float (ADR-0018), so the vectors take half the space in the file and in memory. A search over an index too large for the CPU’s caches is faster for it, by a tenth to two thirds on most of the machines measured, and builds on x86 take 11 to 48% less time. An index that fits in cache searches no faster, and on Neoverse-N2 a fifth more slowly when its vectors are long. Each component is rounded to 11 significant bits, about three decimal digits, and reported distances are to the vectors as kept; on the embeddings measured, recall was the same. A vector with a component of 65,520 or more in magnitude is refused. A file in half precision is format version 4, which releases from before this option refuse to open.

Besides half precision, the way to shrink an index is fewer dimensions. Some embedding models are trained so that the first part of a vector works on its own (“Matryoshka” embeddings, such as OpenAI’s text-embedding-3 with its dimensions argument): 512 of 1,536 dimensions make the index about a third of the size. Measure recall on your own queries first, and cut vectors yourself only with DistanceMetric::Cosine, or normalize them again. max_connections: 12 instead of 16 saves less: a 128-dimension file in half precision is 7% smaller and one of 1,536 dimensions 1%, and builds are 7 to 18% faster, but searches for 0.99 recall at 128 and 384 dimensions take up to 14% longer.

huge_pages is for an index too large for the CPU’s caches: with the vectors on 2 MB pages, searches are up to a quarter faster and batch builds a little faster (ADR-0016). It works on Linux, where the kernel and filesystem keep files on huge pages (ext4 on Linux 6.17 does), and does nothing elsewhere. A writer and its readers each ask for themselves. It is off by default because a page not yet in memory is then read 2 MB at a time, which an index much larger than memory pays for on every miss. VectorIndex::use_huge_pages and IndexReader::use_huge_pages turn it on after opening.

warm is for an index whose file is not in memory yet, as after a reboot. Until it is, each page a search touches is read from the disk, one read at a time. With warm, another thread reads in what the index holds, at the disk’s sequential speed, while searches go on (ADR-0019). On an Apple M5, from the start of a process, the hundredth search over 99,000 vectors of 1,536 dimensions returned after 0.37 s with it and 1.9 s without; on Linux servers with slower disks, after 1.3 to 1.7 s where it took 1.5 to 2.9 s. It reads only what has been written, and changes nothing in the file. It is off by default because an index much larger than memory would push everything else out, itself included, and on a slow disk reading the whole file can take longer than the searches would. It takes Linux 5.14 or later, or macOS; an older Linux reads in only some of the index, and Windows none. VectorIndex::warm and IndexReader::warm ask for it after opening and return at once; from then on the option is on, so a reader that opens the file again after a compaction reads the new one in too.

Tuning Guide:

  • High Recall: Increase ef_construction to 400 and max_connections to 32.
  • Fast Search: Decrease ef_search to 20-30.
  • Low Memory: Decrease max_connections to 8-12.

Data Types

SearchResult

#![allow(unused)]
fn main() {
pub struct SearchResult {
    /// The internal sequential ID of the vector
    pub id: u64,
    
    /// Distance from the query by the index's metric
    pub distance: f32,
}

}