Módulo 1: ¿Qué son Embeddings?
Espacios Vectoriales de Alta Dimensionalidad
Descripción de la cápsula
Los embeddings no viven en un espacio 2D o 3D que podemos visualizar—viven en espacios de alta dimensionalidad (típicamente 300-3072 dimensiones) que tienen propiedades contraintuitivas y fascinantes.
En esta cápsula aprenderás qué es un espacio vectorial de alta dimensionalidad, por qué necesitamos tantas dimensiones para capturar significado semántico, cómo funciona el clustering natural en estos espacios, y por qué la intuición 2D/3D no siempre aplica.
También verás ejemplos prácticos de cómo se distribuyen embeddings reales en espacios de 1536 dimensiones.
¿Qué es un espacio vectorial?
Definición matemática (simplificada):
Un espacio vectorial es un conjunto de vectores donde puedes sumar vectores y multiplicarlos por escalares.
Visualización 2D:
y (dimensión 2)
^
|
| Vector B (3, 4)
| ●
| /
| /
|/
└──────────────> x (dimensión 1)
Origen
Cada punto en el espacio es un vector:
- Vector A = (2, 3) → 2 unidades en x, 3 en y
- Vector B = (3, 4) → 3 unidades en x, 4 en y
Espacio de embeddings (1536D):
# Un embedding es un punto en espacio de 1536 dimensiones:
embedding = [x₁, x₂, x₃, ..., x₁₅₃₆]
└────────────────────────┘
1536 coordenadas
No podemos visualizarlo, pero las matemáticas funcionan igual:
- Distancia entre puntos (cosine similarity)
- Vecindad (embeddings cercanos)
- Clustering (grupos de puntos)
Por qué tantas dimensiones
¿Por qué no 2D o 3D?
Problema: Complejidad semántica del lenguaje
Texto: "Python es un lenguaje de programación interpretado, de alto nivel,
orientado a objetos, con tipado dinámico..."
¿Cuánta información necesitas capturar?
- Dominio: Tecnología
- Tipo: Lenguaje de programación
- Características: Interpretado, alto nivel, OOP, tipado dinámico
- Contexto: Puede ser backend, data science, AI, etc.
- Formalidad: Técnico
- Sentimiento: Neutral
- ... (muchas más propiedades)
2D-3D: Insuficiente para capturar toda esta riqueza semántica.
1536D: Cada dimensión captura un aspecto diferente (aprendido automáticamente).
Trade-off: Dimensiones vs Información
| Dimensiones | Información capturada | Ejemplo modelo |
|---|---|---|
| 50-100 | Mínima (palabras simples) | GloVe básico |
| 300 | Básica (palabras + contexto limitado) | Word2Vec, GloVe |
| 768 | Buena (frases, contexto) | BERT-base |
| 1536 | Muy buena (frases complejas) | OpenAI small |
| 3072 | Excelente (máxima precisión) | OpenAI large |
Más dimensiones = más matices semánticos capturados, pero más costo computacional.
La maldición de la dimensionalidad
Concepto:
A medida que aumentan las dimensiones, los espacios se vuelven "vacíos" y la intuición 2D/3D falla.
Ejemplo contraintuitivo:
import numpy as np
# En 2D: Volumen de esfera vs cubo
radius_2d = 1.0
volume_sphere_2d = np.pi * radius_2d**2 # π ≈ 3.14
volume_cube_2d = (2 * radius_2d)**2 # 4
ratio_2d = volume_sphere_2d / volume_cube_2d # ~0.785 (78.5%)
# En 10D: La esfera ocupa MENOS del cubo
def sphere_volume_nd(n_dims, radius=1.0):
from scipy.special import gamma
return (np.pi**(n_dims/2) * radius**n_dims) / gamma(n_dims/2 + 1)
def cube_volume_nd(n_dims, radius=1.0):
return (2 * radius)**n_dims
ratio_10d = sphere_volume_nd(10) / cube_volume_nd(10)
print(f"2D: Esfera ocupa {ratio_2d:.2%} del cubo")
print(f"10D: Esfera ocupa {ratio_10d:.2%} del cubo")
# → En 10D: solo ~0.25% (espacio mayormente vacío!)
Implicación: En alta dimensionalidad, puntos tienden a estar en las "esquinas" del espacio.
Distancias en alta dimensionalidad:
import numpy as np
# Generar puntos aleatorios en diferentes dimensiones
def avg_distance(n_dims, n_points=1000):
"""Calcula distancia promedio entre puntos aleatorios"""
points = np.random.randn(n_points, n_dims)
# Calcular distancia entre punto 1 y todos los demás
distances = [np.linalg.norm(points[0] - points[i])
for i in range(1, n_points)]
return np.mean(distances), np.std(distances)
# Comparar dimensiones
for n_dims in [2, 10, 100, 1000]:
avg, std = avg_distance(n_dims)
print(f"{n_dims}D: Distancia promedio = {avg:.2f} (std = {std:.2f})")
Output (conceptual):
2D: Distancia promedio = 1.13 (std = 0.52)
10D: Distancia promedio = 3.22 (std = 0.41)
100D: Distancia promedio = 10.05 (std = 0.32)
1000D: Distancia promedio = 31.68 (std = 0.18)
Observa: A más dimensiones, las distancias aumentan PERO la varianza disminuye.
Todos los puntos están aproximadamente igual de lejos entre sí (contraintuitivo).
Clustering natural en alta dimensionalidad
Concepto clave:
Aunque todos los puntos están "lejos", puntos relacionados semánticamente están RELATIVAMENTE más cerca.
Visualización conceptual (proyectado a 2D):
Cluster "Tecnología"
┌────────────────────────┐
│ Python ● │
│ Ruby ● Java ● │
│ JavaScript ● │
└────────────────────────┘
.
. (mucha distancia)
.
┌────────────────────────┐
│ Perro ● Gato ● │
│ Caballo ● │
│ León ● │
└────────────────────────┘
Cluster "Animales"
En 1536D:
- Distancia intra-cluster: ~0.10-0.20 (cercanos)
- Distancia inter-cluster: ~0.40-0.60 (lejanos)
- Ratio relativo permite clustering, aunque absolutamente todos están "lejos"
Ejemplo práctico: Distribución de embeddings
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"))
def get_embedding(text):
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return np.array(response.data[0].embedding)
# Textos de 3 clusters
texts = {
"Tecnología": [
"Python es un lenguaje de programación",
"JavaScript corre en el navegador",
"Ruby es bueno para web development"
],
"Animales": [
"El perro ladra fuerte",
"El gato maúlla suavemente",
"El caballo galopa rápido"
],
"Comida": [
"La pizza es deliciosa",
"La hamburguesa tiene queso",
"Los tacos son mexicanos"
]
}
# Generar embeddings
embeddings_by_cluster = {}
for cluster_name, cluster_texts in texts.items():
embeddings_by_cluster[cluster_name] = [
get_embedding(text) for text in cluster_texts
]
# Calcular distancia promedio INTRA-cluster
print("Distancias INTRA-cluster (similares):")
for cluster_name, cluster_embs in embeddings_by_cluster.items():
distances = []
for i in range(len(cluster_embs)):
for j in range(i+1, len(cluster_embs)):
dist = np.linalg.norm(cluster_embs[i] - cluster_embs[j])
distances.append(dist)
avg_dist = np.mean(distances)
print(f" {cluster_name}: {avg_dist:.4f}")
# Calcular distancia promedio INTER-cluster
print("\nDistancias INTER-cluster (diferentes):")
cluster_names = list(embeddings_by_cluster.keys())
for i in range(len(cluster_names)):
for j in range(i+1, len(cluster_names)):
cluster_1 = embeddings_by_cluster[cluster_names[i]]
cluster_2 = embeddings_by_cluster[cluster_names[j]]
distances = []
for emb_1 in cluster_1:
for emb_2 in cluster_2:
dist = np.linalg.norm(emb_1 - emb_2)
distances.append(dist)
avg_dist = np.mean(distances)
print(f" {cluster_names[i]} vs {cluster_names[j]}: {avg_dist:.4f}")
Output esperado (conceptual):
Distancias INTRA-cluster (similares):
Tecnología: 0.45
Animales: 0.42
Comida: 0.48
Distancias INTER-cluster (diferentes):
Tecnología vs Animales: 0.78
Tecnología vs Comida: 0.82
Animales vs Comida: 0.80
Interpretación: Distancias intra-cluster (~0.45) << inter-cluster (~0.80).
Subspaces: Embeddings organizados en regiones
Concepto:
El espacio de 1536D no es uniforme. Embeddings de temas similares ocupan "subspaces" o regiones del espacio total.
Analogía con ciudades:
Espacio de 1536D = Mundo completo
Subspace "Tecnología" = Continente Europa
→ Python, JavaScript, Ruby están en "países" cercanos
Subspace "Animales" = Continente África
→ Perro, Gato, Caballo están en "países" cercanos
Los continentes están separados (inter-cluster distance alta)
Pero dentro de cada continente hay estructura (ciudades cercanas)
Jerarquía de subspaces:
# Embedding space completo (1536D)
└── Subspace "Tecnología" (ocupa ~dimensiones 1-500)
├── Sub-subspace "Lenguajes" (dim 1-200)
│ ├── Python, JavaScript, Ruby
├── Sub-subspace "Frameworks" (dim 200-400)
│ ├── React, Django, Rails
└── Sub-subspace "Herramientas" (dim 400-500)
├── Git, Docker, Kubernetes
Nota: Esto es conceptual. Las dimensiones NO están asignadas manualmente—el modelo aprende esta estructura automáticamente.
Proximidad en espacio vectorial
Definición operacional:
Dos embeddings son "próximos" si su cosine similarity es alta (>0.80).
def are_proximate(emb_a, emb_b, threshold=0.80):
"""
Determina si dos embeddings están próximos
Args:
emb_a, emb_b: Embeddings a comparar
threshold: Umbral de similaridad (default 0.80)
Returns:
True si cosine similarity > threshold
"""
cos_sim = np.dot(emb_a, emb_b) / (
np.linalg.norm(emb_a) * np.linalg.norm(emb_b)
)
return cos_sim > threshold
# Ejemplo
emb_python = get_embedding("Python")
emb_javascript = get_embedding("JavaScript")
emb_gato = get_embedding("El gato")
print(are_proximate(emb_python, emb_javascript)) # True (ambos lenguajes)
print(are_proximate(emb_python, emb_gato)) # False (dominios diferentes)
Densidad del espacio de embeddings
Pregunta: ¿Qué porcentaje del espacio de 1536D está ocupado por embeddings reales?
Respuesta: Muy poco (< 0.001%).
# Volumen total del espacio de 1536D (conceptual):
# Infinito (espacio continuo)
# Embeddings reales (e.g., 1 millón de documentos):
# Ocupan regiones específicas (clusters)
# Analogía:
# Espacio 1536D = Océano Pacífico
# Embeddings = Islas dispersas
# Búsqueda semántica = Navegar de isla en isla
Implicación: Búsqueda eficiente requiere indexación (vector databases), no búsqueda lineal.
Proyección a espacios de baja dimensionalidad
Problema: No podemos visualizar 1536D.
Solución: Proyectar a 2D/3D para visualización (con pérdida de información).
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
import numpy as np
# Generar embeddings (ejemplo con textos de 3 clusters)
texts = [
"Python", "JavaScript", "Ruby", # Tecnología
"Perro", "Gato", "Caballo", # Animales
"Pizza", "Hamburguesa", "Taco" # Comida
]
embeddings = np.array([get_embedding(text) for text in texts])
# Proyectar de 1536D a 2D con PCA
pca = PCA(n_components=2)
embeddings_2d = pca.fit_transform(embeddings)
# Visualizar
plt.figure(figsize=(10, 6))
# Plot por clusters
colors = ['red', 'blue', 'green']
labels = ['Tecnología', 'Animales', 'Comida']
for i, color, label in zip(range(0, 9, 3), colors, labels):
plt.scatter(
embeddings_2d[i:i+3, 0],
embeddings_2d[i:i+3, 1],
c=color,
label=label,
s=100
)
# Anotar puntos
for j in range(3):
plt.annotate(
texts[i+j],
(embeddings_2d[i+j, 0], embeddings_2d[i+j, 1]),
fontsize=8
)
plt.xlabel('PC1 (Primera componente principal)')
plt.ylabel('PC2 (Segunda componente principal)')
plt.title('Embeddings proyectados de 1536D a 2D')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('embeddings_2d_projection.png')
plt.show()
# Varianza explicada
print(f"Varianza explicada por PC1: {pca.explained_variance_ratio_[0]:.2%}")
print(f"Varianza explicada por PC2: {pca.explained_variance_ratio_[1]:.2%}")
print(f"Total varianza explicada (2D): {sum(pca.explained_variance_ratio_):.2%}")
Output esperado:
Varianza explicada por PC1: 12.5%
Varianza explicada por PC2: 8.3%
Total varianza explicada (2D): 20.8%
Interpretación: Solo capturas ~20% de la información en 2D. Los otros 1534 dimensiones importan.
Dimensiones "útiles" vs dimensiones "ruido"
No todas las dimensiones contribuyen igual:
from sklearn.decomposition import PCA
# Generar embeddings de 100 documentos
embeddings = np.array([get_embedding(text) for text in documents]) # (100, 1536)
# PCA para analizar varianza por dimensión
pca = PCA(n_components=1536)
pca.fit(embeddings)
# Plot varianza explicada acumulada
cumulative_variance = np.cumsum(pca.explained_variance_ratio_)
# ¿Cuántas dimensiones explican 95% de varianza?
n_dims_95 = np.argmax(cumulative_variance >= 0.95) + 1
print(f"Dimensiones para 95% varianza: {n_dims_95}")
# → Típicamente ~400-600 dimensiones
# ¿Cuántas dimensiones explican 99% de varianza?
n_dims_99 = np.argmax(cumulative_variance >= 0.99) + 1
print(f"Dimensiones para 99% varianza: {n_dims_99}")
# → Típicamente ~800-1000 dimensiones
Implicación: Podrías comprimir de 1536D → 400D con pérdida mínima (<5% información).
Trade-off: Ahorras memoria/cómputo pero pierdes matices semánticos.
Geometría de semantic search
Cómo funciona búsqueda en espacio vectorial:
1. Usuario hace query: "Python tutorial"
↓
2. Embed query → query_embedding (punto en 1536D)
↓
3. Calcular distancia a TODOS los docs en corpus:
distances = [dist(query_emb, doc_emb) for doc_emb in corpus_embs]
↓
4. Ordenar por distancia (ascendente)
↓
5. Retornar top-K más cercanos (e.g., top-5)
Visualización conceptual (2D):
Query ●
/ | \
/ | \
/ | \
/ | \
Doc1 ● Doc2 ● Doc3 ●
(0.92) (0.88) (0.75)
↓ Retorna Doc1, Doc2 (top-2)
Complejidad: O(N) para búsqueda lineal (N = tamaño del corpus).
Optimización: Vector databases (HNSW, FAISS) reducen a O(log N).
Sparse vs Dense embeddings
Dense embeddings (lo que hemos visto):
# Dense: Casi todos los valores != 0
dense_emb = [0.023, -0.145, 0.892, -0.234, 0.567, ...] # 1536 valores
non_zero_count = np.count_nonzero(dense_emb) # ~1530 (>99%)
Ventaja: Captura matices semánticos ricos.
Desventaja: Computacionalmente costoso (1536 dimensiones).
Sparse embeddings (alternativa):
# Sparse: Mayoría valores = 0
sparse_emb = [0, 0, 0.5, 0, 0, 0, 0.8, 0, ...] # 10000 dimensiones
non_zero_count = np.count_nonzero(sparse_emb) # ~50 (0.5%)
Ejemplo: SPLADE (Sparse Lexical and Expansion)
Ventaja: Más eficiente en memoria/cómputo.
Desventaja: Menos matices semánticos capturados.
Uso: Híbrido dense + sparse (mejor de ambos mundos).
Ejercicios
Ejercicio 1: Calcular densidad
Dado un embedding, calcula qué porcentaje de valores son no-cero:
import numpy as np
embedding = np.random.randn(1536) # Simulación
# Calcular densidad (% de valores != 0)
Ver solución
import numpy as np
embedding = np.random.randn(1536)
# Contar valores no-cero
non_zero_count = np.count_nonzero(embedding)
# Calcular porcentaje
density = (non_zero_count / len(embedding)) * 100
print(f"Densidad: {density:.2f}%")
print(f"Valores no-cero: {non_zero_count}/{len(embedding)}")
Output esperado:
Densidad: 100.00%
Valores no-cero: 1536/1536
Explicación: np.random.randn() genera valores de distribución normal, casi nunca exactamente 0.0.
Embeddings reales (OpenAI, SBERT) también son ~100% densos.
Ejercicio 2: Proyección PCA
Reduce un conjunto de embeddings de 1536D a 2D y calcula varianza explicada:
from sklearn.decomposition import PCA
import numpy as np
# Simular 100 embeddings
embeddings = np.random.randn(100, 1536)
# Proyectar a 2D con PCA
# ¿Qué % de varianza captura 2D?
Ver solución
from sklearn.decomposition import PCA
import numpy as np
# Simular embeddings
embeddings = np.random.randn(100, 1536)
# PCA a 2D
pca = PCA(n_components=2)
embeddings_2d = pca.fit_transform(embeddings)
# Varianza explicada
variance_explained = sum(pca.explained_variance_ratio_)
print(f"Varianza explicada por 2D: {variance_explained:.2%}")
print(f"PC1: {pca.explained_variance_ratio_[0]:.2%}")
print(f"PC2: {pca.explained_variance_ratio_[1]:.2%}")
Output esperado:
Varianza explicada por 2D: ~1.5-2.5%
PC1: ~0.8-1.3%
PC2: ~0.7-1.2%
Explicación: Con datos aleatorios, 2D captura MUY poca varianza.
Con embeddings reales (estructura semántica), 2D captura ~15-25%.
Ejercicio 3: Encontrar K vecinos más cercanos
Dado un query embedding, encuentra los 3 documentos más similares:
import numpy as np
query_emb = np.random.randn(1536)
doc_embs = np.random.randn(10, 1536) # 10 documentos
# Encuentra los 3 más similares (top-3)
Ver solución
import numpy as np
def cosine_similarity(vec_a, vec_b):
return np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b))
query_emb = np.random.randn(1536)
doc_embs = np.random.randn(10, 1536)
# Calcular similaridades
similarities = []
for i, doc_emb in enumerate(doc_embs):
sim = cosine_similarity(query_emb, doc_emb)
similarities.append((i, sim))
# Ordenar por similaridad (descendente)
similarities.sort(key=lambda x: x[1], reverse=True)
# Top-3
print("Top-3 documentos más similares:")
for rank, (doc_id, sim) in enumerate(similarities[:3], 1):
print(f" {rank}. Doc {doc_id}: {sim:.4f}")
Output (ejemplo):
Top-3 documentos más similares:
1. Doc 7: 0.0523
2. Doc 2: 0.0312
3. Doc 9: 0.0145
Nota: Con datos aleatorios, similaridades son muy bajas (no hay estructura real).
Con embeddings reales, verías scores de 0.70-0.95.
Ejercicio 4: Comparar distancias intra vs inter-cluster
Calcula distancias promedio dentro de clusters vs entre clusters:
# Cluster 1: Tecnología
cluster_1 = [emb_python, emb_javascript, emb_ruby]
# Cluster 2: Animales
cluster_2 = [emb_perro, emb_gato, emb_caballo]
# Calcular:
# 1. Distancia promedio INTRA-cluster 1
# 2. Distancia promedio INTER-cluster (1 vs 2)
Ver solución
import numpy as np
def avg_intra_distance(cluster):
"""Distancia promedio dentro del cluster"""
distances = []
for i in range(len(cluster)):
for j in range(i+1, len(cluster)):
dist = np.linalg.norm(cluster[i] - cluster[j])
distances.append(dist)
return np.mean(distances)
def avg_inter_distance(cluster_1, cluster_2):
"""Distancia promedio entre clusters"""
distances = []
for emb_1 in cluster_1:
for emb_2 in cluster_2:
dist = np.linalg.norm(emb_1 - emb_2)
distances.append(dist)
return np.mean(distances)
# Simular clusters (en producción, usarías get_embedding())
cluster_1 = [np.random.randn(1536) for _ in range(3)]
cluster_2 = [np.random.randn(1536) for _ in range(3)]
intra_dist = avg_intra_distance(cluster_1)
inter_dist = avg_inter_distance(cluster_1, cluster_2)
print(f"Distancia INTRA-cluster: {intra_dist:.4f}")
print(f"Distancia INTER-cluster: {inter_dist:.4f}")
print(f"Ratio (inter/intra): {inter_dist/intra_dist:.2f}x")
Output esperado (datos reales):
Distancia INTRA-cluster: 0.45
Distancia INTER-cluster: 0.78
Ratio (inter/intra): 1.73x
Interpretación: Inter-cluster es ~1.7x mayor que intra-cluster (clusters separados).
Troubleshooting común
Problema 1: Proyección PCA pierde mucha información
# Solo 15% de varianza explicada en 2D
pca = PCA(n_components=2)
embeddings_2d = pca.fit_transform(embeddings)
print(f"Varianza explicada: {sum(pca.explained_variance_ratio_):.2%}") # 15%
Causa: Normal. 2D no puede capturar complejidad de 1536D.
Solución: Usa 3D o incluso 50D para visualización intermedia (no para producción).
Problema 2: Todos los puntos parecen igual de lejanos
# Distancias similares entre todos los puntos
for doc_emb in doc_embs:
dist = np.linalg.norm(query_emb - doc_emb)
print(dist) # 15.2, 15.8, 16.1, 15.9... (todas ~15-16)
Causa: Maldición de la dimensionalidad (alta dim → distancias convergen).
Solución: Usa cosine similarity (ángulo) en vez de euclidean distance (magnitud).
Resumen
Qué aprendiste:
- ✅ Espacio vectorial: Conjunto de vectores de N dimensiones
- ✅ Alta dimensionalidad: 1536D necesario para capturar complejidad semántica
- ✅ Maldición dimensionalidad: Intuición 2D/3D no aplica, distancias convergen
- ✅ Clustering natural: Embeddings relacionados forman clusters (subspaces)
- ✅ Proximidad: Cosine similarity define vecindad en espacio
- ✅ Proyección: PCA reduce dimensiones para visualización (con pérdida)
Conceptos clave:
- Más dimensiones = más información semántica capturada
- Clustering natural emerge sin supervisión
- Proyección a 2D pierde ~80% de información
Recursos adicionales
- Curse of Dimensionality - Explicación matemática
- Visualizing High-Dimensional Data - t-SNE explicado
- PCA Explained - Principal Component Analysis
- Vector Spaces in NLP - Stanford paper
- UMAP for Visualization - Alternativa a PCA
En la siguiente cápsula
Cápsula 05: Casos de Uso Reales
Aprenderás:
- Semantic search en producción
- RAG (Retrieval-Augmented Generation)
- Recommendation systems
- Document classification y clustering
- Ejemplos concretos de aplicaciones reales
De teoría espacial a aplicaciones prácticas.
Módulo 1 - Embeddings Deep Dive Guide Espacios de alta dimensionalidad donde vive el significado