RAG — Módulo 4: embeddings e indexación — el mapa del significado
Módulo 4 del Curso de RAG: qué es realmente un embedding, por qué el bi-encoder permite precomputar el índice, coseno vs producto punto vs euclidiana, comparativa de modelos, cómo leer MTEB, Matryoshka, la asimetría query/documento y el código de indexación con normalización L2.
☰ Índice del contenido
- La tesis del módulo
- Qué es un embedding, sin metáforas vagas
- Bi-encoder: por qué se puede precomputar el índice
- Cómo se mide la cercanía
- Elegir modelo: la tabla que importa
- MTEB: el benchmark y cómo no dejarse engañar
- Matryoshka: cortar el vector sin perder casi nada
- Las tres reglas duras de la indexación
- Asimetría query/documento: recall gratis que casi nadie usa
- Español y multilingüe: qué cuidar
- El pipeline de indexación, dibujado
- Código: indexar de verdad
- Costo real: por qué el embedding casi nunca es el problema
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo anterior cortamos los documentos en chunks — trozos de texto del tamaño adecuado para recuperar y para caber en el prompt. Quedaron listos, ordenados y con su metadata, pero todavía son texto plano: nada que una máquina pueda comparar por significado. Este módulo cierra ese hueco. Vamos a convertir cada chunk en un vector, entender qué significa exactamente “cercanía” entre dos vectores, elegir un modelo de embedding con criterio en vez de por moda, y dejar el índice construido. A partir de aquí el RAG deja de ser una idea y empieza a tener estructura de datos.
La tesis del módulo
Un embedding no guarda el texto: guarda una dirección en un espacio de significado. Todo lo que hace la recuperación vectorial es medir ángulos entre esas direcciones. Por eso la calidad del retrieval se decide antes de buscar — se decide en qué modelo elegiste, cómo normalizaste y si trataste la pregunta distinto del documento.
Qué es un embedding, sin metáforas vagas
Un embedding es un vector: una lista de N números decimales. Un modelo de embedding es una red neuronal entrenada para que textos con significado parecido produzcan vectores que apunten en direcciones parecidas. N (la dimensionalidad) suele ir entre 384 y 4096 según el modelo.
Lo importante, y lo que casi nunca se dice bien: el significado vive en la dirección del vector, no en su magnitud. Dos textos pueden producir vectores de largo muy distinto y significar prácticamente lo mismo; lo que los relaciona es hacia dónde apuntan. La magnitud tiende a correlacionar con cosas irrelevantes para la semántica, como el largo del texto o cuántos tokens raros trae.
De esa observación sale toda la práctica del módulo: si lo que importa es la dirección, normalicemos los vectores para que todos midan lo mismo y la única diferencia que quede sea el ángulo.
Si vienes del módulo 10 del curso de Redes Neuronales esto ya te suena: ahí vimos cómo una capa de embedding aprende representaciones densas. La diferencia es que aquí no embebemos palabras sueltas sino pasajes completos, y el modelo que lo hace es un Transformer entrenado específicamente para que la similitud entre vectores sea útil para buscar.
Bi-encoder: por qué se puede precomputar el índice
Hay dos formas de que un modelo compare una pregunta con un documento.
Un bi-encoder codifica la pregunta y el documento por separado, cada uno en su propio vector, y después compara los dos vectores con una operación aritmética barata. El documento no sabe nada de la pregunta cuando se codifica.
Un cross-encoder mete la pregunta y el documento juntos en la misma pasada del modelo y devuelve directamente un puntaje de relevancia. El modelo puede mirar la interacción entre cada palabra de la pregunta y cada palabra del documento.
El cross-encoder es claramente más preciso. Y es completamente inviable para buscar: si tienes un millón de chunks, tendrías que correr el modelo un millón de veces por cada pregunta.
El bi-encoder gana por logística, no por calidad:
BI-ENCODER (indexable)
documento ──► ┌─────────┐ ──► vector_d (offline, 1 vez)
│ modelo │
pregunta ──► └─────────┘ ──► vector_q (online, por query)
vector_q · vector_d = score
CROSS-ENCODER (no indexable)
pregunta + documento ──► ┌─────────┐ ──► score
│ modelo │
└─────────┘
se corre 1 vez por PAR (online)
Los vectores de documento se calculan una sola vez, se guardan y se reusan para siempre. Solo la pregunta se codifica en caliente. Esa es toda la razón de existir del índice vectorial.
Ojo: el bi-encoder paga esa eficiencia con precisión. Como el documento se codifica sin saber la pregunta, tiene que comprimir “todo lo que este texto podría responder” en un solo vector, y eso pierde matiz. La solución estándar no es elegir uno de los dos: es usar el bi-encoder para traer 50 candidatos rápido y un cross-encoder para reordenar esos 50. Eso es exactamente el módulo 8, rerankers.
Cómo se mide la cercanía
Tres métricas, y una relación entre ellas que conviene entender bien.
Similitud coseno. Mide el coseno del ángulo entre dos vectores. Va de -1 (opuestos) a 1 (misma dirección). Ignora completamente la magnitud: es puro ángulo.
Producto punto (dot product). Suma de los productos componente a componente. No está acotado y sí depende de la magnitud: un vector más largo saca más puntaje aunque apunte igual.
Distancia euclidiana (L2). El largo de la línea recta entre las dos puntas del vector. Aquí menor es mejor, al revés que las otras dos.
La clave práctica: si los vectores están normalizados a largo 1, el coseno y el producto punto son la misma operación, y la distancia euclidiana queda como una función monótona del coseno — o sea, ordenan igual. Normalizar convierte las tres métricas en la misma decisión y te deja usar la más barata de calcular, que es el producto punto.
Normalizar L2 significa dividir cada componente del vector por su norma (la raíz de la suma de cuadrados). El vector resultante mide exactamente 1 y conserva la dirección.
import numpy as np
def normalizar_l2(v: np.ndarray) -> np.ndarray:
"""Deja el vector con largo 1 conservando su direccion."""
norma = np.linalg.norm(v)
# Guarda contra el vector cero (texto vacio, error de API):
# dividir por 0 produce NaN y contamina todo el indice.
if norma == 0:
return v
return v / norma
a = np.array([3.0, 4.0]) # norma 5
b = np.array([6.0, 8.0]) # norma 10, MISMA direccion
print(np.dot(a, b)) # 50.0 -> el dot crudo infla por magnitud
print(np.dot(normalizar_l2(a), normalizar_l2(b))) # 1.0 -> identicos en direccion
Ese 1.0 es el punto: a y b significan lo mismo en términos de dirección, y solo la normalización lo hace evidente.
Casi todos los modelos comerciales ya devuelven vectores normalizados. Igual conviene normalizar explícitamente en tu código: es barato, es idempotente, y te protege del día en que cambies de proveedor y el nuevo no lo haga.
Elegir modelo: la tabla que importa
Los modelos de embedding se mueven rápido. Esta es la foto de 2026, con los ejes que de verdad deciden.
| Modelo | Dims | Contexto máx | Multilingüe | Precio aprox (USD / 1M tokens) | Self-hosted |
|---|---|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 (truncable) | 8191 | Bueno | ~0,02 | No |
| OpenAI text-embedding-3-large | 3072 (truncable) | 8191 | Muy bueno | ~0,13 | No |
| Voyage voyage-4-large | 1024-2048 | 32000 | Muy bueno | ~0,12 | No |
| Voyage voyage-4 | 1024-2048 | 32000 | Muy bueno | ~0,06 | No |
| Voyage voyage-4-lite | 512-1024 | 32000 | Bueno | ~0,02 | No |
| Cohere embed-v4 | 1536 (truncable a 256) | 128000 | Excelente (100+) | ~0,12 | No |
| Gemini Embedding (gemini-embedding-001) | 3072 (truncable) | 2048 | Muy bueno | ~0,15 | No |
| BGE-M3 (open source) | 1024 | 8192 | Excelente | 0 (tu GPU) | Sí |
| E5-large / multilingual-e5 | 1024 | 512 | Muy bueno | 0 (tu GPU) | Sí |
| nomic-embed-text-v1.5 | 768 (truncable) | 8192 | Bueno | 0 (tu GPU) | Sí |
| jina-embeddings-v3 | 1024 (truncable) | 8192 | Muy bueno | 0 (tu GPU) | Sí |
Los precios son referenciales (verificados en septiembre de 2026) y cambian seguido: verifica en la página del proveedor antes de presupuestar. Los rangos de dimensiones indican modelos con dimensiones truncables (lo vemos en un minuto).
Lecturas rápidas de esa tabla:
- Si empiezas y no quieres pensar:
text-embedding-3-smalles barato, decente y multilingüe suficiente. - Si el corpus es en español y la calidad manda: Cohere embed y BGE-M3 rinden notablemente mejor en no-inglés que el promedio.
- Si necesitas self-hosting por datos sensibles: BGE-M3 es hoy el mejor punto de partida abierto, y encima produce representaciones densas y dispersas a la vez (útil para el híbrido del módulo 7).
- Si los chunks son largos: mira el contexto máximo. Un modelo con 512 tokens de contexto trunca silenciosamente todo lo que pase de ahí.
MTEB: el benchmark y cómo no dejarse engañar
MTEB (Massive Text Embedding Benchmark) es el benchmark estándar para modelos de embedding. Corre decenas de datasets agrupados en tareas: retrieval, clasificación, clustering, reranking, similitud semántica (STS). El leaderboard público ordena modelos por promedio.
Tres reglas para leerlo bien:
- Mira solo la columna de retrieval. El promedio general mezcla tareas que no tienen nada que ver con RAG. Un modelo excelente clasificando sentimiento puede ser mediocre buscando.
- Mira el subset de tu idioma. El promedio global está dominado por datasets en inglés. Existen MTEB multilingüe y splits por idioma; úsalos si tu corpus es español.
- El #1 no es el mejor para tu corpus. MTEB usa corpus genéricos: Wikipedia, noticias, foros. Si tu corpus son contratos legales chilenos, fichas técnicas de maquinaria o tickets de soporte con jerga interna, la correlación entre el ranking de MTEB y tu resultado real es débil.
La forma correcta de elegir: toma tus 30-50 preguntas reales con la respuesta correcta anotada, corre dos o tres modelos candidatos sobre tu corpus y mide recall. Eso es media hora de trabajo y vale más que el leaderboard completo. Cómo montar ese set de evaluación es el módulo 10.
Matryoshka: cortar el vector sin perder casi nada
Un modelo entrenado con Matryoshka Representation Learning ordena la información dentro del vector por importancia: las primeras dimensiones concentran lo esencial y las últimas afinan detalles. Como las muñecas rusas, puedes quedarte con una versión más chica del mismo vector.
En la práctica: puedes cortar un vector de 3072 dimensiones a 512 y quedarte con la gran mayoría de la calidad, usando una sexta parte de la memoria y del tiempo de comparación.
from openai import OpenAI
client = OpenAI()
# El parametro `dimensions` pide el vector ya truncado desde la API.
# Cuesta lo mismo (se cobra por tokens de entrada), pero pesa 6x menos.
resp = client.embeddings.create(
model="text-embedding-3-large",
input="¿Cuál es el plazo de garantía del equipo?",
dimensions=512,
)
vec = resp.data[0].embedding
print(len(vec)) # 512
Si truncas a mano en vez de pedirlo por API, normaliza L2 después de cortar: el subvector ya no mide 1 y las comparaciones quedarían sesgadas.
Cuándo usarlo: cuando la memoria del índice es tu cuello de botella. Diez millones de vectores de 3072 dimensiones en float32 son unos 123 GB; a 512 dimensiones bajan a ~20 GB. Esa diferencia decide si el índice cabe en RAM o no, que es la decisión de rendimiento del módulo 5.
Las tres reglas duras de la indexación
No son recomendaciones, son invariantes. Romper cualquiera de las tres produce un sistema que no falla con error sino con resultados malos y silenciosos.
- El mismo modelo para indexar y para consultar. Un vector de
text-embedding-3-smally uno de BGE-M3 viven en espacios distintos aunque tengan el mismo número de dimensiones. Compararlos da números perfectamente válidos y completamente sin sentido. - Cambiar de modelo obliga a reindexar todo el corpus. No hay migración parcial ni conversión. Presupuesta el reindexado completo antes de anunciar el cambio de modelo.
- Nunca mezcles modelos en un mismo índice. Si necesitas convivencia durante una migración, levanta un índice nuevo en paralelo y cambia el tráfico de golpe cuando esté listo.
Corolario operacional: guarda el nombre y la versión del modelo en la metadata del índice. Cuando alguien pregunte en seis meses con qué se indexó, la respuesta tiene que estar en los datos, no en la memoria de alguien.
Asimetría query/documento: recall gratis que casi nadie usa
Este es probablemente el punto de mayor retorno del módulo.
Una pregunta y un pasaje no son el mismo tipo de texto. “¿cuánto dura la garantía?” son seis palabras interrogativas. El pasaje que la responde son 300 palabras afirmativas que quizás nunca usan la palabra “cuánto”. Son objetos lingüísticos distintos, y un modelo que los trate igual los ubica peor de lo que podría.
Varias familias de modelos se entrenaron justamente para distinguirlos y esperan que tú se los digas:
- E5 / multilingual-E5: prefijos literales en el texto,
query:para preguntas ypassage:para documentos. - BGE: prefijo de instrucción en la query (por ejemplo
Represent this sentence for searching relevant passages:), documento sin prefijo. - Cohere y Voyage: un parámetro explícito,
input_type="search_query"vsinput_type="search_document". - OpenAI y Gemini: no lo requieren; están entrenados para funcionar simétricos.
Olvidar el prefijo no lanza ningún error. Simplemente el recall baja varios puntos y nadie sabe por qué.
MODELO = "intfloat/multilingual-e5-large"
def texto_documento(chunk: str) -> str:
"""E5 espera este prefijo literal para los pasajes del indice."""
return f"passage: {chunk}"
def texto_query(pregunta: str) -> str:
"""Y este otro para las preguntas. No son intercambiables."""
return f"query: {pregunta}"
Regla mnemotécnica: si el modelo tiene modo query, usarlo siempre en ambos lados. Poner el prefijo solo en uno es peor que no ponerlo en ninguno.
Español y multilingüe: qué cuidar
Cuatro cosas que muerden específicamente en corpus en español:
- Tokenización. Los modelos entrenados mayormente en inglés parten el español en más tokens que un modelo multilingüe. Más tokens es más costo y, con contexto corto, más truncado.
- Acentos y ñ. Un modelo bien entrenado en español maneja “año” y “ano” como cosas distintas. Uno mediocre no. Pruébalo antes de confiar.
- Idioma mezclado. Corpus con documentación técnica en inglés y consultas en español necesitan un modelo genuinamente cross-lingual (Cohere, BGE-M3, multilingual-E5). Con un modelo monolingüe la recuperación cross-idioma se cae.
- Registro. Los usuarios escriben coloquial (“cuánto me sale”) y los documentos formal (“el valor de la prestación asciende a”). Es exactamente el problema que resuelven las query transformations del módulo 11.
El pipeline de indexación, dibujado
START
│
▼
┌────────────────┐
│ chunk + │
│ metadata │
└───────┬────────┘
│ texto con prefijo
▼
┌────────────────┐
│ modelo de │
│ embedding │
└───────┬────────┘
│ vector crudo
▼
┌────────────────┐
│ normalizar │
│ L2 (largo 1) │
└───────┬────────┘
│
▼
┌────────────────┐ ┌──────────────────┐
│ índice │ ──► │ metadata: │
│ vectorial │ │ fuente, página │
└───────┬────────┘ │ fecha, modelo │
│ └──────────────────┘
▼
END
La metadata viaja al costado, no dentro del vector. Se guarda junto al vector para poder filtrar por ella al buscar y para poder citar la fuente al responder.
Código: indexar de verdad
Lo que sigue es el indexador completo: batching (agrupar textos por llamada para no morir en latencia), reintentos con espera creciente, normalización y persistencia de vectores más metadata.
import time
import json
import numpy as np
from openai import OpenAI, RateLimitError, APIError
client = OpenAI()
MODELO = "text-embedding-3-small"
DIMS = 1536
BATCH = 128 # textos por llamada; sobre ~256 sube el riesgo de timeout
MAX_REINTENTOS = 5
def _embed_batch(textos: list[str]) -> list[list[float]]:
"""Una llamada a la API con backoff exponencial.
Rate limit y errores 5xx son transitorios: se reintentan.
Un 400 (texto invalido, demasiado largo) NO se reintenta:
reventar rapido es mejor que insistir 5 veces con basura.
"""
for intento in range(MAX_REINTENTOS):
try:
resp = client.embeddings.create(
model=MODELO,
input=textos,
dimensions=DIMS,
)
# La API devuelve los items desordenados en teoria:
# ordenar por `index` garantiza correspondencia 1:1 con `textos`.
datos = sorted(resp.data, key=lambda d: d.index)
return [d.embedding for d in datos]
except (RateLimitError, APIError) as e:
espera = 2 ** intento
print(f"[reintento {intento + 1}] {type(e).__name__}, espero {espera}s")
time.sleep(espera)
raise RuntimeError(f"Fallaron {MAX_REINTENTOS} intentos para un batch")
def normalizar_l2(m: np.ndarray) -> np.ndarray:
"""Normaliza fila a fila (cada fila es un vector)."""
normas = np.linalg.norm(m, axis=1, keepdims=True)
normas[normas == 0] = 1.0 # evita NaN en vectores cero
return m / normas
def indexar(chunks: list[dict]) -> tuple[np.ndarray, list[dict]]:
"""chunks: [{"texto": str, "fuente": str, "pagina": int}, ...]
Devuelve la matriz de vectores normalizados y la metadata alineada
por posicion: vectores[i] corresponde a metadata[i].
"""
vectores: list[list[float]] = []
metadata: list[dict] = []
for inicio in range(0, len(chunks), BATCH):
lote = chunks[inicio:inicio + BATCH]
# Los saltos de linea degradan la calidad en varios modelos.
textos = [c["texto"].replace("\n", " ").strip() for c in lote]
# Un texto vacio produce un vector inutil y a veces un 400.
if any(not t for t in textos):
raise ValueError(f"Chunk vacio en el lote que parte en {inicio}")
vectores.extend(_embed_batch(textos))
for c in lote:
metadata.append({
"texto": c["texto"],
"fuente": c["fuente"],
"pagina": c.get("pagina"),
"modelo": MODELO, # regla dura 3: queda registrado
"dims": DIMS,
})
print(f"indexados {min(inicio + BATCH, len(chunks))}/{len(chunks)}")
matriz = normalizar_l2(np.array(vectores, dtype=np.float32))
return matriz, metadata
def embed_query(pregunta: str) -> np.ndarray:
"""Codifica la pregunta con EL MISMO modelo del indice.
Si usaras E5/BGE, el prefijo de query iria aqui y solo aqui.
"""
texto = pregunta.replace("\n", " ").strip()
vec = _embed_batch([texto])[0]
v = np.array(vec, dtype=np.float32)
return v / np.linalg.norm(v)
if __name__ == "__main__":
chunks = [
{"texto": "La garantía del equipo cubre 24 meses desde la compra.",
"fuente": "manual.pdf", "pagina": 12},
{"texto": "El mantenimiento preventivo se realiza cada 6 meses.",
"fuente": "manual.pdf", "pagina": 13},
]
vecs, meta = indexar(chunks)
np.save("vectores.npy", vecs)
with open("metadata.json", "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
q = embed_query("¿cuánto dura la garantía?")
scores = vecs @ q # normalizados: producto punto = coseno
print(scores) # el chunk de garantia deberia liderar
El vecs @ q de la última línea es literalmente toda la búsqueda vectorial: un producto matriz-vector. Funciona perfecto con dos chunks y perfecto con cincuenta mil. Con diez millones deja de funcionar, y eso es el módulo siguiente.
Costo real: por qué el embedding casi nunca es el problema
Números concretos, con text-embedding-3-small a ~0,02 USD por millón de tokens.
| Volumen del corpus | Tokens aprox | Costo de indexar |
|---|---|---|
| 1.000 páginas | ~500 mil | ~0,01 USD |
| 10.000 páginas (corpus corporativo típico) | ~5 millones | ~0,10 USD |
| 100.000 páginas | ~50 millones | ~1 USD |
| 1 millón de páginas | ~500 millones | ~10 USD |
Indexar un corpus corporativo entero cuesta menos que un café. Con el modelo large cuesta seis veces más, o sea, sigue costando menos que un café.
La consecuencia práctica es doble. Primero: no optimices el costo de embeddings, no está ahí tu factura. El costo real de un RAG en producción son los tokens de generación del LLM, que se pagan en cada consulta y no una sola vez. Segundo: reindexar es barato, así que la excusa de “no podemos cambiar de modelo porque habría que reindexar” no es una excusa de plata — es de tiempo de ingeniería y de coordinación del corte.
Lo que sí cuesta: si reindexas el corpus completo cada noche por cambiar tres documentos. Guarda un hash del texto de cada chunk y reembebe solo los que cambiaron.
Errores comunes
- Indexar con un modelo y consultar con otro. No lanza error, solo devuelve resultados aleatorios con puntajes que parecen razonables. Es el bug más caro de diagnosticar del curso completo. Guarda el nombre del modelo en la metadata y valídalo al arrancar el servicio.
- No normalizar y usar producto punto. Los chunks largos, que tienden a producir vectores de mayor magnitud, ganan puntaje por volumen y no por relevancia. Se ve como “siempre me devuelve los mismos pasajes largos”.
- Ignorar el prefijo query/passage. Con E5 o BGE es varios puntos de recall regalados. Y peor: poner el prefijo al indexar y olvidarlo al consultar deja el índice y las queries desalineados.
- Elegir modelo por el #1 de MTEB. El leaderboard mide corpus genéricos en inglés. Media hora midiendo dos candidatos con tus propias preguntas te da una respuesta que el leaderboard no puede darte.
- Chunks más largos que el contexto del modelo. Si el modelo acepta 512 tokens y tus chunks tienen 800, la API trunca sin avisar y el final de cada chunk simplemente no existe en el índice.
- Meter texto vacío o solo espacios. Produce vectores degenerados que aparecen en resultados de cualquier query. Filtra antes de embeber.
La frase para llevarse
“El embedding no guarda lo que el texto dice, guarda hacia dónde apunta. Normaliza y compara ángulos: todo lo demás es ruido de magnitud.”
Qué viene
Tenemos vectores normalizados y una metadata alineada, y una búsqueda que es un producto matricial contra todo el índice. Eso escala hasta unas decenas de miles de chunks y después se cae. En el módulo 5 vamos a ver por qué se cae, qué es ANN (búsqueda aproximada de vecinos), cómo funcionan HNSW e IVF por dentro, cómo se comprime un vector con cuantización para que el índice quepa en RAM, y qué base de datos vectorial elegir — con una recomendación honesta que probablemente sea la que ya tienes instalada.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.