npm.io
0.3.3 • Published 2d ago

ferrovec

Licence
MIT
Version
0.3.3
Deps
1
Size
359 kB
Vulns
0
Weekly
0

ferrovec

npm wasm core size Rust core on crates.io license

Text-in vector search for the browser: transformers.js embeddings over a Rust/wasm HNSW core, running on a dedicated Web Worker, with OPFS persistence so your index survives reloads.

You give it text. It embeds, indexes, and searches — off the main thread.

0.3.3 — the three-line browser API (embeddings + OPFS persistence), now safe across multiple tabs via single-writer leader election. · Website · Live demo · GitHub · Rust core on crates.io

Try the live demo — the real WASM core running semantic search over sentence embeddings entirely in your browser tab. No server, no network: the wasm binary and the vectors are baked into a single HTML file.

Install

npm install ferrovec

Three-line API

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 its id)
const hits = await db.query('a napping kitten', 5); // → [{ id, text, score }, ...]

Full surface:

const id = await db.insert('some text', { id: 'doc-1' }); // explicit id (upsert)
const hits = await db.query('query text', 10);            // k nearest, nearest-first
await db.remove('doc-1');                                 // → boolean
await db.size();                                          // → number of live items
await db.close();                                         // final flush + terminate worker

Each hit is { id: string; text?: string; score: number }, where score is cosine similarity (higher = closer, ≈1 for near-duplicates).

Persistence (OPFS)

By default an index is persisted to the Origin Private File System so it survives page reloads:

const db = await Ferrovec.open('notes');   // persist: true by default
// ... insert / remove ...
await db.close();                          // guarantees a final snapshot to disk

// Later — after a full reload — reopening the same name restores everything:
const again = await Ferrovec.open('notes');
await again.size();                        // your items are still there
console.log(again.persistent);             // true when running from disk

How it works:

  • The worker opens ferrovec/<name>/index.bin in OPFS via a FileSystemSyncAccessHandle (worker-only, secure-context-only).
  • Writes are full snapshots, debounced ~250 ms: each insert/remove marks the store dirty and schedules one write(at:0)truncateflush. close() always performs a final synchronous flush before releasing the lock.
  • The snapshot contains the serialized vector index and the id→text sidecar, framed together so a reload restores both atomically.

Graceful degradation — it never crashes:

  • Opt out with Ferrovec.open('notes', { persist: false }) for pure in-memory.
  • Where OPFS sync access is unavailable (Node, insecure context, unsupported browser) it transparently falls back to in-memory.
  • The sync handle takes an exclusive lock, held by exactly one tab. Other tabs opening the same store no longer degrade — they join as followers that share the one persistent store (see Multi-tab below).

Check the resolved mode with db.persistent (true = writing to disk) and the coordination role with db.role ('leader' | 'follower' | 'solo').

Secure context required. OPFS sync access handles (and the Web Locks API used for leader election) only work over HTTPS or http://localhost. Persistence and cross-tab coordination are silently disabled elsewhere.

Multi-tab

Opening the same store name in two tabs used to make the second tab silently degrade to a private in-memory copy — the two would diverge and the second tab's writes never reached disk. As of 0.3.3 ferrovec runs single-writer leader election so multiple tabs safely share one persistent store:

// Tab A
const a = await Ferrovec.open('notes');
a.role; // 'leader'  — owns the OPFS store + the authoritative index
await a.insert('written in tab A', { id: 'x' });

// Tab B (same origin, same name)
const b = await Ferrovec.open('notes');
b.role; // 'follower' — shares A's store, no divergence
await b.query('written in tab A'); // sees A's write immediately
await b.insert('written in tab B', { id: 'y' }); // proxied to A, persisted once

How it works:

  • Leader election (Web Locks). Each store worker requests an exclusive Web Lock named ferrovec-leader:<name>. The tab that wins opens the OPFS store and owns the only Engine + persistence. Exactly one writer exists at a time, so the old lock-contention fallback can never happen.
  • Followers proxy to the leader over a BroadcastChannel (ferrovec-coord:<name>): insert/query/remove/size are correlation-id request/response round-trips answered by the leader's engine. This proxy model is always consistent — there is one authoritative index, so every tab sees every other tab's writes.
  • Failover. When the leader tab closes or crashes, its Web Lock releases and a waiting follower is promoted: it opens the OPFS store (rehydrating from index.bin) and becomes the new leader. In-flight follower requests are retried and transparently answered by the new leader; db.role flips from 'follower' to 'leader' on promotion. Because the promoting tab holds the exclusive lock, it is the legitimate owner of index.bin: on transient OPFS contention it waits for the handle rather than degrading to an empty in-memory copy, and if promotion genuinely fails (a corrupt index, or the handle never frees) it re-queues for the lock instead of dropping out of the election — so a shared store never ends up with zero leaders.
  • Always degrade, never crash. Where the Web Locks API or BroadcastChannel is unavailable (older browser, insecure context, Node), a store opens as role: 'solo' — the sole owner, exactly the single-tab behaviour. (This best-effort in-memory fallback applies only to a solo/no-lock open, never to a lock-holding leader — a legitimate owner is never silently degraded over an intact index.)

Consistency note. Retries make a failover gap invisible; against a live leader they are deduplicated at-most-once — a retry replays a completed response and, if it races an op still executing (e.g. a slow first embed), coalesces onto it rather than starting a second execution, so no duplicate runs. At the exact instant of a failover a request may reach both the old and new leader. Queries, removes, and explicit-id inserts (upserts) are idempotent, so only an auto-id insert racing that instant could double-insert — a documented trade-off of the dependency-free proxy design.

Liveness. A frozen tab (Chrome tab-freezing does not release Web Locks) can't wedge peers forever: a follower's open() and each follower op are bounded and reject with a clear timeout if no leader ever responds. The defaults are generous, so a leader that is merely slow (still loading its model, a slow first embed) is unaffected.

Bundler setup

Ferrovec.open spawns the worker with the standard, statically-analyzable pattern:

new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });

Modern bundlers understand this out of the box:

  • Vite — works with no configuration.
  • webpack 5 — works with no configuration (native new URL + Web Worker support).

The worker (dist/worker.js) and the wasm core (dist/core/) are shipped in the package and resolved relative to the module URL, so no extra copy step is needed. If your bundler does not support the new URL(..., import.meta.url) worker pattern, point it at the published ferrovec/worker entry.

Advanced / Node usage

Ferrovec is the Worker-backed main-thread proxy. For non-browser environments (or custom embedders), drive the Engine directly — it has no Worker/DOM coupling and accepts injected embedders and cores:

import { Engine, createCore } from 'ferrovec';

const engine = await Engine.create({ embedder: myEmbedder, core: await createCore(384) });
await engine.insert('hello world');
const hits = await engine.query('hi there', 5);

The default embedder is Xenova/all-MiniLM-L6-v2 (384-dim). Pass { model } / { device } to Ferrovec.open or Engine.create to change it.

Roadmap

Milestone Status
M1–M2 Rust HNSW core + WASM boundary + SIMD 0.3.3
M3 Web Worker + transformers.js auto-embedding 0.3.3
M4 OPFS persistence (survives reloads) 0.3.3
M5 Three-line browser API on npm 0.3.3
M6 Cross-tab leader election (Web Locks) 0.3.3

The pure-Rust HNSW core is published separately on crates.io.

License

MIT

Keywords