vector search vanilla javascript
Vector Search in Vanilla JavaScript — No Framework Required
Most vector search tutorials assume you're building a React app. But the majority of sites that would benefit from semantic search are not React apps — they're Jekyll blogs, Hugo documentation sites, Eleventy portfolios, and plain HTML pages. This guide shows how to add altor-vec to any page with vanilla JavaScript and no build step for the search widget itself.
npm install altor-vec | Via import map: see belowWhy vanilla JS matters for search
Static site generators produce plain HTML. Adding a React component to a Jekyll or Hugo site means introducing a build pipeline that wasn't there before. For a search widget, that's disproportionate complexity. altor-vec's WASM module is just a file — you import it, initialize it, and call engine.search(). No JSX, no hooks, no component lifecycle.
The pattern: your build step generates the binary index as a static file. A <script type="module"> tag on your page loads the index and the WASM, handles user input, and renders results. That's it.
Using altor-vec without npm — import map approach
If you don't have a bundler, you can import altor-vec directly from a CDN using an import map in your HTML:
<!-- In your <head> -->
<script type="importmap">
{
"imports": {
"altor-vec": "https://cdn.jsdelivr.net/npm/altor-vec/dist/index.js"
}
}
</script>
<script type="module">
import init, { WasmSearchEngine } from 'altor-vec';
await init('https://cdn.jsdelivr.net/npm/altor-vec/dist/altor_vec_wasm_bg.wasm');
// engine ready
</script>
Note: When using a CDN import, pass the WASM file URL explicitly to init(). When using npm + a bundler, init() can resolve the WASM file automatically. For plain HTML, always pass the URL.
Build the index (one-time, at site build time)
This runs in Node, not in the browser. Add it to your site's build script. Replace the content extraction with whatever fits your site structure.
// scripts/build-search.mjs
import fs from 'node:fs/promises';
import { glob } from 'glob';
import { pipeline } from '@huggingface/transformers';
import init, { WasmSearchEngine } from 'altor-vec';
await init();
const embed = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
// Adjust glob to match your site's content directory
const files = await glob('./_site/**/*.html');
const vectors = [];
const metadata = [];
for (const file of files) {
const html = await fs.readFile(file, 'utf8');
// Extract text content (simplified — use a proper HTML parser for production)
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const title = html.match(/<title>([^<]+)<\/title>/)?.[1] ?? file;
if (text.length < 50) continue;
const out = await embed(`${title}\n${text.slice(0, 800)}`, { pooling: 'mean', normalize: true });
vectors.push(...Array.from(out.data));
const url = '/' + file.replace('./_site/', '').replace('index.html', '');
metadata.push({ id: metadata.length, title, excerpt: text.slice(0, 180), url });
}
const engine = WasmSearchEngine.from_vectors(new Float32Array(vectors), 384, 16, 200, 50);
await fs.writeFile('./_site/search-index.bin', Buffer.from(engine.to_bytes()));
await fs.writeFile('./_site/search-metadata.json', JSON.stringify(metadata));
console.log(`Indexed ${metadata.length} pages`);
The complete search widget — drop into any HTML page
Add this block to any page where you want search. It creates its own DOM elements and handles everything independently.
<!-- Search trigger button -->
<button id="search-trigger" aria-label="Search (press / to open)">
Search
</button>
<!-- Search overlay (hidden by default) -->
<div id="search-overlay" role="dialog" aria-modal="true" aria-label="Search" hidden>
<div id="search-modal">
<input
id="search-input"
type="search"
placeholder="Search..."
autocomplete="off"
aria-label="Search query"
/>
<div id="search-results" role="listbox"></div>
<p id="search-status"></p>
</div>
</div>
<style>
#search-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 9999; display: flex; align-items: flex-start; justify-content: center; padding-top: 80px; }
#search-modal { background: #fff; border-radius: 12px; width: min(600px, 94vw); overflow: hidden; box-shadow: 0 25px 60px rgba(0,0,0,.3); }
#search-input { width: 100%; padding: 16px 18px; font-size: 16px; border: none; outline: none; border-bottom: 1px solid #e5e7eb; box-sizing: border-box; }
#search-results { list-style: none; margin: 0; padding: 8px; max-height: 380px; overflow-y: auto; }
#search-results a { display: block; padding: 10px 12px; border-radius: 8px; text-decoration: none; color: inherit; }
#search-results a:hover, #search-results a:focus { background: #f3f4f6; }
#search-results strong { display: block; font-size: 14px; color: #111827; margin-bottom: 2px; }
#search-results span { display: block; font-size: 13px; color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#search-status { padding: 10px 18px; color: #9ca3af; font-size: 14px; margin: 0; }
</style>
<script type="importmap">
{ "imports": { "altor-vec": "https://cdn.jsdelivr.net/npm/altor-vec@latest/dist/index.js" } }
</script>
<script type="module">
import init, { WasmSearchEngine } from 'altor-vec';
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js';
const WASM_URL = 'https://cdn.jsdelivr.net/npm/altor-vec@latest/dist/altor_vec_wasm_bg.wasm';
const INDEX_URL = '/search-index.bin';
const META_URL = '/search-metadata.json';
let engine, metadata, embedder;
let debounceTimer;
// Initialize engine in background
async function initEngine() {
if (engine) return;
await init(WASM_URL);
const [buf, meta] = await Promise.all([
fetch(INDEX_URL).then(r => r.arrayBuffer()),
fetch(META_URL).then(r => r.json()),
]);
engine = new WasmSearchEngine(new Uint8Array(buf));
metadata = meta;
}
async function initEmbedder() {
if (embedder) return;
setStatus('Loading search model…');
embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
setStatus('');
}
async function runSearch(query) {
if (!query.trim()) { clearResults(); return; }
await initEmbedder();
const out = await embedder(query, { pooling: 'mean', normalize: true });
const hits = JSON.parse(engine.search(new Float32Array(out.data), 6));
renderResults(hits.map(([id, dist]) => ({ ...metadata[id], score: 1 - dist })));
}
function renderResults(results) {
const container = document.getElementById('search-results');
if (!results.length) { container.innerHTML = ''; setStatus('No results.'); return; }
setStatus('');
container.innerHTML = results.map(r => `
<a href="${r.url}" role="option">
<strong>${escapeHtml(r.title)}</strong>
<span>${escapeHtml(r.excerpt)}</span>
</a>
`).join('');
}
function clearResults() { document.getElementById('search-results').innerHTML = ''; setStatus(''); }
function setStatus(msg) { document.getElementById('search-status').textContent = msg; }
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
function openSearch() {
document.getElementById('search-overlay').hidden = false;
document.getElementById('search-input').focus();
initEngine(); // start loading in background
}
function closeSearch() {
document.getElementById('search-overlay').hidden = true;
clearResults();
document.getElementById('search-input').value = '';
}
// Event listeners
document.getElementById('search-trigger').addEventListener('click', openSearch);
document.getElementById('search-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('search-overlay')) closeSearch();
});
document.getElementById('search-input').addEventListener('input', e => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => runSearch(e.target.value), 220);
});
document.addEventListener('keydown', e => {
if (e.key === '/' && !['INPUT','TEXTAREA'].includes(document.activeElement?.tagName)) {
e.preventDefault(); openSearch();
}
if (e.key === 'Escape') closeSearch();
});
// Keyboard navigation through results
document.getElementById('search-results').addEventListener('keydown', e => {
const links = [...document.querySelectorAll('#search-results a')];
const idx = links.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); links[idx + 1]?.focus(); }
if (e.key === 'ArrowUp') { e.preventDefault(); (idx > 0 ? links[idx - 1] : document.getElementById('search-input'))?.focus(); }
});
</script>
For Jekyll sites
Add the search widget to _includes/search.html and include it in your layout:
# _layouts/default.html
<!DOCTYPE html>
<html>
<head>...</head>
<body>
{% include search.html %}
{{ content }}
</body>
</html>
For the build step, add to your Makefile or Rakefile:
build:
bundle exec jekyll build
node scripts/build-search.mjs
For Hugo sites
Add the search widget to layouts/partials/search.html, include it in your base template, and configure Hugo to run the index build post-process:
# In config.toml, add a postProcess hook (Hugo 0.125+)
[build]
[build.hooks]
postProcess = "node scripts/build-search.mjs"
Debouncing without lodash
The example above uses a manual setTimeout debounce. 220ms is a good default — fast enough to feel responsive, slow enough to avoid firing on every keystroke. You don't need lodash's debounce for this use case:
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => runSearch(e.target.value), 220);
});
FAQ
Can I use altor-vec without npm or a bundler?
Yes. Import directly from a CDN using an import map or a dynamic import() call. Pass the WASM file URL explicitly to init(). The examples in this article work with a plain HTML file served from any static host.
Does this work with Jekyll, Hugo, or other static site generators?
Yes. The widget is plain JavaScript that you add to any HTML template. The index build script runs as a post-build step and writes the binary to your static assets directory. No changes to your generator's core configuration are needed.
How do I handle slow connections?
Start loading the engine (WASM + index) on page load — this downloads in the background. Initialize the embedding model only when the user opens the search widget. Show a "Loading search model…" status on first query. After the first load, everything is cached.