I wanted semantic search that ran with no server — the vectors, the index, and the query all inside a browser tab, working offline, nothing leaving the machine. The plan was the boring, winning one: a fast core in Rust, compiled to WebAssembly, wrapped in a plain JS API.
Then I went looking for a Rust HNSW crate to drop in, and every mature one refused to compile to wasm32-unknown-unknown. They hard-depend on rayon, or mmap-rs, or num_cpus — reasonable on a server, immovable walls in the browser. So I wrote my own.
ferrovec is a tiny, dependency-light HNSW vector index — the same approximate-nearest-neighbor algorithm behind Pinecone, Weaviate, and Qdrant. It's at 0.3.1 on crates.io (Rust core) and npm (browser package).
The design constraints (on purpose)
-
Featherweight — the Rust core's only dependencies are
serdeandpostcard. The wasm build is reported at ~33 KB gzipped. -
No
unsafeoutside one audited SIMD kernel —#![deny(unsafe_code)]crate-wide, with a single scoped exception. -
No system randomness — a deterministic seeded splitmix64 PRNG, so there is no
getrandomin the tree. That's exactly what lets it land onwasm32-unknown-unknownwith no shims. - Incremental + portable — upsert inserts, tombstoning removals, and a compact binary format with a versioned header that reloads natively or in the browser.
| Layer | Dependencies | Runs in the browser? |
|---|---|---|
| ferrovec core (Rust) |
serde + postcard — 2, total |
✅ ~33 KB gzipped wasm |
| a typical HNSW crate |
rayon / mmap-rs / num_cpus
|
❌ no wasm32 backend |
The whole learning curve for the Rust core:
use ferrovec::{Hnsw, Metric, Config};
let mut index = Hnsw::new(4); // 4-dim, Cosine by default
index.insert("a", &[1.0, 0.0, 0.0, 0.0]).unwrap();
index.insert("b", &[0.0, 1.0, 0.0, 0.0]).unwrap();
index.insert("c", &[0.9, 0.1, 0.0, 0.0]).unwrap();
let hits = index.search(&[1.0, 0.0, 0.0, 0.0], 2).unwrap();
assert_eq!(hits[0].id, "a"); // nearest first
search returns Neighbor { id, distance }, nearest first. Three metrics, all "smaller = closer": Cosine (default, 1 - cos), Dot (1 - dot), and L2 (squared Euclidean).
Why HNSW is fast
It never scores your query against the whole dataset. HNSW stacks graphs: the bottom layer holds every node densely connected; each layer above is a sparser sample with longer hops, like an express lane over local streets. A search enters at the top, greedily walks toward the query, drops a layer, repeats — so only a handful of comparisons happen at the dense bottom. You tune it with three knobs:
Config {
max_connections: 16, // M — neighbors per node per layer
ef_construction: 200, // candidate list while building
ef_search: 50, // candidate list while querying
metric: Metric::L2,
seed: 42,
}
Determinism is the feature that unlocks the browser
Most index libraries call the OS random source to pick each node's top layer — and that one call is what makes them impossible to compile for the browser, because getrandom has no wasm32-unknown-unknown backend without a JS shim. ferrovec carries its own seeded splitmix64 instead. No getrandom anywhere.
The bonus: builds are reproducible. Same vectors, same order, same seed → byte-identical graphs on your laptop and in a user's browser. Which makes compaction honest — remove only tombstones a node (it stays for graph connectivity, filtered from results), so heavy churn grows memory; compact() rebuilds in place from the live vectors only, rewinding the PRNG to Config::seed so the result is identical to a fresh build of the survivors.
index.remove("drop"); // tombstoned, still using memory
index.compact(); // rebuild from live nodes; PRNG rewound to seed
assert!(index.contains("keep"));
assert!(!index.contains("drop"));
The SIMD kernel — the one place unsafe is allowed
Distance is the inner loop, so it's the one place worth hand-optimizing. Scalar dot/l2 stay as the always-available reference; on wasm32 + simd128 the dispatch swaps in a hand-written SIMD128 kernel that accumulates in four lanes and folds them — and that kernel is the only code allowed to use unsafe. Four-lane accumulation isn't bit-identical to a left-to-right scalar sum (a few ULPs under IEEE-754), so a wasm integration test asserts search returns the same nearest id as a scalar brute force. The wobble never changes who wins.
Into the browser
The core exposes a FerrovecCore class via wasm-bindgen — bring your own Float32Array embeddings. But the npm package closes the last gap: automatic embedding via transformers.js, a dedicated Web Worker so nothing runs on the main thread, and OPFS persistence. The API collapses to three lines:
import { Ferrovec } from "ferrovec";
const db = await Ferrovec.open("notes"); // spawns the worker, loads the model
await db.insert("the cat sat on the mat"); // embed + index
const hits = await db.query("a napping kitten", 5); // → [{ id, text, score },...]
No network call in that snippet, no server on the other end.
Surviving a reload — and a second tab
Indexes persist to the Origin Private File System by default: the worker opens ferrovec/<name>/index.bin via a FileSystemSyncAccessHandle, and writes are full snapshots debounced ~250 ms, with close() forcing a final flush. Where sync access isn't available (Node, insecure context) it degrades to in-memory instead of crashing.
The interesting problem is the second tab. Two workers, one OPFS file — early versions let the second tab silently fork and diverge. The 0.3.x fix is single-writer leader election: every store worker requests an exclusive Web Lock ferrovec-leader:<name>; the winner owns the OPFS file and the only engine, and the losers become followers that proxy insert/query/remove to the leader over a BroadcastChannel as correlation-id round-trips. One authoritative index, so no tab can diverge. db.role tells you which you got: leader, follower, or solo. (Both OPFS sync handles and Web Locks need a secure context, so this switches off gracefully off HTTPS.)
See it run
The live demo is the real wasm core running semantic search over 24 sentence embeddings entirely in your tab — no server, no network, the wasm binary and vectors baked into one HTML file.
Status, honestly
Roadmap complete, both registries at 0.3.1:
| Milestone | Ships | Where |
|---|---|---|
| M1–M2 + compaction | HNSW core, WASM boundary, SIMD128 kernel | crates.io |
| M3–M6 | transformers.js embeddings, OPFS persistence, 3-line API, leader election | npm |
Two things this post does not contain: benchmarks and recall numbers — I haven't measured them, so I won't quote them. The ~33 KB is the reported build size; everything else here you can read out of the source or reproduce from a clone. Benchmarks are the next post, and they'll ship with the commands to reproduce them.
- Project site: https://singhpratech.github.io/ferrovec/
- Live demo: https://singhpratech.github.io/ferrovec/demo.html
- crates.io: https://crates.io/crates/ferrovec ·
cargo add ferrovec - npm: https://www.npmjs.com/package/ferrovec ·
npm install ferrovec - GitHub: https://github.com/singhpratech/ferrovec
ferrovec is an independent open-source project (MIT). Nothing here is a benchmark. transformers.js is a HuggingFace project used by the browser package.
Top comments (0)