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 | ❌ No | Magnitud ~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:
- Embeddings normalizados: magnitud = 1.0
- Dot product (norm) = Cosine similarity
- 2x speedup en búsqueda semántica
Recursos adicionales
- L2 Normalization Explained - Tutorial
- Sentence-BERT Normalization - Default behavior
- Dot Product vs Cosine - Discussion
- 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