ferrovec ▲
A tiny, dependency-light HNSW vector index for approximate nearest-neighbor search — built to compile to WebAssembly.
The winning WebAssembly apps never asked anyone to switch languages — they put a Rust engine inside and a plain API outside. ferrovec brings that pattern to semantic search: a fast nearest-neighbor core in Rust, so you can run private, offline vector search anywhere — in the browser, on the edge, or on a server.
- 🦀 Rust core — a hand-rolled HNSW graph, the same algorithm behind Pinecone, Weaviate, and Qdrant.
- 🪶 Featherweight —
serde+postcardare the only dependencies. The WASM build is ~33 KB gzipped. - 🔒 No
unsafeoutside the audited SIMD kernel (#![deny(unsafe_code)]crate-wide), and no system randomness (a deterministic seeded splitmix64 PRNG) — so it's happy onwasm32-unknown-unknownwith no shims. - ⚡ SIMD-accelerated distance kernels on
wasm32 + simd128, with a scalar reference fallback everywhere else. - ➕ Incremental upsert-style inserts and tombstoning removals — no rebuild-the-whole-index penalty.
- 💾 Portable — compact binary (de)serialization with a versioned header; the same bytes reload natively or in the browser.
Status — v0.2.0 is live. The Rust core (M1) and the WebAssembly boundary (M2, the
FerrovecCoreJS class + SIMD kernel) are published;0.2.0adds in-place compaction to reclaim memory under churn. Next up: auto-embedding via transformers.js (M3) and OPFS persistence (M4), which land the three-line browser API. See the roadmap.
Install
[]
= "0.2"
Quick start
use ;
// A 4-dimensional index using the defaults (Cosine metric).
let mut index = new;
index.insert.unwrap;
index.insert.unwrap;
index.insert.unwrap;
let results = index.search.unwrap;
assert_eq!; // nearest first
assert_eq!;
Tuning
use ;
let index = with_config;
assert_eq!;
Upsert & remove
use Hnsw;
let mut index = new;
index.insert.unwrap;
index.insert.unwrap; // replaces the previous "x"
assert_eq!;
assert!;
assert!; // already gone
assert!;
Compaction & clearing
remove and upserting insert only tombstone a node — it lingers in the graph so the index stays connected, which means heavy churn grows memory over time. compact rebuilds the index in place from the live vectors only, reclaiming that space, while contains reports whether an id is still live:
use Hnsw;
let mut index = new;
index.insert.unwrap;
index.insert.unwrap;
index.remove; // tombstoned, but still occupying memory
index.compact; // rebuild keeping only live nodes
assert_eq!; // live count is unchanged by compaction
assert!;
assert!; // removed ids stay gone
// Live search results are still correct after compaction.
let hits = index.search.unwrap;
assert_eq!;
// `clear` empties the index entirely, keeping its dims and config.
index.clear;
assert!;
index.insert.unwrap; // reusable afterwards
assert_eq!;
Compaction is deterministic: it rewinds the PRNG to Config::seed before rebuilding, so a compacted index matches a fresh build of the same survivors inserted in the same order.
Persistence
use Hnsw;
let mut index = new;
index.insert.unwrap;
let bytes = index.to_bytes.unwrap; // -> Vec<u8> (FVEC header + payload)
let restored = from_bytes.unwrap;
let a = index.search.unwrap;
let b = restored.search.unwrap;
assert_eq!;
Distance metrics
All metrics are expressed so that smaller means closer:
| Metric | Value |
|---|---|
Metric::Cosine |
1 - cos(a, b) (zero-norm ⇒ 1.0) |
Metric::Dot |
1 - dot(a, b) |
Metric::L2 |
squared Euclidean distance |
Vectors that are already L2-normalized (e.g. sentence embeddings) pair naturally with Cosine or Dot.
In the browser
ferrovec compiles to WebAssembly and exposes a FerrovecCore class through wasm-bindgen. Build it with wasm-pack:
# -> pkg/ (ferrovec_bg.wasm ~33 KB gzip, JS bindings, TypeScript types)
Then use it from JavaScript — bring your own embeddings as a Float32Array:
import from "ferrovec";
const index = ; // 384-dim vectors
index.; // Float32Array
const hits = index.; // [{ id, distance }, ...]
const bytes = index.; // Uint8Array — persist anywhere
const restored = ;
The upcoming
js/package (M3–M5) wraps this with automatic embedding via transformers.js and OPFS persistence, so the browser API becomes:const db = await Ferrovec.open('notes'); await db.insert(text); const hits = await db.query('…', 5);
To smoke-test WASM compatibility without packaging:
Roadmap
| Milestone | Status | |
|---|---|---|
| M1 | Pure-Rust HNSW core | ✅ shipped in 0.1.0 |
| M2 | WASM boundary (FerrovecCore) + SIMD128 kernel |
✅ shipped in 0.1.0 |
| M3 | Web Worker + transformers.js auto-embedding | 🚧 in progress |
| M4 | OPFS-backed persistence (survives reloads) | ⏭ next |
| M5 | ferrovec on npm — the three-line browser API |
⏭ next |
| M6 | Cross-tab leader election (Web Locks) | 🔭 0.1.x |
Design notes
- Why hand-rolled? No mature Rust HNSW crate compiles cleanly to
wasm32-unknown-unknown— they hard-depend onrayon,mmap-rs, ornum_cpus. Owning the graph keeps the dependency tree tiny and the WASM artifact small. - Determinism. The build is reproducible from
Config::seed; there is nogetrandomin the dependency tree. - Tombstones & compaction.
removemarks a node deleted and excludes it from results while keeping it for graph connectivity, so heavy churn grows memory over time.compact(added in0.2.0) rebuilds the index in place from the live vectors only — deterministically, by rewinding the PRNG toConfig::seed— reclaiming the space held by tombstoned nodes.clearresets the index to empty while keeping its dimensionality and config.
License
MIT © singhpratech