Back to Blog

ferrovec: a Tiny Rust HNSW Vector Index That Runs Semantic Search Inside the Browser Tab

Prateek SinghJuly 18, 202610 min read
ferrovec: a Tiny Rust HNSW Vector Index That Runs Semantic Search Inside the Browser Tab

I wanted semantic search with no server — and every Rust HNSW crate refused to compile to WebAssembly. So I wrote ferrovec: a hand-rolled HNSW vector index whose only Rust dependencies are serde and postcard, that denies unsafe code crate-wide, uses no system randomness, and produces a wasm build the project reports at ~33 KB gzipped. This is the launch: the algorithm, the determinism, the compaction, and the leap into the browser — the WASM core, transformers.js auto-embedding on a Web Worker, OPFS persistence, and single-writer leader election across tabs.

I wanted semantic search that ran with no server. Not "a small server." No server — the vectors, the index, and the query all living inside a browser tab, working offline, with nothing leaving the machine. The plan was the boring, winning one: a fast core in Rust, compiled to WebAssembly, wrapped in a plain JavaScript API. The same pattern behind every WebAssembly app that actually shipped.

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 choices on a server, and immovable walls in the browser, where there are no threads to fork, no files to memory-map, and no CPUs to count. So I wrote my own. ferrovec is the result: a tiny, dependency-light approximate-nearest-neighbor index whose whole reason to exist is that it fits where the others don't.

What it is

ferrovec is a hand-rolled HNSW vector index — Hierarchical Navigable Small World, the same approximate-nearest-neighbor algorithm behind Pinecone, Weaviate, and Qdrant. It went up at 0.3.1 on both registries: the Rust core on crates.io, and the full browser package on npm. The design constraints were unusually strict, on purpose:

  • Featherweight. The Rust core's only dependencies are serde and postcard. The wasm build is reported at ~33 KB gzipped.
  • No unsafe outside one audited SIMD kernel — #![deny(unsafe_code)] is on crate-wide, and the single exception is a scoped, safety-commented SIMD128 path.
  • No system randomness. A deterministic seeded splitmix64 PRNG means there is no getrandom in the tree, which is exactly what lets it land on wasm32-unknown-unknown with no shims.
  • Incremental and portable. Upsert-style inserts, tombstoning removals, and a compact binary format with a versioned header — the same bytes reload natively or in the browser.
ferrovec core — compiles to wasm32 serde postcard 2 dependencies, total ~33 KB gzipped wasm reported build size · the whole ANN engine what mature HNSW crates pull — and the browser can't run rayon mmap-rs num_cpus ✗ no wasm32 backend
The Rust core depends on serde and postcard and nothing else, and the wasm build is reported at ~33 KB gzipped. The mature HNSW crates pull rayon, mmap-rs, or num_cpus — none of which have a wasm32-unknown-unknown backend, which is why none of them can run in the browser.

Here is 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
assert_eq!(index.len(), 3);

search hands back Neighbor { id, distance } values, nearest first. Three metrics are available, all expressed so that smaller means closer:

MetricValuePairs well with
Cosine (default)1 - cos(a, b) (zero-norm ⇒ 1.0)sentence embeddings
Dot1 - dot(a, b)already-normalized vectors
L2squared Euclidean distanceraw coordinates

How HNSW finds a neighbor without checking every vector

The reason an ANN index is fast is that it never scores your query against the whole dataset. HNSW builds a hierarchy of graphs. The bottom layer holds every node, densely connected to its neighbors. Each layer above is a sparser sample — fewer nodes, longer hops — like an express lane stacked over local streets. A search enters at the top, greedily walks toward the query until it can't get any closer on that layer, then drops down a level and repeats. By the time it reaches the dense bottom layer it's already in the right neighborhood, so only a handful of local comparisons remain.

Layer 2 · sparse · entry Layer 1 Layer 0 · every node · dense enter here nearest
A query enters at the sparse top layer, greedily hops toward the target, then descends layer by layer into the dense layer 0 where it settles on its nearest neighbor. Only the traced nodes are ever scored — not the whole set.

ferrovec's core is one file, hnsw.rs, a little under 700 lines. Nodes carry their per-layer neighbor lists; the candidate queues order by distance with f32::total_cmp so a stray NaN can never scramble the heap. You tune the graph through Config:

let index = Hnsw::with_config(128, Config {
    max_connections: 16,   // M — neighbors kept per node per layer
    ef_construction: 200,  // candidate-list size while building
    ef_search: 50,         // candidate-list size while querying
    metric: Metric::L2,
    seed: 42,
});

Those three knobs are the whole recall-versus-speed dial: bigger M and ef mean a denser graph and more candidates examined — better recall, more work. Layer 0 is allowed up to 2 × M connections, which is the standard HNSW asymmetry.

Determinism is a feature, not a detail

Most index libraries reach for the operating system's random source to assign each new node its top layer. That single call is what makes them impossible to compile for the browser target — getrandom has no wasm32-unknown-unknown backend without a JavaScript shim you have to wire up yourself. ferrovec side-steps the whole problem by carrying its own splitmix64 PRNG seeded from Config::seed. There is no getrandom anywhere in the dependency tree.

The payoff is bigger than portability. Because the randomness is seeded, a build is reproducible: insert the same vectors in the same order with the same seed and you get byte-identical graphs on your laptop and in a user's browser. That property is what makes the next feature honest.

Removals, tombstones, and getting the memory back

Deleting from a navigable graph is genuinely tricky: yank a node out and you can sever the paths that other nodes relied on to stay reachable. ferrovec does the pragmatic thing — remove (and upserting a new vector onto an existing id) only tombstones the node. It stays in the graph for connectivity but is filtered out of every result. Correct, and cheap. The catch is that heavy churn slowly grows memory with dead nodes that no query will ever return.

So there's compact, which rebuilds the index in place from the live vectors only, and here is where determinism pays off: it rewinds the PRNG to Config::seed before rebuilding, so a compacted index is byte-for-byte identical to a fresh build of the same survivors inserted in the same order.

index.remove("drop");   // tombstoned — still occupying memory
index.compact();        // rebuild from live nodes only; PRNG rewound to seed

assert_eq!(index.len(), 1);        // live count unchanged
assert!(index.contains("keep"));
assert!(!index.contains("drop"));  // removed ids stay gone
// live search results are still correct afterward
before compact() — 3 live, 5 tombstoned (still using memory) compact() PRNG rewound to seed → identical to a fresh build of the survivors after compact() — 3 live, memory reclaimed tombstones gone · len() unchanged
remove and upserting insert only tombstone a node — it keeps the graph connected but lingers in memory. compact() rebuilds from the live nodes only, rewinding the PRNG to the seed so the result is byte-identical to a fresh build of the survivors. (Counts shown are illustrative.)

The same bytes, native or browser

Persistence is a compact binary format with a versioned header — four magic bytes, FVEC, then a format version, then the payload. to_bytes gives you a Vec<u8>; from_bytes restores it. The point of the versioned header is boring and important: the same serialized index reloads natively or in the browser, and a future format change can be detected instead of silently misread.

let bytes = index.to_bytes().unwrap();          // FVEC header + payload
let restored = Hnsw::from_bytes(&bytes).unwrap();
// restored.search(...) == index.search(...)
F V E C magic · 4 bytes version = 1 u32 · 4 bytes payload — postcard-encoded graph nodes · per-layer neighbors · ids Versioned header ⇒ the same bytes reload natively or in the browser, and a format change is detected, not misread.
The serialized index starts with four magic bytes — FVEC — then a 32-bit format version, then the postcard-encoded payload. The versioned header is what lets the same bytes reload natively or in the browser, and lets a future format change be detected instead of silently misread.

The SIMD kernel, and the one place unsafe is allowed

Distance is the inner loop of everything an ANN index does, so it's the one place worth hand-optimizing. ferrovec keeps scalar dot and squared-L2 functions as the always-available correctness reference. On wasm32 + simd128 the public dispatch swaps in a hand-written SIMD128 kernel that accumulates in four independent lanes and folds them at the end — and that kernel is the only code in the crate permitted to use unsafe, under a scoped #[allow(unsafe_code)] with safety comments, while everything else lives under the crate-wide deny.

Four-lane accumulation isn't bit-identical to a strictly left-to-right scalar sum — under IEEE-754 the two can differ by a few units in the last place. That could worry you, so the crate pins it down: a wasm integration test asserts that search returns the same nearest id as a scalar brute-force computed alongside it. The few-ULP wobble never changes who wins.

Crossing into the browser

The Rust core exposes a FerrovecCore class through wasm-bindgen. Built with wasm-pack, it's the low-level surface — you bring your own embeddings as a Float32Array:

import { FerrovecCore } from "ferrovec";

const index = new FerrovecCore(384);            // 384-dim vectors
index.insert("doc-1", myEmbedding);             // Float32Array
const hits = index.search(queryEmbedding, 5);   // [{ id, distance }, ...]

const bytes = index.toBytes();                  // Uint8Array — persist anywhere
const restored = FerrovecCore.fromBytes(bytes);

That's powerful but it still asks you to produce embeddings yourself. The npm package closes that gap. On top of the wasm core it layers automatic embedding via transformers.js, a dedicated Web Worker so none of it runs on your main thread, and OPFS persistence — and 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 }, ...]

You give it text; it embeds, indexes, and searches, entirely in the tab. Each hit carries the original text back and a cosine-similarity score where higher is closer. There is no network call in that snippet and no server on the other end of one.

Surviving a reload — and surviving a second tab

By default an index is persisted to the Origin Private File System so it outlives the page. The worker opens ferrovec/<name>/index.bin through a FileSystemSyncAccessHandle, and writes are full snapshots debounced around 250 ms — each insert or remove marks the store dirty and schedules a single write-truncate-flush, with close() forcing one final synchronous flush before it lets go. Where sync access isn't available — Node, an insecure context, an unsupported browser — it degrades quietly to in-memory instead of crashing.

insert /remove mark dirty debounce~250 ms write@0 · truncate · flushclose() ⇒ final synchronous flush OPFS persistence — full-snapshot write path
Each insert or remove marks the store dirty and schedules a single snapshot roughly 250 ms later — write at offset zero, truncate, flush — so a burst of writes collapses into one disk write instead of many. close() forces a final synchronous flush before releasing the lock.

Then there's the problem that only shows up in real use: the user opens your app in a second tab. Two workers, one OPFS file. Early versions let the second tab silently fork into its own in-memory copy, and the two would drift apart with the second tab's writes never reaching disk. The fix in 0.3.x is a proper distributed-systems answer — single-writer leader election.

Tab A — leader holds Web Lock owns Engine + index writes OPFS snapshots OPFS · index.bin Tab B — follower no local index proxies to leader Tab C — follower no local index proxies to leader BroadcastChannel · ferrovec-coord:notes
Each tab's worker requests an exclusive Web Lock named ferrovec-leader:. The winner owns the OPFS file and the only engine; the others become followers that proxy their insert/query/remove calls to the leader over a BroadcastChannel. One authoritative index, so no tab can diverge.

Every store worker asks for an exclusive Web Lock named ferrovec-leader:<name>. Exactly one tab wins it and becomes the leader: it opens the OPFS store and holds the only engine and the only writer. The other tabs become followers and proxy their insert / query / remove / size calls to the leader over a BroadcastChannel as correlation-id request/response round-trips. Because there is exactly one authoritative index, every tab sees every other tab's writes and none of them can diverge. You can check which role a handle took with db.role'leader', 'follower', or 'solo'. (OPFS sync handles and Web Locks both require a secure context, so persistence and coordination switch off gracefully off HTTPS.)

See it run

The clearest proof is the live demo: the real wasm core running semantic search over 24 sentence embeddings, entirely in your browser tab. There's no server and no network request — the wasm binary and the vectors are baked into a single HTML file. Type a query, watch it rank by meaning rather than keyword. That single self-contained page is the whole thesis of the project made concrete.

Where it stands

The roadmap is complete and both registries are on 0.3.1. The Rust core (the HNSW graph, the WASM boundary, the SIMD128 kernel, compaction) ships on crates.io; the browser package (transformers.js auto-embedding, OPFS persistence, the three-line API, and cross-tab leader election) ships on npm.

crates.io · Rust core npm · browser package M1core M2wasm+SIMD compactclear M3embeddings M4OPFS M53-line API M6multi-tab roadmap complete — both registries on 0.3.1
Every milestone is shipped. The Rust core (M1 HNSW graph, M2 WASM boundary + SIMD128, and compaction) is on crates.io; the browser package (M3 Web Worker + transformers.js embeddings, M4 OPFS persistence, M5 the three-line API, M6 cross-tab leader election) is on npm. Both registries are on 0.3.1.

Two things this post deliberately does not contain: benchmarks and recall numbers. I haven't measured them, so I'm not going to quote them — the ~33 KB is the size the build reports, and everything else here is a property you can read straight out of the source or reproduce from a fresh clone with cargo build --target wasm32-unknown-unknown. For a young library, an honest description of what it is seemed better than a number I can't stand behind. The benchmarks are the obvious next post, and they'll come with the commands to reproduce them.

Find ferrovec

ferrovec is an independent open-source project (MIT). Every property described here was read from the repository at v0.3.1; none of the claims are benchmarks. transformers.js is a HuggingFace project used by the browser package.

Share this article

Related Posts