Module 3 · Embeddings & Semantic Search ⏱ 18 min

Build a Tiny Vector Index

By the end of this lesson you will be able to:
  • Implement a brute-force nearest-neighbour index with add and search operations
  • Explain why exact search is linear in collection size and does not scale
  • Describe what approximate nearest-neighbour indexes trade away for speed

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 has two operations: add a vector, and search for the k nearest to a query.

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.

Brute force: score every document, sort by similarity, read off the ranked indices. Press Run.
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
Brute force compares the query against every stored vector — correct, but the cost is one comparison per vector.

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
Exact search is always correct; approximate search trades a sliver of correctness for a large speedup.
Wrap the brute-force search in an object with add and search. Run it.
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.

The cost of one query: vectors times dimensions. Watch it grow linearly. Run it.
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");
}
Exercise

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);
Exercise

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
}
Exercise

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 }
}
Exercise

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;
}
Exercise

Why does exact brute-force nearest-neighbour search struggle to scale to millions of vectors?

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 * D multiply-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.

Checkpoint quiz

How does a brute-force vector index answer a search query?

What does an approximate nearest-neighbour (ANN) index give up to run faster?

Go deeper — technical resources