Tutorial
Build browser-local semantic search with application-supplied embeddings.
Tutorial: local semantic search
Vecal stores and retrieves vectors; your application supplies embeddings. They may come from precomputed assets, a local model, or an application backend. Do not put a provider secret in browser storage or client JavaScript.
1. Define metadata and open the database
Choose the dimension and metric once for a logical database.
import { VectorDB, type Metadata } from 'vecal';
interface ChunkMetadata extends Metadata {
sourceId: string;
title: string;
section: 'guide' | 'api' | 'example';
preview: string;
}
const db = await VectorDB.open<ChunkMetadata>({
name: 'docs-search',
dimension: 384,
metric: 'cosine',
});Cosine vectors are normalized before persistence and cosine queries are normalized in the Worker. Zero vectors and non-finite values are rejected.
2. Import a batch atomically
Generate or load embeddings outside Vecal, then store one batch in one transaction.
type PreparedChunk = {
id: string;
embedding: number[];
metadata: ChunkMetadata;
};
async function indexChunks(chunks: PreparedChunk[]) {
await db.addMany(
chunks.map((chunk) => ({
id: chunk.id,
vector: Float32Array.from(chunk.embedding),
metadata: chunk.metadata,
})),
);
}If any ID already exists, addMany() rejects with RecordConflictError and commits none of the batch. Use upsert() only when replacement is intentional.
3. Establish an exact baseline
Exact search is the correctness oracle and requires no index.
async function searchExactly(queryText: string) {
const query = Float32Array.from(await embed(queryText));
return db.search(query, {
k: 5,
strategy: 'exact',
where: {
section: { $in: ['guide', 'example'] },
},
minScore: 0.35,
});
}embed() is application-owned. Metadata filters combine fields with AND and operate only on top-level fields.
4. Build HNSW intentionally
Build after the initial import and show progress in your UI. Cancellation leaves IndexedDB entries intact.
const controller = new AbortController();
await db.ensureIndex({
type: 'hnsw',
m: 16,
efConstruction: 200,
signal: controller.signal,
onProgress: ({ completed, total, ratio }) => {
renderIndexProgress({ completed, total, percent: Math.round(ratio * 100) });
},
});The build captures the starting data revision. If data changes, Vecal discards the graph and retries once; a second collision throws IndexBuildConflictError.
5. Use automatic strategy selection
async function searchFast(queryText: string, section?: ChunkMetadata['section']) {
const query = Float32Array.from(await embed(queryText));
return db.search(query, {
k: 5,
strategy: 'auto',
efSearch: 100,
where: section ? { section: { $eq: section } } : undefined,
});
}auto uses HNSW only while indexStatus().state === 'ready'; otherwise it performs exact search. Filtered HNSW queries adaptively expand candidates. If the filtered candidates are still fewer than k, Vecal runs a complete exact fallback.
6. Handle lifecycle and staleness
const status = db.indexStatus();
// absent | building | ready | stale | closed
if (status.state === 'stale') {
await db.ensureIndex({ type: 'hnsw' });
}
await db.close();Adds update a ready graph incrementally. Vector updates insert a replacement node and tombstone the old node; deletes tombstone their node. Above a 10% tombstone ratio, the index becomes stale, auto returns to exact search, and the next ensureIndex() performs a clean rebuild.
Snapshots are accelerators, not data. A matching snapshot restores immediately after refresh; an outdated or interrupted snapshot is ignored and exact search remains correct.