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
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.
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
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.
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)}`);
});
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]));
Multiply matching positions: 24 and 35.
Then add: 8 + 15.
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
}
Start a running sum at 0.
Loop over the indices, adding a[i] * b[i] each time.
function dot(a, b) {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
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
}
Square each component and add them up.
Return Math.sqrt of that sum.
function magnitude(v) {
let sum = 0;
for (let i = 0; i < v.length; i++) sum += v[i] * v[i];
return Math.sqrt(sum);
}
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));
}
The denominator should be a product, not a sum.
Change the + between the two magnitudes to a *.
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));
}
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
}
Track the best index and best score, starting from document 0.
Only replace them when a later score is strictly greater, so ties keep 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) {
let bestIndex = 0;
let bestScore = cosineSim(query, docs[0]);
for (let i = 1; i < docs.length; i++) {
const score = cosineSim(query, docs[i]);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
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:
sqrtof the sum of squares — which issqrt(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.