# Chassis > Chassis is an embedded vector index for local semantic search: approximate nearest-neighbor search (HNSW) over embeddings kept in one file, inside your own process, with no server. Python: `pip install chassisdb`, `import chassisdb` (Python 3.11+). Rust: `cargo add chassisdb`. C: a library and `chassis.h` with each GitHub release. Chassis stores vectors and integer ids only: keep text and metadata where the app keeps its data (SQLite, files) under the same id. An index's dimensions, metric, precision and `max_connections` are fixed when it is created and must be given again to reopen it. Adds and deletes are durable after `flush()`; closing or dropping an index without one discards them, as a crash would. One process writes a file at a time; readers take no lock, in any process, and see the last flush. Every error says what was wrong and, on a `help:` line, what to do. ## Docs - [Everything below on one page](https://tanvincible.github.io/chassis/llms-full.txt): the guides, the Python, Rust and C references, the errors, and an example - [Getting started](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/guide/getting-started.md): Rust, with pointers to Python and C - [Python API](https://raw.githubusercontent.com/tanvincible/chassis/main/chassisdb/README.md): `VectorIndex`, `IndexOptions`, exceptions - [Rust API](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/guide/api-reference.md): `VectorIndex`, `IndexReader`, `IndexOptions` - [C API](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/guide/c-api.md): every `chassis_*` function - [Errors](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/guide/errors.md): each kind, and what to do about it - [Next to your database](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/guide/with-a-database.md): one id for both, the write order, what a crash leaves and how to mend it ## Integrations - [LangChain](https://raw.githubusercontent.com/tanvincible/chassis/main/integrations/langchain-chassisdb/README.md): `pip install langchain-chassisdb`, a vector store with metadata filters and MMR - [MCP memory server](https://raw.githubusercontent.com/tanvincible/chassis/main/chassisdb-mcp/README.md): `chassisdb-mcp`, long-term memory for coding agents ## Examples - [Recipes](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/guide/recipes.md): search over notes, retrieval for a prompt, agent memory, "more like this", duplicates; each runs as written - [Notes search](https://raw.githubusercontent.com/tanvincible/chassis/main/chassisdb/examples/notes_search.py): text in SQLite, vectors in Chassis, "more like this", deletes - [Agent skill](https://raw.githubusercontent.com/tanvincible/chassis/main/plugins/chassis/skills/chassis/SKILL.md): the pattern and the rules, for coding agents ## Optional - [How it works](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/architecture/overview.md) - [Performance](https://raw.githubusercontent.com/tanvincible/chassis/main/docs/src/architecture/performance.md) - [Changelog](https://raw.githubusercontent.com/tanvincible/chassis/main/CHANGELOG.md) --- # Chassis Chassis is an embedded vector index for local semantic search, written in Rust. Your index is one file, and Chassis runs inside your process: no server to run, nothing to connect to. Store embeddings under your own ids, search them, and keep the rest of your data where it already lives. ```sh pip install chassisdb # Python 3.11+: import chassisdb cargo add chassisdb # Rust: use chassisdb ``` The C library and its header come with each [release](https://github.com/tanvincible/chassis/releases). ```python from chassisdb import VectorIndex # embeddings: your model's vectors, one row per note; note_ids: the notes' ids index = VectorIndex("notes.chassis", dimensions=768) index.add_batch(embeddings, ids=note_ids) index.flush() for hit in index.search(query, k=5): print(hit.id, hit.distance) ``` An index opens in about a millisecond: only its headers are read until a search needs more. At the same recall, it searches a million 128-dimension vectors faster than hnswlib on every server CPU measured, x86 and ARM. ## Capabilities * One file, used in-process, with no server * Approximate nearest-neighbor search (HNSW) by Euclidean or cosine distance * Filtered search, over the ids your application allows * Your own ids, and deletes * Batch builds on every core * Half-precision vectors, for half the file and half the memory * Durable `flush()`: after a crash, everything up to the last one is kept * One writer and any number of readers, in other processes too * Errors that say what was wrong and what to do instead * Rust, C and Python APIs It is not a database server or a distributed system, and it stores vectors and ids, not metadata. ## Use cases Chassis fits an app that embeds its own data and searches it on the same machine: a desktop or command-line app, a local RAG or agent memory, a cache one process writes while others search it. Other tools fit other shapes: * [sqlite-vec](https://github.com/asg017/sqlite-vec), when the data is in SQLite already and the search should be part of SQL queries. * [LanceDB](https://github.com/lancedb/lancedb), when you want one store for vectors and their columns, with filters in the query and versions of the data. * A server such as pgvector or Qdrant, when many machines write to one index, or other services query it over the network. ## Learn more * [Getting started](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/getting-started.md), and the [Python bindings](https://github.com/tanvincible/chassis/blob/main/chassisdb/README.md) * [How it works](https://github.com/tanvincible/chassis/blob/main/docs/src/architecture/overview.md), and why, in the [decision records](https://github.com/tanvincible/chassis/blob/main/docs/adr) * [Benchmarks](https://github.com/tanvincible/chassis/blob/main/docs/src/architecture/performance.md), and what is [planned](https://github.com/tanvincible/chassis/blob/main/ROADMAP.md) * LangChain: [langchain-chassisdb](https://github.com/tanvincible/chassis/blob/main/integrations/langchain-chassisdb/README.md), a vector store that passes LangChain's standard tests * Long-term memory for agents, on your machine: [chassisdb-mcp](https://github.com/tanvincible/chassis/blob/main/chassisdb-mcp/README.md), an MCP server built on Chassis * Coding with an agent? In Claude Code, `/plugin install chassis --marketplace tanvincible/chassis`; for Cursor, Codex and the rest, see [Chassis for coding agents](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/agents.md), and [llms.txt](https://github.com/tanvincible/chassis/blob/main/llms.txt) ## Status Early, and moving fast. The latest release is v0.8.0. See [CHANGELOG.md](https://github.com/tanvincible/chassis/blob/main/CHANGELOG.md). ## License Chassis is dual licensed under: - Apache License 2.0 - MIT License You may use either license at your option. ## Contributing Contributions and design discussion are welcome. See [CONTRIBUTING.md](https://github.com/tanvincible/chassis/blob/main/CONTRIBUTING.md). --- # Getting started This page takes you from nothing to a first search, in Python or Rust. From C, start with the [C API](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/c-api.md). ## Python ```sh pip install chassisdb ``` ```python from chassisdb import VectorIndex index = VectorIndex("quickstart.chassis", dimensions=3) # created if it doesn't exist index.add([1.0, 0.0, 0.0]) # id 0 index.add([0.0, 1.0, 0.0]) # id 1 index.add([0.0, 0.0, 1.0]) # id 2 index.flush() # on disk once this returns results = index.search([1.0, 0.1, 0.0], k=1) print("nearest:", results[0].id) # 0 ``` The [Python docs](https://tanvincible.github.io/chassis/python/) go on from here: batches, your own ids, options and every method. ## Rust Add Chassis to your `Cargo.toml`: ```toml [dependencies] chassisdb = "0.8" ``` ```rust use chassisdb::{IndexOptions, VectorIndex}; fn main() -> chassisdb::Result<()> { // Created if it doesn't exist let mut index = VectorIndex::open("quickstart.chassis", 3, IndexOptions::default())?; index.add(&[1.0, 0.0, 0.0])?; // id 0 index.add(&[0.0, 1.0, 0.0])?; // id 1 index.add(&[0.0, 0.0, 1.0])?; // id 2 index.flush()?; // on disk once this returns let results = index.search(&[1.0, 0.1, 0.0], 1)?; assert_eq!(results[0].id, 0); println!("nearest: {}", results[0].id); Ok(()) } ``` A debug build runs Chassis's arithmetic many times slower. To build Chassis optimized while your own crate stays in debug: ```toml [profile.dev.package.chassisdb] opt-level = 3 ``` [Basic usage](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/basic-usage.md) goes on from here, and the [Rust API](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/api-reference.md) has every method. --- # Basic usage This page shows the three things you do with an index from Rust: open it, add vectors and keep them, and search them. [Getting started](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/getting-started.md) has the setup, and the [Rust API](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/api-reference.md) has every method. ## Example ```rust use chassisdb::{IndexOptions, VectorIndex}; use std::hash::{DefaultHasher, Hash, Hasher}; /// Stands for your embedding model: here, each word counts in a dimension picked by its hash. fn embed(text: &str) -> Vec { let mut vector = vec![0.0; 1536]; for word in text.split_whitespace() { let mut hasher = DefaultHasher::new(); word.to_lowercase().hash(&mut hasher); vector[hasher.finish() as usize % 1536] += 1.0; } vector } fn main() -> chassisdb::Result<()> { let options = IndexOptions { max_connections: 16, ef_construction: 200, ef_search: 50, ..IndexOptions::default() }; let mut index = VectorIndex::open("data.chassis", 1536, options)?; let id = index.add(&embed("The cat sat on the mat"))?; index.add_with_id(1000, &embed("A dog dug in the garden"))?; index.flush()?; println!("stored under ids {id} and 1000"); for result in index.search(&embed("the cat on the mat"), 5)? { println!("id {}, distance {}", result.id, result.distance); } Ok(()) } ``` ## Open `open` creates the file if it doesn't exist. The dimensions, metric, precision and `max_connections` are fixed when the index is created, and you give the same ones to open it again. The options above are the defaults. ## Add Every vector has an id, a `u64`. `add` gives it the next one. To keep a vector under an id your application already has, such as a database row's, use `add_with_id`, and keep the rest of the row where it already lives. A search finds a vector as soon as it is added, but the vector is on disk only after `flush()`. Dropping the index without a flush discards everything added since the last one, as a crash would. ## Search `search` returns the `k` nearest vectors, nearest first, each with its id and distance. Smaller is nearer. `search` takes `&self`, so many threads can search one `VectorIndex` at once. Wrap it in an `Arc>`: searches take the read lock, and `add`, `delete` and `flush`, which take `&mut self`, take the write lock. To search while another thread or process writes, open an `IndexReader` on the same file. It sees what the writer's last `flush()` committed. --- # chassisdb Python bindings for Chassis, an embedded vector index for local semantic search: one file, in your process, no server. ## Features - Vectors go in as NumPy arrays or lists, and come out as NumPy arrays. - Many threads search one index at once, while writes run one at a time. - Other processes search the file while one writes it, with `read_only=True`. - Every error says what was wrong and what to do, and is also the matching built-in exception. - Type hints throughout. ## Install ```bash pip install chassisdb ``` Import it as `chassisdb`. Wheels are built for Python 3.11 and later on Linux (x86-64 and ARM), macOS on Apple silicon and Windows (x86-64). Elsewhere, or from a checkout of the repository, `pip install .` in this directory builds it, which needs Rust. ### Development ```bash # Build the library, then install with dev dependencies cargo build --release -p chassis-ffi pip install -e ".[dev]" # Run tests pytest # Type checking mypy chassisdb # Formatting black chassisdb tests ruff check chassisdb tests ``` ## Quickstart The vectors here are random; in your app they come from your embedding model. ```python import numpy as np from chassisdb import VectorIndex index = VectorIndex("embeddings.chassis", dimensions=768) # created if it doesn't exist vectors = np.random.rand(1000, 768).astype(np.float32) ids = index.add_batch(vectors) # linked on every core; ids 0 to 999 index.flush() # on disk once this returns query = np.random.rand(768).astype(np.float32) for result in index.search(query, k=10): # nearest first print(result.id, result.distance) print(len(index), index.dimensions) # 1000 768 index.close() ``` ## Usage ### Options ```python from chassisdb import VectorIndex, IndexOptions # Configure HNSW parameters options = IndexOptions( max_connections=32, # Higher = better recall, more memory ef_construction=400, # Higher = better index, slower build ef_search=100, # Higher = better search, slower queries metric="cosine", # "euclidean" (default) or "cosine"; fixed at creation precision="half", # "full" (default) or "half": 16-bit floats, half the file; fixed at creation ) index = VectorIndex("tuned.chassis", dimensions=768, options=options) ``` ### Closing ```python with VectorIndex("vectors.chassis", dimensions=128) as index: index.add([0.1] * 128) # Flushed and closed here ``` Leaving the block flushes, unless an exception is leaving it, then closes the index. Closing without a flush, with `close()` or by garbage collection, discards what was added or deleted since the last `flush()`, as a crash would. ### Batches ```python import numpy as np vectors = np.random.rand(10000, 768).astype(np.float32) queries = np.random.rand(100, 768).astype(np.float32) with VectorIndex("large.chassis", dimensions=768) as index: # Added and linked on every core index.add_batch(vectors) index.flush() # One call for every query: ids and distances are (100, 10) arrays ids, distances = index.search_batch(queries, k=10) ``` ### Inputs A vector can be a NumPy array, a list or a tuple. Each is converted to 32-bit floats. ```python import numpy as np from chassisdb import VectorIndex with VectorIndex("small.chassis", dimensions=3) as index: index.add(np.array([0.1, 0.2, 0.3], dtype=np.float32)) index.add([0.1, 0.2, 0.3]) index.add((0.1, 0.2, 0.3)) ``` ### Errors ```python from chassisdb import VectorIndex, DimensionMismatchError, IndexLockedError index = VectorIndex("vectors.chassis", dimensions=128) try: index.add([0.1] * 64) except DimensionMismatchError as e: print(e) # The vector has 64 components, but this index holds vectors of 128 # help: make the vector with the same model as the index's vectors; this index was created for 128 dimensions try: VectorIndex("vectors.chassis", dimensions=128) except IndexLockedError: # One writer at a time; a reader searches while it writes. reader = VectorIndex("vectors.chassis", dimensions=128, read_only=True) ``` ## Reference ### `VectorIndex` Opens an index file to add, delete and search vectors. #### Constructor ```python VectorIndex(path: str | Path, dimensions: int, options: IndexOptions | None = None, read_only: bool = False) ``` To reopen an existing index, pass the metric, precision and `max_connections` it was created with; others raise `OptionsMismatchError`, which names them. With `read_only=True` it only searches, takes no lock, and takes the metric and precision from the file (`max_connections` has to match); any number can search while one writer writes, in this process or others. #### Methods - **`add(vector: Sequence[float] | ndarray, id: int | None = None) -> int`** Add a vector under `id` (0 to 2**64 - 2; `IdInUseError` if a live vector has it), or else under one past the largest id so far. Returns the id. - **`add_batch(vectors: ndarray, ids: Iterable[int] | None = None) -> ndarray`** Add a `(count, dimensions)` array at once, linking it on every core: much faster than `add` in a loop. Returns the ids. All or nothing: if any vector or id is rejected, none is added. - **`delete(id: int) -> bool`** Delete the vector with this id. Returns whether there was one. Search stops returning it at once; the delete is durable after the next `flush()`. To replace a vector, delete it and add the new one under the same id: the next flush saves both or neither. - **`get(id: int) -> ndarray | None`** The vector with this id, or `None` if no live vector has it. It comes back as the index keeps it: scaled to length 1 in a cosine index, and rounded in half precision. - **`id in index`** Whether a live vector has this id. - **`ids() -> ndarray`** The ids of the live vectors, in the order they were added. - **`search(query: Sequence[float] | ndarray, k: int = 10, allowed: Iterable[int] | ndarray | None = None, excluded: Iterable[int] | ndarray | None = None) -> List[SearchResult]`** Search for k nearest neighbors. Returns sorted list of results. With `allowed`, only those ids can be returned, e.g. `allowed=[row[0] for row in db.execute("SELECT id FROM docs WHERE owner = ? ORDER BY id", (user,))]`; ids in ascending order cost the least. When walking the graph would cost more, as when few vectors match, every vector is checked instead and the results are exact. With `excluded`, those ids are never returned, as for "more like this": `index.search(index.get(42), k=10, excluded=[42])`. - **`search_batch(queries: ndarray, k: int = 10) -> tuple[ndarray, ndarray]`** Search for the k nearest neighbors of each row of a `(count, dimensions)` array, in one call: faster than `search` in a loop. Returns `(ids, distances)`, two `(count, k)` arrays, nearest first; a row with fewer than k results is filled out with id `2**64 - 1` and distance `inf`. - **`flush() -> None`** Make every add and delete so far durable. It waits for the disk, so call it after a batch of changes, not after each one. - **`warm() -> None`** Read the index into memory on another thread, so that searches stop waiting for the disk while its file is not in memory yet, as after a reboot. Returns at once; searches go on meanwhile. `IndexOptions(warm=True)` does the same from the moment the index is opened. - **`compact() -> None`** Rewrite the index without its deleted vectors and with a rebuilt graph, reclaiming their space. Ids don't change. Takes as long as building the index and needs disk for a second copy. Deletes cost speed, not recall: with half the vectors deleted, searches ran at about 70% of their speed, so compacting once `deleted_count()` nears `len()` is a good rule. The file shrinks by whole segments, which double as an index grows, so only when the live vectors fit in fewer of them. - **`close() -> None`** Close the index and free resources. Adds and deletes since the last `flush()` are discarded, as after a crash; leaving a `with` block flushes first, unless an exception is leaving it. - **`__len__() -> int`** The number of live vectors. - **`deleted_count() -> int`** Deleted vectors the file still holds, until `compact()` removes them. - **`user_version: int`** Your application's number for the index, 0 until you set one, like SQLite's `user_version`. The next `flush()` commits it with the data, so after a crash it says which flush the index holds: keep the same number in your own store, in the transaction the flush sits in, and compare the two on open. A `read_only=True` index reports its newest snapshot's; setting it there raises `ReadOnlyError`. - **`is_empty() -> bool`** Whether the index has no live vectors. #### Properties - **`dimensions: int`** - Number of dimensions per vector - **`metric: str`** - `"euclidean"` or `"cosine"`, as the index was created; opening with `read_only=True` and explicit `options` naming another raises `OptionsMismatchError` - **`precision: str`** - `"full"` or `"half"`, as the index was created - **`path: Path`** - Path to the index file - **`options: IndexOptions`** - HNSW configuration ### `IndexOptions` The index's options. `metric`, `precision` and `max_connections` are fixed when the index is created. ```python @dataclass class IndexOptions: max_connections: int = 16 # M parameter ef_construction: int = 200 # Build-time search quality ef_search: int = 50 # Query-time search quality metric: str = "euclidean" # or "cosine": 1 - cosine similarity, unit-length storage precision: str = "full" # or "half": vectors kept as 16-bit floats, half the file and memory huge_pages: bool = False # Linux: keep the vectors on 2 MB pages (faster search on a large index) warm: bool = False # read the index into memory in the background from the start ``` ### `SearchResult` One result: the vector's id, and its distance from the query. ```python @dataclass class SearchResult: id: int # the vector's id distance: float # its distance from the query; smaller is nearer ``` ### Exceptions Each says what was wrong, with the value, and on a line starting `help:` what to do instead. Each is a `ChassisError` and also the built-in exception it is a kind of, so `except ValueError` catches a bad argument. The [errors reference](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/errors.md) lists their causes and fixes. - **`InvalidArgumentError`** (`ValueError`) - an argument out of range: dimensions, an option, an id, a vector with NaN or infinity. A wrong type is a plain `TypeError`. - **`DimensionMismatchError`** (`ValueError`) - a vector or query of other dimensions than the index's - **`OptionsMismatchError`** (`ValueError`) - reopened with another metric, precision or `max_connections` - **`IndexNotFoundError`** (`FileNotFoundError`) - no index at the path, for `read_only=True` - **`NotAnIndexError`** (`ValueError`) - the file isn't a Chassis index - **`IdInUseError`** (`ValueError`) - the id is taken; delete it first to replace it - **`IndexLockedError`** - another writer has the index open - **`ReadOnlyError`** - a `read_only=True` index asked to write - **`CorruptIndexError`**, **`IndexFullError`**, **`InvalidPathError`** (`OSError`) - a damaged file, a full index, an operating-system error ## Threads Every method is safe to call from any thread. Searches run concurrently (ctypes releases the GIL during each call); `add()`, `delete()` and `flush()` take the index's write lock, so they run one at a time and searches wait for them. Don't call `close()` while other threads still use the index. Other processes can search the file while one process writes it: open it with `VectorIndex(path, dimensions, read_only=True)`. Such an index takes no lock, sees the writer's newest `flush()` on every search, runs one search at a time, and raises `ReadOnlyError` on writes. 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. ```python from concurrent.futures import ThreadPoolExecutor import numpy as np from chassisdb import VectorIndex index = VectorIndex("large.chassis", dimensions=768) queries = np.random.rand(100, 768).astype(np.float32) with ThreadPoolExecutor() as pool: results = list(pool.map(lambda q: index.search(q, k=10), queries)) ``` ## Performance 1. Add many vectors with `add_batch`, which links them on every core, and flush once after a batch: a flush waits for the disk. 2. Search many queries with `search_batch`. Measured on 2026-10-10, one call for every query was within 5% of Rust's speed, while a call per query cost 15 to 38 µs more each ([Performance](https://tanvincible.github.io/chassis/architecture/performance.html)). 3. Pass 32-bit float NumPy arrays: lists and other types are converted first. 4. Tune `ef_search`, `ef_construction` and `max_connections` as [Performance & tuning](https://tanvincible.github.io/chassis/python/user-guide/performance/) describes. ## Library chassisdb looks for `libchassis_ffi` in this order: 1. `CHASSIS_LIB_PATH` environment variable 2. Next to the Python package 3. `../target/release` (development) 4. System library paths To set a custom location: ```bash export CHASSIS_LIB_PATH=/path/to/lib python your_script.py ``` ## Examples See the `examples/` directory for complete examples: - `notes_search.py` - Notes in SQLite, their vectors in Chassis: search, "more like this", deletes - `basic_usage.py` - Add and search - `batch_insert.py` - Many vectors at once with `add_batch` ## Testing ```bash # Run all tests pytest # Run with coverage pytest --cov=chassisdb --cov-report=html # Run specific tests pytest tests/test_index.py -k delete ``` ## License Licensed under either of: - Apache License, Version 2.0 ([LICENSE-APACHE](https://github.com/tanvincible/chassis?tab=Apache-2.0-1-ov-file)) - MIT License ([LICENSE-MIT](https://github.com/tanvincible/chassis?tab=MIT-2-ov-file)) at your option. ## Contributing See [CONTRIBUTING.md](https://github.com/tanvincible/chassis?tab=contributing-ov-file) for development guidelines. --- # Rust API Every type and method of the `chassisdb` crate, with what each one does to the file and when it fails. [docs.rs](https://docs.rs/chassisdb) has the same API generated from the code. ## Indexes ### `VectorIndex` `VectorIndex` opens an index file to add, delete and search vectors. One process writes a file at a time; to search a file that another process writes, use [`IndexReader`](#indexreader). The examples below continue from one another, starting with the index opened here. #### Open ```rust use chassisdb::{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. To reopen an existing index, pass the metric, precision and `max_connections` it was created with; others are an `OptionsMismatch` error that names them. **Returns**: * `Ok(VectorIndex)`: Handle to the index. * `Err`: If file is locked, corrupted, or dimensions mismatch. #### Add ```rust 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 ids ```rust 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](https://github.com/tanvincible/chassis/blob/main/docs/adr/007-ids-and-deletes.md)). #### Batches ```rust let rows: Vec> = (0..100).map(|i| vec![i as f32 / 100.0; 768]).collect(); // your vectors let vectors: Vec = 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](https://github.com/tanvincible/chassis/blob/main/docs/adr/010-parallel-batch-builds.md)). 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. #### Delete ```rust let deleted = index.delete(id)?; // false if no live vector has that id ``` 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()`; `deleted_count()` says how many the file holds. #### Compact ```rust index.compact()?; ``` Rewrites the index without its deleted vectors and with a newly built graph, then replaces the file with the copy ([ADR-0011](https://github.com/tanvincible/chassis/blob/main/docs/adr/011-compaction.md)). 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. When to compact: deletes cost speed and space, not recall. With half the vectors deleted, searches ran at about 70% of their speed, and with 90% deleted at a fifth ([ADR-0011](https://github.com/tanvincible/chassis/blob/main/docs/adr/011-compaction.md)). Compacting once `deleted_count()` nears `len()` keeps most of the speed; compact sooner when disk space matters. The file shrinks only by whole segments, which double in size as an index grows, so it gets smaller only when the live vectors fit in fewer of them. #### Search ```rust let query = vec![0.5; 768]; let k = 10; // Neighbors to retrieve let results = index.search(&query, k)?; for hit in results { println!("id {}, distance {}", hit.id, hit.distance); } ``` **Returns**: `Vec`, sorted by distance (nearest first), identified by the ids from `add`/`add_with_id`. Deleted vectors are never returned. `k` is at least 1. The search keeps the best `max(k, ef_search)` candidates, so a `k` above `ef_search` is fine; it returns at most `len()` results. #### Filtered search ```rust use std::collections::HashSet; // Stands for the ids your application allows, e.g. from `SELECT id FROM docs WHERE owner = ?` let allowed: HashSet = HashSet::from([42, 101, 102]); 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. Then 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](https://github.com/tanvincible/chassis/blob/main/docs/adr/009-filtered-search.md)). `IndexReader` has the same method. To leave a few ids out, as "more like this" leaves out the vector it starts from, pass a filter that rejects them: ```rust let vector = index.get(42)?.expect("42 is in the index"); let results = index.search_filtered(&vector, 10, |id| id != 42)?; ``` #### Flush ```rust // Every add and delete so far is durable once this returns. index.flush()?; ``` A flush waits for the disk, so it takes milliseconds. Call it after a batch of changes, every 1,000 vectors say, or before shutting down. To bound what a crash can lose by time instead, flush from a timer. Dropping a `VectorIndex` doesn't flush: what was added or deleted since the last `flush()` is discarded, as after a crash. At rest the index is one file. A `.undo` file sits beside it from the first add after a flush until the next flush, and after a crash until a writer reopens the index ([file format](https://github.com/tanvincible/chassis/blob/main/docs/src/architecture/file-format.md#undo-file)). Move, copy or delete the two together. #### Lookups ```rust let vector = index.get(42)?; // Some(vector), or None if no live vector has id 42 let known = index.contains(42)?; // Whether a live vector has id 42 let ids = index.ids()?; // Every live id, in the order they were added ``` `get` returns the vector as the index keeps it: scaled to length 1 in a cosine index, and rounded in half precision. With ids you chose, the first lookup in a process scans the index once to map ids to vectors, as `add_with_id` does. #### Metadata ```rust let len = index.len(); // Live vectors (deleted ones excluded) let deleted = index.deleted_count(); // Deleted vectors the file holds until compact() 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 ``` #### User version ```rust index.set_user_version(42); // Committed by the next flush, with the data index.flush()?; let version = index.user_version(); // 42; 0 until one is set ``` Like SQLite's `user_version`, a number for your application. The next `flush` commits it with the adds and deletes, so after a crash it says which flush the index holds. Keep the same number in your own store, in the transaction the flush sits in, and compare the two on open: they differ only after a crash between the flush and your commit ([With a database](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/with-a-database.md#opening), [ADR-0023](https://github.com/tanvincible/chassis/blob/main/docs/adr/023-user-version.md)). `IndexReader::user_version` returns the number of the flush its last snapshot shows. ### `IndexReader` Searches an index while a `VectorIndex` writes it, in another process or this one. It takes no lock, so any number of readers can open one file next to its single writer ([ADR-0008](https://github.com/tanvincible/chassis/blob/main/docs/adr/008-format-v3-and-multi-process-readers.md), decision 5). It takes the metric and precision from the file; `max_connections` has to be the one the index was created with. ```rust use chassisdb::{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. `get`, `contains` and `ids` take a new snapshot too. `len()` and `deleted_count()` report 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 `chassisdb::Result`, 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](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/errors.md) lists them, with causes and fixes): ```rust use chassisdb::{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()), } ``` `chassisdb::Error` converts into `anyhow::Error` and `Box`, so `?` works in functions that return those. ## Options ### `IndexOptions` Parameters tuning the HNSW graph trade-offs between recall, speed, and memory. ```rust 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](https://github.com/tanvincible/chassis/blob/main/docs/adr/018-half-precision.md)), 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 a tenth to two thirds faster for it on most of the machines measured. 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. Matryoshka embeddings, such as OpenAI's text-embedding-3 with its `dimensions` argument, are trained so that the first part of a vector works on its own. 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%. 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](https://github.com/tanvincible/chassis/blob/main/docs/adr/016-huge-pages-on-request.md)). 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](https://github.com/tanvincible/chassis/blob/main/docs/adr/019-warm.md)). 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, it returned 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**: `Precision::Half`, or vectors with fewer dimensions. ## Results ### `SearchResult` ```rust pub struct SearchResult { /// The id the vector was added under pub id: u64, /// Distance from the query by the index's metric pub distance: f32, } ``` Results order by distance, then by id, and two are equal only when both their ids and their distances are. Check a search's results with `==`: a result with the right distance but another id doesn't match. --- # C API Chassis from C, or from any language that can call C: a shared library and its header, `chassis.h`. This page shows how to get them and build against them, then describes every function. ## Install Each [release](https://github.com/tanvincible/chassis/releases) has the library and the header for Linux (x86-64 and ARM64), macOS (ARM64) and Windows (x86-64). To build them from a checkout instead, which needs Rust: ```bash cd chassis-ffi cargo build --release ``` That writes the library to `target/release`: `libchassis_ffi.so` on Linux, `libchassis_ffi.dylib` on macOS, `chassis_ffi.dll` on Windows. The header is `chassis-ffi/include/chassis.h`. ## Example ```c #include "chassis.h" #include #include int main(void) { // Open index ChassisIndex* index = chassis_open("vectors.chassis", 768); if (index == NULL) { fprintf(stderr, "Error: %s\n", chassis_last_error_message()); return 1; } // Add vector float vec[768] = {0.1, 0.2, /* ... */}; uint64_t id = chassis_add(index, vec, 768); if (id == UINT64_MAX) { fprintf(stderr, "Error: %s\n", chassis_last_error_message()); chassis_free(index); return 1; } // Flush to disk if (chassis_flush(index) != 0) { fprintf(stderr, "Error: %s\n", chassis_last_error_message()); chassis_free(index); return 1; } // Search float query[768] = {0.1, 0.2, /* ... */}; uint64_t ids[10]; float distances[10]; size_t count = chassis_search(index, query, 768, 10, ids, distances); for (size_t i = 0; i < count; i++) { printf("id %" PRIu64 ", distance %f\n", ids[i], distances[i]); } // Clean up chassis_free(index); return 0; } ``` ### Compiling ```bash # Linux and macOS, from the repository or an unpacked release cc -o myapp myapp.c -Ichassis-ffi/include -Ltarget/release -lchassis_ffi \ -Wl,-rpath,$PWD/target/release ./myapp # Windows (MSVC) cl myapp.c /I chassis-ffi\include /link /LIBPATH:target\release chassis_ffi.dll.lib ``` ## Functions ### Lifecycle #### `chassis_open` ```c ChassisIndex* chassis_open(const char* path, uint32_t dimensions); ``` Open or create an index. Returns `NULL` on error, including when the file is a cosine index or one in half precision: open those with `chassis_open_with_precision` or `chassis_open_reader`. #### `chassis_open_with_options` ```c ChassisIndex* chassis_open_with_options( const char* path, uint32_t dimensions, uint32_t max_connections, uint32_t ef_construction, uint32_t ef_search ); ``` Open with custom HNSW parameters. #### `chassis_open_with_metric` ```c ChassisIndex* chassis_open_with_metric( const char* path, uint32_t dimensions, uint32_t max_connections, uint32_t ef_construction, uint32_t ef_search, uint32_t metric ); ``` Like `chassis_open_with_options`, with a distance metric: `CHASSIS_METRIC_EUCLIDEAN` (0) or `CHASSIS_METRIC_COSINE` (1, `1 - cosine similarity`; vectors are stored at unit length and zero vectors are rejected). The metric is fixed when the index is created; reopening with another one fails. #### `chassis_open_with_precision` ```c ChassisIndex* chassis_open_with_precision( const char* path, uint32_t dimensions, uint32_t max_connections, uint32_t ef_construction, uint32_t ef_search, uint32_t metric, uint32_t precision ); ``` Like `chassis_open_with_metric`, with a precision: `CHASSIS_PRECISION_FULL` (0) keeps each component of a vector as a 32-bit float, `CHASSIS_PRECISION_HALF` (1) as a 16-bit float ([ADR-0018](https://github.com/tanvincible/chassis/blob/main/docs/adr/018-half-precision.md)). Half precision halves the vectors' size in the file and in memory, and a search over an index too large for the CPU's caches is faster for it. Each component is rounded to about three decimal digits, and a vector with a component of 65,520 or more in magnitude is refused. The precision is fixed when the index is created; reopening with another one fails, and a release without this function can't open a file in half precision. #### `chassis_open_reader` ```c ChassisIndex* chassis_open_reader( const char* path, uint32_t dimensions, uint32_t max_connections, uint32_t ef_search ); ``` Open an existing index to search it while a writer, possibly in another process, adds to it. Takes no lock, so any number of readers can open the file next to one writer. Each search sees the writer's newest flush, which becomes visible just before its final fsync; `chassis_len` takes a new snapshot first. Adds, deletes and flushes on the handle fail. A file in an older format must be opened once with `chassis_open` to migrate it. The metric and precision come from the file; `max_connections` has to be the one the index was created with. #### `chassis_use_huge_pages` ```c int chassis_use_huge_pages(ChassisIndex* index); ``` Ask the operating system to keep the index's vectors on huge pages ([ADR-0016](https://github.com/tanvincible/chassis/blob/main/docs/adr/016-huge-pages-on-request.md)). On an index too large for the CPU's caches, searches are up to a quarter faster. Linux only, where the kernel and filesystem keep files on huge pages (ext4 on Linux 6.17 does); elsewhere it does nothing. Call it right after opening, on a writer's handle or a reader's. Returns `0`, or `-1` for a NULL handle. #### `chassis_warm` ```c int chassis_warm(ChassisIndex* index); ``` Read the index into memory on another thread ([ADR-0019](https://github.com/tanvincible/chassis/blob/main/docs/adr/019-warm.md)), so that searches stop waiting for the disk one page at a time while its file is not in memory yet, as after a reboot. Doesn't wait for the reading: searches go on meanwhile. What is read is what the index holds when this is called; while that is under way, calling again does nothing. A reader that opens the file again after a compaction reads the new one in too. For an index that fits in memory: one much larger would push everything else out. Linux 5.14 or macOS; an older Linux reads only some of the index, and Windows none. Call it on a writer's handle or a reader's, right after opening or later. Returns `0`, or `-1` for a NULL handle. #### `chassis_free` ```c void chassis_free(ChassisIndex* index); ``` Free an index and release resources. Safe to call with `NULL`. It doesn't flush: adds and deletes since the last `chassis_flush` are discarded, as after a crash. ### Operations #### `chassis_add` ```c uint64_t chassis_add( ChassisIndex* index, const float* vector, size_t len ); ``` Add a vector. Returns the id assigned (one past the largest id used so far) or `UINT64_MAX` on error. **Thread Safety**: Safe from any thread; writes run one at a time and searches wait for them #### `chassis_add_with_id` ```c int chassis_add_with_id( ChassisIndex* index, uint64_t id, const float* vector, size_t len ); ``` Add a vector under your own id; search reports it. Returns `0`, or `-1` if a live vector already has `id`, `id` is `UINT64_MAX`, or the add fails. **Thread Safety**: Safe from any thread; writes run one at a time and searches wait for them #### `chassis_add_batch` ```c size_t chassis_add_batch( ChassisIndex* index, const float* vectors, // count * dim floats, row-major size_t count, size_t dim, uint64_t* out_ids // room for count ids ); ``` Add many vectors at once, linking them on every core: a large batch builds many times faster than `chassis_add` in a loop ([ADR-0010](https://github.com/tanvincible/chassis/blob/main/docs/adr/010-parallel-batch-builds.md)). Writes their ids, assigned as `chassis_add` assigns them, to `out_ids`. Returns `count`, or `0` if the batch fails, in which case none of it is added. #### `chassis_add_batch_with_ids` ```c int chassis_add_batch_with_ids( ChassisIndex* index, const uint64_t* ids, // count ids const float* vectors, // count * dim floats, row-major size_t count, size_t dim ); ``` `chassis_add_with_id` for a whole batch, linked on every core. Returns `0`, or `-1` with none of the batch added if an id repeats, already exists or is `UINT64_MAX`. **Thread Safety** (both): a batch holds the write lock until it is linked, so searches on the same handle wait for the whole batch. #### `chassis_delete` ```c int chassis_delete(ChassisIndex* index, uint64_t id); ``` Delete the vector with `id`. Returns `1` if deleted, `0` if no live vector has `id`, `-1` on error. Durable after the next `chassis_flush()`; a crash before then rolls it back. **Thread Safety**: Safe from any thread; writes run one at a time and searches wait for them #### `chassis_search` ```c size_t chassis_search( const ChassisIndex* index, const float* query, size_t len, size_t k, uint64_t* out_ids, float* out_dists ); ``` Search for k nearest neighbors. Returns how many results were written, or `0` on error, which `chassis_last_error_code()` tells apart from an index with nothing to return. **Thread Safety**: Safe from any thread; runs concurrently with other searches #### `chassis_search_filtered` ```c size_t chassis_search_filtered( const ChassisIndex* index, const float* query, size_t len, size_t k, const uint64_t* allowed_ids, size_t allowed_len, uint64_t* out_ids, float* out_dists ); ``` Like `chassis_search`, returning only vectors whose id is in `allowed_ids`. Ids not in the index are ignored; an empty list matches nothing. When walking the graph would cost more, as when few vectors match, every vector is checked instead and the results are exact. Ids in ascending order are read where they lie; in any other order they are copied into a set first, a pass over all of them per call. #### `chassis_search_excluding` ```c size_t chassis_search_excluding( const ChassisIndex* index, const float* query, size_t len, size_t k, const uint64_t* excluded_ids, size_t excluded_len, uint64_t* out_ids, float* out_dists ); ``` Like `chassis_search`, never returning a vector whose id is in `excluded_ids`, as "more like this" leaves out the vector it starts from (see `chassis_get`). Ids not in the index are ignored; an empty list leaves out nothing. #### `chassis_search_batch` ```c size_t chassis_search_batch( const ChassisIndex* index, const float* queries, size_t count, size_t dim, size_t k, uint64_t* out_ids, float* out_dists ); ``` Search for the k nearest neighbors of each of `count` queries, given one after another, `dim` floats each. `out_ids` and `out_dists` hold `count * k` values: row `i` is query `i`'s results, nearest first. A row with fewer than `k` results is filled out with id `UINT64_MAX`, which no vector has, and distance `INFINITY`. Returns `count`, or `0` on error or for an empty batch, which sets no error. An error names the query that failed, as in `Query 3 of the batch has NaN at component 0`. **Thread Safety**: Safe from any thread; runs concurrently with other searches, and a write waits for the whole batch #### `chassis_flush` ```c int chassis_flush(ChassisIndex* index); ``` Flush changes to disk. Returns `0` on success, `-1` on error. **Thread Safety**: Safe from any thread; writes run one at a time and searches wait for them #### `chassis_compact` ```c int chassis_compact(ChassisIndex* index); ``` Rewrite the index without its deleted vectors and with a newly built graph, then replace the file with the copy ([ADR-0011](https://github.com/tanvincible/chassis/blob/main/docs/adr/011-compaction.md)). Ids don't change. Like `chassis_flush`, it makes every add and delete so far durable. Returns `0`, or `-1` with the index left as it was; on Windows it fails while another process has the index open. It takes as long as building the index and needs free disk for a second copy. **Thread Safety**: holds the write lock for its whole run, so searches on this handle wait; readers in other processes keep searching ### Introspection #### `chassis_len` ```c uint64_t chassis_len(const ChassisIndex* index); ``` Get number of vectors in the index. #### `chassis_get` ```c int chassis_get(const ChassisIndex* index, uint64_t id, float* out, size_t len); ``` Copy the vector with `id` into `out`, which has space for `len` floats: the index's dimensions. Returns `1` if a live vector has `id`, `0` if none has, `-1` on error. The vector comes back as the index keeps it: scaled to length 1 in a cosine index, and rounded in half precision. #### `chassis_contains` ```c int chassis_contains(const ChassisIndex* index, uint64_t id); ``` Returns `1` if a live vector has `id`, `0` if none has, `-1` on error. #### `chassis_ids` ```c size_t chassis_ids(const ChassisIndex* index, uint64_t* out, size_t cap); ``` Copy the ids of the live vectors into `out`, in the order they were added, and return how many there are. Only the first `cap` are written, so if the return is more than `cap`, call again with more space; a reader can see a newer flush than a `chassis_len` just before. Returns `0` on error, as for an empty index, which sets no error. ```c size_t cap = chassis_len(index), count; uint64_t* ids = NULL; do { ids = realloc(ids, (cap ? cap : 1) * sizeof *ids); count = chassis_ids(index, ids, cap); } while (count > cap && (cap = count)); ``` #### `chassis_deleted_count` ```c uint64_t chassis_deleted_count(const ChassisIndex* index); ``` Deleted vectors the file still holds, until `chassis_compact` removes them. #### `chassis_user_version` ```c uint64_t chassis_user_version(const ChassisIndex* index); ``` Your application's number for the index, 0 until one is set: like SQLite's `user_version`. Each `chassis_flush` commits the number set before it with the data, so after a crash it says which flush the index holds; compare it on open with the same number kept in your own store. A reader handle returns the number in its newest snapshot. Returns `0` for a NULL handle, with an error set. #### `chassis_set_user_version` ```c int chassis_set_user_version(ChassisIndex* index, uint64_t version); ``` Set the number the next `chassis_flush` commits. Until then, a crash leaves the one flushed before. Returns `0`, or `-1` for a reader handle or a NULL one. #### `chassis_is_empty` ```c int chassis_is_empty(const ChassisIndex* index); ``` Check if index is empty. Returns `1` if empty, `0` otherwise. #### `chassis_dimensions` ```c uint32_t chassis_dimensions(const ChassisIndex* index); ``` Get vector dimensionality. #### `chassis_metric` ```c int chassis_metric(const ChassisIndex* index); ``` The metric the index was created with: `0` Euclidean, `1` cosine, `-1` if `index` is `NULL`. #### `chassis_precision` ```c int chassis_precision(const ChassisIndex* index); ``` The precision the index keeps its vectors in: `0` full, `1` half, `-1` if `index` is `NULL`. ### Errors #### `chassis_last_error_message` ```c const char* chassis_last_error_message(void); ``` The last error on this thread: what was wrong, with the value, then a line starting `help:` with what to do instead. Returns `NULL` if no error. **Lifetime**: Valid until next FFI call on this thread. #### `chassis_last_error_code` ```c int chassis_last_error_code(void); ``` The last error's kind, for a program to act on: one of the `CHASSIS_ERROR_` constants (`CHASSIS_ERROR_INVALID_ARGUMENT`, `CHASSIS_ERROR_DIMENSION_MISMATCH`, `CHASSIS_ERROR_LOCKED`, …), or `CHASSIS_OK` (0) after a call that succeeded. [Errors](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/errors.md) lists them, with their causes and fixes. ### Version #### `chassis_version` ```c const char* chassis_version(void); ``` Get library version string. ## Threads Every function except `chassis_free` is safe to call from any thread on the same handle. Searches, `chassis_len`, `chassis_is_empty` and `chassis_dimensions` run concurrently. `chassis_add`, `chassis_add_with_id`, `chassis_add_batch`, `chassis_delete` and `chassis_flush` take the handle's write lock, so they run one at a time and searches wait for them. `chassis_free` must not race with any other call on the same handle. A handle from `chassis_open_reader` runs one call at a time, introspection included; open one per thread to search in parallel. In this example, `generate_vector`, `generate_query`, `process_results` and `keep_running` stand for your own code. ```c // Thread 1: Writer void* writer_thread(void* arg) { ChassisIndex* index = (ChassisIndex*)arg; // Each add takes the write lock; searches in other threads wait for it float vec[768]; for (int i = 0; i < 1000; i++) { generate_vector(vec, i); chassis_add(index, vec, 768); } chassis_flush(index); return NULL; } // Thread 2: Reader void* reader_thread(void* arg) { const ChassisIndex* index = (const ChassisIndex*)arg; // Runs concurrently with other searches, and waits while a write runs float query[768]; uint64_t ids[10]; float dists[10]; while (keep_running) { generate_query(query); chassis_search(index, query, 768, 10, ids, dists); process_results(ids, dists); } return NULL; } ``` ## Error checks A function's return value says whether it failed: `NULL`, `UINT64_MAX` or `-1`, or a count of `0` with an error set, as its entry above says. `chassis_last_error_message` and `chassis_last_error_code` then describe the failure. Each thread keeps its own last error, so threads never see each other's. ```c if (chassis_flush(index) != 0) { fprintf(stderr, "Flush failed: %s\n", chassis_last_error_message()); chassis_free(index); exit(1); } ``` A panic inside Chassis never unwinds into C. The call returns its error value with a message that starts `Chassis panicked:`. After a panic in a write, the handle refuses later calls: free it and open the index again. ## Safety Each function's entry in `chassis.h` lists what it needs from you. These hold for all of them: 1. Never pass `NULL` where a function doesn't say it accepts one. 2. A vector's length must match the index's dimensions. 3. The message from `chassis_last_error_message` is valid until the next call on the same thread. 4. Don't call `chassis_free` while another thread still uses the handle, or use the handle after. ## Performance 1. Add many vectors before each `chassis_flush`: a flush waits for the disk. 2. Allocate result buffers once, and reuse them for every search. 3. Tune `max_connections`, `ef_construction` and `ef_search` as the [Rust API](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/api-reference.md#indexoptions) describes; the options mean the same in C. `chassis-ffi/examples/example.c` is a complete program, with a Makefile beside it. --- # Errors Every error from Chassis says what was wrong, with the value that was wrong, and on a line starting `help:` what to do instead: ```text The index at vectors.chassis holds vectors of 768 dimensions, not 1536 help: open it with 768 dimensions, or create a new index at another path ``` Each also has a kind, the same in every language, for a program to act on: `Error::kind()` in Rust, `chassis_last_error_code()` in C, and the exception's class in Python. Each Python class is also the built-in exception it is a kind of, so `except ValueError` catches a bad argument ([ADR-0020](https://github.com/tanvincible/chassis/blob/main/docs/adr/020-errors.md)). | Kind | Rust `ErrorKind` | C code | Python exception | | --- | --- | --- | --- | | [Invalid argument](#invalid-argument) | `InvalidArgument` | `CHASSIS_ERROR_INVALID_ARGUMENT` (1) | `InvalidArgumentError` (`ValueError`), or `TypeError` | | [Dimension mismatch](#dimension-mismatch) | `DimensionMismatch` | `CHASSIS_ERROR_DIMENSION_MISMATCH` (2) | `DimensionMismatchError` (`ValueError`) | | [Options mismatch](#options-mismatch) | `OptionsMismatch` | `CHASSIS_ERROR_OPTIONS_MISMATCH` (3) | `OptionsMismatchError` (`ValueError`) | | [Not found](#not-found) | `NotFound` | `CHASSIS_ERROR_NOT_FOUND` (4) | `IndexNotFoundError` (`FileNotFoundError`) | | [Not an index](#not-an-index) | `NotAnIndex` | `CHASSIS_ERROR_NOT_AN_INDEX` (5) | `NotAnIndexError` (`ValueError`) | | [Id in use](#id-in-use) | `IdInUse` | `CHASSIS_ERROR_ID_IN_USE` (6) | `IdInUseError` (`ValueError`) | | [Locked](#locked) | `Locked` | `CHASSIS_ERROR_LOCKED` (7) | `IndexLockedError` | | [Read-only](#read-only) | `ReadOnly` | `CHASSIS_ERROR_READ_ONLY` (8) | `ReadOnlyError` | | [Corrupt](#corrupt) | `Corrupt` | `CHASSIS_ERROR_CORRUPT` (9) | `CorruptIndexError` | | [Full](#full) | `Full` | `CHASSIS_ERROR_FULL` (10) | `IndexFullError` | | [Input or output](#input-or-output) | `Io` | `CHASSIS_ERROR_IO` (11) | `InvalidPathError` (`OSError`) | | Other | `Other` | `CHASSIS_ERROR_OTHER` (12) | `ChassisError` | Every Python class is a `ChassisError`. In C, a call that succeeds sets the code back to `CHASSIS_OK` (0). ## Invalid argument An argument is out of range or malformed. The message names it and its value. - **Dimensions** are 1 to 4,096: the length of the vectors the index will hold. - **`max_connections`** is 2 to 32,767; 16 suits most indexes. - **`k`** is at least 1. In Python it is an `int`; `5.0` is a `TypeError`. - **A vector or query with NaN or infinity** is refused, at every metric and precision, with the component's position. NaN usually comes from dividing by zero, as in normalizing a zero vector. - **A zero vector** in a cosine index has no direction, so it can't be compared by angle. - **A component of 65,520 or more** in magnitude doesn't fit half precision, which holds up to 65,504. Scale the vectors down, or use full precision. - **Ids** are integers from 0 to 2⁶⁴ − 2. In Python a string id is a `TypeError`: keep a dict from your own keys to integer ids. - **A path that is a directory**: give the path of a file in it. - **In Python**, an unknown option is a `TypeError` that names the option it was likely meant to be, including other libraries' names for them (`ef` is `ef_search`, `M` is `max_connections`). `metric` takes any case, and `"l2"` for `"euclidean"`; `precision` takes `"float16"` and `"f16"` for `"half"`. There is no inner-product metric: for vectors of length 1, cosine orders results the same way. ## Dimension mismatch A vector or query has another number of components than the index's vectors, or the file holds vectors of other dimensions than it was opened with. The usual cause is a vector made by another embedding model than the index's. - **Python, a 2-D array** where one vector belongs: `vector[0]` or `vector.ravel()` for one row, `add_batch` to add several, and `search_batch` to search several. - **A batch** is its vectors back to back, `dimensions` floats each: a `(count, dimensions)` array in Python, `dim` equal to the index's in C. ## Options mismatch The file was created with another metric, precision or `max_connections` than the one asked for. All three are fixed when the index is created, and a writer has to name the same ones again. A reader takes the metric and precision from the file, but names the same `max_connections`. Open the index with the ones it was created with, or create a new index at another path. ## Not found There is no index at the path a reader was given, or the directory of a path a writer was given doesn't exist. A reader opens an index that exists: create it by opening it as a writer first. ## Not an index The file at the path is not a Chassis index. Chassis never writes to such a file. Check the path; to create a new index, give a path where no file exists yet. ## Id in use `add_with_id` (`add(..., id=)` or `add_batch(..., ids=)` in Python) gave an id that a live vector already has. Delete that vector first to replace it, or use another id. Within one batch, ids have to differ. ## Locked Another writer has the index open, in this process or another. An index has one writer at a time. Close the other writer first, or open a reader (`IndexReader`, `chassis_open_reader`, `read_only=True`), which searches while the writer writes. ## Read-only A reader was asked to add, delete, flush or compact. Open the index as a writer to change it. ## Corrupt The file is damaged: its header or regions don't add up. Restore it from a backup, or rebuild it from its vectors. A crash or power loss doesn't do this (the index recovers to its last `flush()`); a disk error or another program writing the file can. So can a copy that stopped partway: a file shorter than its header says is refused with both lengths in the message. ## Full The index can hold no more: 4,294,967,295 vectors, or no room for another region in its tables. Compact it to drop deleted vectors, or split the vectors across indexes. ## Input or output The operating system refused: no permission, a read-only or full disk, a failed read or write. The message ends with the system's own words. An earlier flush that failed makes later ones refuse too, since what reached the disk is unknown: open the index again. --- # With a database Chassis keeps vectors and integer ids; the rows they belong to live in your database. This page is how to keep the two in step, through a crash at any point. The examples use SQLite from Python; the same order works with Postgres, and from Rust or C. ## One id Use the row's integer key as the vector's id. In SQLite that is `INTEGER PRIMARY KEY AUTOINCREMENT`: `AUTOINCREMENT` never gives a deleted row's id out again, which plain `INTEGER PRIMARY KEY` does. ```python import sqlite3 from chassisdb import IndexOptions, VectorIndex db = sqlite3.connect("notes.db", isolation_level=None) # Transactions only where we BEGIN them db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT)") # The number of the last write, which the index keeps too; see Opening. db.execute("CREATE TABLE IF NOT EXISTS sync (version INTEGER NOT NULL)") db.execute("INSERT INTO sync SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM sync)") index = VectorIndex("notes.chassis", dimensions=384, options=IndexOptions(metric="cosine")) ``` `embed(texts)` below stands for your embedding model. ## Writing Write the rows in a transaction, change the index, `flush()` it, and only then commit. Each write also counts itself in `sync`, and gives the index the same number as its `user_version`, which the flush commits with the vectors: ```python def count_write(): """Counts this write in the open transaction; the index's next flush commits the same number.""" db.execute("UPDATE sync SET version = version + 1") index.user_version = db.execute("SELECT version FROM sync").fetchone()[0] def add_notes(texts): vectors = embed(texts) # Before the transaction: a model can take a while db.execute("BEGIN IMMEDIATE") try: ids = [db.execute("INSERT INTO notes (text) VALUES (?)", (t,)).lastrowid for t in texts] for id in ids: if id in index: # Left by a commit that failed after its flush index.delete(id) index.add_batch(vectors, ids=ids) count_write() index.flush() except BaseException: db.execute("ROLLBACK") raise db.execute("COMMIT") return ids def delete_note(id): db.execute("BEGIN IMMEDIATE") try: index.delete(id) count_write() index.flush() db.execute("DELETE FROM notes WHERE id = ?", (id,)) except BaseException: db.execute("ROLLBACK") raise db.execute("COMMIT") ``` What a crash leaves: - **Before the flush**: nothing. The transaction rolls back, and the index discards what wasn't flushed, as it does whenever it is closed without one. - **Between the flush and the commit**: vectors whose rows don't exist, or a row whose vector is gone, and an index one write ahead of the database. The next start sees that and mends it, as Opening shows. A commit that fails without a crash leaves the same; its rolled-back insert gives its id back for the next insert, which is what `if id in index: index.delete(id)` is for, since adding an id the index holds is an `IdInUseError`. To replace a vector, `delete(id)` then `add(vector, id=id)`. Until the next flush neither is saved, and the flush saves both or neither. ## Reading Search the index, then join the rows back by id: ```python def search(query, k=5): hits = index.search(embed([query])[0], k=k) rows = dict(db.execute( f"SELECT id, text FROM notes WHERE id IN ({','.join('?' * len(hits))})", [hit.id for hit in hits], ).fetchall()) if hits else {} return [(hit.distance, rows[hit.id]) for hit in hits if hit.id in rows] ``` A vector a crash left without its row finds no row in the join and is dropped, and the next start mends it, so a search needs no filter for it. Don't pass every row's id as `allowed` to keep such vectors out: building the filter costs a pass over all of them on every search. For a filter of your own, pass the ids it selects as `allowed`, in id order: `SELECT id FROM notes WHERE owner = ? ORDER BY id`. Ids in ascending order are searched where they lie; in any other order they are copied into a set first ([ADR-0022](https://github.com/tanvincible/chassis/blob/main/docs/adr/022-allowed-ids.md)). ## Opening When the app starts, compare the two numbers. They differ only after a crash between a flush and its commit, the one case that leaves anything to mend: ```python def check(): (version,) = db.execute("SELECT version FROM sync").fetchone() if index.user_version != version: return mend() return 0, 0 ``` ## Mending What a crash left is found by comparing the two sets of ids, which takes one pass over each, so run it only when `check` finds the numbers differ: ```python def mend(): rows = {id: text for id, text in db.execute("SELECT id, text FROM notes")} held = set(index.ids()) for id in held - rows.keys(): # Vectors without a row index.delete(id) missing = sorted(rows.keys() - held) # Rows without a vector if missing: index.add_batch(embed([rows[id] for id in missing]), ids=missing) index.user_version = db.execute("SELECT version FROM sync").fetchone()[0] index.flush() return len(held - rows.keys()), len(missing) ``` ## Other processes Processes that only search open the index with `read_only=True` (Python), `IndexReader` (Rust) or `chassis_open_reader` (C). They see each flush, so with the order above a reader can see a vector a moment before its row is committed: join on the rows, as `search` does, and it never shows. --- # Coding agents A coding agent writes better code with Chassis when it has the rules that matter in front of it: what to keep where, when writes are durable, which options are fixed. Give it one of these. ## Claude Code ```text /plugin install chassis --marketplace tanvincible/chassis ``` That works in one step on Claude Code 2.1.275 or later. On older versions, run `/plugin marketplace add tanvincible/chassis`, then `/plugin install chassis@chassis`. The plugin adds a skill that Claude uses when you ask for semantic search, "more like this", RAG retrieval or agent memory. ## Cursor ```sh mkdir -p .cursor/rules curl -fsSL https://raw.githubusercontent.com/tanvincible/chassis/main/plugins/chassis/cursor/chassis.mdc \ -o .cursor/rules/chassis.mdc ``` The rule says the same as the skill, and Cursor applies it when a task calls for vector search. ## AGENTS.md For Codex and the other agents that read `AGENTS.md`, add this line to the project's `AGENTS.md`: ```text For vector or semantic search, use Chassis (Python: chassisdb; Rust: chassisdb). Read https://tanvincible.github.io/chassis/llms-full.txt before writing code with it. ``` ## Memory [chassisdb-mcp](https://github.com/tanvincible/chassis/tree/main/chassisdb-mcp) is an MCP server built on Chassis that gives agents a long-term memory on your machine: `remember`, `recall` and `forget`, with the embedding model you pick, local or an API. ```sh claude mcp add --scope user chassis-memory -- \ uvx --from 'chassisdb-mcp[fastembed]' chassisdb-mcp --embedder fastembed:BAAI/bge-small-en-v1.5 ``` Claude Code keeps a memory of its own and uses it when both are there: set `"autoMemoryEnabled": false` in `~/.claude/settings.json` to have it use this one. Its [README](https://github.com/tanvincible/chassis/tree/main/chassisdb-mcp) has the setup for Cursor, Codex and other clients, the embedding models, and a command line to see and change what is remembered. ## LangChain [langchain-chassisdb](https://github.com/tanvincible/chassis/tree/main/integrations/langchain-chassisdb) is a LangChain vector store on Chassis (`pip install langchain-chassisdb`): `ChassisVectorStore`, with metadata filters, maximal marginal relevance and readers in other processes. It passes LangChain's standard tests for vector stores. ## MCP servers For an exception other than its own `ToolError`, the MCP Python SDK shows an agent only "Error executing tool". Chassis's messages and their `help:` lines never reach the model unless the tool passes them on: ```python from chassisdb import ChassisError from mcp.server.mcpserver.exceptions import ToolError def search_notes(query: str, k: int = 5) -> list[dict]: try: return find(query, k) except ChassisError as e: raise ToolError(str(e)) from e ``` The SDK runs synchronous tools on worker threads: open SQLite with `check_same_thread=False` and take a lock around it. Keep the index and your rows in step as [With a database](https://github.com/tanvincible/chassis/blob/main/docs/src/guide/with-a-database.md) says. ## Docs - [llms.txt](https://tanvincible.github.io/chassis/llms.txt): an index of the docs - [llms-full.txt](https://tanvincible.github.io/chassis/llms-full.txt): the guides, the Python, Rust and C references, the errors and an example, as one page of Markdown - Each page of this book: its address with `.md` in place of `.html`, as [guide/errors.md](https://tanvincible.github.io/chassis/guide/errors.md) --- ```python """Semantic search over notes: text in SQLite, vectors in Chassis, joined by id. Runs as is: `embed` below hashes words into a vector, so it needs no model. To search by meaning, replace it with your embedding model, for example: from sentence_transformers import SentenceTransformer model = SentenceTransformer("all-MiniLM-L6-v2") # 384 dimensions def embed(texts): return model.encode(texts, normalize_embeddings=True) """ import hashlib import re import sqlite3 import tempfile from contextlib import closing from pathlib import Path import numpy as np from chassisdb import IndexOptions, VectorIndex DIMENSIONS = 256 # Your model's output size def embed(texts): """A stand-in for an embedding model: each word longer than 3 letters adds 1 to one of DIMENSIONS buckets.""" vectors = np.zeros((len(texts), DIMENSIONS), dtype=np.float32) for row, text in enumerate(texts): for word in re.findall(r"[a-z]{4,}", text.lower()): bucket = int.from_bytes( hashlib.blake2b(word.encode(), digest_size=4).digest(), "little" ) vectors[row, bucket % DIMENSIONS] += 1.0 return vectors NOTES = [ "Buy milk, eggs and bread on the way home", "The quarterly report is due on Friday", "Call the dentist to move Tuesday's appointment", "Ideas for the team offsite: hiking or a cooking class", "Draft the report summary and send it to the team", "Pick up bread and coffee beans", ] def main(folder: Path) -> None: with closing(sqlite3.connect(folder / "notes.db")) as db: search_notes(folder, db) def search_notes(folder: Path, db: sqlite3.Connection) -> None: # AUTOINCREMENT: a deleted note's id is never given out again. db.execute( "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL)" ) # Most text embedding models are compared by cosine distance. options = IndexOptions(metric="cosine") with VectorIndex(folder / "notes.chassis", dimensions=DIMENSIONS, options=options) as index: # Each note's SQLite row id is its id in the index too. ids = [db.execute("INSERT INTO notes (text) VALUES (?)", (t,)).lastrowid for t in NOTES] db.commit() index.add_batch(embed(NOTES), ids=ids) # Leaving the `with` block flushes; call index.flush() yourself to save sooner. # Open it again, as a later run of the app would. The metric has to be the same. with VectorIndex(folder / "notes.chassis", dimensions=DIMENSIONS, options=options) as index: query = "when is the report due" print(f"Notes like {query!r}:") for hit in index.search(embed([query])[0], k=3): (text,) = db.execute("SELECT text FROM notes WHERE id = ?", (hit.id,)).fetchone() print(f" {hit.distance:.3f} {text}") # "More like this": start from a stored note, and leave that note out. first = ids[0] print(f"\nMore like {NOTES[0]!r}:") for hit in index.search(index.get(first), k=2, excluded=[first]): (text,) = db.execute("SELECT text FROM notes WHERE id = ?", (hit.id,)).fetchone() print(f" {hit.distance:.3f} {text}") # Deleting a note: from both stores. db.execute("DELETE FROM notes WHERE id = ?", (first,)) db.commit() index.delete(first) assert first not in index print(f"\n{len(index)} notes left, {index.deleted_count()} deleted in the index file") if __name__ == "__main__": with tempfile.TemporaryDirectory() as folder: main(Path(folder)) ```