Distance and Cosine Similarity
On this page
Objectives
- Understand cosine similarity and why it’s preferred
- Implement vector comparison in Python
- Interpret similarity scores
Measuring Proximity Between Vectors
Once you’ve converted your texts into vectors, everything hinges on one question: how do you decide whether two vectors are “close”? Three answers coexist, and the choice matters.
Euclidean distance is the classic geometric distance—the one you’d measure with a ruler between two points. It’s intuitive, but it depends on vector magnitude: two texts with the same meaning, where one is encoded with higher amplitude, would appear artificially far apart.
import numpy as np
def euclidean_distance(a: list[float], b: list[float]) -> float:
return np.linalg.norm(np.array(a) - np.array(b))
The dot product sums the element-wise products. It’s the fastest operation in the family—the one the processor executes most efficiently on large matrices.
def dot_product(a: list[float], b: list[float]) -> float:
return np.dot(a, b)
Cosine similarity, finally, measures the angle between two vectors regardless of their length, making it the standard metric for embeddings: only the direction of meaning matters, not its intensity.
def cosine_similarity(a: list[float], b: list[float]) -> float:
a, b = np.array(a), np.array(b)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
It ranges from -1 for opposite directions to +1 for identical directions. One practical detail matters: OpenAI embeddings are already normalized, meaning they have a length of 1. The denominator therefore equals 1, and the dot product yields exactly the same result as cosine similarity—at a lower computational cost. This is the shortcut we’ll use throughout this course.
Practical Comparison
Nothing beats measuring real sentences. Let’s compare a sentence, its full paraphrase, and an off-topic sentence:
from openai import OpenAI
client = OpenAI()
def get_embedding(text: str) -> list[float]:
response = client.embeddings.create(
input=text,
model="text-embedding-3-large"
)
return response.data[0].embedding
# Sentences to compare
sentences = {
"a": "The cat sleeps on the couch",
"b": "The feline dozes on the sofa",
"c": "Python is a programming language",
}
embeddings = {k: get_embedding(v) for k, v in sentences.items()}
# Compare all pairs
from itertools import combinations
for (k1, v1), (k2, v2) in combinations(sentences.items(), 2):
sim = cosine_similarity(embeddings[k1], embeddings[k2])
print(f"sim({k1}, {k2}) = {sim:.4f}")
print(f" {v1}")
print(f" {v2}")
print()
Typical result:
sim(a, b) = 0.8923 ← very close meaning
sim(a, c) = 0.1247 ← different topics
sim(b, c) = 0.1189 ← different topics
Sentences a and b share no words—cat/feline, sleeps/dozes, couch/sofa—yet they score 0.89. This is exactly what no lexical method can achieve.
Interpreting Scores
Thresholds depend on your use case, but the table below gives reference points for reading a score without missing by an order of magnitude.
| Score | Interpretation |
|---|---|
| > 0.85 | Very similar (paraphrases, synonyms) |
| 0.70 – 0.85 | Thematically related |
| 0.40 – 0.70 | Loosely related |
| < 0.40 | Different topics |
These values vary by model and domain: a legal corpus, where all texts share the same formal vocabulary, produces generally higher scores than a general-purpose corpus, to the point where a 0.70 threshold becomes too permissive. Always calibrate on your own data by observing thirty or so pairs where you know the expected answer.
Nearest Neighbor Search
Finding a document means calculating similarity between the query vector and all vectors in the corpus, then keeping the top k. The trick is to avoid looping in Python: a NumPy matrix multiplication calculates all similarities at once, in a fraction of the time.
def search_similar(
query_embedding: list[float],
corpus_embeddings: np.ndarray,
k: int = 5
) -> list[tuple[int, float]]:
"""Returns the k most similar documents."""
query = np.array(query_embedding)
# Vectorized calculation of all similarities
similarities = corpus_embeddings @ query
# (works because OpenAI embeddings are normalized)
# Indices of the top k scores
top_k_indices = np.argsort(similarities)[-k:][::-1]
return [(int(i), float(similarities[i])) for i in top_k_indices]
# Usage
corpus = np.load("embeddings.npy") # matrix (n, 3072)
query_emb = get_embedding("How do I install Python?")
results = search_similar(query_emb, corpus, k=5)
for idx, score in results:
print(f"Document {idx}: score = {score:.4f}")
The [-k:][::-1] deserves a second look: argsort sorts in ascending order, so we take the end of the array to get the best scores, then reverse to rank them from most to least relevant.
With scikit-learn
When you’re not searching for a document but want to compare the entire corpus with itself—to detect duplicates, for example, or prepare for clustering—scikit-learn provides an optimized function that returns the full matrix.
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
# Similarity matrix between all documents
corpus = np.array(embeddings_list)
sim_matrix = cosine_similarity(corpus)
print(f"Shape: {sim_matrix.shape}") # (n, n)
print(f"Similarity doc 0 ↔ doc 1: {sim_matrix[0][1]:.4f}")
Keep in mind that this matrix is n × n in size: ten thousand documents produce one hundred million values, about 400 MB in float32. Beyond a few thousand elements, process in blocks rather than making a single call.
Summary
- Cosine similarity measures the angle between vectors, independent of their magnitude
- OpenAI embeddings are normalized: dot product = cosine
- A score > 0.85 indicates very similar texts
- Nearest neighbor search is done efficiently with NumPy or scikit-learn