<YC />
Back to blog
Part 3 of 54 min read

Embeddings and Cosine Similarity: Why Unrelated Text Doesn't Score Zero

Embeddings turn text into 3072-number vectors of meaning. Cosine similarity scored two coffee sentences at 0.801 and an unrelated sentence at 0.526 — far from zero.

Part 3 of 5Show parts

Two coffee sentences scored 0.801 on cosine similarity; the same coffee line against cement prices scored 0.526 — not zero. That surprise is the whole lesson.

This is part 3 of Learning AI Engineering in Public.

One-sentence answer: An embedding turns text into a vector of meaning, and cosine similarity tells you how aligned two of those vectors are.

Vectors of meaning

An embedding is a long list of numbers that stands in for the meaning of a piece of text. Similar sentences land closer together in that space.

A useful analogy: an embedding is like GPS coordinates for meaning. Two coffee sentences sit in the same neighborhood. A sentence about cement prices sits somewhere else on the map.

I used gemini-embedding-001. Each vector has 3072 dimensions — 3072 numbers on that map. I embedded plain text here. In Part 4: Semantic Search, I switch to retrieval-specific task types for documents and queries. The same model indexes my site for the /lab chatbot.

import { GoogleGenAI } from "@google/genai";
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
 
async function embed(text: string): Promise<number[]> {
  const response = await ai.models.embedContent({
    model: "gemini-embedding-001",
    contents: text,
  });
 
  const values = response.embeddings?.[0]?.values;
 
  if (!values) {
    throw new Error("No embedding returned");
  }
 
  return values;
}

What this block does:

  • The client is the same @google/genai client from part 2.
  • embedContent sends text and gets back one embedding per input.
  • values is the number list. For this model, values.length is 3072.
  • The early throw exists so a missing embedding fails loudly instead of becoming a silent undefined later.

Cosine similarity by hand

I did not pull in a math library. Cosine similarity answers: do these two GPS points point the same direction?

function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0;
  let magA = 0;
  let magB = 0;
 
  for (let i = 0; i < a.length; i++) {
    const x = a[i] ?? 0;
    const y = b[i] ?? 0;
    dot += x * y;
    magA += x * x;
    magB += y * y;
  }
 
  const magnitude = Math.sqrt(magA) * Math.sqrt(magB);
  if (magnitude === 0) return 0;
 
  return dot / magnitude;
}

Walkthrough, line by line:

  • dot builds the dot product: multiply matching numbers from both vectors and add those products. When the vectors point the same way, this sum grows.
  • magA and magB accumulate the squared length of each vector (x * x, y * y).
  • Math.sqrt(magA) * Math.sqrt(magB) is the product of the two norms (lengths). Dividing by length removes "how long is this vector" so you compare direction.
  • If a magnitude is zero, returning 0 avoids dividing by zero.
  • The final dot / magnitude is the cosine similarity score. Higher means more aligned.
const coffee = await embed("I love drinking coffee");
const hobby = await embed("My hobby is having coffee");
const cement = await embed("Cement prices are rising");
 
console.log(cosineSimilarity(coffee, hobby));
console.log(cosineSimilarity(coffee, cement));

What this block does:

  • Three strings become three vectors.
  • The first comparison is two coffee sentences. The second pairs coffee with cement prices.
  • Printing the scores is how I checked whether "nearby on the map" matched my intuition.

What I measured

Text AText BScore
I love drinking coffeeMy hobby is having coffee0.801
I love drinking coffeeCement prices are rising0.526

Why this works (and why 0.526 is not "zero")

The coffee pair scored 0.801 because both sentences are about enjoying coffee. Different words, same neighborhood on the meaning map — that is why the high score is accurate for "related."

The cement sentence scored 0.526. That is not a match, but it is also not zero — far from zero, in fact. So a raw score is not a yes-or-no label by itself. What matters is the relative gap: 0.801 vs 0.526.

That is why a RAG threshold has to be tested on real pairs before you trust it. If you pick 0.5 as "relevant," almost everything passes. If you pick something higher, you need evidence from your own documents.

Takeaways

  • gemini-embedding-001 turns text into a 3072-dimensional vector; cosine similarity compares direction with a short dot-product loop.
  • On my run, related coffee sentences scored much higher than coffee vs cement — compare scores on the same scale, not against an imaginary zero.
  • Plain-text embedding here; retrieval task types and top-k search come in Part 4.
  • Test a RAG cutoff on your own document pairs before you ship one.