Migration Guide
Migrate from Fuse.js to altor-vec
Fuse.js does fuzzy string matching. altor-vec does semantic vector search. Here is the exact code diff, the one build pipeline change you need, and the edge cases to handle before you ship.
What changes, what stays the same
| Concern | Fuse.js | altor-vec |
|---|---|---|
| Index built from | Raw text objects, at runtime | Pre-computed embeddings, at build time |
| Query matching | Fuzzy string (Bitap / Levenshtein) | Semantic vector similarity (HNSW cosine) |
| Handles synonyms | No | Yes |
| Handles typos | Yes, built-in | No, needs pre-processing |
| Bundle size | ~24 KB | ~54 KB WASM |
| Result shape | { item, score, refIndex } | { item, score } |
| Server required | No | No |
| Works offline | Yes | Yes |
The one mandatory addition: a build-time embedding step. Everything else — search call, result shape, UI rendering — is a near drop-in replacement.
Step-by-step migration
1Install altor-vec
npm install altor-vec
npm install --save-dev @xenova/transformers # for build-time embeddings
# Remove Fuse.js after you have confirmed altor-vec works
npm uninstall fuse.js
2Generate embeddings at build time
Create a script that runs once at deploy — not on every page load. It reads your data, embeds the same fields you previously passed to Fuse.js keys, and writes a static index file.
// scripts/build-search-index.mjs
import { pipeline } from '@xenova/transformers';
import { writeFileSync } from 'fs';
import { yourData } from '../src/data.js';
const embedder = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2'
);
const records = [];
for (const item of yourData) {
// Concatenate the same fields you passed to Fuse.js `keys`
const text = [item.title, item.description].filter(Boolean).join(' ');
const out = await embedder(text, { pooling: 'mean', normalize: true });
records.push({ id: item.id, vector: Array.from(out.data), item });
}
writeFileSync('./public/search-index.json', JSON.stringify(records));
console.log(`Built ${records.length} embeddings`);
Wire it into your build:
// package.json
"scripts": {
"build:index": "node scripts/build-search-index.mjs",
"build": "npm run build:index && vite build"
}
3Replace the search call
The diff is small. The search result shape is compatible — both return an array of { item, score } objects referencing your original data.
Before (Fuse.js)
import Fuse from 'fuse.js';
const fuse = new Fuse(items, {
keys: ['title', 'description'],
threshold: 0.4,
includeScore: true,
});
function search(query) {
return fuse.search(query);
// [{ item, score, refIndex }]
}
After (altor-vec)
import { AltorIndex } from 'altor-vec';
import { pipeline } from '@xenova/transformers';
const data = await fetch('/search-index.json')
.then(r => r.json());
const index = await AltorIndex.load(data);
const embedder = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2'
);
async function search(query) {
const out = await embedder(query,
{ pooling: 'mean', normalize: true });
return index.search(
Array.from(out.data), { topK: 10 }
);
// [{ item, score }]
}
4Make the handler async and flip the score direction
Two changes to call sites: add await, and invert threshold logic. Fuse.js scores 0 as a perfect match; altor-vec scores 1 as a perfect match.
// Fuse.js — lower score = better match
results.filter(r => r.score < 0.4)
// altor-vec — higher score = better match
results.filter(r => r.score > 0.5)
Your .map(r => r.item) and render calls are unchanged.
5Handle typo tolerance if you need it
altor-vec does not do character-level fuzzy matching. For most content search this is fine — "javascript frameworks" still matches docs titled "JS Framework Guide". For short autocomplete queries where users often mistype, pick one of these approaches:
// Option A: spell-correct before embedding (add nspell or similar)
async function search(rawQuery) {
const query = spellCorrect(rawQuery) ?? rawQuery;
const out = await embedder(query,
{ pooling: 'mean', normalize: true });
return index.search(Array.from(out.data), { topK: 10 });
}
// Option B: Fuse.js fallback for short queries only
async function search(query) {
if (query.trim().split(/\s+/).length <= 2) {
return fuseIndex.search(query); // short: fuzzy
}
const out = await embedder(query,
{ pooling: 'mean', normalize: true });
return index.search(Array.from(out.data), { topK: 10 });
}
Skip this step if you are searching technical documentation or product catalogs — semantic similarity handles abbreviations and phrasing variants well enough that typos rarely matter at query lengths above 3 words.
React and Next.js: load once, search fast
Initialize the index and embedder outside the component lifecycle so they are only loaded once, not on every render or route change.
// lib/search.ts
import { AltorIndex } from 'altor-vec';
import { pipeline } from '@xenova/transformers';
let index: AltorIndex | null = null;
let embedder: ReturnType<typeof pipeline> extends Promise<infer T> ? T : never;
async function init() {
if (index) return { index, embedder };
const data = await fetch('/search-index.json').then(r => r.json());
[index, embedder] = await Promise.all([
AltorIndex.load(data),
pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'),
]);
return { index, embedder };
}
export async function search(query: string, topK = 10) {
const { index: idx, embedder: emb } = await init();
const out = await emb(query, { pooling: 'mean', normalize: true });
return idx.search(Array.from(out.data as Float32Array), { topK });
}
Index size and build time estimates
| Documents | Avg words/doc | Index size | Build time (local) |
|---|---|---|---|
| 1,000 | 50 | ~1.5 MB | <15 s |
| 5,000 | 100 | ~6 MB | ~1 min |
| 10,000 | 200 | ~12 MB | ~3 min |
| 50,000 | 100 | ~60 MB | ~12 min |
For large indexes, split into shards and load segments on demand, or serve the index from a CDN with Cache-Control: max-age=86400 to avoid re-downloading on every page load.
Frequently asked questions
Does altor-vec support typo tolerance like Fuse.js?
Not directly. Fuse.js handles typos via character-level Bitap distance. altor-vec matches by meaning, not by character overlap. Add a spell-correction pass before embedding, or keep Fuse.js for short queries under 3 words as a fallback.
How much does embedding generation cost?
Build-time embedding with @xenova/transformers (all-MiniLM-L6-v2) is free and runs locally in Node.js. No API calls. For 10,000 documents at ~50 words, expect 2-3 minutes of build time. The index is typically 5-15 MB.
Can I migrate without changing my search UI?
Yes. Wrap index.search() to return { item, score } matching Fuse.js output shape and your render code is unchanged. The only call-site change is adding await.
Does altor-vec accept the same data format as Fuse.js?
altor-vec indexes pre-computed vectors, not raw text. You add a build step once. Search results reference your original data objects, so downstream code is unchanged.
How do I handle real-time index updates?
For infrequently changing content, rebuild the static index at deploy time. For real-time additions, generate an embedding for the new item and call IndexBuilder.add() at runtime. The HNSW graph updates in-memory without a full rebuild.