Client-side vector search. Rust + WASM. 54KB. Sub-millisecond.
Zero server. Zero API keys. Zero per-query cost. Your users' data never leaves their browser.
altor-vec is an HNSW vector similarity search engine written in Rust that compiles to 54KB of WebAssembly. Search 10,000 vectors in under 1ms — entirely client-side.
Is this for you?
- Docs site — want semantic search without Algolia DocSearch fees?
- Using Fuse.js — need it to understand meaning, not just character similarity? ("cancel subscription" should find "end your plan")
- React / Next.js app — want vector search without a server or API keys?
- Privacy requirement — queries must never leave the device?
Why altor-vec?
| altor-vec | Algolia | Fuse.js | Orama | Voy | |
|---|---|---|---|---|---|
| Runs client-side | ✅ | ❌ server | ✅ | ✅ | ✅ |
| Semantic (meaning-based) | ✅ HNSW | ✅ paid add-on | ❌ fuzzy text | partial | ❌ |
| Bundle size | 54KB gz | N/A | ~5KB | ~2KB* | 75KB gz |
| p95 latency | 0.6ms | ~50ms (network) | ~2ms | ~5ms | ~2ms |
| Per-query cost | $0 | $0.50/1K | $0 | $0 | $0 |
| "cancel" → "end subscription" | ✅ | ✅ | ❌ | partial | ❌ |
*Orama's 2KB is keyword-only; vector search adds significant size.
vs Fuse.js
Fuse.js is great for fuzzy string matching. altor-vec does semantic matching — meaning, not characters.
| altor-vec | Fuse.js | |
|---|---|---|
| Algorithm | HNSW (vector similarity) | Bitap / Levenshtein |
| "cancel plan" → "end subscription" | ✅ | ❌ |
| Typo tolerance | Via semantic neighbors | ✅ native |
| Bundle size | 54KB WASM | ~5KB |
| Needs embeddings | Yes (build-time) | No |
→ Full comparison: altorlab.dev/vs/fuse-js
Get started in 30 seconds
import init, { WasmSearchEngine } from 'altor-vec'; await init(); const resp = await fetch('/search-index.bin'); const engine = WasmSearchEngine.from_bytes(new Uint8Array(await resp.arrayBuffer())); // Search returns in <1ms const results = JSON.parse(engine.search(queryEmbedding, 5)); // => [[nodeId, distance], ...]
→ Full guide: altorlab.dev/getting-started
Framework quickstarts
React
import { useState, useEffect, useRef } from 'react'; import init, { WasmSearchEngine } from 'altor-vec'; export function SearchWidget({ docs }) { const engineRef = useRef(null); const [results, setResults] = useState([]); useEffect(() => { init().then(async () => { const res = await fetch('/search-index.bin'); engineRef.current = WasmSearchEngine.from_bytes( new Uint8Array(await res.arrayBuffer()) ); }); }, []); async function handleSearch(queryEmbedding) { const hits = JSON.parse(engineRef.current.search(queryEmbedding, 5)); setResults(hits.map(([id]) => docs[id])); } return <input onChange={e => /* embed then handleSearch() */ null} />; }
→ altorlab.dev/guides/react/document-search
Next.js (App Router)
'use client'; import { useRef, useEffect } from 'react'; import init, { WasmSearchEngine } from 'altor-vec'; export default function Search() { const engineRef = useRef(null); useEffect(() => { init().then(async () => { const res = await fetch('/search-index.bin'); engineRef.current = WasmSearchEngine.from_bytes( new Uint8Array(await res.arrayBuffer()) ); }); }, []); // ... }
→ altorlab.dev/guides/nextjs/document-search
Vue 3
<script setup> import { onMounted } from 'vue'; import init, { WasmSearchEngine } from 'altor-vec'; let engine; onMounted(async () => { await init(); const res = await fetch('/search-index.bin'); engine = WasmSearchEngine.from_bytes(new Uint8Array(await res.arrayBuffer())); }); </script>
→ altorlab.dev/guides/vue/document-search
Building the index (once, at deploy time)
// scripts/build-search-index.mjs import { pipeline } from '@huggingface/transformers'; import { WasmSearchEngine } from 'altor-vec/node'; import fs from 'fs'; const embed = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'); const docs = [ { id: 0, text: 'How to cancel your subscription' }, { id: 1, text: 'Account settings and profile preferences' }, { id: 2, text: 'Getting started with the API' }, ]; const vectors = []; for (const doc of docs) { const out = await embed(doc.text, { pooling: 'mean', normalize: true }); vectors.push(...Array.from(out.data)); } const engine = WasmSearchEngine.from_vectors( new Float32Array(vectors), 384, 16, 200, 50 ); fs.writeFileSync('./public/search-index.bin', Buffer.from(engine.serialize()));
Add to package.json:
{ "scripts": { "prebuild": "node scripts/build-search-index.mjs", "build": "vite build" } }Web Worker (recommended for production)
// search-worker.js import init, { WasmSearchEngine } from 'altor-vec'; let engine; self.onmessage = async (e) => { if (e.data.type === 'init') { await init(); const resp = await fetch(e.data.indexUrl); engine = WasmSearchEngine.from_bytes(new Uint8Array(await resp.arrayBuffer())); postMessage({ type: 'ready' }); } if (e.data.type === 'search') { const results = JSON.parse(engine.search(new Float32Array(e.data.query), e.data.topK)); postMessage({ type: 'results', results }); } };
Benchmarks
|
Latency (10K vectors, 384d)
|
Size
|
API
| Method | Description |
|---|---|
WasmSearchEngine.from_bytes(bytes) |
Load serialized index |
WasmSearchEngine.from_vectors(flat, dims, m, ef_c, ef_s) |
Build from flat float array |
.search(query, topK) |
Returns JSON [[id, dist], ...] |
.add_vectors(flat, dims) |
Add vectors to existing index |
.serialize() |
Serialize to Uint8Array |
.len() |
Vector count |
.free() |
Free WASM memory |
HNSW params: m=16 (connections/node), ef_construction=200 (build quality), ef_search=50 (query recall)
→ Full reference: altorlab.dev/api
Embedding models
| Model | Dims | Runs in browser |
|---|---|---|
| all-MiniLM-L6-v2 | 384 | ✅ via Transformers.js |
| nomic-embed-text | 768 | ✅ via Transformers.js |
| text-embedding-3-small | 1536 | Build-time only (OpenAI API) |
| embed-english-v3 | 1024 | Build-time only (Cohere API) |
Common use cases
- Documentation search — altorlab.dev/use-cases/document-search
- Browser RAG (retrieval without a server) — altorlab.dev/blog/browser-rag-tutorial
- Product search — altorlab.dev/use-cases/product-search
- Semantic autocomplete — altorlab.dev/use-cases/autocomplete
- Offline-first search — altorlab.dev/use-cases/offline-search
- Chat memory — altorlab.dev/use-cases/chat-memory
Migration guides
- Algolia → altorlab.dev/migrate-from/algolia
- Pinecone → altorlab.dev/migrate-from/pinecone
- Fuse.js → altorlab.dev/vs/fuse-js
- Pagefind → altorlab.dev/migrate-from/pagefind
- ChromaDB → altorlab.dev/migrate-from/chromadb
- FAISS → altorlab.dev/migrate-from/faiss
- Meilisearch → altorlab.dev/migrate-from/meilisearch
- Typesense → altorlab.dev/migrate-from/typesense
How it works
altor-vec uses HNSW (Hierarchical Navigable Small World) — the same algorithm behind Pinecone, Qdrant, and pgvector. Builds a multi-layer graph; upper layers are express lanes for coarse navigation, bottom layer has all vectors for fine-grained search. O(log n) queries. All vectors are L2-normalized at insert so dot product = cosine similarity.
Architecture
src/
├── lib.rs # Public API
├── distance.rs # Dot product + normalization (SIMD-vectorized)
└── hnsw/
├── mod.rs # HnswIndex: API + serialization
├── graph.rs # Layered graph structure
├── search.rs # Greedy beam search
└── construction.rs # Insert + random layer selection
wasm/
└── src/lib.rs # WasmSearchEngine (wasm-bindgen wrapper)
Full documentation
| Getting started | altorlab.dev/getting-started |
| API reference | altorlab.dev/api |
| React guide | altorlab.dev/guides/react/document-search |
| Next.js guide | altorlab.dev/guides/nextjs/document-search |
| Vue guide | altorlab.dev/guides/vue/document-search |
| Node.js guide | altorlab.dev/guides/node/document-search |
| All comparisons | altorlab.dev/vs |
| Migration guides | altorlab.dev/migrate-from |
| Benchmarks | altorlab.dev/benchmarks |
| Live examples | altorlab.dev/examples/document-search |
Build from source
cargo test cargo bench cd wasm && wasm-pack build --target web --release
Contributing
See CONTRIBUTING.md for build instructions, code style, and PR process.
License
Built by altor-lab · altorlab.dev · npm · issues · anshul@altorlab.com