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

DimensionesInformación capturadaEjemplo modelo
50-100Mínima (palabras simples)GloVe básico
300Básica (palabras + contexto limitado)Word2Vec, GloVe
768Buena (frases, contexto)BERT-base
1536Muy buena (frases complejas)OpenAI small
3072Excelente (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:

  1. Más dimensiones = más información semántica capturada
  2. Clustering natural emerge sin supervisión
  3. Proyección a 2D pierde ~80% de información

Recursos adicionales

  1. Curse of Dimensionality - Explicación matemática
  2. Visualizing High-Dimensional Data - t-SNE explicado
  3. PCA Explained - Principal Component Analysis
  4. Vector Spaces in NLP - Stanford paper
  5. 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