code example
Build Document Search with altor-vec
What this pattern solves: Browser-native retrieval for knowledge bases, policy libraries, and internal manuals.
Document portals become hard to navigate when users remember a concept but not a file name. A vector index lets them ask for 'security review checklist' and retrieve the closest policy chunks instead of relying on exact phrasing.
For static manuals, docs bundles, and packaged internal portals, local search means instant retrieval with no dependency on a live API. That is especially valuable for field teams or air-gapped review environments.
Install
npm install altor-vec
Concept explanation
In a document search workflow, users usually describe intent in their own words. That is why vector search works well here: each record is turned into an embedding, the embeddings are indexed once, and later queries retrieve the nearest semantic neighbors instead of relying only on exact tokens. In practice this means the interface can respond to paraphrases, shorthand, and partial descriptions far better than a literal-only search box.
The browser is often the right place to do this when the corpus is moderate in size and safe to ship. The instant benefit is lower latency. The architectural benefit is that you remove a whole search service from the request path. That matters for keystroke-heavy interactions, offline-capable apps, and product surfaces where search should feel like a UI primitive rather than a network round trip.
This page uses a deterministic embedding helper so the sample is runnable with only altor-vec installed. That keeps the example honest and easy to paste into a demo project. For long documents, chunk them before indexing so the result points to a specific section instead of a whole PDF or page title.
Runnable JavaScript example
The following snippet indexes a small in-memory dataset, performs a semantic lookup for vendor security review checklist, and prints the nearest matches. It uses the real altor-vec API, including init(), WasmSearchEngine.from_vectors(), and search().
import init, { WasmSearchEngine } from 'altor-vec';
const dims = 12;
const records = [
{
"title": "Incident response playbook",
"text": "Checklist for triage, communication, and postmortems during security incidents.",
"meta": "security"
},
{
"title": "Vendor review policy",
"text": "Security questionnaire and approval steps for third-party vendors.",
"meta": "procurement"
},
{
"title": "Onboarding guide",
"text": "First-week checklist for new hires, tooling access, and team introductions.",
"meta": "people"
},
{
"title": "Travel reimbursement rules",
"text": "Allowed expenses, receipt thresholds, and reimbursement timelines.",
"meta": "finance"
},
{
"title": "Design system handbook",
"text": "Usage rules for colors, tokens, spacing, and component accessibility.",
"meta": "design"
},
{
"title": "Data retention policy",
"text": "Rules for storing, deleting, and archiving customer information.",
"meta": "legal"
}
];
function embedText(text) {
const vector = new Float32Array(dims);
for (const token of text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
let hash = 2166136261;
for (const char of token) {
hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
}
const slot = Math.abs(hash) % dims;
vector[slot] += 1;
vector[(slot + token.length) % dims] += token.length / 10;
}
const magnitude = Math.hypot(...vector) || 1;
return Array.from(vector, (value) => value / magnitude);
}
async function main() {
await init();
const flat = new Float32Array(
records.flatMap((record) => embedText(`${record.title} ${record.text} ${record.meta}`))
);
const engine = WasmSearchEngine.from_vectors(flat, dims, 16, 200, 64);
const hits = JSON.parse(engine.search(new Float32Array(embedText('vendor security review checklist')), 4));
const results = hits.map(([id, distance]) => ({
...records[id],
similarity: Number((1 - distance).toFixed(3)),
}));
console.table(results);
engine.free();
}
main();
React component version
The React version keeps the same index build but wires it into component state so the UI can query on input changes. That is usually how teams introduce semantic retrieval into an existing product: initialize once, keep the engine in memory, and map nearest-neighbor hits back to the original records.
import { useEffect, useState } from 'react';
import init, { WasmSearchEngine } from 'altor-vec';
const dims = 12;
const records = [
{
"title": "Incident response playbook",
"text": "Checklist for triage, communication, and postmortems during security incidents.",
"meta": "security"
},
{
"title": "Vendor review policy",
"text": "Security questionnaire and approval steps for third-party vendors.",
"meta": "procurement"
},
{
"title": "Onboarding guide",
"text": "First-week checklist for new hires, tooling access, and team introductions.",
"meta": "people"
},
{
"title": "Travel reimbursement rules",
"text": "Allowed expenses, receipt thresholds, and reimbursement timelines.",
"meta": "finance"
},
{
"title": "Design system handbook",
"text": "Usage rules for colors, tokens, spacing, and component accessibility.",
"meta": "design"
},
{
"title": "Data retention policy",
"text": "Rules for storing, deleting, and archiving customer information.",
"meta": "legal"
}
];
function embedText(text) {
const vector = new Float32Array(dims);
for (const token of text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
let hash = 2166136261;
for (const char of token) {
hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
}
const slot = Math.abs(hash) % dims;
vector[slot] += 1;
vector[(slot + token.length) % dims] += token.length / 10;
}
const magnitude = Math.hypot(...vector) || 1;
return Array.from(vector, (value) => value / magnitude);
}
export function DocumentSearchExample() {
const [engine, setEngine] = useState(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
let cancelled = false;
let instance;
(async () => {
await init();
const flat = new Float32Array(
records.flatMap((record) => embedText(`${record.title} ${record.text} ${record.meta}`))
);
instance = WasmSearchEngine.from_vectors(flat, dims, 16, 200, 64);
if (!cancelled) setEngine(instance);
})();
return () => {
cancelled = true;
instance?.free();
};
}, []);
useEffect(() => {
if (!engine || query.trim().length < 2) {
setResults([]);
return;
}
const hits = JSON.parse(engine.search(new Float32Array(embedText(query)), 5));
setResults(
hits.map(([id, distance]) => ({
...records[id],
similarity: Number((1 - distance).toFixed(3)),
}))
);
}, [engine, query]);
return (
<section>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search your documents"
/>
<ul>
{results.map((result) => (
<li key={result.title}>
<strong>{result.title}</strong> — {result.meta} (score {result.similarity})
</li>
))}
</ul>
</section>
);
}
How this example works
The pattern has three moving parts. First, you choose what text represents each record: title, description, metadata, or a chunk of content. Second, you turn that text into vectors and flatten them into one Float32Array. Third, you build the HNSW graph and query it with a vector created from the user input. The library returns nearest-neighbor IDs and distances, and your app decides how to display or post-process them.
Because the retrieval step is approximate nearest-neighbor search, it stays fast even as the dataset grows beyond trivial linear scans. The most important quality lever is still the embedding model. Better vectors usually matter more than micro-optimizing ANN parameters, so teams should benchmark retrieval quality on real user phrasing before shipping the experience widely.
When to use this pattern
This is a practical fit when the search corpus is small to medium, shipped with the app, and searched frequently enough that backend latency would be noticeable. Common examples include docs portals, embedded support widgets, local-first assistants, and curated catalogs.
- Handbooks
- Policy centers
- Offline manuals
- Embedded doc search in SaaS apps
Limitations
If documents are access-controlled per user or change minute by minute, shipping the corpus to the browser is usually the wrong architecture. Search quality also depends heavily on chunking strategy and embeddings.
Be especially careful about corpus size, update frequency, and data sensitivity. Browser vector search is excellent when those three constraints are favorable, but it is not the right answer when the dataset is huge, private, or changing constantly for every user.
FAQ
Is this good for offline manuals?
Yes. Static manuals are one of the clearest wins because the index can be bundled once and searched locally.
Should I index whole documents or chunks?
Chunks are usually better. Smaller units produce more precise retrieval and make it easier to jump to the exact section a user needs.
What is the main architectural limit?
The browser can only search what you ship to it, so sensitive or highly dynamic document stores usually belong on the server.
Get started: npm install altor-vec · GitHub