← Todo el material
Curso Básico 23 de agosto de 2026

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

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.

ModeloDimsContexto máxMultilingüePrecio aprox (USD / 1M tokens)Self-hosted
OpenAI text-embedding-3-small1536 (truncable)8191Bueno~0,02No
OpenAI text-embedding-3-large3072 (truncable)8191Muy bueno~0,13No
Voyage voyage-4-large1024-204832000Muy bueno~0,12No
Voyage voyage-41024-204832000Muy bueno~0,06No
Voyage voyage-4-lite512-102432000Bueno~0,02No
Cohere embed-v41536 (truncable a 256)128000Excelente (100+)~0,12No
Gemini Embedding (gemini-embedding-001)3072 (truncable)2048Muy bueno~0,15No
BGE-M3 (open source)10248192Excelente0 (tu GPU)Sí
E5-large / multilingual-e51024512Muy bueno0 (tu GPU)Sí
nomic-embed-text-v1.5768 (truncable)8192Bueno0 (tu GPU)Sí
jina-embeddings-v31024 (truncable)8192Muy bueno0 (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-small es 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:

  1. 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.
  2. 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.
  3. 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.

  1. El mismo modelo para indexar y para consultar. Un vector de text-embedding-3-small y 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.
  2. 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.
  3. 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 y passage: 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" vs input_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 corpusTokens aproxCosto 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

RAGEmbeddingsIndexaciónMTEBSimilitud

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.