Embeddings Deep Dive
Transform text into mathematical vectors that capture meaning, enabling semantic search and similarity computations at scale.
What Are Embeddings?
Core Idea
An embedding is a dense vector representation of text (or images, audio, etc.) in a continuous vector space. Words and sentences with similar meanings are mapped to nearby points in this space. Unlike sparse representations (like one-hot encoding with 50,000+ dimensions), embeddings compress meaning into 256–3072 dimensions.
Think of embeddings as coordinates in a "meaning space." Just as GPS coordinates tell you physical proximity, embedding vectors tell you semantic proximity. "King" is close to "Queen" and "Monarch" but far from "Bicycle."
Words in 2D Embedding Space (Simplified)
The Embedding Pipeline
Every embedding workflow follows the same pattern: raw text goes in, a fixed-size numeric vector comes out.
Key Properties
- Fixed dimensionality: Regardless of input length, output is always the same size (e.g., 1536-d for OpenAI text-embedding-3-small)
- Semantic preservation: Similar meanings → similar vectors
- Compositionality: Sentence embeddings capture relationships between words, not just individual word meanings
- Language-agnostic: Many models map "dog" (English) and "chien" (French) to nearby vectors
Distance Metrics: Measuring Similarity
Once you have vectors, you need to compare them. The three main metrics:
| Metric | Formula | Range | Best For |
|---|---|---|---|
| Cosine Similarity | cos(θ) = (A·B) / (‖A‖·‖B‖) | [-1, 1] | Text similarity (direction matters, not magnitude) |
| Euclidean Distance | ‖A - B‖₂ | [0, ∞) | When absolute position in space matters |
| Dot Product | A · B = Σ(aᵢ × bᵢ) | (-∞, ∞) | When vectors are normalized (same as cosine) |
import numpy as np
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calculate cosine similarity between two vectors."""
a, b = np.array(a), np.array(b)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def euclidean_distance(a: list[float], b: list[float]) -> float:
"""Calculate Euclidean distance between two vectors."""
a, b = np.array(a), np.array(b)
return np.linalg.norm(a - b)
# Example
vec_king = [0.2, 0.8, 0.5, 0.1]
vec_queen = [0.21, 0.79, 0.48, 0.12]
vec_car = [0.9, 0.1, 0.3, 0.7]
print(f"king ↔ queen: {cosine_similarity(vec_king, vec_queen):.4f}") # ~0.999
print(f"king ↔ car: {cosine_similarity(vec_king, vec_car):.4f}") # ~0.56
Embedding Models Comparison
Choosing the right embedding model is critical. Here's how the major options compare:
| Model | Provider | Dimensions | Max Tokens | Cost (per 1M tokens) | Best For |
|---|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536 | 8191 | $0.02 | General purpose, cost-effective |
| text-embedding-3-large | OpenAI | 3072 | 8191 | $0.13 | Max quality, large-scale retrieval |
| all-MiniLM-L6-v2 | Sentence Transformers | 384 | 256 | Free (local) | Fast, lightweight, on-device |
| all-mpnet-base-v2 | Sentence Transformers | 768 | 384 | Free (local) | Best open-source quality |
| embed-english-v3.0 | Cohere | 1024 | 512 | $0.10 | Search-optimized, input types |
| voyage-large-2 | Voyage AI | 1536 | 16000 | $0.12 | Code + long documents |
Batch Embedding with OpenAI
In production, you'll embed hundreds or thousands of texts. Here's the efficient approach:
from openai import OpenAI
import numpy as np
from typing import List
client = OpenAI()
def get_embeddings(texts: List[str], model: str = "text-embedding-3-small") -> List[List[float]]:
"""Batch embed texts using OpenAI API.
The API supports up to 2048 texts per request.
For larger batches, chunk into groups.
"""
# Remove newlines (they degrade quality)
texts = [t.replace("\n", " ").strip() for t in texts]
response = client.embeddings.create(
input=texts,
model=model
)
# Return embeddings in input order
return [item.embedding for item in response.data]
def batch_embed_large(texts: List[str], batch_size: int = 1000) -> List[List[float]]:
"""Handle large document collections with batching."""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
embeddings = get_embeddings(batch)
all_embeddings.extend(embeddings)
print(f"Embedded {min(i + batch_size, len(texts))}/{len(texts)}")
return all_embeddings
# Usage: Semantic similarity search
documents = [
"Python is a high-level programming language",
"Machine learning models require training data",
"The cat sat on the mat",
"Deep learning uses neural networks with many layers",
"JavaScript runs in web browsers",
"Natural language processing analyzes human text",
]
# Embed all documents
doc_embeddings = get_embeddings(documents)
# Embed a query
query = "How do neural networks learn?"
query_embedding = get_embeddings([query])[0]
# Calculate similarities
similarities = [
cosine_similarity(query_embedding, doc_emb)
for doc_emb in doc_embeddings
]
# Rank by similarity
ranked = sorted(
zip(documents, similarities),
key=lambda x: x[1],
reverse=True
)
print("\nQuery:", query)
print("\nResults (ranked by similarity):")
for doc, score in ranked[:3]:
print(f" [{score:.4f}] {doc}")
# Output:
# [0.8234] Deep learning uses neural networks with many layers
# [0.7891] Machine learning models require training data
# [0.7102] Natural language processing analyzes human text
Local Embeddings with Sentence Transformers
For privacy-sensitive data or to avoid API costs, run embeddings locally:
from sentence_transformers import SentenceTransformer
import numpy as np
# Load model (downloads ~90MB on first use)
model = SentenceTransformer('all-MiniLM-L6-v2')
# Embed texts (runs on CPU or GPU)
sentences = [
"Embeddings capture semantic meaning",
"Vector representations encode text as numbers",
"The weather is sunny today",
]
# Returns numpy array of shape (3, 384)
embeddings = model.encode(sentences, show_progress_bar=True)
print(f"Shape: {embeddings.shape}") # (3, 384)
print(f"Type: {type(embeddings)}") # numpy.ndarray
# Similarity between first two (semantically related)
sim_12 = np.dot(embeddings[0], embeddings[1]) / (
np.linalg.norm(embeddings[0]) * np.linalg.norm(embeddings[1])
)
print(f"Semantic vs Vector: {sim_12:.4f}") # ~0.72
# Similarity between first and third (unrelated)
sim_13 = np.dot(embeddings[0], embeddings[2]) / (
np.linalg.norm(embeddings[0]) * np.linalg.norm(embeddings[2])
)
print(f"Semantic vs Weather: {sim_13:.4f}") # ~0.15
Visualizing Embeddings: PCA & t-SNE
High-dimensional embeddings can be projected to 2D/3D for visualization. This helps you verify that your embeddings cluster semantically related content.
Two Main Approaches
- PCA (Principal Component Analysis): Linear projection preserving maximum variance. Fast, deterministic, but may miss non-linear structure.
- t-SNE (t-distributed Stochastic Neighbor Embedding): Non-linear, preserves local neighborhood structure. Better visual clusters, but slower and non-deterministic.
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
# Assume `embeddings` is shape (N, 1536) from OpenAI
# and `labels` is a list of category labels
# PCA: reduce to 2D
pca = PCA(n_components=2)
coords_pca = pca.fit_transform(embeddings)
print(f"Variance explained: {pca.explained_variance_ratio_.sum():.2%}")
# t-SNE: reduce to 2D (better for visualization)
tsne = TSNE(n_components=2, perplexity=30, random_state=42)
coords_tsne = tsne.fit_transform(embeddings)
# Plot t-SNE results
plt.figure(figsize=(10, 8))
categories = list(set(labels))
colors = plt.cm.tab10(range(len(categories)))
for cat, color in zip(categories, colors):
mask = [l == cat for l in labels]
plt.scatter(
coords_tsne[mask, 0],
coords_tsne[mask, 1],
c=[color], label=cat, alpha=0.7
)
plt.legend()
plt.title("Document Embeddings (t-SNE)")
plt.savefig("embedding_clusters.png", dpi=150)
Embedding Best Practices
🔑 Key Principles
- Preprocess text: Remove excessive whitespace, normalize unicode, strip irrelevant boilerplate
- Respect token limits: Text beyond the model's max tokens is silently truncated — chunk first!
- Match query/doc style: If you embed docs as paragraphs, query with full questions (not keywords)
- Batch for efficiency: Single API call with 100 texts is cheaper and faster than 100 individual calls
- Cache embeddings: Store computed embeddings — never re-embed unchanged content
- Use input types (Cohere): Some models accept "search_document" vs "search_query" for asymmetric search
- Dimensionality reduction: OpenAI's v3 models support
dimensionsparameter to truncate while preserving quality
# OpenAI's native dimensionality reduction (v3 models only)
response = client.embeddings.create(
input="Hello world",
model="text-embedding-3-large",
dimensions=256 # Reduce from 3072 → 256 (saves storage + search speed)
)
# The returned vector is already normalized at the lower dimension
embedding = response.data[0].embedding
print(len(embedding)) # 256
🛠️ Mini-Project: Semantic Search Over Notes
Build a local semantic search engine over your personal notes or documents.
Steps:
- Create a folder with 10–20 text/markdown files (meeting notes, articles, ideas)
- Read all files and embed each one using OpenAI or Sentence Transformers
- Store embeddings in a JSON file alongside metadata (filename, first 100 chars)
- Build a search function: embed query → compute cosine similarity against all docs → return top-3
- Add a CLI interface that accepts a natural language query and prints ranked results
- Bonus: Visualize your note embeddings with t-SNE to discover hidden topic clusters
"""Semantic Search Over Notes - Starter Code"""
import json
import os
from pathlib import Path
from openai import OpenAI
client = OpenAI()
NOTES_DIR = "./my_notes"
INDEX_FILE = "./notes_index.json"
def build_index():
"""Embed all notes and save index."""
notes = []
for filepath in Path(NOTES_DIR).glob("*.md"):
content = filepath.read_text()
notes.append({
"path": str(filepath),
"content": content,
"preview": content[:100]
})
# Batch embed all note contents
texts = [n["content"] for n in notes]
embeddings = get_embeddings(texts)
# Save index
index = [
{**note, "embedding": emb}
for note, emb in zip(notes, embeddings)
]
with open(INDEX_FILE, "w") as f:
json.dump(index, f)
print(f"Indexed {len(index)} notes")
return index
def search(query: str, top_k: int = 3):
"""Search notes by semantic similarity."""
# Load index
with open(INDEX_FILE) as f:
index = json.load(f)
# Embed query
query_emb = get_embeddings([query])[0]
# Rank by similarity
results = []
for note in index:
score = cosine_similarity(query_emb, note["embedding"])
results.append((note["path"], note["preview"], score))
results.sort(key=lambda x: x[2], reverse=True)
return results[:top_k]
if __name__ == "__main__":
import sys
if "--build" in sys.argv:
build_index()
else:
query = input("Search: ")
for path, preview, score in search(query):
print(f"\n[{score:.3f}] {path}")
print(f" {preview}...")
📋 Key Takeaways
- Embeddings map text to dense vectors where semantic similarity = geometric proximity
- Cosine similarity is the standard metric for comparing text embeddings
- OpenAI's text-embedding-3-small offers best cost/quality ratio; local models are free but less capable
- Always batch your embedding calls and cache results
- Embeddings are the foundation of RAG — the quality of your embeddings determines retrieval quality