WeaveDocs
Basis

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

StrandContentsStatus
vector_strandOne VectorEntry::Add or VectorEntry::Remove per block, serde_json-encodedCanonical source of truth
index_strandReserved for HNSW snapshotsCurrently 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.

Warning

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:

  1. Appends VectorEntry::Remove { id } to the vector strand. The tombstone is durable immediately.
  2. If the id is present in the id_to_internal_index map, calls rebuild_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

FailureWhat survivesRecovery
Process crash mid-addStrand may or may not contain the new block, depending on flush stateOn restart, the rebuild picks up whatever the strand contains. The in-memory graph is consistent with strand content.
Disk corruption on vector_strandStrand WAL repair runs firstAfter Strand repair, reconstruct Basis from the recovered log
Disk corruption on index_strandNo data loss (it is a stub today)Delete and recreate the index strand directory
Replicating to a new peerStrand content is transferred via the standard replication pathThe 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_strand survives — 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.