vector search react
Vector Search in React: Complete Tutorial
React is a great fit for semantic retrieval UIs because component state maps cleanly to query lifecycle: loading engine, processing query embeddings, ranking candidates, and rendering results. This tutorial builds a complete vector search module with altor-vec, including debounced input handling, worker messaging, similarity-score rendering, and production hardening. The goal is a practical implementation you can paste into a Vite/React project without introducing server routing changes.
npm install altor-vecProject structure
src/
search/
useVectorSearch.ts
embed.worker.ts
vectorSearch.ts
types.ts
components/
SearchBox.tsx
SearchResultList.tsx
public/
index.bin
metadata.json
Keep retrieval logic separate from UI. This makes it easy to unit-test ranking behavior and reuse the hook in multiple search surfaces (header search, docs page search, command palette).
Step 1: initialize the vector engine
// vectorSearch.ts
import init, { WasmSearchEngine } from 'altor-vec';
let engine: WasmSearchEngine | null = null;
let meta: Array<{ id: number; title: string; slug: string; excerpt: string }> = [];
export async function initVectorSearch() {
await init();
const [iRes, mRes] = await Promise.all([fetch('/index.bin'), fetch('/metadata.json')]);
engine = new WasmSearchEngine(new Uint8Array(await iRes.arrayBuffer()));
meta = await mRes.json();
}
export function queryVector(vec: number[], topK = 6) {
if (!engine) throw new Error('engine not initialized');
const hits: [number, number][] = JSON.parse(engine.search(new Float32Array(vec), topK));
return hits.map(([id, distance]) => ({ ...meta[id], distance, score: 1 - distance }));
}
Step 2: worker for embeddings
Embedding generation can be much heavier than vector lookup. Move it off the main thread using a dedicated worker.
// embed.worker.ts
import { pipeline } from '@huggingface/transformers';
let embedder: any;
self.onmessage = async (e: MessageEvent) => {
if (e.data.type === 'init') {
embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
postMessage({ type: 'ready' });
}
if (e.data.type === 'embed') {
const out = await embedder(e.data.text, { pooling: 'mean', normalize: true });
postMessage({ type: 'embedding', id: e.data.id, vector: Array.from(out.data) });
}
};
Step 3: create a reusable hook
// useVectorSearch.ts
import { useEffect, useRef, useState } from 'react';
import EmbedWorker from './embed.worker?worker';
import { initVectorSearch, queryVector } from './vectorSearch';
export function useVectorSearch() {
const [ready, setReady] = useState(false);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const workerRef = useRef<Worker | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<Map<number, (v: number[]) => void>>(new Map());
useEffect(() => {
const worker = new EmbedWorker();
workerRef.current = worker;
worker.onmessage = (e) => {
if (e.data.type === 'embedding') {
pendingRef.current.get(e.data.id)?.(e.data.vector);
pendingRef.current.delete(e.data.id);
}
};
(async () => {
await initVectorSearch();
worker.postMessage({ type: 'init' });
setReady(true);
})();
return () => worker.terminate();
}, []);
const embed = (text: string) => new Promise<number[]>((resolve) => {
const id = Date.now() + Math.random();
pendingRef.current.set(id, resolve);
workerRef.current?.postMessage({ type: 'embed', id, text });
});
const search = (text: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!text.trim()) return setResults([]);
timerRef.current = setTimeout(async () => {
setLoading(true);
const vec = await embed(text);
setResults(queryVector(vec, 8));
setLoading(false);
}, 150);
};
return { ready, loading, results, search };
}
Step 4: UI components and score rendering
// SearchBox.tsx
export function SearchBox() {
const { ready, loading, results, search } = useVectorSearch();
return (
<section>
<input
placeholder={ready ? 'Search docs semantically' : 'Loading search...'}
disabled={!ready}
onChange={(e) => search(e.target.value)}
/>
{loading && <p>Embedding query...</p>}
<ul>
{results.map((r) => (
<li key={r.id}>
<a href={r.slug}>{r.title}</a>
<small>score: {r.score.toFixed(3)}</small>
</li>
))}
</ul>
</section>
);
}
Don’t over-interpret absolute score values. Use them primarily for relative ordering and optional debug displays.
Production tips
Use AbortController for stale requests
If your embedding path can involve remote fallback, cancel stale calls when input changes quickly.
Cache model and index aggressively
Model and index files are static assets; cache with long max-age and versioned filenames.
Guard against dimension mismatch
When model version changes, dimension may change too. Validate before querying, or ship manifest metadata.
Combine with lexical boost
For exact token queries (error codes, function names), merge lexical matches with semantic results.
Error handling strategy
try {
const vec = await embed(query);
setResults(queryVector(vec));
} catch (err) {
// Degrade gracefully
setResults([]);
setBanner('Semantic engine unavailable; showing keyword results.');
}
Keep UX resilient. A search box that occasionally degrades is acceptable; a search box that crashes is not.
Testing checklist
- Typing stress test: fast input bursts should not freeze UI.
- Worker restart: ensure component can recover if worker terminates unexpectedly.
- Cold-start latency: first search interaction under throttled network.
- Ranking sanity: benchmark queries return expected top-k docs.
Conclusion
React + browser-native vector search is a practical stack for modern developer products. The key pattern is separation of concerns: keep retrieval core deterministic, isolate embedding in a worker, and expose simple hook APIs to UI components. With altor-vec, you get fast local ANN retrieval and predictable cost characteristics while maintaining full control in frontend code.