Introduction
Here is the situation. You have a site made of files. HTML, CSS, a little JavaScript. It is served from a CDN, it costs almost nothing to run, and it will still work in ten years. You want it to do something that sounds like it requires a backend: understand what a page is about, so that a search for "how do I stop my robot from drifting" surfaces the Kalman filter lesson even though the word "Kalman" never appears in the query.
The reflex answer is to add infrastructure. Stand up a vector database, put an embedding API behind a serverless function, pay per query forever. That answer is correct for a search engine over documents you have never seen. It is the wrong answer for your own site, because your site has a property a general search engine does not: you know the entire corpus before anyone visits.
Every page, every title, every keyword — all of it exists on disk at the moment you run
npm run build. Nothing about the corpus is a surprise at request time. And when the
corpus is fully known in advance, almost every question a visitor can ask about relationships
inside that corpus can be answered in advance too. "What is this lesson near?" — computable
at build. "Which cluster does this belong to?" — computable at build. "Where does everything sit
relative to everything else?" — computable at build.
So we compute it at build. We run a small embedding model over every document once, on the machine that builds the site, and we write the useful consequences of those vectors — neighbor lists, 2D coordinates, cluster assignments — into flat JSON files that ship with the CSS. At runtime there is no model, no database, no key, and no per-query cost. There is a fetch of a static file.
I call this layer the semantic spine: one vector space, built once, that every discovery surface on the site leans on. This article builds it from zero. We will choose a model and justify the choice with numbers, extract a corpus from a pile of HTML, embed it with a content-addressed cache so that adding one lesson re-embeds exactly one lesson, do the byte arithmetic on what we can afford to ship, project 384 dimensions down to a map without lying to the reader about what projection destroys, embed the query in the browser with the same model, and finally measure whether any of it works.
The build-time embedding pattern end to end: model selection, corpus extraction from HTML, hash-based caching, artifact design and byte budgets (float32 vs float16 vs int8), 2D projection via classical MDS, query-time embedding with version pinning, and evaluation on a corpus you own. Code is JavaScript running in Node and the browser; the ideas transfer to any language.
Every number in this article is measured against a real corpus: 1,184 documents, 384 dimensions, artifacts of 254 KB and 356 KB, a cache of 1,394 entries occupying 11 MB. When I say a build takes half a minute cold and a second warm, that is a stopwatch, not a guess.
1 — The Idea: A Semantic Layer With No Server
Start with the thing we are trying to buy. A visitor types a phrase. We want to return documents that mean something similar, not documents that contain the same letters.
Keyword search — the kind you can implement in twenty lines with String.includes —
fails in one specific, very common way. It has no notion of synonymy. A visitor typing
"sensor drift correction" gets nothing from a lesson titled "Extended Kalman Filter", because the
strings do not overlap. The visitor concludes the site has nothing on the topic. It has eleven
lessons on the topic.
An embedding model fixes this by mapping text to a point in a high-dimensional space, trained so that texts about the same thing land near each other. "Sensor drift correction" and "Extended Kalman Filter" end up as two nearby points. Nearness is measured by cosine similarity — the cosine of the angle between the two vectors, which is 1 when they point the same way and 0 when they are perpendicular.
Where a · b is the dot product — multiply the vectors component by component and add up the results — and ‖a‖ is the length of a, the square root of the sum of its squared components. That is the entire mathematical machinery of this article. Everything else is engineering around it.
Take two toy 4-dimensional vectors so we can do this with a pencil. a = (2, 1, 0, 3) and b = (1, 1, 1, 2).
| Step | Arithmetic | Result |
|---|---|---|
| dot product | 2×1 + 1×1 + 0×1 + 3×2 = 2 + 1 + 0 + 6 | 9 |
| ‖a‖ | √(4 + 1 + 0 + 9) = √14 | 3.7417 |
| ‖b‖ | √(1 + 1 + 1 + 4) = √7 | 2.6458 |
| product of norms | √14 × √7 = √98 | 9.8995 |
| cosine | 9 / 9.8995 | 0.9091 |
Now pre-normalize instead. Divide each vector by its own length: â = (0.5345, 0.2673, 0, 0.8018), b̂ = (0.3780, 0.3780, 0.3780, 0.7559). Their plain dot product is 0.5345×0.3780 + 0.2673×0.3780 + 0 + 0.8018×0.7559 = 0.2020 + 0.1010 + 0 + 0.6061 = 0.9091. Identical — and it took four multiplies and three adds instead of also computing two square roots.
That is not a cute trick, it is the single most important optimization in the whole system: normalize once at build time, and every similarity afterwards is a bare dot product.
What runtime inference actually costs
Let us price the alternative honestly, because "it's free" is a claim that deserves arithmetic rather than enthusiasm.
A runtime architecture embeds the query on every search. Say a modest 50,000 searches a month. Each query is maybe 8 tokens. A hosted small-embedding endpoint charges on the order of $0.02 per million tokens, so 50,000 × 8 = 400,000 tokens costs about $0.008. The API bill is genuinely nothing.
The API bill was never the problem. The problems are:
- Latency. A cross-continent round trip to an embedding endpoint is 80–300 ms before the vector search even starts. Typeahead search wants results in under 50 ms, which means a network hop per keystroke is out of the question. You debounce, and the interface feels laggy.
- A key on the critical path. Now your static site needs a serverless function to hold the key, which needs deployment, secrets management, rate limiting (or a stranger runs your key up), and monitoring. You have traded a file for a service.
- Non-reproducibility. The provider silently updates the model. Your stored document vectors were made by the old one. Cosines between old documents and new queries are now subtly wrong — not broken enough to alarm you, just bad enough to quietly degrade every result. We will return to this failure in §7, because it is the one that actually bites.
- Offline builds. A build that requires network access to a third party is a build that fails on a plane, in CI without egress, and on the day the provider has an incident.
Build-time embedding removes all four at once. The model is a file on disk. The vectors are computed
on your machine. The artifacts are checked into git and served from the CDN like any other asset.
The marginal cost of a search is the cost of one fetch of a file the browser has
probably already cached.
One spine, three surfaces
The reason to build this as a shared layer rather than a feature is that a single vector space serves several very different-looking products. On this site the same 1,184 vectors feed:
| Surface | What it needs from the spine | Shipped as |
|---|---|---|
| Semantic search | Cosine between a live query vector and every document vector | Document text (the backend re-embeds) or a shipped matrix |
| Related lessons | The top-k nearest documents to a given document | A precomputed integer list per document — no vectors at all |
| Knowledge map | An (x, y) coordinate per document, plus neighbor edges | Two floats and a small int array per document |
Notice the third column. Two of the three surfaces never need the vectors themselves — they need conclusions drawn from the vectors. A related-lessons widget needs eight integers. A map needs two floats. The 384 numbers that produced them can stay on the build machine.
This is the central design move of the whole article, so let me state it plainly:
Do not ship the model. Do not ship the vectors unless a surface genuinely requires live vector math. Ship the answers. An embedding is an intermediate representation; the product is the neighbor list.
On the real site this collapses a 1.73 MB vector matrix into a 254 KB JSON file that carries coordinates, neighbor lists, titles and URLs for every document — and 62 KB of that over the wire after gzip. We will do that arithmetic properly in §5.
2 — Choosing the Embedder
There are, roughly, two families of text embedder you can point at a corpus in 2026: small open
encoder models you run yourself (the BERT-shaped ones — bge-small,
gte-small, all-MiniLM, e5-small), and large hosted models
you call over HTTP. The instinct is that bigger is better, so let us examine that instinct.
What 384 dimensions actually buys you
bge-small-en-v1.5 is a 12-layer transformer encoder with hidden size 384 and about
33 million parameters. It emits one 384-dimensional vector per input text. For comparison, a large
hosted embedder might emit 1,536 or 3,072 dimensions from a model two orders of magnitude bigger.
What does the extra capacity buy? On public retrieval benchmarks (MTEB and friends), the honest answer is: a handful of points of average nDCG. The small model lands in the low-to-mid 60s; the big hosted models in the mid-60s. That gap is real and it matters enormously if you are building a general web search engine over billions of documents you have never seen.
It matters much less here, and the reason is worth internalizing. Our retrieval task is easy. We are not disambiguating among a billion near-duplicate web pages. We are picking a handful of documents out of roughly a thousand, in a domain where the vocabulary is technical and distinctive. "Kalman filter" and "particle filter" are close in every embedding model ever trained; "Kalman filter" and "diffusion sampler" are far apart in every one of them. The discriminations we need are the easy ones.
With N = 1,184 documents and a query that has one obviously correct answer, the model only needs to rank the right document above 1,183 others. With N = 109, it needs to rank it above a billion, including thousands of adversarially similar near-misses. Benchmark deltas are measured on the second task. You are running the first.
A useful sanity check: if a keyword search already gets 70% of your queries right, an embedding layer only has to fix the remaining 30% — and the fixes it needs to make are synonym-shaped, which is exactly what every embedding model is best at.
There is also a cost to dimensions that nobody mentions in the benchmark tables. Dimensions are bytes, and bytes are download time. A 3,072-dimensional model produces vectors 8× larger than a 384-dimensional one. If you ever want to ship the matrix to the browser, that is the difference between a 900 KB download and a 7 MB download for the same corpus. We will make this concrete in §5, but hold the thought: dimensionality is a shipping-weight decision as much as a quality decision.
Local vs API — the honest table
| Dimension | Small local model | Hosted API model |
|---|---|---|
| Retrieval quality | Good. A few points below the frontier. | Better. This is a real advantage, not a tie. |
| Long documents | 512-token window. Long pages must be chunked. | 8k+ tokens. Often no chunking needed. |
| Multilingual | English-only variants are common; multilingual small models are weaker. | Strong out of the box. |
| Cost at build | $0. CPU time on a machine you already pay for. | Cents. Genuinely small, but not zero, and it recurs on every full rebuild. |
| Reproducibility | Exact. Pinned weights, same bytes forever. | Provider-controlled. Model can change under you. |
| Offline / CI | Works. Cache the weights once. | Needs network egress and a live key. |
| Same model at query time | Yes — the ONNX build runs in the browser. | Only via another API call. |
| Cold build time | Seconds to minutes of CPU. | Network-bound; often faster for huge corpora. |
Read that table honestly and the hosted model wins on quality, context length, and multilingual coverage. It loses on reproducibility, offline capability, and — decisively for our purposes — the ability to run the identical model at query time inside the visitor's browser.
That last row is the one that settles it. If the document vectors and the query vector do not come from the same model, their cosines are meaningless. A local ONNX model can embed documents in Node during the build and embed a query in the browser during a search, producing vectors in literally the same space. A hosted model can only do the second half by making a network call — which puts us right back into the runtime architecture we were trying to avoid.
Choose the small local model when the corpus is small, the domain is narrow, the build must be reproducible, and you want query-side embedding without a server. Choose the hosted model when documents are long, the corpus is multilingual, or retrieval quality is the product rather than a navigation aid. This site is squarely in the first case; a legal-document search engine would be squarely in the second.
Pinning the version
Whichever you choose, the model identifier is a piece of load-bearing configuration and it belongs in exactly one place. Not "a bge model". Not "the small one". A string, exported from one module, imported everywhere:
// The single source of truth for "which vector space are we in".
// 384-d, strong quality per byte, and — crucially — it has an ONNX build,
// so the identical weights run in Node at build time AND in the browser
// at query time. Every consumer imports this constant; nobody hardcodes it.
export const LOCAL_MODEL = 'Xenova/bge-small-en-v1.5';
This single line prevents a whole category of bug. The build script imports it. The client-side query embedder imports it. Any backfill job imports it. When you eventually upgrade the model, you change one string, delete the cache, and rebuild everything at once — which is the only safe way to do it, because a corpus half-embedded by two different models is worse than a corpus with no embeddings at all. §7 has the full autopsy.
3 — The Corpus Pass
Before we can embed anything we need text. This is the step everyone underestimates, and it is the step that determines whether your search is good. The model is a commodity; the corpus is the product.
What text to embed
The naive approach is to read each HTML file, strip the tags, and embed whatever falls out. This produces bad vectors, and the reason is worth understanding rather than just avoiding.
A typical lesson page on this site is 1,500 lines of HTML. Of that, perhaps 60% is the shared chrome: the header, the navigation, the footer, the theme toggle, the sidebar. Another 20% is code samples and inline JavaScript for the interactive demos. Maybe 20% is prose about the actual topic.
Now think about what mean-pooling does. The model produces one vector per token, and pooling averages them. If 80% of the tokens are boilerplate identical to every other page, then 80% of every document vector is the same vector. All your documents get pulled toward a common centroid. Cosines between unrelated pages rise toward 0.95, the spread between "related" and "unrelated" collapses, and your top-8 neighbor lists become effectively random.
If two documents share 80% of their tokens, their cosine is dominated by the shared part no matter how different the remaining 20% is. Every hour you spend improving corpus extraction buys more retrieval quality than every hour you spend shopping for a better model.
There are two ways out, and the better one may surprise you.
Option A — parse the HTML and extract the content region. Load each file, select
article.article-main or main, drop <script>,
<style>, <nav>, <pre>, collapse whitespace.
Works on any site. Requires a parser and a lot of per-site tuning.
Option B — never touch the HTML at all. If you already maintain a catalog of your content — titles, one-line descriptions, keywords, cluster assignments — then that catalog is a corpus, and it is a far better one than the page bodies. It is human-written, dense, free of boilerplate by construction, and it describes what the page is about rather than reproducing everything the page happens to say.
This site has such a catalog: a single JavaScript file holding a NODES array, one entry
per lesson, that already drives the navigation. The entire corpus pass is therefore twelve lines:
import { readFileSync } from 'node:fs';
// The catalog is a browser-global file: `window.NODES = [...]`. Rather than
// parse it, we EXECUTE it with a fake `window` and read what it assigns.
// Cheap, exact, and it means the build can never drift from the nav.
const g = {};
new Function('window', readFileSync(
new URL('../js/constellation-data.js', import.meta.url), 'utf8'))(g);
const NODES = g.NODES || [];
const CLUSTERS = g.CLUSTERS || [];
if (!NODES.length) { console.error('[embeddings] no NODES found'); process.exit(1); }
const clusterLabel = Object.fromEntries(
CLUSTERS.map(c => [c.id, c.label || c.id]));
// One corpus row per node. The embedded text blends four fields, in
// descending order of signal density.
const corpus = NODES.map((n, i) => ({
id: i, // dense integer — the join key for every artifact
nid: n.id, // stable string slug — survives reordering
url: n.url,
title: n.title,
tier: n.tier,
cluster: n.cluster,
text: [
n.title, // strongest signal, ~5 words
n.desc || '', // human one-liner, ~25 words
(n.keywords || []).join(', '), // the vocabulary a searcher will use
clusterLabel[n.cluster] || '', // domain context: "Bayesian & Estimation"
].filter(Boolean).join('. '),
}));
A real row comes out looking like this — 164 characters on average across the whole corpus:
{
"id": 0,
"url": "micro/lessons/ml-maths.html",
"title": "ML Maths",
"tier": "gleam",
"cluster": "foundations",
"text": "ML Maths. The mathematical foundations every ML practitioner needs:
linear algebra, calculus, probability, and information theory..
linear algebra, calculus, probability, statistics. ML Foundations"
}
Every token in that string is about the topic. Compare that to 1,500 lines of page HTML where the word "Engineermaxxing" appears eleven times and "Kalman" appears twice.
Appending the human-readable cluster name ("Bayesian & Estimation") is a deliberate nudge. It pulls every document in a cluster slightly toward a shared regional centroid, which tightens the map's territories and makes topic-level queries like "robotics" behave sensibly. It is a mild thumb on the scale — a hand-authored prior injected into a learned space — and you should know you are doing it. Overdo it (append a paragraph of cluster boilerplate) and you have reinvented the boilerplate problem from three paragraphs ago.
Stripping the noise, when you must
If you do not have a catalog, you have to parse. Here is a version that works on any static site and encodes the lessons above — it is deliberately conservative about what it keeps:
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import { JSDOM } from 'jsdom';
// Elements that are chrome, decoration, or machine-readable noise. Removing
// <pre> is contentious: code IS content on a technical site. But code tokens
// (`const`, `return`, `ctx`) are near-identical across every page, so they
// behave exactly like boilerplate. Keep code out of the EMBEDDING; keep it in
// the keyword index where exact matching is the point.
const DROP = 'script, style, nav, header, footer, aside, noscript, pre, svg, .toc-sidebar';
function extract(html) {
const doc = new JSDOM(html).window.document;
const root = doc.querySelector('article.article-main')
|| doc.querySelector('main')
|| doc.body;
root.querySelectorAll(DROP).forEach(el => el.remove());
const title = (doc.querySelector('title')?.textContent || '')
.replace(/\s*[—|-]\s*[^—|-]+$/, ''); // drop the " — Site Name" suffix
const desc = doc.querySelector('meta[name="description"]')?.content || '';
const keys = doc.querySelector('meta[name="keywords"]')?.content || '';
// Headings carry outsized signal per token: they are the author's own
// summary of each section. Weight them by repeating once.
const heads = [...root.querySelectorAll('h1, h2, h3')]
.map(h => h.textContent.trim()).join('. ');
const body = root.textContent
.replace(/\s+/g, ' ') // collapse the whitespace HTML is full of
.replace(/ /g, ' ') // non-breaking spaces are not word separators
.trim();
return { title, text: [title, desc, keys, heads, body].filter(Boolean).join('. ') };
}
Two judgement calls in there deserve defending.
Dropping <pre>. On a site about engineering this feels like
vandalism. But look at what code contributes to a mean-pooled vector: the tokens
const, function, return, ctx,
import appear in every single lesson. They are, statistically, the same boilerplate as
the navigation. Meanwhile the genuinely discriminative parts of a code sample — the API names, the
variable names — usually also appear in the surrounding prose. So you lose little and you remove a
lot of homogenizing mass. If exact code search matters to you, index code separately with a plain
inverted index, where exact token matching is a feature rather than a limitation.
Repeating the headings. Mean pooling weights every token equally, so the only lever you have for "this text is more important" is to include it more than once. Headings are the author's own compression of each section — the highest signal-per-token text on the page. Including them once in addition to their appearance in the body roughly doubles their weight. Do not go further; three or four repetitions start to produce vectors that are more table-of-contents than document.
Chunking decisions
Small encoder models have a hard limit: 512 tokens, roughly 380 English words. Text beyond that is truncated, silently. A 6,000-word article embedded naively becomes a vector describing its introduction.
Three strategies, in increasing order of effort:
| Strategy | How | When it is right |
|---|---|---|
| Summary vector | Embed title + description + keywords only. One vector per document, never truncated because it was never long. | Navigation, related-content, maps. What this site does. |
| Section chunks | One vector per <h2> section. Document score = max over its chunks. |
Deep search that must land on the right part of a long page. |
| Sliding window | ~300-token windows with ~60-token overlap so no idea is split at a boundary. | Unstructured text with no reliable headings: transcripts, PDFs, emails. |
The summary-vector strategy deserves more respect than it gets. Its critics say it throws away the body. Its defenders point out that for navigation, the body is mostly noise — a user searching "kalman filter" wants the lesson called "Kalman Filter", not the seven other lessons that mention Kalman filters in passing. Embedding only the summary makes the vector describe the document's subject rather than its contents, and for a discovery surface that is exactly the right thing to describe.
It also makes the arithmetic trivially small. 1,184 documents, one vector each. Section chunking the same corpus at ~10 sections per lesson would give ~12,000 vectors — ten times the embedding time, ten times the storage, and an O(n²) neighbor computation that just got 100× more expensive. Start with summaries. Add chunking only when a measured failure demands it.
The id scheme
This looks like bookkeeping. It is the thing that will break in six months if you get it wrong, so it is worth three minutes now.
Every artifact needs to reference documents. There are two natural identifiers and you need both:
- A dense integer
id(0…N−1, the array index). Cheap to store — a neighbor list is[860, 1090, 813, …], a couple of bytes each instead of a 40-character URL. Fast to look up: a direct array index, no hash map. This is what goes inside every artifact. - A stable string
nid(a slug like"ml-maths"). This is what humans, URLs, and other systems use. It survives reordering the catalog; the integer does not.
Insert a lesson at position 40 and every integer id above it shifts by one. Any artifact holding integers that was not regenerated in the same build now points at the wrong documents — silently, plausibly, with no error. Neighbor lists will still render; they will just be wrong.
Two rules make this safe. First: every artifact containing integer ids must be
regenerated by the same build step, atomically. Never hand-edit one. Second: anything
that crosses a boundary — a database row, a URL, an API response, a file another repo reads —
uses the string nid, never the integer.
The map builder on this site does exactly this join, and its header comment says so in as many words:
// Joins: galaxy.id ⋈ corpus.id (numeric — same build only)
// galaxy.nid ⋈ constellation.id (string — stable across builds)
//
// Both artifacts come out of ONE run of gen-embeddings.mjs, so the numeric
// join is safe. The string join is what lets us reach back into the catalog
// for prereqs and difficulty, which live in a file with its own edit history.
4 — Embedding at Build Time
We have N strings. We want N unit vectors. In between sits the part that costs real seconds, so it is also the part that most deserves engineering.
Pooling and normalization
An encoder model does not emit one vector per text. It emits one vector per token. A 40-token input produces a 40×384 matrix. Something has to collapse that into a single 384-vector, and that something is called pooling.
Two options are in common use. CLS pooling takes the vector at position 0, the special classification token, on the theory that training taught it to summarize the sequence. Mean pooling averages all token vectors (over the non-padding positions).
The right choice is not a matter of taste: it is whatever the model was trained with. Use
mean pooling on a model trained with CLS pooling and you get vectors that are not wrong exactly,
but are measurably worse — a few points of retrieval quality thrown away for nothing. Read the
model card. The BGE family is trained for CLS on some variants and behaves well with mean pooling
in the -v1.5 line; the sentence-transformers ports standardize on mean. When in doubt,
embed twenty documents both ways and eyeball the neighbor lists.
Then normalize. Divide each vector by its L2 length so it lands on the unit sphere:
After this, as we proved in §1, cosine similarity is the dot product. Every downstream computation — neighbors, projection, query matching — becomes a multiply-accumulate loop with no square roots in the inner loop. Normalize once, at build, and never think about it again.
let _extractor = null; // module-level: load the ~130 MB of weights ONCE
async function localExtractor(model) {
if (_extractor) return _extractor;
const { pipeline, env } = await import('@huggingface/transformers');
env.allowLocalModels = false; // pull ONNX from the hub, cache in ~/.cache
_extractor = await pipeline('feature-extraction', model);
return _extractor;
}
async function localEmbed(texts, model) {
const ex = await localExtractor(model);
const out = [];
for (let i = 0; i < texts.length; i += 64) { // batch of 64
const t = await ex(texts.slice(i, i + 64), {
pooling: 'mean', // must match what the model was trained with
normalize: true, // unit length -> cosine becomes a dot product
});
for (const row of t.tolist()) out.push(row);
}
return out;
}
The batch size of 64 is not arbitrary. Batching amortizes the per-call overhead of crossing into the ONNX runtime and lets it use matrix kernels instead of vector ones. But every item in a batch is padded to the length of the longest item in that batch, so a batch containing one 500-token outlier and 63 short strings does 500 tokens of work for all 64. On a corpus of short, uniform summaries, 64 sits comfortably in the flat part of the curve. On a corpus with wildly varying lengths, sort by length first so each batch is internally uniform — that one line can halve total time.
The content-hash cache
Here is the trick that makes this pattern pleasant to live with rather than a chore you avoid.
Without a cache, adding one lesson re-embeds all 1,184 documents. Thirty seconds. Thirty seconds is short enough that you will not build a cache, and long enough that you will stop running the build during authoring — so your search index quietly falls behind your content, which defeats the whole point.
With a cache, adding one lesson embeds exactly one document. The cache key is a hash of the two things that determine the output: the model identifier and the text.
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// On-disk cache keyed by sha256(model + '\n' + text).
// computeMissing(texts) -> vectors[] is injected, so this function is
// provider-agnostic: local, hosted, or anything else you bolt on later.
async function cachedEmbed(texts, model, cacheDir, computeMissing) {
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
const vecs = new Array(texts.length);
const miss = [];
texts.forEach((txt, idx) => {
// The model id is IN the key. Switch models and every entry misses —
// which is exactly right: those vectors live in a different space.
const h = createHash('sha256').update(model + '\n' + txt).digest('hex');
const f = join(cacheDir, h + '.json');
if (existsSync(f)) vecs[idx] = JSON.parse(readFileSync(f, 'utf8'));
else miss.push({ idx, f });
});
if (miss.length) {
const fresh = await computeMissing(miss.map(m => texts[m.idx]));
miss.forEach((m, j) => {
vecs[m.idx] = fresh[j];
writeFileSync(m.f, JSON.stringify(fresh[j])); // one file per vector
});
}
return vecs;
}
Four properties fall out of this design, and each one is doing work:
- Content-addressed, not path-addressed. Rename a file, reorder the catalog, move a lesson between clusters — the text is unchanged, so the hash is unchanged, so it is a cache hit. Only editing the text costs anything.
- The model is in the key. Change
LOCAL_MODELand every entry misses at once. There is no way to end up with a corpus half-embedded by two models, because the cache physically cannot serve an old-model vector to a new-model build. - One file per vector. A single big JSON index would need read-modify-write and
would corrupt if the build was interrupted mid-write. Separate files make writes atomic per entry
and let you delete one bad vector with
rm. - The provider is a parameter.
computeMissingis injected, so the same cache serves the local model, a hosted API, or a deterministic lexical fallback for offline builds. That fallback matters: it means a build on a plane produces a worse index rather than no index.
On the real repository the cache directory currently holds 1,394 files totalling 11 MB
— about 8,056 bytes per entry. That number is worth a second of thought: 384
float64 values serialized as JSON text run about 20 characters each
(0.05432187952101231), so 384 × 20 ≈ 7,700 bytes plus brackets and commas.
The measurement matches the prediction, which is how you know you understand what you built.
(Storing these as raw Float32Array buffers instead of JSON would cut the cache to
1,536 bytes per entry — a 5× saving. For a build cache that never leaves your machine, JSON's
debuggability is worth the disk. For anything you ship, see §5.)
Throughput arithmetic
Let us cost the whole build properly, because the answer contains a genuine surprise.
Cold build. 1,184 documents, average 164 characters ≈ 40 tokens.
bge-small on a modern laptop CPU through ONNX runs roughly 40–50 short documents per
second in batches of 64. Take 45:
| Quantity | Arithmetic | Value |
|---|---|---|
| documents | — | 1,184 |
| throughput | — | 45 docs/s |
| embed time | 1,184 ÷ 45 | 26.3 s |
| model load | one-time ONNX init | ~3 s |
| total cold | 26.3 + 3 | ~29 s |
Warm build, one lesson added. 1,183 hits, 1 miss.
| Quantity | Arithmetic | Value |
|---|---|---|
| fraction recomputed | 1 ÷ 1,184 | 0.084% |
| embed time | 1 ÷ 45 | 0.02 s |
| model load | still paid — one doc is a doc | ~3 s |
| cache reads | 1,183 files, ~8 KB each ≈ 9.5 MB | ~0.4 s |
| embedding total | 0.02 + 3 + 0.4 | ~3.4 s |
| if nothing changed | cache reads only, model never loads | ~0.4 s |
Eight times faster with one edit, seventy times faster with none. Notice what the second row exposes: once the cache is warm, the fixed 3-second cost of loading the model dwarfs the 0.02 seconds of actually running it. And now the real surprise: the warm build is no longer dominated by the model at all.
What dominates instead is the geometry. Computing all-pairs similarity for the neighbor lists and the projection is O(N²D):
| N | pairs = N(N−1)/2 | multiply-adds = pairs × 384 | N×N float64 matrix |
|---|---|---|---|
| 1,184 | 700,336 | 269 million | 10.7 MiB |
| 5,000 | 12,497,500 | 4.8 billion | 190.7 MiB |
| 20,000 | 199,990,000 | 77 billion | 2.98 GiB |
At 1,184 documents the whole geometry pass takes a second or two in plain JavaScript and nobody notices. At 5,000 the classical-MDS projection allocates a 190 MiB matrix — and it allocates two of them, the squared-distance matrix and the double-centered matrix, so 380 MiB — and the build starts to feel slow. At 20,000 it does not fit in a Node heap at all.
A content-hash cache makes embedding free after the first run. That is exactly when the quadratic parts of your pipeline become the thing you feel. Plan the transition before you hit it: approximate nearest neighbors (HNSW) instead of exhaustive kNN, and landmark MDS or a random projection instead of full classical MDS. The crossover for a plain-JS implementation is somewhere around N ≈ 5,000.
Drag the corpus size and the number of documents whose text changed since the last build. The stage widths are proportional to measured time. Watch the bottleneck move from the embedder to the geometry as the cache warms up.
Play with it for a moment and the shape of the system becomes obvious. With the cache off, the embedder is 90% of the bar at every corpus size. With the cache on and one document changed, the embedder vanishes and the quadratic stage swells until it is the entire build. That is the transition the callout above warns about, drawn.
5 — Serving Without a Server
We now have N unit vectors sitting in memory on the build machine. The visitor's browser is about to receive a pile of static files. What, exactly, goes in them?
This is the design decision with the largest user-visible consequence in the entire system, because it decides how many bytes someone on a phone downloads before your search box works.
Two shapes of artifact
Shape A — ship conclusions. Precompute the answers on the build machine. For every document, store its k nearest neighbors as integers and its 2D map coordinates as two floats. Throw the vectors away.
import { project2d, knn } from './layout.mjs';
const embedded = await embedTexts(
corpus.map(c => ({ id: c.id, text: c.text })), { provider: 'auto' });
const vecs = embedded.map(e => e.vec);
const coords = project2d(vecs); // N x 384 -> N x 2
const neigh = knn(vecs, 8); // N x 384 -> N x 8 integer ids
// (1) Search corpus: TEXT only, no vectors. Whoever runs search re-embeds
// these with the same pinned model. 356 KB, 87 KB gzipped.
writeFileSync(new URL('site-corpus.json', dir), JSON.stringify(
corpus.map(c => ({ id: c.id, url: c.url, title: c.title,
tier: c.tier, cluster: c.cluster, text: c.text }))));
// (2) Galaxy map: coordinates + neighbours. No raw vectors -> compact.
// Coordinates rounded to 4 dp: sub-pixel precision on any screen,
// and it halves the JSON size versus full float64 text.
writeFileSync(new URL('site-galaxy.json', dir), JSON.stringify({
clusters: CLUSTERS.map(c => ({ id: c.id, label: c.label, color: c.color,
x: c.x, y: c.y })),
nodes: corpus.map((c, i) => ({
id: c.id, nid: c.nid, url: c.url, title: c.title,
tier: c.tier, cluster: c.cluster,
x: +coords[i].x.toFixed(4),
y: +coords[i].y.toFixed(4),
nn: neigh[i], // e.g. [860, 1090, 813, 1091, ...]
})),
}));
Shape B — ship the matrix. Serialize all N×D floats into a binary file. The browser fetches it once, and can then compute cosine against any query vector — including one it just produced from text the visitor typed. This is what you need for genuine client-side semantic search.
Shape A is small and complete for related-content and maps. Shape B is large and required for live querying. Most sites want A plus a lazily-loaded B behind the search box. To choose sensibly you need the byte arithmetic.
The byte budget
A raw matrix costs N × D × bytes-per-value. That is the whole formula. Let us put our real numbers through it.
| Encoding | Arithmetic | Bytes | Human |
|---|---|---|---|
| float32 | 1,184 × 384 × 4 | 1,818,624 | 1.73 MiB |
| float16 | 1,184 × 384 × 2 | 909,312 | 888 KiB |
| int8 | 1,184 × 384 × 1 | 454,656 | 444 KiB |
| int8 + per-row scale | 454,656 + 1,184 × 4 | 459,392 | 449 KiB |
| neighbours only (k=8, uint16) | 1,184 × 8 × 2 | 18,944 | 18.5 KiB |
And the artifacts this site actually ships, measured on disk and after gzip:
| File | Raw | Gzipped | Ratio |
|---|---|---|---|
| data/site-galaxy.json | 254,377 B | 62,095 B | 24.4% |
| data/site-corpus.json | 356,143 B | 87,385 B | 24.5% |
Read the last line of the first table again. 18.5 KiB. The complete related-content graph for the entire site — every document's eight nearest semantic neighbors — is smaller than a single web font. It is smaller than most logo PNGs. The 1.73 MiB matrix that produced it stayed on the build machine.
That ~98% reduction is not compression. It is the difference between shipping a representation and shipping a conclusion, and it is available to you for free the moment you accept that the neighbor list is what the product actually needed.
Text JSON compresses to about a quarter of its size because it is full of repeated field names
and ASCII digits. A packed binary Float32Array is close to incompressible — the low
mantissa bits are essentially random, and gzip typically saves under 5%. So the comparison
that matters is gzipped JSON versus raw binary: 888 KiB of raw float16 beats
1.73 MiB of float32 whether or not either is compressed, but neither beats not shipping vectors
at all.
Quantizing to int8 — and what it costs you
If you do need to ship the matrix, quantization is the highest-leverage optimization available: 4× smaller than float32, at an accuracy cost we can compute exactly rather than guess at.
The scheme: for each row, find the largest absolute component, call it s. Map the range [−s, +s] onto the 255 integer levels of a signed byte:
Take the normalized vector from §1, â = (0.5345, 0.2673, 0, 0.8018). Its largest absolute component is s = 0.8018. Quantize the first component:
| Step | Arithmetic | Result |
|---|---|---|
| scale to [−1, 1] | 0.5345 ÷ 0.8018 | 0.66663 |
| scale to byte range | 0.66663 × 127 | 84.661 |
| round | round(84.661) | 85 |
| dequantize | (85 ÷ 127) × 0.8018 | 0.53664 |
| absolute error | 0.53664 − 0.53450 | 0.00214 |
| relative error | 0.00214 ÷ 0.5345 | 0.40% |
Now the question that matters: how much does that perturb a cosine? The quantization step is Δ = s/127 = 0.8018/127 = 0.006313. Rounding error is uniform on [−Δ/2, +Δ/2], whose standard deviation is σ = Δ/√12 = 0.006313 / 3.4641 = 0.001822.
The perturbed dot product is Σi (vi + ei)·bi = (v·b) + Σi ei bi. The errors are independent, so the error term has variance σ² Σi bi². And b is a unit vector, so Σi bi² = 1 exactly. Therefore:
A beautifully clean result, and independent of D: because the other vector is unit-length, the error in the similarity is just the per-component quantization noise. Two thousandths of a cosine unit, against neighbor scores that typically differ by 0.01–0.05 between adjacent rank positions. Ranks shuffle at the margins; the top of the list does not move.
// ── BUILD SIDE ──────────────────────────────────────────────────────────
// Layout: [Int8 payload N*D][Float32 scales N]. One fetch, one ArrayBuffer,
// no parsing. A JSON file of the same data would be ~6x larger and would
// cost a multi-megabyte JSON.parse on the main thread.
export function packInt8(vectors) {
const N = vectors.length, D = vectors[0].length;
const q = new Int8Array(N * D);
const scales = new Float32Array(N);
for (let i = 0; i < N; i++) {
const v = vectors[i];
let s = 0;
for (let d = 0; d < D; d++) s = Math.max(s, Math.abs(v[d]));
s = s || 1e-8; // guard the all-zero row
scales[i] = s;
for (let d = 0; d < D; d++) {
// clamp defends against a rounding edge landing on 128, which would
// wrap to -128 in an Int8Array and flip the sign of that component.
q[i * D + d] = Math.max(-127, Math.min(127, Math.round(127 * v[d] / s)));
}
}
const out = new Uint8Array(q.byteLength + scales.byteLength);
out.set(new Uint8Array(q.buffer), 0);
out.set(new Uint8Array(scales.buffer), q.byteLength);
return out; // write this with writeFileSync
}
// ── BROWSER SIDE ────────────────────────────────────────────────────────
// The query vector stays float32 (it is one vector; quantizing it saves
// nothing and costs accuracy). Only the corpus is quantized.
export function searchInt8(buf, D, query, k = 8) {
const N = Math.floor(buf.byteLength / (D + 4));
const q = new Int8Array(buf, 0, N * D);
const scales = new Float32Array(buf, N * D, N);
const best = [];
for (let i = 0; i < N; i++) {
let dot = 0;
const off = i * D;
for (let d = 0; d < D; d++) dot += q[off + d] * query[d]; // int x float
// Fold the two constants out of the inner loop: one multiply per DOC,
// not per dimension. On 1,184 docs that removes ~454,000 multiplies.
const sim = dot * scales[i] / 127;
if (best.length < k) { best.push([i, sim]); best.sort((a, b) => b[1] - a[1]); }
else if (sim > best[k - 1][1]) {
best[k - 1] = [i, sim];
best.sort((a, b) => b[1] - a[1]);
}
}
return best;
}
How fast is that inner loop in a browser? N × D = 1,184 × 384 = 454,656 multiply-adds over typed arrays. A modern JavaScript engine does that in roughly 1–3 milliseconds. You can run it on every keystroke without debouncing, which is precisely the interaction quality the network round trip was preventing.
Exhaustive search stays comfortable up to roughly 50,000 documents (about 19 million multiply-adds, ~40 ms — still under a frame budget if you do it off the main thread). Beyond that you want an approximate index, and the honest answer is that at that scale you have outgrown the pattern this article describes.
Set your corpus size, model dimensionality, and encoding. The bars are drawn to scale against a 500 KB budget line — a reasonable ceiling for something fetched before search can respond.
Two lessons come out of playing with that. First, D is as expensive as N — going from 384 to 1,536 dimensions costs the same as quadrupling your corpus. Second, the neighbors-only bar barely moves no matter what you do to D, because neighbor lists do not contain dimensions at all. If you can express your feature as "the top k", the dimensionality question stops being a shipping question entirely.
6 — The Galaxy: Projecting to Two Dimensions
A map of everything the site knows is the most seductive thing you can build on top of a semantic spine. It is also the one where it is easiest to fool yourself and your readers, so we are going to build it and then immediately attack it.
The task: turn N points in 384-dimensional space into N points on a screen, such that things that are semantically close appear visually close.
Classical MDS from zero
UMAP and t-SNE are the famous answers. They are also several thousand lines of dependency, they are stochastic (different layout on every build, which makes your map jump around between deploys), and they have hyperparameters whose effects are notoriously easy to misread.
Classical multidimensional scaling is sixty lines with no dependencies, it is deterministic, and it has an exact interpretation: it finds the 2D configuration whose pairwise distances best match the original pairwise distances in a least-squares sense. Let us derive it.
Step 1: the distance matrix. For every pair, compute cosine distance (1 minus cosine similarity), and square it. Squared distances are what the algebra needs.
Step 2: double-centering. Here is the pivot the whole method turns on. Distances are invariant to where you put the origin, which is why you cannot read coordinates off them directly. But if you choose the origin to be the centroid of the points, then squared distances and inner products are related by an exact identity:
where row̄i is the mean of row i of D², and grand̄ is the mean of all of D². Subtracting the row and column means and adding back the grand mean is exactly the operation that recenters the implied point cloud on its own centroid. The result B is the Gram matrix — Bij is the inner product of points i and j about the centroid.
Step 3: eigendecomposition. If B = XXT for some coordinate matrix X, then the eigenvectors of B, scaled by the square roots of their eigenvalues, are the coordinates. Taking the top two eigenvectors gives the 2D configuration that preserves as much of the original geometry as any 2D configuration can.
And you do not need a linear-algebra library for the top two eigenvectors. Power iteration finds the largest; deflation removes it; power iteration again finds the second.
function cosine(a, b) {
let d = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { d += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
return d / Math.sqrt((na * nb) || 1);
}
// Dominant eigenpair of a symmetric matrix. Repeatedly multiplying any
// starting vector by M amplifies the component along the largest eigenvector
// fastest; renormalizing each step keeps it finite. 200 iterations is far
// more than needed for the separation we see in practice.
function powerIter(M, iters = 200) {
const n = M.length;
// Deterministic non-degenerate start: sin(i+1) never lands orthogonal to
// the target the way a constant vector can, and it never differs between
// builds the way Math.random() would.
let v = new Array(n).fill(0).map((_, i) => Math.sin(i + 1));
let norm = Math.sqrt(v.reduce((s, x) => s + x*x, 0));
v = v.map(x => x / norm);
let lambda = 0;
for (let t = 0; t < iters; t++) {
const w = new Array(n).fill(0);
for (let i = 0; i < n; i++) { let s = 0; for (let j = 0; j < n; j++) s += M[i][j]*v[j]; w[i] = s; }
norm = Math.sqrt(w.reduce((s, x) => s + x*x, 0)) || 1;
lambda = norm; // Rayleigh quotient for a unit v
v = w.map(x => x / norm);
}
return { lambda, vec: v };
}
// Remove the found eigenpair so the next power iteration finds the SECOND.
function deflate(M, lambda, vec) {
const n = M.length, R = M.map(r => r.slice());
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) R[i][j] -= lambda*vec[i]*vec[j];
return R;
}
export function project2d(vectors) {
const n = vectors.length;
if (n === 0) return [];
if (n === 1) return [{ x: 0.5, y: 0.5 }];
// (1) squared cosine-distance matrix
const D2 = Array.from({ length: n }, () => new Array(n).fill(0));
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
const d = 1 - cosine(vectors[i], vectors[j]);
D2[i][j] = D2[j][i] = d * d;
}
// (2) double-centering: B = -1/2 J D2 J
const rowMean = D2.map(r => r.reduce((a, b) => a + b, 0) / n);
const grand = rowMean.reduce((a, b) => a + b, 0) / n;
const B = Array.from({ length: n }, (_, i) => new Array(n));
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++)
B[i][j] = -0.5 * (D2[i][j] - rowMean[i] - rowMean[j] + grand);
// (3) top two eigenpairs -> coordinates
const e1 = powerIter(B);
const e2 = powerIter(deflate(B, e1.lambda, e1.vec));
const s1 = Math.sqrt(Math.max(e1.lambda, 0));
const s2 = Math.sqrt(Math.max(e2.lambda, 0));
let xs = e1.vec.map(v => v * s1), ys = e2.vec.map(v => v * s2);
// (4) normalize into [0.05, 0.95] so the renderer never needs to know
// anything about the scale of the underlying space.
const norm = arr => { const mn = Math.min(...arr), mx = Math.max(...arr), r = (mx - mn) || 1;
return arr.map(v => 0.05 + 0.9 * (v - mn) / r); };
xs = norm(xs); ys = norm(ys);
return xs.map((x, i) => ({ x, y: ys[i] }));
}
// Exhaustive k-nearest-neighbours. O(n^2 D) and utterly unapologetic about
// it: at n in the low thousands it is a second, and it is EXACT, which
// makes it the ground truth you evaluate approximate indexes against.
export function knn(vectors, k) {
const n = vectors.length;
return vectors.map((vi, i) => {
const sims = [];
for (let j = 0; j < n; j++) if (j !== i) sims.push([j, cosine(vi, vectors[j])]);
sims.sort((a, b) => b[1] - a[1]);
return sims.slice(0, k).map(s => s[0]);
});
}
The starting vector is sin(i+1), not Math.random(). That single choice
means two builds of the same corpus produce byte-identical coordinates. Your map does not
reshuffle on every deploy, your artifact diffs stay readable in git, and a coordinate change in a
pull request means the content actually changed. Stochastic layout algorithms give this
up, and you feel the loss every time you review a diff.
What projection destroys
Now the attack. We compressed 384 numbers into 2. That is a loss of 382 degrees of freedom, and it is not free.
Quantify it with the eigenvalues, which the algorithm hands you for nothing. The fraction of the original geometry retained is:
On real text-embedding corpora this number is typically 15–30%. Which means 70–85% of the structure in your data is not on the map. Any two points that look close might be genuinely close, or might be 382 dimensions apart in a direction the projection collapsed.
- Distances between clusters are not meaningful. Two blobs drawn far apart may be no further apart than two drawn adjacent. Projection preserves local structure much better than global structure — and t-SNE and UMAP are worse at this than MDS, because they optimize neighborhood preservation explicitly at the cost of global geometry.
- Cluster sizes are not meaningful. A tight visual blob may be a diffuse region of the real space that happened to collapse along the discarded axes.
- Empty space is not meaningful. A gap on the map is not a gap in the corpus. It is a place where the projection had nothing to put, which in 384 dimensions is almost everywhere.
So how do you build an honest map? By never using the 2D coordinates for anything except
placement. Edges, "related" links, and the neighbor drawer all read from
nn — the neighbor lists computed in the full 384-dimensional space. The coordinates
decide where a dot is drawn. The truth about what is near what comes from a different
array entirely.
The demo below makes the gap visible. Click any dot: the solid lines are its true high-dimensional neighbors; the dashed circle contains its nearest neighbors in the picture. The overlap between those two sets is the honest measure of how much the map can be trusted.
A synthetic 24-dimensional corpus with four topics, projected to 2D by the same classical MDS used above. Click a point. Solid teal = its true nearest neighbours in 24-D. Dashed warm = its nearest neighbours on the screen. Where they disagree, the map is lying.
Suppose you click a point, k = 8, and 3 of its 8 true neighbors also appear among its 8 screen-nearest neighbors. Recall@8 = 3/8 = 0.375.
What does 0.375 mean? Compare it to chance. The demo holds N = 160 points, so picking 8 at random gives an expected overlap of 8 × 8/159 = 0.40 points, i.e. recall ≈ 0.05. So 0.375 is about 7× better than chance — the map is carrying real signal — and it is also wrong about five neighbors out of eight. Both statements are true, and a map interface must be designed around the second one.
Practical consequence: use the map for orientation ("robotics is over there, generative models are over here") and never for recommendation. Recommendation reads the high-dimensional neighbor list.
Labeling the regions
A field of unlabeled dots is pretty and useless. Three ways to attach meaning, in increasing order of trustworthiness:
Cluster then label. Run k-means in the original 384-d space (never in 2D — you would be clustering the artifacts of the projection), then name each cluster by the highest TF-IDF terms among its members. Fully automatic, and the names are usually mediocre because term frequency is a poor summarizer.
Use the categories you already have. If your content is already organized — and most sites' is — color by that. The embedding decides where; your taxonomy decides what it is called. This is what the site does: 20 hand-authored clusters, each with a label and a color, and a fixed anchor position.
{"clusters":[
{"id":"foundations","label":"ML Foundations", "color":"#b07aff","x":0.12,"y":0.12},
{"id":"vision", "label":"Computer Vision", "color":"#6b9eff","x":0.32,"y":0.10},
{"id":"nlp", "label":"NLP & Language", "color":"#5cc9b5","x":0.52,"y":0.10},
{"id":"estimation", "label":"Bayesian & Estimation","color":"#e87aaa","x":0.30,"y":0.30}
]}
Anchor the layout to the taxonomy. The strongest option, and the one the production map uses: keep the hand-authored anchors as fixed continent centers, and use the embedding coordinates only for local scatter within a region. You get a macro-layout that is stable across rebuilds and legible to a human, plus micro-placement that genuinely reflects semantics. New content lands in the right territory automatically; the territories themselves never wander.
Embeddings are excellent at relative placement and terrible at naming. Humans are excellent at naming and terrible at placing a thousand items consistently. Give each what it is good at: taxonomy sets the anchors and the words, the model fills in the fine structure.
7 — Query Time
Everything so far happened on your machine. Now a visitor types "how do I fuse noisy sensors" and we have milliseconds to turn that into a vector that lives in the same space as the corpus.
In the browser, with transformers.js
The ONNX build of the same model runs in the browser. Which means the query vector can be produced on the visitor's device, with no network call and no key.
// Same constant as the build. Import it; never retype it.
const LOCAL_MODEL = 'Xenova/bge-small-en-v1.5';
let extractorPromise = null;
// Lazy: ~30 MB of quantized ONNX weights. NOT on page load — on first
// focus of the search box, which buys you the seconds a human spends
// deciding what to type.
function getExtractor() {
if (!extractorPromise) {
extractorPromise = import('https://cdn.jsdelivr.net/npm/@huggingface/transformers')
.then(({ pipeline, env }) => {
env.allowLocalModels = false;
env.useBrowserCache = true; // IndexedDB: second visit is instant
return pipeline('feature-extraction', LOCAL_MODEL, { dtype: 'q8' });
});
}
return extractorPromise;
}
export async function embedQuery(text) {
const ex = await getExtractor();
// IDENTICAL options to the build. Any divergence here silently corrupts
// every cosine in the system.
const out = await ex(text, { pooling: 'mean', normalize: true });
return Array.from(out.data); // Float32Array -> plain array
}
// Warm the model the instant intent appears, so the first search is fast.
document.querySelector('#search-input')
?.addEventListener('focus', () => { getExtractor(); }, { once: true });
The costs, stated plainly: a ~30 MB one-time download of quantized weights, cached in IndexedDB afterwards; 200–500 ms of warm-up on first use; then 10–30 ms per query. Combined with a 444 KB int8 corpus matrix and a 1–3 ms dot-product scan, you have a complete semantic search engine with zero server components.
Thirty megabytes is not nothing. Load it lazily, load it on focus, and never on page load — the visitor who never searches should never pay for it.
At the edge
The alternative: embed the query in a small edge function and keep vectors in a database with a vector index. Round trip 30–80 ms from a nearby region, zero client download, and you get server-side filtering and ranking for free. Worth it when the corpus is too large to ship, when results must respect per-user permissions, or when you want query analytics.
This site runs both, for different surfaces. The site-wide catalog search is small enough to ship. The research feed — tens of thousands of rows, growing daily — lives in Postgres with pgvector and an edge function that embeds the query. Which brings us to the failure mode that connects them.
The mixed-version trap
This is the bug that will cost you a weekend, so here it is in full.
Two vectors are comparable only if the same weights produced them. Not "the same architecture." Not "the same family." The same weights, the same pooling, the same normalization, and the same text construction. Violate any one and cosine similarity keeps returning numbers — plausible numbers, between 0 and 1 — that mean nothing.
A broken search does not throw. It returns results. They are sorted. Some of them are even vaguely relevant, because two different English embedding models still agree that documents about dogs are not documents about compilers. The quality degradation looks like "the model just isn't very good at this", which is the single most plausible and most misleading explanation available to you.
Concrete ways to fall in:
- The build uses
bge-small; the edge function's built-in embedder isgte-small. Both are 384-d, so nothing errors, dimensions line up perfectly, and every cosine is garbage. - You upgrade the model and re-embed new documents but not old ones. The corpus is now split across two spaces and new content mysteriously never ranks against old content.
- The build joins fields as
title. desc. keywordsand the runtime joins them astitle | desc | keywords. Same model, same pooling, different text — and a measurably different vector.
The defense is a build-time assertion. Before writing a single vector, take a row that the other system already embedded, embed it locally, and check the cosine is ~1.0. If it is not, abort the build. Never poison the index.
// EXACT match to the edge function's rowText(): same fields, same order,
// same join, same truncation. Copy it; do not paraphrase it.
function rowText(r) {
const topics = Array.isArray(r.topics) ? r.topics.join(', ') : '';
return [r.title, r.summary, r.why, topics].filter(Boolean).join('. ').slice(0, 4000);
}
// Take a row the EDGE already embedded and re-embed it LOCALLY. If the two
// models are the same model, the cosine is ~1.0. Anything below 0.999 means
// we are about to write vectors into a space that is not the query's space.
async function verify() {
for (const [table, cols] of Object.entries(TABLES)) {
const rows = await sb(`${table}?select=${cols},embedding&embedding=not.is.null&limit=1`);
if (!rows.length) continue;
const stored = typeof rows[0].embedding === 'string'
? JSON.parse(rows[0].embedding) : rows[0].embedding;
const local = await embed(rowText(rows[0]));
const cos = cosine(stored, local); // both already normalized
console.log(`[verify] ${table} cosine(edge, local) = ${cos.toFixed(5)}`);
return cos;
}
return null;
}
const cos = await verify();
if (cos !== null && cos < 0.999) {
console.error(`[abort] model divergence: cosine ${cos.toFixed(5)} < 0.999.`);
console.error(' Writing now would produce incomparable vectors.');
process.exit(1);
}
Ten lines. It has caught a real divergence on this site, and every hour it has ever cost was repaid the first time it fired.
- One exported constant for the model id, imported by every consumer.
- The model id is part of the cache key, so a change invalidates everything at once.
- The text-construction function is shared code, not two copies that look alike.
- A verify step compares against a vector the other system produced, and aborts on mismatch.
- Model upgrades are a full re-embed of the whole corpus, in one deploy. Never incremental.
8 — Evaluating on Your Own Corpus
You have shipped a semantic layer. Is it any good? "The neighbor lists look reasonable" is how every bad search system in history got shipped, so let us do better.
The critical framing: public benchmark scores tell you nothing about your corpus. MTEB is averaged over Wikipedia, Reddit, scientific abstracts, and financial filings. Your corpus is none of those. A model that ranks third overall may be first on your content, or twentieth. The only measurement that means anything is the one you run on your own documents with your own queries.
Click-through goldens
The cheapest useful evaluation set costs one afternoon. Write down 30–50 real queries — pull them from your analytics if you have any, and otherwise write the questions you actually get asked — and for each one, name the document that should be the top result.
[
{ "q": "how do I fuse noisy sensors", "want": "kalman-filter" },
{ "q": "why does my robot drift over time", "want": "imu-bias-estimation" },
{ "q": "make images from noise", "want": "diffusion-models" },
{ "q": "what is attention", "want": "attention-transformer" },
{ "q": "picking actions under uncertainty", "want": "mdp" },
{ "q": "shrink a model to run on a phone", "want": "quantization" }
]
Note the shape of these queries. Not one contains the title of the document it should retrieve. That is deliberate: queries that share words with the target are already handled by keyword search. The golden set should test exactly the capability the embedding layer was added to provide.
import { readFileSync } from 'node:fs';
import { embedTexts } from '../scripts/embed.mjs';
const corpus = JSON.parse(readFileSync('data/site-corpus.json', 'utf8'));
const goldens = JSON.parse(readFileSync('eval/goldens.json', 'utf8'));
// Embed corpus (cache: instant) and queries (30 items: well under a second)
// through the SAME function, so there is no way for them to diverge.
const docVecs = (await embedTexts(
corpus.map((c, i) => ({ id: i, text: c.text })))).map(e => e.vec);
const qVecs = (await embedTexts(
goldens.map((g, i) => ({ id: i, text: g.q })))).map(e => e.vec);
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]*b[i]; return s; };
let hits1 = 0, hits5 = 0, rrSum = 0;
for (let i = 0; i < goldens.length; i++) {
const ranked = corpus
.map((c, j) => ({ url: c.url, sim: dot(qVecs[i], docVecs[j]) }))
.sort((a, b) => b.sim - a.sim);
// rank is 1-indexed; 0 means "not in the top 20 at all"
const rank = ranked.findIndex(r => r.url.includes(goldens[i].want)) + 1;
if (rank === 1) hits1++;
if (rank >= 1 && rank <= 5) hits5++;
rrSum += rank ? 1 / rank : 0;
const flag = rank === 1 ? 'ok ' : rank && rank <= 5 ? '~ ' : 'MISS';
console.log(`${flag} rank=${rank || '>20'} "${goldens[i].q}"`);
if (rank !== 1) console.log(` got: ${ranked[0].url} (${ranked[0].sim.toFixed(3)})`);
}
const n = goldens.length;
console.log(`\nrecall@1 ${(hits1/n*100).toFixed(1)}% ` +
`recall@5 ${(hits5/n*100).toFixed(1)}% ` +
`MRR ${(rrSum/n).toFixed(3)}`);
Mean Reciprocal Rank asks: on average, how far down the list is the right answer? Score each query as 1/rank, then average. Five queries, ranks 1, 1, 3, not-found, 2:
| Query | Rank of correct doc | Reciprocal rank | Value |
|---|---|---|---|
| fuse noisy sensors | 1 | 1/1 | 1.0000 |
| robot drift | 1 | 1/1 | 1.0000 |
| make images from noise | 3 | 1/3 | 0.3333 |
| what is attention | not in top 20 | 0 | 0.0000 |
| actions under uncertainty | 2 | 1/2 | 0.5000 |
| sum | 1 + 1 + 0.3333 + 0 + 0.5 | 2.8333 | |
| MRR | 2.8333 ÷ 5 | 0.5667 | |
recall@1 = 2/5 = 0.40, recall@5 = 4/5 = 0.80, MRR = 0.567. Now the number that matters is not the 0.567 — it is what the 0.567 becomes when you change something. Rerun after every corpus tweak. If appending the cluster label moves MRR from 0.567 to 0.61, keep it. If it moves it to 0.52, you have just learned something the vibe check would never have told you.
The neighbor audit
Goldens test search. They do not test related content, which is a different operation — document-to-document rather than query-to-document — and which fails in its own way. The audit for that is embarrassingly simple and shockingly effective: print the top neighbors of a random sample and read them.
const galaxy = JSON.parse(readFileSync('data/site-galaxy.json', 'utf8'));
const byId = Object.fromEntries(galaxy.nodes.map(n => [n.id, n]));
// Deterministic sample: same 25 rows every run, so a diff between two runs
// is a diff in the DATA and not in which rows you happened to draw.
const step = Math.floor(galaxy.nodes.length / 25);
for (let i = 0; i < galaxy.nodes.length; i += step) {
const n = galaxy.nodes[i];
console.log(`\n## ${n.title} [${n.cluster}]`);
for (const j of n.nn.slice(0, 5)) {
const m = byId[j];
// The cross-cluster marker is the cheap tell. Some are the BEST links
// in the system (the same idea reached from another field). Some are
// pure noise. Reading the marked ones first finds bugs fastest.
const mark = m.cluster === n.cluster ? ' ' : ' * ';
console.log(`${mark}${m.title} [${m.cluster}]`);
}
}
Twenty-five documents, five neighbors each — a hundred and twenty-five judgements, about ten minutes of reading. In that ten minutes you will reliably find things no metric would have surfaced:
- A document whose neighbors are all from a different topic. Almost always a corpus bug — a missing description, a wrong cluster field, keywords copy-pasted from another lesson.
- A hub document that appears in everyone's neighbor list. Usually a page with unusually generic text ("Overview", "Introduction to Machine Learning") sitting near the centroid of the whole space. Fix by making its description specific, not by tuning the model.
- Near-duplicate pairs. Two documents with cosine above ~0.95 are the same document wearing two titles. That is a content problem the embedding just found for you.
Cosine values have no absolute meaning across models. Some models run "hot": on
gte-small, two entirely unrelated technical texts still score around 0.75, because
the model devotes much of the sphere to "this is technical English" before it gets to topic. On
another model, 0.75 might be a strong match.
So never import a threshold from a blog post. Sample 50 pairs from your own data, label them true/false by hand, and plot the two distributions. Your threshold is where they separate — and if they do not separate, no threshold will save you and you need better corpus text.
9 — Extending the Spine
Once one vector space exists, new features stop looking like projects and start looking like queries against it. Here are three that earn their keep, told with the numbers from the real implementation.
Feed-to-lesson bridging
The site runs a daily research feed: new papers and releases, tens of thousands of rows in Postgres. The feature we wanted: when an item is about something the site already teaches, show a chip linking to that lesson. New research, connected to the foundations that explain it.
Mechanically this is one cosine. Embed the feed item, embed every lesson, take the top match, and emit a chip if the similarity clears a threshold. All the engineering is in that last word.
Set the threshold too low and you get confident nonsense — a chip that says "learn the foundations" pointing at something unrelated, which is worse than no chip because it teaches the reader that the chips are noise. Set it too high and the feature almost never fires.
The method: run the matcher in dry-run mode over 60 random unprocessed items, print the best similarity for each with the lesson it chose, and hand-label them true or false. Here is what that looked like, sorted by score:
| Cosine | Feed item → chosen lesson | Verdict |
|---|---|---|
| 0.936 | Qwen-VLA release → Qwen-VLA paper lesson | true |
| 0.931 | LingBot-Map → mapping lesson | true |
| 0.911 | VLA-Corrector → ACT | true |
| 0.899 | Gaze Heads → microVLM | true |
| 0.897 | MemSyco → Agent Memory | true |
| 0.877 | Instruct-Particulate → I3D | false |
| 0.874 | TabFM (tables) → TinyML Workbook | false |
| 0.873 | iLLaDA → Discrete Diffusion | true |
| 0.867 | Bayesian ICL → Bayes Filter | false |
Read rows 6 through 9 carefully, because they contain the entire decision. In the band 0.86–0.88 the labels are mixed, and worse, they are mixed out of order: a false match at 0.874 outranks a true one at 0.873. No threshold placed inside that band separates the classes, because within the band the score is not carrying the information.
So we cut at 0.88, above the mess, and accept losing the true matches inside it. The reasoning is asymmetric cost: a wrong chip damages trust in every chip, while a missing chip is invisible. Better zero chips than a wrong chip.
The outcome at that threshold, over a full run: 2,132 items matched and 9,707 unmatched out of 11,839 — an 18.0% hit rate. Which sounds like a failing grade until you look at what the misses are.
The best part: the misses are a content roadmap
An unmatched item means: something is happening in the field, and the site has nothing that explains it. That is not an error. That is a request.
So the matcher writes every miss to a ranked markdown file, sorted by nearest-miss similarity. The top of that file is the highest-value content backlog anyone could hand you: real things people are publishing, that your readers are seeing, that you almost cover.
# Feed → Lesson Gaps
Feed items with no site lesson above the precision threshold. Ranked by
nearest-miss similarity — the top of each run is the most promising backlog.
## Run 2026-07-06 — 2132 matched · 9707 unmatched (threshold 0.88)
| date | title | best_sim | nearest lesson |
|------------|------------------------------------------------|----------|-------------------|
| 2026-06-22 | Site-Specific MIMO Channel Generation via ... | 0.88 | Diffusion Workbook |
| 2026-06-29 | VGB: masked diffusion erases its own mistakes | 0.88 | Discrete Diffusion |
| 2026-06-12 | How does test-time scaling impact robots? | 0.88 | Test-Time Compute |
| 2026-06-15 | ACE-Ego-0: Egocentric Human + Robot VLA data | 0.88 | Human-to-Robot |
A rejected match is a measurement of a hole in your corpus, at the exact boundary of what you already cover. Every retrieval system you build should log its failures somewhere a human will read them.
Dedup, and drift
Deduplication falls out of the same neighbor lists you already computed. Any pair
whose cosine exceeds ~0.95 is worth a look; above ~0.97 on summary vectors it is almost certainly
the same content twice. Since knn already produced a sorted top-k, this is a
filter over data you have:
const DUP = 0.95;
const seen = new Set();
for (let i = 0; i < vecs.length; i++) {
for (const j of neigh[i]) {
if (j <= i) continue; // each unordered pair once
const sim = dot(vecs[i], vecs[j]);
if (sim < DUP) break; // nn is sorted: nothing below can qualify
const key = i + ':' + j;
if (seen.has(key)) continue;
seen.add(key);
console.log(`${sim.toFixed(3)} ${corpus[i].title} == ${corpus[j].title}`);
}
}
The break matters: because the neighbor list is sorted descending, the first entry
below the threshold means every remaining entry is too. It turns a full scan into an early exit.
Drift is the slow one. Your corpus is not static; you add documents for years. Two things move underneath you.
The corpus centroid shifts as the balance of topics changes. If you add two hundred robotics lessons to a corpus that was mostly NLP, the center of mass of the whole space migrates. Absolute cosines stay valid — the model has not changed — but thresholds calibrated on the old distribution no longer sit in the same place relative to it. Re-run the threshold calibration from §8 whenever the corpus grows by more than about half.
The projection drifts harder. The eigenvectors of the double-centered matrix are computed from all pairwise distances, so adding a whole new region of content rotates the axes and every coordinate changes. Your map reshuffles. This is the deepest argument for the anchored layout from §6: with fixed continent anchors and embedding coordinates used only for local scatter, a hundred new documents land inside their territory instead of rearranging the world.
- Cache size. Content-addressed entries are never deleted — edit a description fifty times and you have fifty orphaned vectors. Prune to the current hash set periodically.
- The quadratic wall. Track build time per stage. When the geometry stage passes the embedding stage, you are on the curve from §4 and the crossover is coming.
- Threshold validity. Any hard-coded cosine cut is a snapshot of one distribution. Put the date and the sample it was calibrated on in a comment next to it, so the next person knows when it went stale.
10 — The Full Reference Implementation
Everything above, assembled. Six files, no services, no keys.
scripts/
embed.mjs the embedding core: providers, batching, hash cache.
Exports LOCAL_MODEL — the one pinned model id.
layout.mjs project2d() via classical MDS + knn(). No dependencies.
gen-embeddings.mjs the build step: catalog -> corpus -> vectors -> artifacts.
data/
site-corpus.json [{id,url,title,tier,cluster,text}] text only, no vectors
site-galaxy.json {clusters, nodes:[{id,nid,url,title,x,y,nn}]}
js/
query.js client-side query embedding with the SAME pinned model
eval/
goldens.json 30-50 (query, expected document) pairs
run.mjs recall@1, recall@5, MRR
audit.mjs deterministic neighbour-graph sample for human reading
Wired into the build in dependency order, so that one command rebuilds the entire discovery surface from the catalog:
{
"scripts": {
"index": "node scripts/gen-embeddings.mjs && node scripts/build-map-data.mjs && node eval/run.mjs",
"audit": "node eval/audit.mjs"
}
}
Note that the evaluation runs inside the index command. Metrics you have to remember to run are metrics you will not run. Printing recall@1 and MRR at the end of every rebuild costs a second and means a corpus change that hurts retrieval announces itself the moment you make it.
The checklist
| Stage | The decision that matters | Default that works |
|---|---|---|
| Model | Local or hosted; can it run at query time? | Small local ONNX model, 384-d, pinned to one constant |
| Corpus | Signal-to-boilerplate ratio of the embedded text | Title + description + keywords + category label |
| Chunking | One vector per document, or per section? | Per document until a measured failure says otherwise |
| Ids | Integer inside artifacts, string across boundaries | Both, regenerated atomically in one build step |
| Cache | Key on content and model id | sha256(model + text), one JSON file per vector |
| Artifacts | Ship conclusions, not representations | Neighbour lists + coordinates; matrix only if searching client-side |
| Projection | Deterministic; never used for "related" | Classical MDS, taxonomy-anchored, placement only |
| Query | Same model, same pooling, same text construction | Shared constant + a verify step that aborts on mismatch |
| Evaluation | Your corpus, your queries, every build | 30 goldens, recall@1/@5, MRR, plus a neighbour audit |
What to build next
With the spine in place, each of these is a weekend rather than a project:
- Hybrid search. Combine the semantic ranking with a plain keyword ranking via reciprocal rank fusion: score = Σ 1/(60 + rankmethod). It fixes the one case embeddings reliably lose — exact matches on rare strings like a product name or an error code — and it needs no tuning.
- Prerequisite ordering. You have a similarity graph; add a difficulty field and you can compute a reading order by topological sort over "similar but easier".
- Duplicate-intent detection at authoring time. Before writing a new document, embed its planned title and description and show the five nearest existing documents. Catches redundant work before it is written rather than after.
- Matryoshka truncation. Models trained with Matryoshka Representation Learning
let you truncate a 768-d vector to its first 128 dimensions and keep most of the quality — a 6×
size cut with one
slice, which is the cheapest win available on the byte budget from §5. - Query expansion for the map. Embed the search box query and highlight the matching region of the galaxy. Search and map become the same interface instead of two.
The pattern generalizes far past a documentation site. Any collection you fully control at build time — a product catalog, a photo library with captions, an internal wiki, a code repository — can have a semantic layer that costs nothing per query and keeps working when the API you would otherwise have depended on is deprecated.
Embed everything once, on the machine that builds the site. Cache by content hash so incremental builds are free. Ship the conclusions — neighbor lists and coordinates — not the vectors. Pin one model everywhere, verify it, and measure on your own corpus every single build.
References
- Xiao, S., Liu, Z., Zhang, P., Muennighoff, N. "C-Pack: Packed Resources For General Chinese Embeddings." arXiv preprint, 2023. The BGE family, including
bge-small-en-v1.5. arXiv:2309.07597 - Li, Z., Zhang, X., Zhang, Y., Long, D., Xie, P., Zhang, M. "Towards General Text Embeddings with Multi-stage Contrastive Learning." arXiv preprint, 2023. The GTE models. arXiv:2308.03281
- Reimers, N., Gurevych, I. "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks." EMNLP, 2019. Where mean pooling for sentence embeddings was established. arXiv:1908.10084
- Muennighoff, N., Tazi, N., Magne, L., Reimers, N. "MTEB: Massive Text Embedding Benchmark." EACL, 2023. The benchmark — and, read carefully, the reason its averages should not decide your corpus. arXiv:2210.07316
- Kusupati, A. et al. "Matryoshka Representation Learning." NeurIPS, 2022. Nested embeddings you can truncate for a direct size/quality trade. arXiv:2205.13147
- Torgerson, W. S. "Multidimensional scaling: I. Theory and method." Psychometrika 17(4), 1952. The double-centering identity used in
project2d. - van der Maaten, L., Hinton, G. "Visualizing Data using t-SNE." JMLR 9, 2008. JMLR
- McInnes, L., Healy, J., Melville, J. "UMAP: Uniform Manifold Approximation and Projection." arXiv preprint, 2018. arXiv:1802.03426
- Wattenberg, M., Viégas, F., Johnson, I. "How to Use t-SNE Effectively." Distill, 2016. The definitive demonstration that cluster sizes and inter-cluster distances in 2D projections are not meaningful. distill.pub
- Malkov, Yu. A., Yashunin, D. A. "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs." IEEE TPAMI, 2018. What to reach for past the exhaustive-kNN crossover. arXiv:1603.09320
- Cormack, G. V., Clarke, C. L. A., Buettcher, S. "Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods." SIGIR, 2009. The hybrid-search fusion rule from §10.
- Johnson, W. B., Lindenstrauss, J. "Extensions of Lipschitz mappings into a Hilbert space." Contemporary Mathematics 26, 1984. Why random projection preserves distances well enough to replace MDS at scale.