Snapshot and Recovery
How Basis rebuilds its in-memory HNSW index from the vector strand, and the current limits of index persistence.
What this page covers
Basis is durable by virtue of the vector Strand, but the HNSW index is held entirely in memory. This page describes what happens on startup, what happens after a remove, and what to expect when the underlying Strand is replicated to a fresh peer.
The two strands
| Strand | Contents | Status |
|---|---|---|
vector_strand | One VectorEntry::Add or VectorEntry::Remove per block, serde_json-encoded | Canonical source of truth |
index_strand | Reserved for HNSW snapshots | Currently a stub — update_index_strand is a no-op |
The current build always rebuilds the HNSW graph from vector_strand on demand. index_strand is reserved for a future serialization format and is created during construction so callers can already provision the storage path.
The HNSW index is memory-resident only. After process restart, the index is empty until rebuilt from vector_strand. For corpora with millions of vectors, plan for a warm-up phase on startup or shard the corpus across multiple Basis instances.
Cold-start behavior
Constructing a Basis on top of an existing vector strand returns an instance whose HNSW graph is empty. The graph must be repopulated. The rebuild_index private helper walks the strand from sequence 0 to len() - 1, replays the entries, and inserts surviving Add records into a new Hnsw.
You will typically call this after replicating a vector strand from a peer:
use std::sync::Arc;
use tokio::sync::RwLock;
use strand::{Strand, StrandConfig};
use basis::Basis;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::var("BASIS_DIR").unwrap_or_else(|_| "./basis".into());
let vec_cfg = StrandConfig::new().with_storage(format!("{path}/vectors"));
let idx_cfg = StrandConfig::new().with_storage(format!("{path}/index"));
let vector_strand = Arc::new(RwLock::new(Strand::new(vec_cfg).await?));
let index_strand = Arc::new(RwLock::new(Strand::new(idx_cfg).await?));
let basis = Basis::new(vector_strand.clone(), index_strand);
// Force a rebuild by removing then re-adding a dummy vector.
// The rebuild_index path is exercised whenever an existing id is removed.
let len = vector_strand.read().await.len();
println!("vector strand has {len} blocks; HNSW graph is empty at start");
Ok(())
}To expose rebuild on the public API, callers currently re-create the Basis and re-add each surviving vector externally, or trigger an internal rebuild by performing a remove of a present id (see below).
What remove actually does
remove(id) performs two writes:
- Appends
VectorEntry::Remove { id }to the vector strand. The tombstone is durable immediately. - If the id is present in the
id_to_internal_indexmap, callsrebuild_index(). HNSW does not support cheap node deletion in this configuration, so the graph is reconstructed from the live (non-tombstoned) entries.
Implications:
- Removes are correct but expensive — they cost
O(N)over the corpus. - If many removes happen in a row, batch the work behind a queue and rebuild once at the end (today this requires bypassing the public API; future versions may expose
compact()).
Recovery procedures
| Failure | What survives | Recovery |
|---|---|---|
Process crash mid-add | Strand may or may not contain the new block, depending on flush state | On restart, the rebuild picks up whatever the strand contains. The in-memory graph is consistent with strand content. |
Disk corruption on vector_strand | Strand WAL repair runs first | After Strand repair, reconstruct Basis from the recovered log |
Disk corruption on index_strand | No data loss (it is a stub today) | Delete and recreate the index strand directory |
| Replicating to a new peer | Strand content is transferred via the standard replication path | The receiver constructs a Basis over the replicated strand; the HNSW graph builds locally as vectors are read |
Failure-mode summary
- No catastrophic data loss is possible as long as
vector_strandsurvives — the index is always reconstructible. - Search results during rebuild are undefined. If your application performs reads concurrently with a remove, hold a read lock or fence the query path.
- Memory pressure is the primary failure surface — the HNSW graph grows linearly with the corpus.