How this search works
The four statements behind it — create, insert, search, and change — with the code this demo actually runs. It is ordinary SQL over a real database file; the WASM module that executes it here is ~660 KiB gzipped.
One table, one vector column, two indexes
A page is a row. The VECTOR(384) column holds each page's
embedding, and the two indexes are what make retrieval fast: one for
keyword ranking, one for nearest-vector lookup. Everything below is
the engine's own dialect — SQLite's SQL plus vectors.
CREATE TABLE pages (
id INTEGER PRIMARY KEY,
path TEXT, title TEXT, body TEXT,
embedding VECTOR(384) -- one vector per page
);
CREATE INDEX pages_body ON pages (body); -- full-text (BM25)
CREATE INDEX pages_embedding ON pages (embedding); -- vectors (HNSW)
In the browser this is one call: db.execute(sql). At
build time the same statements run through the native Rust database —
that is how site.inlay was written.
Each page's text becomes a vector on the way in
Parameters are bound as a JSON array — a nested array of numbers is a vector. The embedder here is the engine's stand-in (character trigrams, so it matches pages that spell alike); a real site puts its own model's output here instead, and the rest is unchanged.
// one page, as this demo's build script runs it for all 45
db.execute(
"INSERT INTO pages (path, title, body, embedding) VALUES (?, ?, ?, ?)",
JSON.stringify([
"/services/passport/renew.html",
"Renew a passport",
body, // the page's full text
Array.from(embed(body, dim)), // ← the vector
]),
);
// write the retrieval indexes into the file itself, then ship it
const bytes = db.export(); // checkpointed: BM25 + HNSW inside
The exported bytes are the deployment: site.inlay
is fetched like an image and opened with
Database.open(bytes). That is the whole "backend" — a
file on a web server.
The query becomes a vector, then one statement ranks everything
Your query text goes through the same embedder that built the index, so query and pages live in the same vector space. Then the ranking mode you picked changes one function call:
bm25_score(body, ?) — keyword relevance, the classic search-engine score over the full-text index. Finds exact words: "renew", "passport".
vector_score(embedding, ?) — semantic similarity against your query's vector, using the HNSW approximate-nearest-neighbour index. Finds pages about the same topic even when no query word appears: "help with rent" finds rent assistance.
fuse(vector_score(…), bm25_score(…)) — hybrid: both scores are computed and combined with reciprocal rank fusion, which merges the two rankings rather than the two raw numbers, so a page that does well on either gets pulled up.
That statement runs inside the WASM module in this tab against the database fetched at load — every keystroke-to-result hop is local. No search API exists to attack, log, rate-limit or take down.
Update, delete, and ship the result
Nothing above is a snapshot trick: this is a mutable database. When a page's content changes, update the row and its vector; when a page disappears, delete it. The indexes maintain themselves incrementally — no rebuild.
// a page's content changed — re-embed it, update the row
db.execute(
"UPDATE pages SET body = ?, embedding = ? WHERE path = ?",
JSON.stringify([
newBody,
Array.from(embed(newBody, dim)),
"/services/passport/renew.html",
]),
);
// a page was removed
db.execute("DELETE FROM pages WHERE path = ?", JSON.stringify([path]));
// persist: export() checkpoints first, so the bytes carry fresh indexes
const updated = db.export(); // upload these bytes, done
For a static site this maps directly onto publishing: content changed? Update the rows, export, upload. No index rebuild, no downtime, no cache invalidation beyond the one file.