ferrovec: A WASM-First HNSW Vector Index for In-Browser Approximate Nearest-Neighbor Search
Prateek Singh

Abstract
ferrovec is a dependency-light HNSW vector index for approximate nearest-neighbor search, built to compile to WebAssembly. Mature Rust HNSW crates hard-depend on rayon, mmap-rs, or num_cpus and do not build for wasm32-unknown-unknown; ferrovec owns its graph instead, depending only on serde and postcard, denying unsafe code crate-wide outside one scoped SIMD128 kernel, and replacing system randomness with a deterministic seeded splitmix64 PRNG so no getrandom enters the dependency tree. This report covers the layered HNSW core and its three distance metrics, deterministic construction and compaction, a versioned serialization format that reloads identically native or in-browser, and a browser package layering transformers.js auto-embedding, OPFS persistence, and cross-tab single-writer leader election into a three-line API. No benchmarks are claimed; none are measured in the repository.
1. Introduction
Approximate nearest-neighbor (ANN) search over high-dimensional embeddings is the retrieval primitive underneath semantic search, recommendation, and retrieval-augmented generation. In production it is usually a network service: a managed vector database, or a self-hosted index sitting behind an API. That shape carries a fixed cost — a server to provision, a round-trip to pay, and a copy of the user's data leaving the user's machine. For a large and growing class of applications — private note search, offline-capable web apps, on-device personalization — that cost buys nothing the application needed. What those applications want is an index that runs in the page: the vectors, the graph, and the query all resident in a browser tab, working offline, with nothing crossing the network.
The natural way to put a fast retrieval core in a browser is the pattern every successful WebAssembly application has used: write the engine in a systems language, compile it to WebAssembly, and expose a small JavaScript surface over it. The plan for ferrovec was exactly that — a hand-rolled HNSW core in Rust, compiled to wasm32-unknown-unknown, wrapped in a plain JS API. The obstacle was not the pattern but the ecosystem: the mature Rust HNSW crates do not compile to the browser target. They hard-depend on rayon for data parallelism, mmap-rs for memory-mapped storage, or num_cpus for thread-pool sizing — reasonable dependencies on a server, and immovable walls in a browser, where there are no OS threads to fork, no files to memory-map, and no CPU count to query. None of those crates has a wasm32-unknown-unknown backend.
ferrovec is the result of owning the graph rather than importing it. It is a tiny, dependency-light ANN index whose reason to exist is that it fits where the established crates do not, without sacrificing the algorithm they implement. This report describes the core algorithm and its knobs, the determinism that makes browser deployment possible and compaction honest, the serialization format, the single place unsafe is permitted, and the browser package that turns the raw core into a three-line semantic-search API. It reports no benchmarks: none are measured in the repository, and everything below is a structural property that can be read from the source or reproduced from a fresh clone.
2. The HNSW Core
ferrovec implements Hierarchical Navigable Small World (HNSW) graphs — the same ANN algorithm behind Pinecone, Weaviate, and Qdrant. HNSW is fast because it never scores the query against the whole dataset. It builds a stack of proximity graphs: the bottom layer (layer 0) holds every node, densely connected to its near neighbors; each layer above is a progressively sparser sample of the nodes below it, with longer hops between them — an express network layered over local streets. A search enters at the top layer, greedily walks toward the query until it can get no closer on the current layer, then descends one level and repeats. By the time it reaches the dense bottom layer it is already in the right neighborhood, so only a handful of local comparisons remain. The maximum layer count is capped (MAX_LEVEL = 32), and a node's top layer is drawn from an exponentially decaying distribution.
The whole core is one file, hnsw.rs — a little under 700 lines of the roughly 1,086-line crate. A node carries its identifier, a per-layer list of neighbor indices, and a deleted flag. Candidate queues are ordered by distance with f32::total_cmp, so a stray NaN can never corrupt the heap ordering. The public surface is small and upsert-oriented:
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);
Hnsw::new(dims) builds an index with the Cosine metric; insert(id, &[f32]) adds a vector and replaces any existing vector under the same id (an upsert); search(query, k) returns a Vec<Neighbor> of Neighbor { id, distance } values, nearest first. The rest of the surface is the obvious complement: remove(id) -> bool, contains(id) -> bool, len(), is_empty(), and dims().
2.1 Distance metrics
Three metrics are available, all expressed so that smaller means closer, which lets the same ordering logic serve every metric:
| Metric | Value | Notes |
|---|---|---|
Cosine (default) | 1 - cos(a, b) | zero-norm vectors yield 1.0 |
Dot | 1 - dot(a, b) | for already-normalized vectors |
L2 | squared Euclidean distance | raw coordinates; monotone in true L2 |
Vectors that are already L2-normalized — sentence embeddings, typically — pair naturally with Cosine or Dot. Using squared Euclidean rather than its square root is a standard optimization: the square root is monotone, so it never changes which neighbor is nearest, and skipping it removes a transcendental from the inner loop.
2.2 Construction and search parameters
Graph quality is governed by a small Config. The defaults follow the HNSW literature, and the layer-0 fan-out is deliberately asymmetric — layer 0 is permitted up to 2 × max_connections edges per node, the standard HNSW arrangement that keeps the base layer well-connected without inflating the sparser upper layers.
| Parameter | Meaning | Default |
|---|---|---|
max_connections (M) | neighbors kept per node per layer (layer 0: up to 2M) | 16 |
ef_construction | candidate-list size while building | 200 |
ef_search | candidate-list size while querying | 50 |
metric | distance metric | Cosine |
seed | seed for the deterministic PRNG | 0x9E3779B97F4A7C15 |
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,
});
M, ef_construction, and ef_search together form the recall-versus-work dial: larger values build a denser graph and examine more candidates, trading time and memory for accuracy. ferrovec exposes these directly rather than hiding them behind a preset, so the caller owns the trade-off.
3. Determinism as an Enabling Constraint
Standard HNSW implementations draw a fresh random value per insertion to assign each new node its top layer, and they reach for the operating system's entropy source to do it. In Rust that means the getrandom crate, and getrandom is precisely what breaks the browser target: it has no wasm32-unknown-unknown backend unless the caller wires up a JavaScript shim by hand. A single innocuous call to the system RNG is enough to make an otherwise-portable index refuse to compile for the browser.
ferrovec removes the dependency entirely. It carries its own deterministic splitmix64 pseudo-random generator, seeded from Config::seed (default 0x9E3779B97F4A7C15), and uses it for every layer assignment. There is no getrandom anywhere in the dependency tree, so the crate lands on wasm32-unknown-unknown with no shims and no configuration.
The payoff is larger than portability. Because the randomness is seeded and internal, construction is reproducible: inserting the same vectors in the same order with the same seed produces byte-identical graphs on a developer's laptop and in an end user's browser. That reproducibility is not a curiosity — it is the property that makes the compaction guarantee in the next section exact rather than approximate.
4. Incremental Maintenance: Tombstones and Compaction
Deletion from a navigable small-world graph is genuinely hard: physically removing a node can sever the paths that other nodes relied on to remain reachable, degrading recall in ways that are expensive to detect and repair. ferrovec takes the pragmatic route. remove(id) — and upserting a new vector onto an existing id — only tombstones the node: it stays in the graph to preserve connectivity but is filtered out of every search result and reported absent by contains. This keeps deletion correct and cheap. The cost is that sustained churn slowly accumulates dead nodes, growing memory with vectors no query will ever return.
compact() (added in 0.2.0) reclaims that space by rebuilding the index in place from the live vectors only. This is where determinism is spent: compact 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. Live search results are unchanged by compaction; only the tombstoned memory is released. clear() is the related reset — it empties the index while retaining its dimensionality and configuration, leaving it immediately reusable.
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 remain correct afterward
5. Serialization: The Same Bytes, Native or Browser
An index is a durable artifact, so ferrovec ships a compact binary format with a versioned header rather than relying on a general-purpose format alone. The layout is deliberately plain: an 8-byte header — the four magic bytes FVEC followed by a little-endian 32-bit format version (currently FORMAT_VERSION = 1) — and then a postcard-encoded payload holding the nodes, their per-layer neighbor lists, and the id table.
let bytes = index.to_bytes().unwrap(); // FVEC header + postcard payload
let restored = Hnsw::from_bytes(&bytes).unwrap();
// restored.search(...) matches index.search(...)
to_bytes() returns a Vec<u8>; from_bytes(&[u8]) restores it. The versioned header does two quiet but important jobs. First, portability: the identical byte stream reloads natively or in the browser, because the format is target-independent. Second, safety: a wrong or future format is detected — the crate returns a typed error (a bad or missing magic header, or an unsupported version) instead of silently misreading arbitrary bytes as a graph.
6. SIMD Kernels and the Single Permitted unsafe
Distance computation is the inner loop of every ANN operation, so it is the one place worth hand-optimizing. ferrovec keeps scalar dot and squared-L2 functions as the always-available correctness reference. On wasm32 with the simd128 feature enabled, the public dot / l2_sq dispatch swaps in a hand-written SIMD128 kernel that accumulates the products in four independent lanes and folds them at the end; on every other target it forwards straight to the scalar path.
Crucially, that SIMD128 kernel is the only code in the crate permitted to use unsafe. The crate declares #![deny(unsafe_code)] at the root, and the kernel carries a narrowly scoped #[allow(unsafe_code)] with safety comments explaining each intrinsic; all native, non-SIMD code is unsafe-free. Four-lane accumulation is not bit-identical to a strictly left-to-right scalar sum — under IEEE-754 floating point, reassociating the additions can differ by a few units in the last place. That difference is real but immaterial to the task: a wasm integration test asserts that search returns the same nearest id as a scalar brute-force computed alongside it, so the few-ULP wobble never changes which neighbor wins. Correctness is defined by ordering, and the ordering is preserved.
7. Crossing Into the Browser
The Rust core exposes a FerrovecCore class through wasm-bindgen, built with wasm-pack build --target bundler --release into a pkg/ directory (the wasm binary — reported at ~33 KB gzipped — plus JS bindings and TypeScript types). This is the low-level surface: the caller brings its own embeddings as a Float32Array. A separate cargo build --target wasm32-unknown-unknown smoke-tests wasm compatibility without packaging.
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);
The raw core still asks the caller to produce embeddings. The npm browser package closes that gap. On top of the wasm core it layers automatic embedding via transformers.js running on a dedicated Web Worker — so neither the model nor the index touches the main thread — and OPFS-backed 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, returns an id
const hits = await db.query("a napping kitten", 5); // → [{ id, text, score }, ...]
The caller passes text; the package embeds it, indexes it, and searches it, entirely within the tab. Each hit carries the original text back alongside a cosine-similarity score where higher means closer (a value near 1.0 indicates a near-duplicate). The complementary db.remove(id), db.size(), and db.close() (a final flush followed by worker termination) round out the surface. No snippet above makes a network call, and no server sits on the other end of one.
8. Persistence and Multi-Tab Coordination
By default an index persists to the Origin Private File System (OPFS) so it survives page reloads. The worker opens ferrovec/<name>/index.bin through a FileSystemSyncAccessHandle — an interface available only inside a worker and only in a secure context. Writes are full snapshots, debounced roughly 250 ms: each insert or remove marks the store dirty and schedules a single write-at-offset-zero, truncate, and flush, so a burst of writes collapses into one disk write rather than many. close() forces a final synchronous flush before releasing the handle, and each snapshot frames the serialized index and its id-to-text sidecar together so a reload is atomic. Where OPFS sync access is unavailable — Node, an insecure context, an unsupported browser — the store degrades quietly to in-memory rather than crashing, and db.persistent reports the resolved mode. Persistence can be opted out with open(name, { persist: false }).
The failure that only appears in real use is the second tab. Two workers, one OPFS file: earlier versions let a 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 0.3.x answer is a proper distributed-systems construction — single-writer leader election. Every store worker requests an exclusive Web Lock named ferrovec-leader:<name>. Exactly one worker wins it and becomes the leader: it opens the OPFS file and owns the only engine and the only writer. The other tabs become followers and proxy their insert, query, remove, and size calls to the leader over a BroadcastChannel named ferrovec-coord:<name>, as correlation-id request/response round-trips answered by the leader's engine.
Because there is exactly one authoritative index, every tab observes every other tab's writes and none of them can diverge — the system is always consistent by construction rather than by reconciliation. A handle reports which role it took through db.role: 'leader', 'follower', or 'solo'. Both OPFS sync handles and the Web Locks API require a secure context (HTTPS or localhost), so persistence and coordination switch off gracefully — never fatally — where the context is insecure.
9. Status and Roadmap
The roadmap is complete and both registries are published at 0.3.1. The Rust core is on crates.io; the full browser package is on npm. The split is clean: crates.io carries the algorithm, the wasm boundary, the SIMD kernel, and compaction, while npm layers the embedding, persistence, ergonomics, and coordination on top.
| Milestone | Deliverable | Registry | Status |
|---|---|---|---|
| M1 | Pure-Rust HNSW core | crates.io | Shipped |
| M2 | WASM boundary (FerrovecCore) + SIMD128 kernel | crates.io | Shipped |
| — | compact() / clear() in-place compaction | crates.io | Shipped |
| M3 | Web Worker + transformers.js auto-embedding | npm | Shipped |
| M4 | OPFS-backed persistence | npm | Shipped |
| M5 | Three-line browser API | npm | Shipped |
| M6 | Cross-tab single-writer leader election | npm | Shipped |
Two categories of claim are deliberately absent from this report: performance benchmarks and recall measurements. Neither is measured in the repository, so neither is quoted. The ~33 KB gzipped figure is the size the wasm build is reported to produce, presented as a reported value rather than a result reproduced here; every other property above can be read directly from the source or reproduced from a fresh clone — for example, wasm compatibility via cargo build --target wasm32-unknown-unknown. For a young library, an accurate description of what the system is is more useful than a number that cannot yet be stood behind. A benchmarking study, with the commands to reproduce it, is the natural next report.
A self-contained demonstration is available: the live demo runs the real wasm core over 24 sentence embeddings entirely in the browser tab, with the wasm binary and vectors baked into a single HTML file — no server and no network request in the query path.
10. References and Resources
- Yu. A. Malkov and D. A. Yashunin. Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. arXiv:1603.09320, 2016. arxiv.org/abs/1603.09320.
- ferrovec — source, README, and demo. github.com/singhpratech/ferrovec (MIT).
- ferrovec on crates.io (Rust core). crates.io/crates/ferrovec · API docs: docs.rs/ferrovec.
- ferrovec on npm (browser package). npmjs.com/package/ferrovec.
- Project site and live demo. singhpratech.github.io/ferrovec · demo.html.
- transformers.js (HuggingFace) — used by the browser package for auto-embedding. github.com/huggingface/transformers.js.
- MDN — Origin Private File System. developer.mozilla.org.
- MDN — Web Locks API. developer.mozilla.org.
ferrovec is an independent open-source project (MIT). Every property described here was read from the repository at v0.3.1 on crates.io and npm; none of the claims are benchmarks, and none are measured performance, recall, or adoption figures. The ~33 KB gzipped value is the project's reported wasm build size. transformers.js is a HuggingFace project used by the browser package; no affiliation is claimed.