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:

ModeloTokenizadorVocabulario
OpenAI (GPT-4)tiktoken (BPE)~100K tokens
BERTWordPiece~30K tokens
Sentence-BERTWordPiece~30K tokens
LLaMASentencePiece (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:

StrategyVentajaDesventajaUso típico
MeanSimple, todos los tokens importanTokens irrelevantes diluyenSentence-BERT
CLSToken entrenado para resumirIgnora otros tokensBERT original
MaxCaptura features salientesPierde información promedioMenos 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:

FactorImpactoEjemplo
ArquitecturaLayers, hidden sizeBERT-base (12 layers) vs BGE (24 layers)
TokenizadorCómo se divide textotiktoken vs WordPiece
PoolingCómo se agregaMean vs CLS
Training dataQué aprendióEnglish-only vs multilingual
Training objectiveQué 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:

  1. Self-attention permite embeddings contextuales (vs word embeddings legacy)
  2. Pooling reduce múltiples tokens → 1 embedding
  3. Diferentes modelos → diferentes espacios vectoriales (incomparables)

Recursos adicionales

  1. Illustrated Transformer - Visualización excelente
  2. BERT Explained - Arquitectura BERT
  3. Sentence-BERT Paper - Mean pooling justificado
  4. tiktoken GitHub - Tokenizador OpenAI
  5. 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