Module 2: How Do Embeddings Work?
Normalization: Scaling Embeddings to Unit Magnitude
Capsule overview
Normalization is the final (optional) step of the embedding pipeline: scaling the vector to magnitude = 1.0 (a unit vector). Although it's optional, normalizing embeddings has important advantages for computational efficiency and comparison consistency.
In this capsule you'll learn what L2 normalization is, why you normalize embeddings, how to normalize with numpy, and when normalization is necessary (it depends on the model). You'll also see the mathematical equivalence between dot product (normalized embeddings) and cosine similarity.
By the end, you'll be able to optimize your similarity calculations and make informed decisions about normalization.
What is normalization?
Definition:
Scaling a vector so that its magnitude (length) is exactly 1.0.
Formula:
v_normalized = v / ||v||
Where:
- v: The original vector
- ||v||: The magnitude (L2 norm) of the vector
- v_normalized: The normalized vector
2D example (visualizable):
import numpy as np
# Original vector
vec = np.array([3.0, 4.0])
# Calculate magnitude
magnitude = np.linalg.norm(vec)
print(f"Original vector: {vec}")
print(f"Magnitude: {magnitude}") # sqrt(3² + 4²) = 5.0
# Normalize
vec_normalized = vec / magnitude
print(f"Normalized vector: {vec_normalized}")
# Verify the magnitude
magnitude_normalized = np.linalg.norm(vec_normalized)
print(f"Normalized magnitude: {magnitude_normalized}") # 1.0
Output:
Original vector: [3. 4.]
Magnitude: 5.0
Normalized vector: [0.6 0.8]
Normalized magnitude: 1.0
Visualization:
y
^
|
4 | ● (3, 4) Magnitude = 5.0
| /
| /
|/____________> x
0 3
After normalization:
y
^
|
0.8 | ● (0.6, 0.8) Magnitude = 1.0
| /
|/____________> x
0 0.6
The vector points in the SAME direction, but with length = 1.0.
L2 Normalization (Unit Vector)
Mathematical formula:
||v|| = sqrt(v₁² + v₂² + v₃² + ... + vₙ²)
v_normalized = [v₁/||v||, v₂/||v||, v₃/||v||, ..., vₙ/||v||]
Implementation with numpy:
def normalize_embedding(embedding):
"""
Normalize an embedding to magnitude 1.0 (L2 norm)
Args:
embedding: A vector (numpy array or list)
Returns:
The normalized vector
"""
embedding = np.array(embedding)
norm = np.linalg.norm(embedding)
if norm == 0:
return embedding # Avoid division by 0
return embedding / norm
# Example with a real embedding (simulated)
embedding = np.random.randn(1536) # Simulation of an OpenAI embedding
print(f"Original magnitude: {np.linalg.norm(embedding):.4f}")
embedding_normalized = normalize_embedding(embedding)
print(f"Normalized magnitude: {np.linalg.norm(embedding_normalized):.4f}")
Output:
Original magnitude: 39.2341
Normalized magnitude: 1.0000
Why normalize embeddings
Advantage #1: Dot product = Cosine similarity
Without normalizing:
# Cosine similarity (costly - 2 divisions):
cos_sim = np.dot(emb_a, emb_b) / (np.linalg.norm(emb_a) * np.linalg.norm(emb_b))
With normalizing:
# With normalized embeddings:
emb_a_norm = normalize(emb_a)
emb_b_norm = normalize(emb_b)
dot_prod = np.dot(emb_a_norm, emb_b_norm)
# dot_prod == cosine_similarity(emb_a, emb_b) ✅
# More efficient (only 1 operation)
Speedup: ~2x faster when searching 1M documents.
Advantage #2: Comparison consistency
# Without normalizing:
emb_a = [10.0, 0.0] # Magnitude = 10.0
emb_b = [1.0, 0.0] # Magnitude = 1.0 (same direction)
# Dot product:
dot = np.dot(emb_a, emb_b) # 10.0 (depends on magnitude)
# Cosine similarity:
cos = cosine_similarity(emb_a, emb_b) # 1.0 (direction only)
# With normalizing:
emb_a_norm = normalize(emb_a) # [1.0, 0.0]
emb_b_norm = normalize(emb_b) # [1.0, 0.0]
dot_norm = np.dot(emb_a_norm, emb_b_norm) # 1.0 ✅
Normalization removes the effect of magnitude (only direction matters).
When to normalize embeddings
Models that normalize automatically:
| Model | Normalizes? | Verification |
|---|---|---|
| Sentence-BERT | ✅ Yes (default) | model.encode(..., normalize_embeddings=True) |
| OpenAI | ❌ No | Magnitude ~1.5-2.5 typically |
| BGE | ❌ No (manual) | Variable magnitude |
| Instructor | ✅ Yes (default) | Normalized |
Check whether an embedding is normalized:
from openai import OpenAI
import numpy as np
import os
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# Generate an embedding
response = client.embeddings.create(
model="text-embedding-3-small",
input="Python is a language"
)
embedding = np.array(response.data[0].embedding)
magnitude = np.linalg.norm(embedding)
print(f"Magnitude: {magnitude:.4f}")
if 0.99 <= magnitude <= 1.01:
print("✅ Normalized embedding")
else:
print("❌ Embedding NOT normalized")
Output (OpenAI):
Magnitude: 1.5234
❌ Embedding NOT normalized
Normalizing OpenAI embeddings
Reusable code:
def get_normalized_embedding(text, model="text-embedding-3-small"):
"""
Generate a normalized OpenAI embedding
Args:
text: Text to convert into an embedding
model: The embedding model
Returns:
A normalized embedding (magnitude = 1.0)
"""
# Generate the embedding
response = client.embeddings.create(
model=model,
input=text
)
embedding = np.array(response.data[0].embedding)
# Normalize
magnitude = np.linalg.norm(embedding)
if magnitude == 0:
return embedding # Avoid division by 0
return embedding / magnitude
# Usage
embedding = get_normalized_embedding("Python is popular")
print(f"Magnitude: {np.linalg.norm(embedding):.4f}") # 1.0000
Equivalence: Dot Product vs Cosine Similarity
With normalized embeddings:
# Normalized embeddings
emb_a = normalize(embedding_a)
emb_b = normalize(embedding_b)
# These are equivalent:
dot_product = np.dot(emb_a, emb_b)
cosine_sim = np.dot(emb_a, emb_b) / (np.linalg.norm(emb_a) * np.linalg.norm(emb_b))
print(f"Dot product: {dot_product:.6f}")
print(f"Cosine sim: {cosine_sim:.6f}")
# → Identical because ||emb_a|| = ||emb_b|| = 1.0
Implication: With normalized embeddings, use dot product (faster).
Performance benchmark:
import time
import numpy as np
# Generate test embeddings
n_docs = 10000
embeddings = np.random.randn(n_docs, 1536)
query_emb = np.random.randn(1536)
# Method 1: Cosine similarity (without normalizing)
start = time.time()
for doc_emb in embeddings:
sim = np.dot(query_emb, doc_emb) / (np.linalg.norm(query_emb) * np.linalg.norm(doc_emb))
time_cosine = time.time() - start
# Method 2: Dot product (with normalizing)
query_norm = query_emb / np.linalg.norm(query_emb)
embeddings_norm = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
start = time.time()
for doc_emb_norm in embeddings_norm:
sim = np.dot(query_norm, doc_emb_norm)
time_dot = time.time() - start
print(f"Cosine similarity: {time_cosine:.4f}s")
print(f"Dot product (normalized): {time_dot:.4f}s")
print(f"Speedup: {time_cosine / time_dot:.2f}x")
Typical output:
Cosine similarity: 0.8234s
Dot product (normalized): 0.4123s
Speedup: 2.00x
Exercises
Exercise 1: Normalize a vector
Normalize this vector to magnitude 1.0:
vec = np.array([6.0, 8.0])
# Normalize manually (without using a function)
See solution
vec = np.array([6.0, 8.0])
# Calculate magnitude
magnitude = np.sqrt(vec[0]**2 + vec[1]**2)
# Or: magnitude = np.linalg.norm(vec)
print(f"Magnitude: {magnitude}") # 10.0
# Normalize
vec_normalized = vec / magnitude
print(f"Normalized: {vec_normalized}") # [0.6, 0.8]
# Verify
print(f"New magnitude: {np.linalg.norm(vec_normalized)}") # 1.0
Exercise 2: Batch normalization
Normalize multiple embeddings at once:
embeddings = np.array([
[3.0, 4.0],
[5.0, 12.0],
[8.0, 15.0]
])
# Normalize all the embeddings
See solution
def normalize_batch(embeddings):
"""Normalize a batch of embeddings"""
# Calculate magnitudes (one per embedding)
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
# Normalize
return embeddings / norms
embeddings_normalized = normalize_batch(embeddings)
print("Normalized embeddings:")
print(embeddings_normalized)
# Verify magnitudes
magnitudes = np.linalg.norm(embeddings_normalized, axis=1)
print(f"\nMagnitudes: {magnitudes}") # All ~1.0
Output:
Normalized embeddings:
[[0.6 0.8 ]
[0.3846 0.9231]
[0.4706 0.8824]]
Magnitudes: [1. 1. 1.]
Exercise 3: Compare dot vs cosine
Verify that dot product (normalized) = cosine similarity:
emb_a = np.array([3.0, 4.0, 0.0])
emb_b = np.array([4.0, 3.0, 0.0])
# Calculate both and compare
See solution
emb_a = np.array([3.0, 4.0, 0.0])
emb_b = np.array([4.0, 3.0, 0.0])
# Cosine similarity
cosine_sim = np.dot(emb_a, emb_b) / (np.linalg.norm(emb_a) * np.linalg.norm(emb_b))
print(f"Cosine similarity: {cosine_sim:.6f}")
# Normalize
emb_a_norm = emb_a / np.linalg.norm(emb_a)
emb_b_norm = emb_b / np.linalg.norm(emb_b)
# Dot product (normalized)
dot_prod = np.dot(emb_a_norm, emb_b_norm)
print(f"Dot product (norm): {dot_prod:.6f}")
# Are they equal?
print(f"\nEqual? {np.isclose(cosine_sim, dot_prod)}")
Output:
Cosine similarity: 0.960000
Dot product (norm): 0.960000
Equal? True
Summary
What you learned:
- ✅ Normalization: Scale to magnitude = 1.0
- ✅ L2 norm: Formula and numpy code
- ✅ Advantages: Dot product = cosine (2x faster)
- ✅ Models: SBERT normalizes, OpenAI doesn't
- ✅ Best practice: Normalize for efficiency
Key concepts:
- Normalized embeddings: magnitude = 1.0
- Dot product (norm) = Cosine similarity
- 2x speedup in semantic search
Additional resources
- L2 Normalization Explained - Tutorial
- Sentence-BERT Normalization - Default behavior
- Dot Product vs Cosine - Discussion
- NumPy linalg.norm - Docs
In the next capsule
Capsule 07: OpenAI API Advanced
You'll learn:
- API parameters (dimensions, encoding_format)
- Batch processing (embed multiple texts)
- Robust error handling
- Rate limiting (avoid throttling)
- Cost optimization
From normalization to production.
Module 2 - Embeddings Deep Dive Guide Optimizing embeddings for efficiency