Módulo 2: ¿Cómo funcionan Embeddings?

Normalization: Escalando Embeddings a Magnitud Unitaria

Descripción de la cápsula

La normalización es el paso final (opcional) del pipeline de embeddings: escalar el vector a magnitud = 1.0 (vector unitario). Aunque es opcional, normalizar embeddings tiene ventajas importantes para eficiencia computacional y consistencia de comparaciones.

En esta cápsula aprenderás qué es L2 normalization, por qué normalizar embeddings, cómo normalizar con numpy, y cuándo es necesario normalizar (depende del modelo). También verás la equivalencia matemática entre dot product (embeddings normalizados) y cosine similarity.

Al final, podrás optimizar tus cálculos de similaridad y tomar decisiones informadas sobre normalización.


¿Qué es normalización?

Definición:

Escalar un vector para que su magnitud (longitud) sea exactamente 1.0.

Fórmula:

v_normalized = v / ||v||

Donde:
- v: Vector original
- ||v||: Magnitud (norma L2) del vector
- v_normalized: Vector normalizado

Ejemplo 2D (visualizable):

import numpy as np

# Vector original
vec = np.array([3.0, 4.0])

# Calcular magnitud
magnitude = np.linalg.norm(vec)
print(f"Vector original: {vec}")
print(f"Magnitud: {magnitude}")  # sqrt(3² + 4²) = 5.0

# Normalizar
vec_normalized = vec / magnitude
print(f"Vector normalizado: {vec_normalized}")

# Verificar magnitud
magnitude_normalized = np.linalg.norm(vec_normalized)
print(f"Magnitud normalizada: {magnitude_normalized}")  # 1.0

Output:

Vector original: [3. 4.]
Magnitud: 5.0
Vector normalizado: [0.6 0.8]
Magnitud normalizada: 1.0

Visualización:

       y
       ^
       |
    4  |      ● (3, 4)  Magnitud = 5.0
       |    /
       |  /
       |/____________> x
      0    3

Después de normalización:
       y
       ^
       |
   0.8 |   ● (0.6, 0.8)  Magnitud = 1.0
       | /
       |/____________> x
      0   0.6

El vector apunta en la MISMA dirección, pero longitud = 1.0.


L2 Normalization (Unit Vector)

Fórmula matemática:

||v|| = sqrt(v₁² + v₂² + v₃² + ... + vₙ²)

v_normalized = [v₁/||v||, v₂/||v||, v₃/||v||, ..., vₙ/||v||]

Implementación con numpy:

def normalize_embedding(embedding):
    """
    Normalizar embedding a magnitud 1.0 (L2 norm)
    
    Args:
        embedding: Vector (numpy array o lista)
    
    Returns:
        Vector normalizado
    """
    embedding = np.array(embedding)
    norm = np.linalg.norm(embedding)
    
    if norm == 0:
        return embedding  # Evitar división por 0
    
    return embedding / norm

# Ejemplo con embedding real (simulado)
embedding = np.random.randn(1536)  # Simulación de OpenAI embedding

print(f"Magnitud original: {np.linalg.norm(embedding):.4f}")

embedding_normalized = normalize_embedding(embedding)

print(f"Magnitud normalizada: {np.linalg.norm(embedding_normalized):.4f}")

Output:

Magnitud original: 39.2341
Magnitud normalizada: 1.0000

Por qué normalizar embeddings

Ventaja #1: Dot product = Cosine similarity

Sin normalizar:

# Cosine similarity (costoso - 2 divisiones):
cos_sim = np.dot(emb_a, emb_b) / (np.linalg.norm(emb_a) * np.linalg.norm(emb_b))

Con normalizar:

# Con embeddings normalizados:
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) ✅

# Más eficiente (solo 1 operación)

Speedup: ~2x más rápido en búsqueda de 1M documentos.


Ventaja #2: Consistencia de comparaciones

# Sin normalizar:
emb_a = [10.0, 0.0]   # Magnitud = 10.0
emb_b = [1.0, 0.0]    # Magnitud = 1.0 (misma dirección)

# Dot product:
dot = np.dot(emb_a, emb_b)  # 10.0 (depende de magnitud)

# Cosine similarity:
cos = cosine_similarity(emb_a, emb_b)  # 1.0 (solo dirección)

# Con normalizar:
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 ✅

Normalización elimina efecto de magnitud (solo importa dirección).


Cuándo normalizar embeddings

Modelos que normalizan automáticamente:

Modelo¿Normaliza?Verificación
Sentence-BERT✅ Sí (default)model.encode(..., normalize_embeddings=True)
OpenAI❌ NoMagnitud ~1.5-2.5 típicamente
BGE❌ No (manual)Magnitud variable
Instructor✅ Sí (default)Normalizado

Verificar si embedding está normalizado:

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"))

# Generar embedding
response = client.embeddings.create(
    model="text-embedding-3-small",
    input="Python es un lenguaje"
)

embedding = np.array(response.data[0].embedding)
magnitude = np.linalg.norm(embedding)

print(f"Magnitud: {magnitude:.4f}")

if 0.99 <= magnitude <= 1.01:
    print("✅ Embedding normalizado")
else:
    print("❌ Embedding NO normalizado")

Output (OpenAI):

Magnitud: 1.5234
❌ Embedding NO normalizado

Normalizar embeddings de OpenAI

Código reusable:

def get_normalized_embedding(text, model="text-embedding-3-small"):
    """
    Generar embedding normalizado de OpenAI
    
    Args:
        text: Texto a convertir en embedding
        model: Modelo de embeddings
    
    Returns:
        Embedding normalizado (magnitud = 1.0)
    """
    # Generar embedding
    response = client.embeddings.create(
        model=model,
        input=text
    )
    embedding = np.array(response.data[0].embedding)
    
    # Normalizar
    magnitude = np.linalg.norm(embedding)
    if magnitude == 0:
        return embedding  # Evitar división por 0
    
    return embedding / magnitude

# Uso
embedding = get_normalized_embedding("Python es popular")
print(f"Magnitud: {np.linalg.norm(embedding):.4f}")  # 1.0000

Equivalencia: Dot Product vs Cosine Similarity

Con embeddings normalizados:

# Embeddings normalizados
emb_a = normalize(embedding_a)
emb_b = normalize(embedding_b)

# Estos son equivalentes:
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}")
# → Idénticos porque ||emb_a|| = ||emb_b|| = 1.0

Implicación: Con embeddings normalizados, usa dot product (más rápido).


Benchmark de performance:

import time
import numpy as np

# Generar embeddings de prueba
n_docs = 10000
embeddings = np.random.randn(n_docs, 1536)
query_emb = np.random.randn(1536)

# Método 1: Cosine similarity (sin normalizar)
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

# Método 2: Dot product (con normalizar)
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")

Output típico:

Cosine similarity: 0.8234s
Dot product (normalized): 0.4123s
Speedup: 2.00x

Ejercicios

Ejercicio 1: Normalizar vector

Normaliza este vector a magnitud 1.0:

vec = np.array([6.0, 8.0])

# Normaliza manualmente (sin usar función)
Ver solución
vec = np.array([6.0, 8.0])

# Calcular magnitud
magnitude = np.sqrt(vec[0]**2 + vec[1]**2)
# O: magnitude = np.linalg.norm(vec)
print(f"Magnitud: {magnitude}")  # 10.0

# Normalizar
vec_normalized = vec / magnitude
print(f"Normalizado: {vec_normalized}")  # [0.6, 0.8]

# Verificar
print(f"Nueva magnitud: {np.linalg.norm(vec_normalized)}")  # 1.0

Ejercicio 2: Batch normalization

Normaliza múltiples embeddings a la vez:

embeddings = np.array([
    [3.0, 4.0],
    [5.0, 12.0],
    [8.0, 15.0]
])

# Normaliza todos los embeddings
Ver solución
def normalize_batch(embeddings):
    """Normalizar batch de embeddings"""
    # Calcular magnitudes (uno por embedding)
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
    
    # Normalizar
    return embeddings / norms

embeddings_normalized = normalize_batch(embeddings)
print("Embeddings normalizados:")
print(embeddings_normalized)

# Verificar magnitudes
magnitudes = np.linalg.norm(embeddings_normalized, axis=1)
print(f"\nMagnitudes: {magnitudes}")  # Todos ~1.0

Output:

Embeddings normalizados:
[[0.6    0.8   ]
 [0.3846 0.9231]
 [0.4706 0.8824]]

Magnitudes: [1. 1. 1.]

Ejercicio 3: Comparar dot vs cosine

Verifica que 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])

# Calcula ambos y compara
Ver solución
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}")

# Normalizar
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}")

# ¿Son iguales?
print(f"\n¿Iguales? {np.isclose(cosine_sim, dot_prod)}")

Output:

Cosine similarity: 0.960000
Dot product (norm): 0.960000

¿Iguales? True

Resumen

Qué aprendiste:

  • Normalización: Escalar a magnitud = 1.0
  • L2 norm: Formula y código numpy
  • Ventajas: Dot product = cosine (2x más rápido)
  • Modelos: SBERT normaliza, OpenAI no
  • Best practice: Normalizar para eficiencia

Conceptos clave:

  1. Embeddings normalizados: magnitud = 1.0
  2. Dot product (norm) = Cosine similarity
  3. 2x speedup en búsqueda semántica

Recursos adicionales

  1. L2 Normalization Explained - Tutorial
  2. Sentence-BERT Normalization - Default behavior
  3. Dot Product vs Cosine - Discussion
  4. NumPy linalg.norm - Docs

En la siguiente cápsula

Cápsula 07: OpenAI API Advanced

Aprenderás:

  • API parameters (dimensions, encoding_format)
  • Batch processing (embed múltiples textos)
  • Error handling robusto
  • Rate limiting (evitar throttling)
  • Cost optimization

De normalización a producción.


Módulo 2 - Embeddings Deep Dive Guide Optimizando embeddings para eficiencia