Cosine similarity is the friendliest operation in machine learning: a dot product divided by two norms. It is also the operation a retrieval system performs thousands of times per query. In a browser, where every millisecond competes with rendering and user input, the naive implementation is rarely the right one. This article breaks down the actual cost of cosine similarity and the four optimizations that matter: precomputed norms, typed arrays, bounded top-k selection, and query memoization.
The Baseline Cost Model
For two vectors a and b, cosine similarity is:
cos(a, b) = (a . b) / (||a|| * ||b||)
The dot product is sum(a_i * b_i) over all dimensions, and each norm is sqrt(sum(a_i^2)). With 384-dimensional embeddings — the dimensionality of all-MiniLM-L6-v2, which ReLU.chat runs in-browser — one comparison is a 384-element dot product plus two norm passes: roughly 2,300 floating-point operations in the naive implementation. A query against 1,000 fragments is about 2.3 million operations; against 10,000 fragments, about 23 million. The naive implementation also computes both norms inside the comparison loop, so every comparison repeats work that could have been done once.
Precompute the Norms
The single cheapest optimization is to stop computing norms at query time. Normalize every stored vector once when the index is built, so that ||b|| = 1 for all fragments. Then cosine similarity reduces to a pure dot product:
// At index time: store v / norm(v), never the raw vector.
function normalize(v) {
let sum = 0;
for (let i = 0; i < v.length; i++) sum += v[i] * v[i];
const norm = Math.sqrt(sum);
for (let i = 0; i < v.length; i++) v[i] /= norm;
return v;
}
// At query time: one norm for the query, pure dot products after.
function score(query, normalizedFragment) {
let dot = 0;
for (let i = 0; i < query.length; i++) dot += query[i] * normalizedFragment[i];
return dot;
}
This removes two passes and two square roots from every fragment comparison — for a 10,000-fragment query, that is 20,000 fewer norm passes and 20,000 fewer square roots, plus a one-time pass to normalize the query. It also changes the semantics slightly — scores become dot products in the unit sphere rather than true cosine values — but since normalization is monotonic per vector, ranking is unaffected.
Typed Arrays and Loop Structure
Where the vectors live matters as much as the math. Plain JavaScript arrays of numbers can hold doubles, carry prototype overhead, and force the engine to guess element types. Float32Array is the right container for embedding vectors: embeddings are float32 by default, and 384 float32 values occupy 1,536 bytes — half the 3,072 bytes the same array would use as float64. Smaller memory means better cache behavior, and cache misses are the hidden cost in vector math.
Loop structure matters too. V8 and other engines optimize tight loops best when the loop is simple and the array access is predictable:
function topScore(query, fragments, n) {
const q = query; // hoist the query reference
let best = -Infinity;
for (let f = 0; f < n; f++) {
const frag = fragments[f]; // one load per fragment
let dot = 0;
for (let i = 0; i < q.length; i++) {
dot += q[i] * frag[i]; // no function calls, no allocations
}
if (dot > best) best = dot;
}
return best;
}
Avoid function calls and allocations inside the inner loop. A helper that computes a single dot product is fine for readability; a closure that allocates per call is a measurable tax. If you need the top-k fragments rather than the single best, keep the running top-k buffer outside the loop and update it with the same inlined dot product.
Bounded Top-k, Memoization, and Measurement
The most common retrieval mistake is scoring everything, then sorting. A full sort is O(N log N); selecting the top-k is O(N log k) with a min-heap, or simply O(N * k) with a small insertion buffer when k is tiny. For retrieval, k is usually 5 to 20, so the heap or insertion buffer wins by an order of magnitude. ReLU.chat uses bounded top-k selection rather than a full sort: keep a min-heap of the best k scores seen so far, and for each new fragment, compare against the heap root, evicting the smallest when a better score arrives. The heap never exceeds k entries, so memory stays flat and the total work stays linear in the number of fragments.
The same principle applies to sparse scores. BM25 scores can be computed incrementally over inverted-index postings — you only touch fragments that share a term with the query — and the top-k can be maintained without ever materializing a full score array. For hybrid retrieval, fuse the dense and sparse top-k lists rather than scoring every fragment in both systems.
The query side of retrieval repeats work across turns. Embedding the same query twice wastes a full model inference; ReLU.chat memoizes query embeddings so identical queries skip re-embedding entirely. The same pattern applies to the other repeated pieces: normalized fragment vectors (computed once at index time), BM25 idf values (pre-built at index time, not recomputed per query), and dot products for repeated queries. An LRU cache is the right shape for this memoization — queries repeat in bursts, so a small recency-based cache captures most of the savings. Bound the cache size so memory stays predictable, and store only the derived values (query embedding, fused top-k) rather than the raw query strings.
Every optimization above trades complexity for speed, and the right trade depends on your fragment count and query rate. The order that works in practice: profile the scoring loop with performance.now(), normalize at index time and switch to typed arrays, replace the full sort with bounded top-k, memoize repeated query work, then re-measure. In a browser, also test on the weakest device you support — embedding models like the quantized MiniLM ONNX file ReLU.chat loads are memory-heavy, and vector math that is instant on a laptop can stall a phone.
Key Takeaway
Cosine similarity is cheap per comparison and expensive in aggregate: 384 dimensions times thousands of fragments adds up fast in a browser. Precompute norms at index time, store vectors as Float32Array, replace full sorts with bounded top-k selection, and memoize query embeddings with an LRU cache. Each optimization removes a constant factor from the hot path, and together they turn vector search from a jank source into an instant lookup.