Skip to content

Commit 0cb3e06

Browse files
committed
ferrovec 0.2.0 — dependency-light Rust/WASM HNSW vector search
A hand-rolled HNSW vector index in Rust (native + wasm32), a FerrovecCore wasm-bindgen boundary with a SIMD128 distance kernel, in-place compaction, and a three-line browser package (transformers.js embeddings + OPFS persistence). Published on crates.io and npm; site at https://singhpratech.github.io/ferrovec/
0 parents  commit 0cb3e06

37 files changed

Lines changed: 7154 additions & 0 deletions

.cargo/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[target.wasm32-unknown-unknown]
2+
rustflags = ["-C", "target-feature=+simd128"]

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/target
2+
js/pkg
3+
node_modules
4+
Cargo.lock
5+
6+
# M3 JS package
7+
js/node_modules
8+
js/dist
9+
js/.tsup
10+
.tsup
11+
*.tsbuildinfo

Cargo.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[package]
2+
name = "ferrovec"
3+
version = "0.2.0"
4+
edition = "2021"
5+
description = "A tiny, dependency-light HNSW vector index for approximate nearest-neighbor search — built to compile to WebAssembly."
6+
license = "MIT"
7+
repository = "https://github.com/singhpratech/ferrovec"
8+
homepage = "https://singhpratech.github.io/ferrovec/"
9+
readme = "README.md"
10+
keywords = ["hnsw", "vector-search", "embeddings", "ann", "wasm"]
11+
categories = ["algorithms", "data-structures", "wasm"]
12+
# Keep the published crate lean — the JS package, site, and build outputs
13+
# are not part of the Rust crate.
14+
exclude = ["js/", "docs/", "pkg/", "PUBLISHING.md", ".github/", "*.html"]
15+
16+
[lib]
17+
crate-type = ["cdylib", "rlib"]
18+
19+
[dependencies]
20+
serde = { version = "1", features = ["derive"] }
21+
postcard = { version = "1", features = ["alloc"] }
22+
23+
# wasm-only boundary deps: native builds (and `cargo publish`) never see these.
24+
[target.'cfg(target_arch = "wasm32")'.dependencies]
25+
wasm-bindgen = "0.2"
26+
js-sys = "0.3"
27+
serde-wasm-bindgen = "0.6"
28+
console_error_panic_hook = "0.1"
29+
30+
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
31+
wasm-bindgen-test = "0.3"
32+
33+
[profile.release]
34+
opt-level = "s"
35+
lto = true
36+
panic = "abort"
37+
codegen-units = 1

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 singhpratech
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# ferrovec ▲
2+
3+
**A tiny, dependency-light [HNSW](https://arxiv.org/abs/1603.09320) vector index for approximate nearest-neighbor search — built to compile to WebAssembly.**
4+
5+
[![crates.io](https://img.shields.io/crates/v/ferrovec.svg)](https://crates.io/crates/ferrovec)
6+
[![docs.rs](https://img.shields.io/docsrs/ferrovec)](https://docs.rs/ferrovec)
7+
[![downloads](https://img.shields.io/crates/d/ferrovec.svg)](https://crates.io/crates/ferrovec)
8+
[![license](https://img.shields.io/crates/l/ferrovec.svg)](./LICENSE)
9+
[![wasm](https://img.shields.io/badge/wasm32-ready-5B48D8.svg)](https://developer.mozilla.org/en-US/docs/WebAssembly)
10+
11+
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.
12+
13+
- 🦀 **Rust core** — a hand-rolled HNSW graph, the same algorithm behind Pinecone, Weaviate, and Qdrant.
14+
- 🪶 **Featherweight**`serde` + `postcard` are the *only* dependencies. The WASM build is ~33 KB gzipped.
15+
- 🔒 **No `unsafe`** outside the audited SIMD kernel (`#![deny(unsafe_code)]` crate-wide), and **no system randomness** (a deterministic seeded splitmix64 PRNG) — so it's happy on `wasm32-unknown-unknown` with no shims.
16+
-**SIMD-accelerated** distance kernels on `wasm32 + simd128`, with a scalar reference fallback everywhere else.
17+
-**Incremental** upsert-style inserts and tombstoning removals — no rebuild-the-whole-index penalty.
18+
- 💾 **Portable** — compact binary (de)serialization with a versioned header; the same bytes reload natively or in the browser.
19+
20+
> **Status — crates.io `0.2.0` is live** (Rust core **M1** + WASM boundary **M2** + in-place [compaction](#compaction--clearing)). The browser package is landing next: transformers.js auto-embedding (**M3**) is done and OPFS persistence (**M4**) ships the three-line API as **npm `0.2.0`**. See the [roadmap](#roadmap).
21+
22+
---
23+
24+
## Install
25+
26+
```toml
27+
[dependencies]
28+
ferrovec = "0.2"
29+
```
30+
31+
## Quick start
32+
33+
```rust
34+
use ferrovec::{Hnsw, Metric, Config};
35+
36+
// A 4-dimensional index using the defaults (Cosine metric).
37+
let mut index = Hnsw::new(4);
38+
39+
index.insert("a", &[1.0, 0.0, 0.0, 0.0]).unwrap();
40+
index.insert("b", &[0.0, 1.0, 0.0, 0.0]).unwrap();
41+
index.insert("c", &[0.9, 0.1, 0.0, 0.0]).unwrap();
42+
43+
let results = index.search(&[1.0, 0.0, 0.0, 0.0], 2).unwrap();
44+
assert_eq!(results[0].id, "a"); // nearest first
45+
assert_eq!(index.len(), 3);
46+
```
47+
48+
### Tuning
49+
50+
```rust
51+
use ferrovec::{Hnsw, Config, Metric};
52+
53+
let index = Hnsw::with_config(
54+
128,
55+
Config {
56+
max_connections: 16, // M — neighbors per node per layer
57+
ef_construction: 200, // build-time candidate list size
58+
ef_search: 50, // query-time candidate list size
59+
metric: Metric::L2,
60+
seed: 42,
61+
},
62+
);
63+
assert_eq!(index.dims(), 128);
64+
```
65+
66+
### Upsert & remove
67+
68+
```rust
69+
use ferrovec::Hnsw;
70+
71+
let mut index = Hnsw::new(2);
72+
index.insert("x", &[0.0, 1.0]).unwrap();
73+
index.insert("x", &[1.0, 0.0]).unwrap(); // replaces the previous "x"
74+
assert_eq!(index.len(), 1);
75+
76+
assert!(index.remove("x"));
77+
assert!(!index.remove("x")); // already gone
78+
assert!(index.is_empty());
79+
```
80+
81+
### Compaction & clearing
82+
83+
`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:
84+
85+
```rust
86+
use ferrovec::Hnsw;
87+
88+
let mut index = Hnsw::new(2);
89+
index.insert("keep", &[1.0, 0.0]).unwrap();
90+
index.insert("drop", &[0.0, 1.0]).unwrap();
91+
index.remove("drop"); // tombstoned, but still occupying memory
92+
93+
index.compact(); // rebuild keeping only live nodes
94+
95+
assert_eq!(index.len(), 1); // live count is unchanged by compaction
96+
assert!(index.contains("keep"));
97+
assert!(!index.contains("drop")); // removed ids stay gone
98+
99+
// Live search results are still correct after compaction.
100+
let hits = index.search(&[1.0, 0.0], 1).unwrap();
101+
assert_eq!(hits[0].id, "keep");
102+
103+
// `clear` empties the index entirely, keeping its dims and config.
104+
index.clear();
105+
assert!(index.is_empty());
106+
index.insert("fresh", &[0.5, 0.5]).unwrap(); // reusable afterwards
107+
assert_eq!(index.len(), 1);
108+
```
109+
110+
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.
111+
112+
### Persistence
113+
114+
```rust
115+
use ferrovec::Hnsw;
116+
117+
let mut index = Hnsw::new(3);
118+
index.insert("p", &[1.0, 2.0, 3.0]).unwrap();
119+
120+
let bytes = index.to_bytes().unwrap(); // -> Vec<u8> (FVEC header + payload)
121+
let restored = Hnsw::from_bytes(&bytes).unwrap();
122+
123+
let a = index.search(&[1.0, 2.0, 3.0], 1).unwrap();
124+
let b = restored.search(&[1.0, 2.0, 3.0], 1).unwrap();
125+
assert_eq!(a, b);
126+
```
127+
128+
## Distance metrics
129+
130+
All metrics are expressed so that **smaller means closer**:
131+
132+
| Metric | Value |
133+
| ---------------- | --------------------------------------- |
134+
| `Metric::Cosine` | `1 - cos(a, b)` (zero-norm ⇒ `1.0`) |
135+
| `Metric::Dot` | `1 - dot(a, b)` |
136+
| `Metric::L2` | squared Euclidean distance |
137+
138+
Vectors that are already L2-normalized (e.g. sentence embeddings) pair naturally with `Cosine` or `Dot`.
139+
140+
## In the browser
141+
142+
`ferrovec` compiles to WebAssembly and exposes a `FerrovecCore` class through `wasm-bindgen`. Build it with [`wasm-pack`](https://rustwasm.github.io/wasm-pack/):
143+
144+
```sh
145+
wasm-pack build --target bundler --release
146+
# -> pkg/ (ferrovec_bg.wasm ~33 KB gzip, JS bindings, TypeScript types)
147+
```
148+
149+
Then use it from JavaScript — bring your own embeddings as a `Float32Array`:
150+
151+
```js
152+
import { FerrovecCore } from "ferrovec";
153+
154+
const index = new FerrovecCore(384); // 384-dim vectors
155+
index.insert("doc-1", myEmbedding); // Float32Array
156+
const hits = index.search(queryEmbedding, 5); // [{ id, distance }, ...]
157+
158+
const bytes = index.toBytes(); // Uint8Array — persist anywhere
159+
const restored = FerrovecCore.fromBytes(bytes);
160+
```
161+
162+
> The `js/` package wraps this with automatic embedding via transformers.js
163+
> (**M3**, done) and OPFS persistence (**M4**), so the browser API becomes:
164+
> `const db = await Ferrovec.open('notes'); await db.insert(text); const hits = await db.query('…', 5);`
165+
> — shipping as npm `0.2.0`.
166+
167+
To smoke-test WASM compatibility without packaging:
168+
169+
```sh
170+
cargo build --target wasm32-unknown-unknown
171+
```
172+
173+
## Roadmap
174+
175+
| | Milestone | Status |
176+
| --- | --- | --- |
177+
| **M1** | Pure-Rust HNSW core | ✅ shipped (`0.1.0`) |
178+
| **M2** | WASM boundary (`FerrovecCore`) + SIMD128 kernel | ✅ shipped (`0.1.0`) |
179+
| **** | `compact()` / `clear()` compaction | ✅ shipped (crates.io `0.2.0`) |
180+
| **M3** | Web Worker + transformers.js auto-embedding | ✅ done → npm `0.2.0` |
181+
| **M4** | OPFS-backed persistence (survives reloads) | 🚧 finishing → npm `0.2.0` |
182+
| **M5** | `ferrovec` on npm — the three-line browser API | 🚧 → npm `0.2.0` |
183+
| **M6** | Cross-tab leader election (Web Locks) | ⏭ next → npm `0.3.0` |
184+
185+
## Design notes
186+
187+
- **Why hand-rolled?** No mature Rust HNSW crate compiles cleanly to `wasm32-unknown-unknown` — they hard-depend on `rayon`, `mmap-rs`, or `num_cpus`. Owning the graph keeps the dependency tree tiny and the WASM artifact small.
188+
- **Determinism.** The build is reproducible from `Config::seed`; there is no `getrandom` in the dependency tree.
189+
- **Tombstones & compaction.** `remove` marks a node deleted and excludes it from results while keeping it for graph connectivity, so heavy churn grows memory over time. `compact` (added in `0.2.0`) rebuilds the index in place from the live vectors only — deterministically, by rewinding the PRNG to `Config::seed` — reclaiming the space held by tombstoned nodes. `clear` resets the index to empty while keeping its dimensionality and config.
190+
191+
## License
192+
193+
MIT © singhpratech

0 commit comments

Comments
 (0)