You can already rank three documents against a query — score each, sort, take the best. That is retrieval, in full. But a real system does not hold three documents; it holds thousands, often millions, and it must answer hundreds of queries a second. Ranking three is arithmetic. Ranking a million, fast and repeatedly, is an engineering problem with a name.
The structure that holds the vectors and answers which are nearest? is a vector index. In this lesson you build the honest, slow version — a brute-force index that scores every stored vector on every query — and then see exactly why production systems reach for something cleverer. You keep the cosine similarity from the last lesson; the new part is the storage and the search loop wrapped around it.
flowchart LR ADD["add a vector"] --> STORE["stored vectors"] Q["search with query and k"] --> SCAN["score every stored vector"] SCAN --> TOP["return top-k indices"] style STORE fill:#8b5cf6,color:#fff style TOP fill:#166534,color:#fff
A vector index, in two operations
An index has only two jobs. Add a vector, remembering it. Search with a query vector and a number k, returning the indices of the k most similar stored vectors. Everything else — metadata, filters, persistence — is decoration layered on top of those two operations.
Notice the index returns indices, not vectors. The caller already added those vectors and knows what each one represents; the index's only responsibility is the ranking. Keeping that boundary clean is what lets you swap a slow exact index for a fast approximate one later without touching any of the code that uses it.
Brute force: score them all
The simplest index that could possibly work stores vectors in an array and, on every search, scores the query against every entry with cosine similarity, then sorts and takes the top k. This is exact search — the answer is guaranteed correct, because nothing was skipped. For a few thousand vectors it is perfectly fine, and it is the implementation every retrieval tutorial starts with. The cost is one dot product per stored vector per query, so a search over N vectors of D dimensions does roughly N * D multiply-adds. That product is the number to keep watching.
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; }
function magnitude(v) { return Math.sqrt(dot(v, v)); }
function cosineSim(a, b) {
const m = magnitude(a) * magnitude(b);
return m === 0 ? 0 : dot(a, b) / m;
}
const docs = [[1, 0, 0], [1, 1, 0], [0, 0, 1]];
const query = [1, 1, 0];
const scored = docs.map((d, i) => ({ i, score: cosineSim(query, d) }));
scored.sort((a, b) => b.score - a.score);
console.log(scored.map((s) => s.i));
Reading the brute-force search
That loop is the entire index: map every stored vector to a score, sort the scores descending, and take the first k indices. There is no clever data structure and no shortcut — the work is proportional to the number of vectors, every single time. Two details in the code matter. The map keeps each vector's original index alongside its score, so sorting never loses track of which vector won. And the sort is descending on score, so the most similar document lands first. Both details survive into every faster index you will meet later.
flowchart LR Q["the query"] --> V0["vector 0"] Q --> V1["vector 1"] Q --> V2["vector 2"] Q --> VDOT["and every other vector"] style Q fill:#b45309,color:#fff
Why exact search stops scaling
Watch that N * D product as the collection grows. A million 1536-dimensional vectors means roughly 1.5 billion multiply-adds for a single query — and a busy service runs many queries a second. Doubling the corpus doubles the work per query, forever. The index is correct, but it is linear in the one quantity you can least afford to be linear in: the size of your own data.
Production systems trade exactness for speed with approximate nearest neighbour search. Instead of scoring every vector, an approximate index organises the vectors — into clusters, graphs, or quantised buckets — so it can skip most of them and still usually return the true neighbours. It gives up a guarantee of correctness for orders of magnitude less work per query.
flowchart TD EXACT["exact search checks every vector"] -->|"slow but always correct"| R1["100 percent recall"] ANN["approximate search checks a subset"] -->|"fast but occasionally misses"| R2["below 100 percent recall"] style EXACT fill:#166534,color:#fff style ANN fill:#b45309,color:#fff
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; }
function magnitude(v) { return Math.sqrt(dot(v, v)); }
function cosineSim(a, b) {
const m = magnitude(a) * magnitude(b);
return m === 0 ? 0 : dot(a, b) / m;
}
function makeIndex() {
const vectors = [];
return {
add(v) { vectors.push(v); return vectors.length - 1; },
search(query, k) {
const scored = vectors.map((v, i) => ({ i, score: cosineSim(query, v) }));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, k).map((s) => s.i);
},
size() { return vectors.length; }
};
}
const index = makeIndex();
index.add([1, 0, 0]);
index.add([1, 1, 0]);
index.add([0, 0, 1]);
console.log("size:", index.size());
console.log("top 2:", index.search([1, 1, 0], 2));
The trade is deliberate, not a defect
Approximate indexes report a recall figure: of the true top-k, what fraction did the fast search recover? Recovering ninety-five of the true top hundred, at a hundredth of the compute, is a trade most teams happily make. The reason to build the brute-force version first is that the fast one is only an optimisation of it — the measure, the ranking, the top-k contract are all identical. Once the slow version is correct, the fast one stops feeling like magic.
function searchCost(vectors, dims) {
// one dot product per stored vector, each costing 'dims' multiply-adds
return vectors * dims;
}
const dims = 1536;
for (const n of [10000, 100000, 1000000]) {
console.log(n, "vectors x", dims, "dims =", searchCost(n, dims), "multiply-adds per query");
}
What does this print? The loop tracks the index of the most similar document to the query.
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]*b[i]; return s; }
function mag(v) { return Math.sqrt(dot(v, v)); }
function sim(a, b) { const m = mag(a) * mag(b); return m === 0 ? 0 : dot(a, b) / m; }
const docs = [[1,0],[0,1],[1,1]];
const q = [1,1];
let best = 0;
for (let i = 1; i < docs.length; i++) if (sim(q, docs[i]) > sim(q, docs[best])) best = i;
console.log(best);
Score each document against q = [1,1].
[1,1] points the same way as [1,1], so document 2 is the closest.
Write topK(query, docs, k) that returns the indices of the k documents most similar to query by cosine similarity, most-similar first. Include the helper functions yourself.
function dot(a, b) {
// position-by-position product, summed
}
function mag(v) {
// sqrt of the sum of squares
}
function sim(a, b) {
// cosine similarity, guarding a zero magnitude
}
function topK(query, docs, k) {
// return the top-k indices, most similar first
}
Map each document to an object holding its original index and its similarity score.
Sort the scored list descending by score, then slice the first k and keep their indices.
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]*b[i]; return s; }
function mag(v) { return Math.sqrt(dot(v, v)); }
function sim(a, b) { const m = mag(a) * mag(b); return m === 0 ? 0 : dot(a, b) / m; }
function topK(query, docs, k) {
const scored = docs.map((d, i) => ({ i, score: sim(query, d) }));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, k).map((s) => s.i);
}
Write makeIndex() that returns an object with add(v) (stores a vector), search(query, k) (returns the top-k indices by cosine similarity, most similar first), and size() (number of stored vectors). Include the helper functions yourself.
function dot(a, b) {
}
function mag(v) {
}
function sim(a, b) {
}
function makeIndex() {
// return { add, search, size }
}
Close over an internal array so add can push and search can read it.
search reuses the same score-then-sort-then-slice logic as topK.
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]*b[i]; return s; }
function mag(v) { return Math.sqrt(dot(v, v)); }
function sim(a, b) { const m = mag(a) * mag(b); return m === 0 ? 0 : dot(a, b) / m; }
function makeIndex() {
const vectors = [];
return {
add(v) { vectors.push(v); return vectors.length - 1; },
search(query, k) {
const scored = vectors.map((v, i) => ({ i, score: sim(query, v) }));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, k).map((s) => s.i);
},
size() { return vectors.length; }
};
}
This searchTop1 is meant to return the single most similar document, but it sorts the scores ascending — so it hands back the least similar one. Fix the one comparator so the most similar document comes first.
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]*b[i]; return s; }
function mag(v) { return Math.sqrt(dot(v, v)); }
function sim(a, b) { const m = mag(a) * mag(b); return m === 0 ? 0 : dot(a, b) / m; }
function searchTop1(query, docs) {
const scored = docs.map((d, i) => ({ i, score: sim(query, d) }));
scored.sort((a, b) => a.score - b.score);
return scored[0].i;
}
Ascending sort puts the smallest score first — the least similar document.
Swap the comparator so larger scores come first.
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]*b[i]; return s; }
function mag(v) { return Math.sqrt(dot(v, v)); }
function sim(a, b) { const m = mag(a) * mag(b); return m === 0 ? 0 : dot(a, b) / m; }
function searchTop1(query, docs) {
const scored = docs.map((d, i) => ({ i, score: sim(query, d) }));
scored.sort((a, b) => b.score - a.score);
return scored[0].i;
}
Why does exact brute-force nearest-neighbour search struggle to scale to millions of vectors?
A brute-force index scores all N vectors per query, so doubling the collection doubles every query's cost. That linear dependence on data size is what approximate indexes exist to escape.
Recap
- A vector index stores vectors and answers two questions: add a vector, and search(query, k) for the k nearest.
- Brute-force search scores every stored vector on every query — exact, but it costs about
N * Dmultiply-adds per query. - Exact search is linear in collection size, so it does not scale to millions of vectors under real load.
- Approximate nearest neighbour indexes trade guaranteed correctness for speed, reporting a recall figure instead of promising 100 percent.
- Mind the top-k edges: cap k at the collection size, exclude the query's own source document, and keep ties stable with a stable sort.
Next you will see why what you put into the index — how you chop documents into chunks — matters as much as the search itself.