Módulo 1: ¿Qué son Embeddings?
Arquitectura Overview: Cómo se Generan Embeddings
Descripción de la cápsula
Has usado embeddings como "cajas negras" (texto → vector), pero ¿cómo se generan realmente esos vectores internamente?
En esta cápsula aprenderás la arquitectura high-level detrás de modelos de embeddings modernos: Transformers encoder-only, tokenización, self-attention (conceptual), y pooling strategies. No profundizaremos en matemáticas complejas—el objetivo es que entiendas el flujo end-to-end y puedas tomar decisiones informadas como AI Engineer.
También verás código práctico con tiktoken (tokenizador de OpenAI) y entenderás por qué diferentes modelos producen embeddings diferentes.
El flujo completo: Texto → Embedding
Pipeline de 4 pasos:
Texto:
"Python es un lenguaje de programación"
↓ (1) Tokenización
Tokens:
["Python", "es", "un", "lenguaje", "de", "programación"]
↓ (2) Transformer Encoder
Hidden states (un vector por token):
[[0.1, 0.2, ...], [0.3, 0.4, ...], [0.5, 0.6, ...], ...]
↑ Python ↑ es ↑ un
↓ (3) Pooling
Embedding final (un solo vector):
[0.023, -0.145, 0.892, ..., 0.567] (1536 dims)
↓ (4) Normalización (opcional)
Embedding normalizado:
[0.014, -0.089, 0.548, ..., 0.348] (magnitud = 1.0)
Cada paso tiene un propósito específico. Veamos cada uno en detalle.
Paso 1: Tokenización
Qué es:
Dividir texto en "tokens" (sub-palabras o palabras) que el modelo entiende.
¿Por qué no procesar caracteres directamente?
- Vocabulario de caracteres = ~100 (a-z, A-Z, 0-9, símbolos)
- Vocabulario de palabras = millones (ineficiente)
- Tokens (sub-palabras) = balance perfecto (~50K-100K tokens)
Ejemplo con tiktoken (OpenAI):
import tiktoken
# Cargar tokenizador de OpenAI
encoding = tiktoken.encoding_for_model("gpt-4")
# Tokenizar texto
text = "Python es un lenguaje de programación"
tokens = encoding.encode(text)
print(f"Texto: {text}")
print(f"Tokens (IDs): {tokens}")
print(f"Cantidad: {len(tokens)} tokens")
# Decodificar tokens individuales
for token_id in tokens:
token_str = encoding.decode([token_id])
print(f" Token {token_id}: '{token_str}'")
Output:
Texto: Python es un lenguaje de programación
Tokens (IDs): [31380, 1560, 653, 41317, 451, 56586]
Cantidad: 6 tokens
Token 31380: 'Python'
Token 1560: ' es'
Token 653: ' un'
Token 41317: ' lenguaje'
Token 451: ' de'
Token 56586: ' programación'
Observa: Cada palabra → 1 token (en español muchas palabras son 1 token).
Tokenización de palabras complejas:
# Palabras largas pueden ser múltiples tokens
text = "anticonstitucionalísimamente"
tokens = encoding.encode(text)
print(f"Texto: {text}")
print(f"Tokens: {len(tokens)}")
for token_id in tokens:
print(f" '{encoding.decode([token_id])}'")
Output:
Texto: anticonstitucionalísimamente
Tokens: 8
'anti'
'const'
'itu'
'cion'
'al'
'ísima'
'mente'
Ventaja de sub-palabras: Modelo puede entender palabras que nunca vio (compone de sub-partes).
Diferentes tokenizadores:
| Modelo | Tokenizador | Vocabulario |
|---|---|---|
| OpenAI (GPT-4) | tiktoken (BPE) | ~100K tokens |
| BERT | WordPiece | ~30K tokens |
| Sentence-BERT | WordPiece | ~30K tokens |
| LLaMA | SentencePiece (BPE) | ~32K tokens |
BPE (Byte Pair Encoding): Algoritmo que aprende sub-palabras más frecuentes.
Paso 2: Transformer Encoder
Qué es:
Red neuronal que convierte tokens en vectores contextuales (hidden states).
Arquitectura high-level:
Input tokens:
[Python, es, un, lenguaje]
↓ (Embedding lookup)
Token embeddings (iniciales):
[[0.1, 0.2, ...], [0.3, 0.4, ...], ...]
↓ (Self-Attention Layers × N)
Contextualized embeddings:
[[0.5, 0.3, ...], [0.7, 0.2, ...], ...]
↑ Ahora "Python" sabe que va con "lenguaje"
↓ (Feed-Forward Layers)
Hidden states (finales):
[[0.8, 0.1, ...], [0.9, 0.3, ...], ...]
Clave: Self-attention permite que cada token "vea" todos los otros tokens.
Self-Attention (conceptual):
Texto: "El banco del río"
Sin contexto (word embeddings legacy):
banco → [0.5, 0.3, 0.8] (siempre el mismo vector)
Con self-attention (contextual embeddings):
"El banco del río"
↑
banco ve "río" → embedding_A = [0.2, 0.5, 0.1] (orilla)
"Fui al banco a sacar dinero"
↑
banco ve "dinero" → embedding_B = [0.8, 0.3, 0.6] (financiero)
Self-attention captura que "banco" + "río" ≠ "banco" + "dinero".
Visualización de attention:
# Conceptual (no ejecutable sin modelo cargado)
text = "Python es un lenguaje"
# Matriz de attention (simplificada):
# Python es un lenguaje
# Python 1.0 0.2 0.1 0.8 ← "Python" presta atención a "lenguaje"
# es 0.2 1.0 0.7 0.1 ← "es" presta atención a "un"
# un 0.1 0.7 1.0 0.3
# lenguaje 0.8 0.1 0.3 1.0 ← "lenguaje" presta atención a "Python"
Valores altos → tokens relacionados.
Nota: Esta matriz se aprende automáticamente durante entrenamiento (no la defines manualmente).
Paso 3: Pooling
Qué es:
Reducir múltiples hidden states (uno por token) a UN SOLO vector (embedding de la frase).
Problema:
Tokens: ["Python", "es", "un", "lenguaje"]
Hidden states (uno por token):
[
[0.8, 0.1, 0.3, ...], ← Python
[0.9, 0.3, 0.2, ...], ← es
[0.7, 0.2, 0.4, ...], ← un
[0.6, 0.4, 0.1, ...] ← lenguaje
]
¿Cómo obtener UN SOLO vector para toda la frase?
Solución: Pooling.
Estrategias de pooling:
1. Mean Pooling (promedio)
import numpy as np
# Hidden states (simplificado a 3 dims)
hidden_states = np.array([
[0.8, 0.1, 0.3], # Python
[0.9, 0.3, 0.2], # es
[0.7, 0.2, 0.4], # un
[0.6, 0.4, 0.1] # lenguaje
])
# Mean pooling
embedding = np.mean(hidden_states, axis=0)
print(f"Mean pooling: {embedding}")
# → [0.75, 0.25, 0.25] (promedio de cada dimensión)
Ventaja: Considera todos los tokens por igual.
Usado por: Sentence-BERT, Instructor Embeddings.
2. CLS Pooling (token especial)
# BERT agrega token especial [CLS] al inicio:
tokens = ["[CLS]", "Python", "es", "un", "lenguaje"]
# Hidden states:
hidden_states = [
[0.5, 0.6, 0.7], # [CLS] ← Usamos SOLO este
[0.8, 0.1, 0.3], # Python
[0.9, 0.3, 0.2], # es
[0.7, 0.2, 0.4], # un
[0.6, 0.4, 0.1] # lenguaje
]
# CLS pooling: Tomar SOLO el primer hidden state
embedding = hidden_states[0]
print(f"CLS pooling: {embedding}")
# → [0.5, 0.6, 0.7]
Ventaja: [CLS] token aprendió a "resumir" toda la frase durante entrenamiento.
Usado por: BERT original, algunos fine-tuned BERT models.
3. Max Pooling (máximo por dimensión)
# Max pooling: Tomar valor máximo de cada dimensión
embedding = np.max(hidden_states, axis=0)
print(f"Max pooling: {embedding}")
# → [0.9, 0.4, 0.4] (máximo de cada columna)
Ventaja: Captura features más "salientes".
Usado por: Menos común, algunos modelos especializados.
Comparación de pooling strategies:
| Strategy | Ventaja | Desventaja | Uso típico |
|---|---|---|---|
| Mean | Simple, todos los tokens importan | Tokens irrelevantes diluyen | Sentence-BERT |
| CLS | Token entrenado para resumir | Ignora otros tokens | BERT original |
| Max | Captura features salientes | Pierde información promedio | Menos común |
Best practice: Mean pooling para sentence embeddings (balance mejor).
Paso 4: Normalización (opcional)
Qué es:
Escalar el embedding a magnitud = 1.0 (vector unitario).
import numpy as np
# Embedding sin normalizar
embedding = np.array([3.0, 4.0])
print(f"Original: {embedding}")
print(f"Magnitud: {np.linalg.norm(embedding)}") # 5.0
# Normalizar (L2 normalization)
embedding_normalized = embedding / np.linalg.norm(embedding)
print(f"Normalizado: {embedding_normalized}")
print(f"Magnitud: {np.linalg.norm(embedding_normalized)}") # 1.0
Output:
Original: [3. 4.]
Magnitud: 5.0
Normalizado: [0.6 0.8]
Magnitud: 1.0
¿Por qué normalizar?
Ventaja: Dot product = Cosine similarity (más eficiente).
# Con embeddings normalizados:
dot_product = np.dot(emb_a_normalized, emb_b_normalized)
# → dot_product == cosine_similarity(emb_a, emb_b) ✅
# Sin normalizar (menos eficiente):
cosine_sim = np.dot(emb_a, emb_b) / (np.linalg.norm(emb_a) * np.linalg.norm(emb_b))
Modelos que normalizan:
- ✅ Sentence-BERT (normalizados por defecto)
- ❌ OpenAI embeddings (NO normalizados por defecto)
Arquitecturas de modelos populares
1. OpenAI text-embedding-3-small
Arquitectura: Transformer encoder-only
Specs:
- Capas: 12 transformer layers
- Hidden size: 1536
- Attention heads: 12
- Vocabulario: ~100K tokens (tiktoken)
- Pooling: No documentado (probablemente mean)
- Normalización: NO (manualmente si quieres)
Entrenamiento:
- Contrastive learning (pares similares/no similares)
- Dataset: Massive text corpus (web, libros, código)
Código (flujo completo conceptual):
# Paso 1: Tokenización
tokens = tiktoken.encode(text)
# Paso 2: Transformer encoder (OpenAI internal)
hidden_states = transformer_encoder(tokens)
# Paso 3: Pooling (mean)
embedding = mean_pooling(hidden_states)
# Paso 4: Return (sin normalizar)
return embedding # [1536 dims]
2. Sentence-BERT (all-MiniLM-L6-v2)
Arquitectura: BERT fine-tuned para sentence embeddings
Specs:
- Capas: 6 transformer layers (MiniLM = lightweight)
- Hidden size: 384
- Attention heads: 12
- Vocabulario: ~30K tokens (WordPiece)
- Pooling: Mean pooling (explícito)
- Normalización: SÍ (automática)
Entrenamiento:
- Siamese network (pares de frases similares)
- Dataset: NLI, STS, QA pairs
Código (SBERT real):
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
# Flujo interno:
# 1. Tokenización (WordPiece)
# 2. BERT encoder (6 layers)
# 3. Mean pooling
# 4. Normalización L2
embedding = model.encode("Python es un lenguaje")
# → [384 dims], normalizado
3. BGE (bge-large-en-v1.5)
Arquitectura: BERT-large fine-tuned
Specs:
- Capas: 24 transformer layers
- Hidden size: 1024
- Attention heads: 16
- Vocabulario: ~30K tokens
- Pooling: CLS token
- Normalización: Opcional
Training:
- Contrastive learning + hard negatives
- Dataset: C-MTEB (Chinese + English)
Diferencia clave: BGE usa CLS pooling (vs mean pooling de SBERT).
Ejemplo práctico: Flujo end-to-end
Generando embeddings paso a paso (conceptual con OpenAI):
from openai import OpenAI
import tiktoken
import os
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
text = "Python es un lenguaje de programación"
# Paso 1: Tokenización (manual con tiktoken)
encoding = tiktoken.encoding_for_model("text-embedding-3-small")
tokens = encoding.encode(text)
print("=== PASO 1: TOKENIZACIÓN ===")
print(f"Texto: {text}")
print(f"Tokens (IDs): {tokens}")
print(f"Cantidad: {len(tokens)} tokens\n")
for token_id in tokens:
token_str = encoding.decode([token_id])
print(f" Token {token_id}: '{token_str}'")
# Paso 2-4: Transformer + Pooling (OpenAI internal)
print("\n=== PASOS 2-4: TRANSFORMER + POOLING ===")
print("(Procesamiento interno de OpenAI)\n")
# Llamada a API (hace pasos 2-4 internamente)
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
embedding = response.data[0].embedding
print("=== RESULTADO FINAL ===")
print(f"Embedding dimensiones: {len(embedding)}")
print(f"Primeros 10 valores: {embedding[:10]}")
print(f"Últimos 10 valores: {embedding[-10:]}")
# Verificar si está normalizado
import numpy as np
embedding_array = np.array(embedding)
magnitude = np.linalg.norm(embedding_array)
print(f"\nMagnitud: {magnitude:.4f}")
if magnitude < 1.1:
print("✅ Embedding normalizado (magnitud ~1.0)")
else:
print("❌ Embedding NO normalizado")
Output:
=== PASO 1: TOKENIZACIÓN ===
Texto: Python es un lenguaje de programación
Tokens (IDs): [31380, 1560, 653, 41317, 451, 56586]
Cantidad: 6 tokens
Token 31380: 'Python'
Token 1560: ' es'
Token 653: ' un'
Token 41317: ' lenguaje'
Token 451: ' de'
Token 56586: ' programación'
=== PASOS 2-4: TRANSFORMER + POOLING ===
(Procesamiento interno de OpenAI)
=== RESULTADO FINAL ===
Embedding dimensiones: 1536
Primeros 10 valores: [0.023, -0.145, 0.892, -0.234, 0.567, -0.342, 0.123, -0.678, 0.432, 0.987]
Últimos 10 valores: [0.234, -0.567, 0.890, -0.123, 0.456, -0.789, 0.012, -0.345, 0.678, 0.901]
Magnitud: 1.523
❌ Embedding NO normalizado
Diferencias entre modelos
Por qué diferentes modelos → diferentes embeddings:
| Factor | Impacto | Ejemplo |
|---|---|---|
| Arquitectura | Layers, hidden size | BERT-base (12 layers) vs BGE (24 layers) |
| Tokenizador | Cómo se divide texto | tiktoken vs WordPiece |
| Pooling | Cómo se agrega | Mean vs CLS |
| Training data | Qué aprendió | English-only vs multilingual |
| Training objective | Qué optimizó | Contrastive vs MLM |
Resultado: NO puedes comparar embeddings de diferentes modelos (espacios vectoriales diferentes).
Limitaciones arquitecturales
1. Límite de tokens (context window):
# OpenAI text-embedding-3-small: Max 8191 tokens
text_largo = "..." * 10000 # Texto muy largo
tokens = encoding.encode(text_largo)
print(f"Tokens: {len(tokens)}")
if len(tokens) > 8191:
print("❌ Texto excede límite. Debes truncar o chunkear.")
Solución: Chunkear texto largo (Módulo 4).
2. Pérdida de información con pooling:
# Texto: "Python es bueno pero JavaScript es mejor"
# Mean pooling promedia TODO:
# → Pierde matiz de "pero" (contraste)
# Alternativa: Chunk en 2 frases:
# Chunk 1: "Python es bueno"
# Chunk 2: "JavaScript es mejor"
# → Captura matices de cada frase
3. Sensibilidad al orden:
# Embeddings SON sensibles al orden (self-attention lo captura):
text_1 = "El perro persigue al gato"
text_2 = "El gato persigue al perro"
emb_1 = get_embedding(text_1)
emb_2 = get_embedding(text_2)
# Similarity ~0.85 (alta pero NO idéntica)
# Captura que son diferentes (orden importa)
Ejercicios
Ejercicio 1: Tokenizar con tiktoken
Tokeniza este texto y cuenta cuántos tokens genera:
text = "anticonstitucionalísimamente es una palabra larga"
# ¿Cuántos tokens?
Ver solución
import tiktoken
encoding = tiktoken.encoding_for_model("gpt-4")
tokens = encoding.encode(text)
print(f"Texto: {text}")
print(f"Cantidad de tokens: {len(tokens)}")
print("\nTokens individuales:")
for token_id in tokens:
print(f" '{encoding.decode([token_id])}'")
Output esperado:
Texto: anticonstitucionalísimamente es una palabra larga
Cantidad de tokens: 14
Tokens individuales:
'anti'
'const'
'itu'
'cion'
'al'
'ísima'
'mente'
' es'
' una'
' pal'
'abra'
' l'
'arga'
Nota: La palabra larga se divide en múltiples sub-tokens.
Ejercicio 2: Implementar mean pooling
Implementa mean pooling manualmente:
import numpy as np
# Hidden states (4 tokens × 3 dims)
hidden_states = np.array([
[0.8, 0.1, 0.3],
[0.9, 0.3, 0.2],
[0.7, 0.2, 0.4],
[0.6, 0.4, 0.1]
])
# Implementa mean pooling
Ver solución
def mean_pooling(hidden_states):
"""
Mean pooling: Promedio de todos los hidden states
"""
return np.mean(hidden_states, axis=0)
embedding = mean_pooling(hidden_states)
print(f"Embedding: {embedding}")
Output:
Embedding: [0.75 0.25 0.25]
Explicación:
- Dim 0: (0.8 + 0.9 + 0.7 + 0.6) / 4 = 0.75
- Dim 1: (0.1 + 0.3 + 0.2 + 0.4) / 4 = 0.25
- Dim 2: (0.3 + 0.2 + 0.4 + 0.1) / 4 = 0.25
Ejercicio 3: Normalizar embedding
Normaliza este embedding a magnitud 1.0:
embedding = np.array([6.0, 8.0])
# Normaliza a magnitud = 1.0
Ver solución
def normalize_embedding(embedding):
"""L2 normalization"""
return embedding / np.linalg.norm(embedding)
embedding = np.array([6.0, 8.0])
embedding_normalized = normalize_embedding(embedding)
print(f"Original: {embedding}")
print(f"Magnitud original: {np.linalg.norm(embedding)}")
print(f"\nNormalizado: {embedding_normalized}")
print(f"Magnitud normalizada: {np.linalg.norm(embedding_normalized)}")
Output:
Original: [6. 8.]
Magnitud original: 10.0
Normalizado: [0.6 0.8]
Magnitud normalizada: 1.0
Resumen
Qué aprendiste:
- ✅ Flujo completo: Tokenización → Transformer → Pooling → Normalización
- ✅ Tokenización: tiktoken (BPE), WordPiece (BERT)
- ✅ Transformer: Self-attention captura contexto
- ✅ Pooling: Mean (SBERT), CLS (BERT), Max (raro)
- ✅ Normalización: L2 norm para magnitud = 1.0
- ✅ Diferencias modelos: Arquitectura, training data, pooling
Conceptos clave:
- Self-attention permite embeddings contextuales (vs word embeddings legacy)
- Pooling reduce múltiples tokens → 1 embedding
- Diferentes modelos → diferentes espacios vectoriales (incomparables)
Recursos adicionales
- Illustrated Transformer - Visualización excelente
- BERT Explained - Arquitectura BERT
- Sentence-BERT Paper - Mean pooling justificado
- tiktoken GitHub - Tokenizador OpenAI
- Attention Is All You Need - Transformer original
En la siguiente cápsula
Cápsula 08: Mini-Proyecto - Primer Embedding
Construirás:
- Similarity calculator CLI
- Setup OpenAI API
- Embed 10 documentos
- Calcular top-3 similaridades
- Código completo production-ready
De teoría arquitectural a implementación práctica.
Módulo 1 - Embeddings Deep Dive Guide Entendiendo la magia detrás de los vectores