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.

Install altor-vec: npm install altor-vec

Project 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

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.

CTA: npm install altor-vec · Star on GitHub