Module 3 · Embeddings & Semantic Search ⏱ 16 min

Cosine Similarity from Scratch

By the end of this lesson you will be able to:
  • Explain why semantic search compares the ANGLE between vectors, not their length
  • Implement the dot product and vector magnitude in plain JavaScript
  • Combine them into cosine similarity and interpret the score
  • Rank a set of documents against a query the way a retrieval system does

Every retrieval system — the search behind a RAG pipeline, the 'related items' on a page, a model picking which memory to recall — rests on one small question: how similar are these two vectors? An embedding turns a piece of text into a list of numbers, and similar meanings land at similar points. But 'similar point' needs a precise definition, and the obvious one — distance — is the wrong one.

The measure that actually works is cosine similarity: the cosine of the angle between two vectors. It ignores how long the vectors are and looks only at the direction they point. In this lesson you build it from the two pieces it is made of, the dot product and the magnitude, and use it to rank documents against a query — no library, no model, just arithmetic you can run.

Why angle, not distance

Imagine two documents about the same topic, one a short note and one a long essay. Their embeddings point the same way — same meaning — but the essay's vector is longer simply because there is more text. Straight-line distance would call them far apart and miss that they mean the same thing.

Angle does not have that problem. Two vectors that point the same direction have an angle of zero between them no matter their lengths, and the cosine of zero is 1. Point at right angles (unrelated meaning) and the cosine is 0. Point in opposite directions and it is -1. That is why cosine similarity, not distance, is the default in semantic search: it compares meaning while ignoring size.

flowchart LR
  Q["query vector"] --> A["same direction<br/>angle 0 -> cos = 1.0"]
  Q --> B["right angle<br/>angle 90 -> cos = 0.0"]
  Q --> C["opposite<br/>angle 180 -> cos = -1.0"]
  style A fill:#166534,color:#fff
  style B fill:#b45309,color:#fff
  style C fill:#b91c1c,color:#fff
Cosine similarity reads the angle between vectors, so a short and a long vector pointing the same way still score 1.0.

Piece one: the dot product

The dot product multiplies two vectors position by position and adds up the results. For [1, 2, 3] and [2, 4, 6] it is 1*2 + 2*4 + 3*6 = 28. It is a single number that grows when the two vectors have large values in the same positions — an unnormalised measure of how much they agree. It is also the engine inside cosine similarity, so it is the first thing to build.

The dot product: multiply position by position, then sum. Press Run.
function dot(a, b) {
  let sum = 0;
  for (let i = 0; i < a.length; i++) {
    sum += a[i] * b[i];
  }
  return sum;
}

console.log(dot([1, 2, 3], [2, 4, 6]));  // 2 + 8 + 18
console.log(dot([1, 0], [0, 1]));        // share nothing

Piece two: the magnitude

The magnitude (or length) of a vector is how far it reaches from the origin. By Pythagoras it is the square root of the sum of its squared components: [3, 4] has magnitude sqrt(9 + 16) = 5. Notice that the sum of squares is just a vector's dot product with itself, so once you have dot, magnitude is one line. Dividing by the magnitudes is what strips length out of the comparison and leaves pure direction.

flowchart TD
  A["dot(a, b)"] --> C["cosine = dot(a,b) / (mag(a) * mag(b))"]
  B["mag(a) * mag(b)"] --> C
  C --> R["score in [-1, 1]"]
  style C fill:#8b5cf6,color:#fff
  style R fill:#166534,color:#fff
Cosine similarity is the dot product divided by the product of the two magnitudes.
Magnitude reuses dot; cosine similarity divides one by the product of the two. Run it.
function dot(a, b) {
  let sum = 0;
  for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
  return sum;
}
function magnitude(v) {
  return Math.sqrt(dot(v, v));
}
function cosineSim(a, b) {
  return dot(a, b) / (magnitude(a) * magnitude(b));
}

console.log(cosineSim([1, 2, 3], [2, 4, 6]).toFixed(4));  // parallel
console.log(cosineSim([1, 0], [0, 1]).toFixed(4));        // right angle
console.log(cosineSim([1, 1], [1, 0]).toFixed(4));        // 45 degrees

Ranking: what retrieval actually does

A retrieval step is just this: embed the query, then score every candidate document against it with cosine similarity and sort by the score. The highest score is the best match. Everything fancier — vector databases, approximate nearest-neighbour indexes — is an optimisation of how fast you do this over millions of vectors, not a change to what you do. Build the honest, slow version once and the fast versions stop being mysterious.

Score three documents against one query and read the ranking. 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) { return dot(a, b) / (magnitude(a) * magnitude(b)); }

const query = [1, 1, 0];
const docs = [[1, 0, 0], [1, 1, 0], [0, 0, 1]];
docs.forEach((d, i) => {
  console.log(`doc ${i}: ${cosineSim(query, d).toFixed(3)}`);
});
Exercise

What does this print? The dot product multiplies position by position and sums.

function dot(a, b) {
  let s = 0;
  for (let i = 0; i < a.length; i++) s += a[i] * b[i];
  return s;
}
console.log(dot([2, 3], [4, 5]));
Exercise

Write dot(a, b) that returns the dot product of two equal-length numeric arrays: multiply each pair of matching positions and sum the results.

function dot(a, b) {
  // multiply position by position and sum
}
Exercise

Write magnitude(v) that returns the length of a vector: the square root of the sum of its squared components. (Math.sqrt is available.)

function magnitude(v) {
  // sqrt of the sum of squares
}
Exercise

This cosineSimilarity is supposed to divide the dot product by the product of the magnitudes, but it divides by their sum. Two parallel vectors should score 1.0; right now they do not. Fix the one operator.

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 cosineSimilarity(a, b) {
  return dot(a, b) / (magnitude(a) + magnitude(b));
}
Exercise

Write mostSimilar(query, docs) that returns the INDEX of the document vector most similar to query by cosine similarity. dot, magnitude, and cosineSim are already defined for you. Assume at least one document; on a tie, return the earlier index.

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) { return dot(a, b) / (magnitude(a) * magnitude(b)); }

function mostSimilar(query, docs) {
  // return the index of the best-matching document
}

Recap

  • Semantic search compares the angle between embedding vectors, not their distance, so length (text size) does not distort meaning.
  • Dot product: multiply position by position and sum. It is the agreement signal and the engine inside cosine similarity.
  • Magnitude: sqrt of the sum of squares — which is sqrt(dot(v, v)).
  • Cosine similarity = dot(a, b) / (mag(a) * mag(b)), giving a score from -1 (opposite) through 0 (unrelated) to 1 (same direction).
  • Retrieval is: score every candidate against the query and sort. Vector databases only make that lookup faster; the measure is unchanged.
  • Guard the zero vector: a zero magnitude divides by zero and returns NaN, which silently corrupts a ranking.

Next you will use this to build a tiny vector index and then a full retrieval step, the heart of a RAG pipeline.

Checkpoint quiz

Why does semantic search use cosine similarity instead of straight-line distance?

What is the cosine similarity of two vectors that point in exactly the same direction?

What goes wrong when you compute cosine similarity with an all-zero vector?

Go deeper — technical resources