By Bonaventure Ogeto|

Cosine Similarity Explained for Developers

Cosine similarity measures how similar two vectors are by calculating the cosine of the angle between them. A value of 1.0 means the vectors point in exactly the same direction (identical meaning). A value of 0 means they are perpendicular (unrelated). A value of -1 means they point in opposite directions. In practice, text embeddings rarely produce negative values, so the range is usually 0 to 1.

The intuition: direction, not distance

Imagine two arrows starting from the same point. If both arrows point in the same direction, they are similar, regardless of how long they are. Cosine similarity measures this: the angle between the arrows.

This is different from Euclidean distance, which measures the straight-line gap between the tips of the arrows. Two vectors can have different magnitudes (lengths) but still point in the same direction, giving them a cosine similarity of 1.0.

For text embeddings, this matters because the "direction" of a vector encodes meaning. "M-Pesa is a mobile money service" and "Safaricom's M-Pesa enables mobile payments" have embeddings that point in nearly the same direction, giving a high cosine similarity. "The Nairobi Expressway has reduced commute times" points in a completely different direction.

The formula (one paragraph, no prerequisites)

Cosine similarity between vectors A and B is:

similarity = dot(A, B) / (magnitude(A) * magnitude(B))

Where dot(A, B) is the sum of element-wise products (A[0]*B[0] + A[1]*B[1] + ...), and magnitude(A) is the square root of the sum of squared elements. That is all the math you need. The rest is just code.

Worked example in Python

Here is a complete example that embeds three sentences and compares them:

import numpy as np

# Three example embedding vectors (shortened to 5 dimensions for clarity)
# In practice, these would be 384 to 3072 dimensions from an embedding model
mpesa_vec    = np.array([0.8, 0.6, 0.1, 0.3, 0.9])
wallet_vec   = np.array([0.75, 0.55, 0.15, 0.35, 0.85])
highway_vec  = np.array([0.1, 0.2, 0.9, 0.8, 0.05])

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    dot_product = np.dot(a, b)
    magnitude_a = np.linalg.norm(a)
    magnitude_b = np.linalg.norm(b)
    return dot_product / (magnitude_a * magnitude_b)

# Compare M-Pesa description vs mobile wallet description
print("M-Pesa vs Mobile Wallet:", 
      round(cosine_similarity(mpesa_vec, wallet_vec), 4))
# Output: 0.9965 (very similar, both about mobile money)

# Compare M-Pesa description vs highway description
print("M-Pesa vs Highway:", 
      round(cosine_similarity(mpesa_vec, highway_vec), 4))
# Output: 0.4215 (low similarity, different topics)

# Compare mobile wallet vs highway
print("Mobile Wallet vs Highway:", 
      round(cosine_similarity(wallet_vec, highway_vec), 4))
# Output: 0.4601 (also low similarity)

The numbers confirm what intuition expects: the two mobile money descriptions are nearly identical (0.99), while the highway description is far from both (0.42 and 0.46).

The same example in TypeScript

If Python is not your stack, here is the same calculation in TypeScript:

function cosineSimilarity(a: number[], b: number[]): number {
  let dotProduct = 0;
  let magnitudeA = 0;
  let magnitudeB = 0;

  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    magnitudeA += a[i] * a[i];
    magnitudeB += b[i] * b[i];
  }

  return dotProduct / (Math.sqrt(magnitudeA) * Math.sqrt(magnitudeB));
}

// Example vectors (5 dimensions for clarity)
const mpesaVec = [0.8, 0.6, 0.1, 0.3, 0.9];
const walletVec = [0.75, 0.55, 0.15, 0.35, 0.85];
const highwayVec = [0.1, 0.2, 0.9, 0.8, 0.05];

console.log(
  'M-Pesa vs Wallet:',
  cosineSimilarity(mpesaVec, walletVec).toFixed(4)
);
// 0.9965

console.log(
  'M-Pesa vs Highway:',
  cosineSimilarity(mpesaVec, highwayVec).toFixed(4)
);
// 0.4215

In production, you would not write this function yourself. Your vector database (pgvector, Pinecone) handles the comparison internally. But understanding the math helps you reason about similarity scores and set meaningful thresholds.

What similarity scores mean in practice

When you build a RAG system or semantic search, you need to decide: what similarity score counts as "relevant"? Here are rough guidelines based on common embedding models:

  • 0.90 to 1.00: Very similar. Near-duplicate content or a rephrased version of the same text. Reliable match.
  • 0.75 to 0.90: Same topic. The texts discuss the same subject but may cover different aspects. Good retrieval for RAG.
  • 0.50 to 0.75: Related. The texts share some thematic overlap but are about different things. May or may not be relevant depending on the use case.
  • Below 0.50: Likely unrelated. Not a useful match for most applications.

These ranges are approximate and vary by embedding model. A model trained for code embeddings will have different similarity distributions than one trained on general text. Always calibrate thresholds against your specific data and use case.

In pgvector, you can set a minimum similarity threshold to avoid returning irrelevant results:

-- Only return results above 0.7 similarity
SELECT content, 1 - (embedding <=> query_embedding) AS similarity
FROM documents
WHERE 1 - (embedding <=> query_embedding) > 0.7
ORDER BY embedding <=> query_embedding
LIMIT 5;

Cosine similarity vs other distance metrics

Cosine similarity is not the only option. Other metrics include:

  • Euclidean distance (L2): Measures the straight-line distance between vector endpoints. Sensitive to vector magnitude. If your embeddings are not normalized, Euclidean distance and cosine similarity can give different rankings.
  • Dot product (inner product): Simply the sum of element-wise products, without dividing by magnitudes. Faster to compute. For normalized vectors (which most embedding models produce), dot product and cosine similarity give the same ranking.

For most text embedding use cases, cosine similarity is the standard choice. It is insensitive to vector magnitude (which can vary between documents of different lengths) and produces a clean 0 to 1 scale. If you are using pgvector, the <=> operator computes cosine distance (1 minus cosine similarity).

Frequently Asked Questions

Do I need to understand cosine similarity to use vector search?
Not strictly. Your vector database handles the computation. But understanding the concept helps you set meaningful similarity thresholds, debug retrieval issues ("why did this irrelevant document score 0.8?"), and reason about your embedding model's behavior.
Can cosine similarity be negative for text embeddings?
In theory, yes (it ranges from -1 to 1). In practice, text embeddings from modern models almost always produce values between 0 and 1. Negative similarity would mean two texts have opposite meaning, which is rare in natural language.
Why not just use Euclidean distance?
Euclidean distance is affected by vector magnitude. Two vectors can be far apart in Euclidean space but point in the same direction (high cosine similarity). For text embeddings, direction encodes meaning and magnitude is an artifact of text length. Cosine similarity ignores magnitude, making it a better fit for comparing meaning.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon. Ship 8 production apps with M-Pesa, USSD, and WhatsApp integrations, and get career support until placement.

See Programs

Also available: AI Features micro-course