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:

ModelNormalizes?Verification
Sentence-BERT✅ Yes (default)model.encode(..., normalize_embeddings=True)
OpenAI❌ NoMagnitude ~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:

  1. Normalized embeddings: magnitude = 1.0
  2. Dot product (norm) = Cosine similarity
  3. 2x speedup in semantic search

Additional resources

  1. L2 Normalization Explained - Tutorial
  2. Sentence-BERT Normalization - Default behavior
  3. Dot Product vs Cosine - Discussion
  4. 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