← Todo el material
Curso Intermedio 23 de agosto de 2026

RAG — Módulo 8: rerankers, la etapa que más calidad da por dólar

Octavo módulo del Curso de RAG: la diferencia entre bi-encoder y cross-encoder, el patrón de cascada que junta recall barato con precisión cara, comparación de Cohere Rerank, Voyage, Jina, BGE y LLM-as-reranker, ColBERT y late interaction, y MMR para que los chunks no se repitan.

☰ Índice del contenido

En el módulo 7 dejamos el pipeline con dos ramas de búsqueda fusionadas por RRF y un top-20 de candidatos esperando. Falta la última etapa antes del prompt, y es la que más rendimiento entrega por unidad de esfuerzo en todo el curso: el reranker. Si tu RAG “no encuentra” y solo puedes hacer un cambio, este es el cambio. No requiere reindexar nada, no toca el chunking, no cambia los embeddings — se agrega al final del pipeline y suele mover la métrica más que cualquier otra intervención individual.

La tesis del módulo

El retriever es rápido pero tonto; el reranker es lento pero preciso. Por eso no compiten: se usan en cascada. El primero recorre millones de documentos para traer 50 candidatos plausibles, el segundo lee con atención esos 50 y elige los 5 buenos. Ninguno de los dos puede hacer el trabajo del otro, y esa asimetría es toda la arquitectura.

Bi-encoder vs cross-encoder

Esta es la distinción central del módulo y explica por qué existe una segunda etapa.

El bi-encoder es lo que venimos usando desde el módulo 4. Codifica la pregunta y el documento por separado, cada uno en su propio vector, y después los compara con una operación aritmética barata (coseno o producto punto). “Bi” porque hay dos pasadas independientes por el modelo.

Su superpoder es que los vectores de los documentos se precomputan. Indexas una vez, y en tiempo de consulta solo codificas la pregunta y comparas contra millones de vectores ya guardados. Por eso escala. Su límite es estructural: el modelo nunca ve la pregunta y el documento juntos. Cuando comprimió el documento en un vector, todavía no existía la pregunta, así que tuvo que producir un resumen genérico que sirviera para cualquier consulta futura. Toda la comparación ocurre después, entre dos resúmenes ya cerrados.

El cross-encoder hace lo contrario. Concatena [pregunta, documento] en una sola secuencia y la mete junta al modelo. La atención cruzada (el mecanismo del Transformer que deja que cada token mire a todos los demás, ver atención desde cero) compara token a token: la palabra “garantía” de la pregunta puede mirar directamente la frase “6 meses por fallas de fábrica” del documento y evaluar si responde o no. La salida no es un vector, es un número: la relevancia de ese par.

Eso es mucho más preciso, y también mucho más caro. Como el score depende del par, no se puede precomputar nada: hay que correr el modelo una vez por cada combinación pregunta-documento. Para 20 candidatos son 20 pasadas. Para buscar en 10 millones de documentos serían 10 millones de pasadas por consulta, lo que es sencillamente imposible. De ahí la regla: el cross-encoder sirve para reordenar decenas de candidatos, jamás para buscar en el corpus completo.

BI-ENCODER (retriever, módulo 4)

┌────────────┐              ┌────────────┐
│  pregunta  │              │ documento  │
└─────┬──────┘              └─────┬──────┘
      │                           │
      ▼                           ▼
┌────────────┐              ┌────────────┐
│  modelo    │              │  modelo    │
└─────┬──────┘              └─────┬──────┘
      │                           │
      ▼                           ▼
  vector q                    vector d
      │                           │
      └───────────┬───────────────┘
                  ▼
           ┌─────────────┐
           │   coseno    │ ──► score
           └─────────────┘
   documento precomputado: escala a millones


CROSS-ENCODER (reranker, este módulo)

┌──────────────────────────────────┐
│   pregunta  [SEP]   documento    │
└────────────────┬─────────────────┘
                 │
                 ▼
        ┌──────────────────┐
        │  modelo con      │
        │  atención cruzada│
        │  token a token   │
        └────────┬─────────┘
                 │
                 ▼
        ┌──────────────────┐
        │   relevancia     │ ──► score
        └──────────────────┘
   nada precomputable: 1 pasada por PAR

La comparación directa:

EjeBi-encoder (retriever)Cross-encoder (reranker)
EntradaPregunta y doc por separadoPregunta y doc juntos
SalidaDos vectoresUn score de relevancia
PrecomputableSí, se indexa una vezNo, depende del par
Costo por consultaComparar vectores (µs)1 pasada de modelo por doc
Escala aMillones de documentosDecenas (20-100)
PrecisiónBuenaNotoriamente mejor
Rol en el pipelineTraer candidatos (recall)Elegir finalistas (precisión)

La cascada: recall arriba, precisión abajo

Con eso, el embudo del módulo 7 se justifica solo. Cada etapa evalúa menos documentos con más cuidado:

        millones de chunks
                │
                ▼
      ┌──────────────────┐
      │  retriever       │
      │  hibrido         │  barato por doc
      │  top-50          │  objetivo RECALL
      └────────┬─────────┘
               │
               ▼
      ┌──────────────────┐
      │  reranker        │
      │  cross-encoder   │  caro por doc
      │  top-5           │  objetivo PRECISIÓN
      └────────┬─────────┘
               │
               ▼
      ┌──────────────────┐
      │  prompt del LLM  │
      └──────────────────┘

Los dos objetivos son distintos a propósito. Recall es “¿está el documento correcto en alguna parte del conjunto?”. Precisión es “¿los que quedaron son todos buenos?”. Arriba te importa no perder la respuesta aunque entre basura con ella; abajo te importa que no quede basura.

De ahí sale la consecuencia más importante del módulo, y conviene decirla sin adornos: el techo del reranker es el recall del retriever. Si el chunk que contiene la respuesta no vino entre los 50, el reranker no tiene cómo inventarlo. Reordenar es elegir dentro de lo que llegó. Por eso cuando el sistema falla hay que diagnosticar en orden: primero si el chunk correcto está entre los candidatos (problema de retrieval), y recién después si quedó bien rankeado (problema de reranking).

El catálogo de rerankers

RerankerModoLatencia (20 docs)Precio aproxEspañolContexto máx
Cohere Rerank 3.5API~100-200 ms~$2 / 1K búsquedasExcelente (100+ idiomas)~4.000 tokens
Voyage rerank-2.5API~150-250 ms~$0,05 / 1M tokensBueno~32.000 tokens (query + doc)
Jina Reranker v3.5API o self-host~100-200 msGratis self-hostBueno (multilingüe)~8.000 tokens
BGE-reranker-v2-m3Self-host~50-150 ms con GPUSolo tu infraBueno (m3 es multilingüe)~8.000 tokens
LLM-as-rerankerAPI~500-2.000 msAlto (tokens del LLM)ExcelenteLa ventana del LLM

Cómo leer la tabla. Cohere Rerank es el default razonable si puedes mandar datos a una API: es el más maduro, el multilingüe es genuinamente bueno en español y la integración toma diez minutos. BGE-reranker-v2-m3 es la respuesta cuando los datos no pueden salir de tu infra: es open source, corre en una GPU modesta y su calidad está cerca de las APIs comerciales. Ojo con la variante: bge-reranker-base está entrenado principalmente en inglés y chino; para español hay que usar la familia m3. LLM-as-reranker consiste en pedirle a un modelo chico que puntúe la relevancia de cada chunk; es flexible y permite criterios en lenguaje natural (“prioriza documentos posteriores a 2024”), pero es un orden de magnitud más lento y caro, así que se justifica en volúmenes bajos o criterios raros.

Código: reranker local con BGE

from sentence_transformers import CrossEncoder

# El modelo se descarga una vez. m3 = multilingue, sirve en español.
# max_length recorta el par [query, doc]: si tus chunks son largos,
# el final del documento se pierde silenciosamente. Ojo con eso.
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

pregunta = "¿Cuánto dura la garantía legal en Chile?"

# Estos 5 vienen del hibrido del modulo 7, EN ESE ORDEN.
candidatos = [
    "El despacho a regiones tarda entre 3 y 5 días hábiles.",
    "La garantía del fabricante es opcional y la define cada marca.",
    "La garantía legal en Chile cubre 6 meses por fallas de fábrica.",
    "Nuestra política de reembolsos permite devoluciones en 30 días.",
    "La garantía legal se hace valer con la boleta en cualquier local.",
]

# El cross-encoder puntua CADA PAR (pregunta, candidato).
pares = [(pregunta, c) for c in candidatos]
scores = reranker.predict(pares)   # una pasada de modelo por par

ordenado = sorted(zip(scores, candidatos), reverse=True)
for score, texto in ordenado:
    print(f"{score:6.3f}  {texto[:60]}")

# ANTES (orden del retriever hibrido):
#   1. despacho a regiones        <- irrelevante, pero salio primero
#   2. garantia del fabricante    <- tema parecido, no responde
#   3. garantia legal 6 meses     <- LA RESPUESTA, en tercer lugar
#
# DESPUES (orden del reranker):
#   8.42  La garantía legal en Chile cubre 6 meses...   <- sube a 1
#   3.11  La garantía legal se hace valer con la boleta...
#  -1.87  La garantía del fabricante es opcional...
#  -9.55  El despacho a regiones tarda...               <- se hunde

Fíjate en dos cosas. La respuesta correcta subió del puesto 3 al 1, que es justo lo que necesitas cuando al prompt solo le pasas 3 chunks. Y los scores del cross-encoder no están acotados ni son probabilidades: son logits, pueden ser negativos, y solo tienen sentido comparados entre sí dentro de la misma consulta. No los uses como umbral absoluto sin calibrarlos primero con tu propio set.

Código: Cohere Rerank vía API

import cohere

co = cohere.ClientV2(api_key="...")

respuesta = co.rerank(
    model="rerank-v3.5",
    query=pregunta,
    documents=candidatos,   # lista de strings
    top_n=3,                # cuantos devuelve, no cuantos evalua
)

# Devuelve indices sobre la lista original + su score de relevancia.
# Aqui el score SI esta normalizado a [0, 1], a diferencia de BGE.
for r in respuesta.results:
    print(f"{r.relevance_score:.3f}  {candidatos[r.index][:60]}")

La función integrada al pipeline del módulo 7

def buscar_con_reranking(pregunta: str,
                         k_rama: int = 50,
                         k_fusion: int = 20,
                         top_n: int = 5) -> list[dict]:
    """Pipeline completo: hibrido (modulo 7) + reranking (este modulo).

    El embudo: 50 por rama -> 20 fusionados -> 5 finales.
    """
    # 1) Las dos ramas, en paralelo idealmente (ver modulo 7).
    lexicos = buscar_bm25(pregunta, k=k_rama)
    densos = buscar_vectorial(pregunta, k=k_rama)

    # 2) Fusion por ranking. Devuelve [(chunk_id, score_rrf)].
    fusionados = rrf([lexicos, densos], k=60, top_n=k_fusion)

    # 3) Reranking. Solo aqui se paga el modelo caro, y solo por 20 docs.
    chunks = [cargar_chunk(cid) for cid, _ in fusionados]
    pares = [(pregunta, c["texto"]) for c in chunks]
    scores = reranker.predict(pares)

    for c, s in zip(chunks, scores):
        c["score_rerank"] = float(s)

    chunks.sort(key=lambda c: c["score_rerank"], reverse=True)

    # 4) Diversidad antes de cortar: evita 5 versiones del mismo parrafo.
    #    mmr() compara vectores, asi que recibe el embedding de la
    #    pregunta, no el texto. Definida mas abajo en este modulo.
    chunks = mmr(embed([pregunta])[0], chunks, top_n=top_n, lambda_=0.7)
    return chunks

Cuánto cuesta y cómo elegir el top_n de entrada

El costo tiene dos monedas. Latencia: entre 100 y 300 ms típicos para 20-50 candidatos, sumados a lo que ya gastaste en las dos búsquedas. Dinero: por consulta, y escala lineal con la cantidad de documentos que le mandas.

Esa linealidad es la clave para elegir el top_n de entrada. Pasar de 20 a 100 candidatos multiplica el costo por 5, pero la ganancia en calidad se aplana rápido: los documentos en las posiciones 50 a 100 del retriever rara vez son la respuesta. La zona razonable está entre 20 y 50 candidatos de entrada, y top_n de salida entre 3 y 8 según cuántos chunks aguante tu prompt (módulo 9). Si necesitas más de 50 de entrada para encontrar la respuesta, el problema es el retriever, no el reranker.

Ojo: el reranker no puede recuperar lo que nunca llegó. Su techo absoluto es el recall del retriever, así que agregarlo a un pipeline cuyo retriever trae basura solo ordena la basura con más precisión. Si al medir (módulo 10) descubres que el chunk correcto entra en el top-50 apenas el 60% de las veces, tu trabajo está en el chunking y en el híbrido, no acá.

ColBERT y late interaction

Entre los dos extremos hay un punto medio que vale la pena conocer. ColBERT (Contextualized Late Interaction over BERT) no comprime el documento en un solo vector: guarda un vector por token. En la consulta, compara cada token de la pregunta contra todos los tokens del documento y se queda con el mejor match de cada uno (operación llamada MaxSim), sumando esos máximos.

Se llama late interaction (interacción tardía) porque la comparación entre pregunta y documento ocurre al final, sobre representaciones ya calculadas, en vez de ocurrir dentro del modelo como en el cross-encoder. Eso le da parte de la precisión del cross-encoder conservando la posibilidad de precomputar los documentos.

El precio es el almacenamiento, y es brutal: en vez de un vector por chunk, guardas uno por token. Un chunk de 400 tokens pasa de 1 vector a 400 — dos órdenes de magnitud más índice. Existen técnicas de compresión que lo alivian, pero esa aritmética explica por qué ColBERT es minoritario en producción frente al par retriever + cross-encoder.

EnfoquePrecisiónCosto de índiceCosto por consulta
Bi-encoderBase1 vector por chunkMínimo
ColBERTMedia-alta~1 vector por tokenMedio
Cross-encoderMáximaCero (no indexa)Alto, por par

MMR: que los 5 mejores no sean el mismo párrafo

Hay una patología que el reranking puro empeora en vez de arreglar. El reranker ordena por relevancia y nada más, así que si tu corpus tiene la misma política repetida en cinco documentos, los cinco primeros van a ser cinco versiones del mismo párrafo. El prompt recibe 5 chunks y la información de uno solo. Desperdiciaste el contexto.

MMR (Maximal Marginal Relevance, relevancia marginal máxima) corrige eso eligiendo los chunks de a uno y penalizando a los que se parecen demasiado a lo ya elegido:

import numpy as np

def mmr(pregunta_vec, chunks, top_n=5, lambda_=0.7):
    """Selecciona top_n chunks equilibrando relevancia y diversidad.

    lambda_ = 1.0 -> puro relevancia (el orden del reranker, sin cambios)
    lambda_ = 0.0 -> pura diversidad (ignora si responde la pregunta)
    0.7 es un punto de partida sensato: prioriza relevancia pero corta
    la redundancia obvia.
    """
    seleccionados, candidatos = [], list(range(len(chunks)))

    while len(seleccionados) < top_n and candidatos:
        mejor_idx, mejor_valor = None, -np.inf

        for i in candidatos:
            relevancia = np.dot(pregunta_vec, chunks[i]["vector"])

            # Cuanto se parece al MAS parecido de los ya elegidos.
            if seleccionados:
                redundancia = max(
                    np.dot(chunks[i]["vector"], chunks[j]["vector"])
                    for j in seleccionados
                )
            else:
                redundancia = 0.0

            valor = lambda_ * relevancia - (1 - lambda_) * redundancia
            if valor > mejor_valor:
                mejor_idx, mejor_valor = i, valor

        seleccionados.append(mejor_idx)
        candidatos.remove(mejor_idx)

    return [chunks[i] for i in seleccionados]

Una alternativa más simple y a veces suficiente es la deduplicación por documento fuente: permitir un máximo de 2 chunks por documento de origen, para que un manual de 300 páginas no monopolice el contexto. Es una regla tosca pero barata, y en corpus con documentos largos suele dar la mayor parte del beneficio de MMR con una fracción del código.

Errores comunes

  • Rerankear 500 documentos. El costo es lineal en la cantidad de pares y la ganancia se aplana después de los primeros 50. Mandar 500 candidatos multiplica el gasto por 10 para rescatar un documento que casi nunca era la respuesta. El rango sano es 20-50.
  • Rerankear cuando el retriever ya falló. El techo del reranker es el recall del retriever: no puede subir lo que nunca llegó. Si mides que el chunk correcto entra al top-50 solo la mitad de las veces, el reranker no es la solución, es maquillaje.
  • Usar un reranker inglés con corpus en español. bge-reranker-base fue entrenado sobre todo en inglés y chino, y en español degrada de forma silenciosa: no falla con error, simplemente ordena peor. Usa la familia m3 o una API multilingüe, y compáralas con tus propias queries.
  • No medir el aporte. El reranker suele ser la mejora individual más grande, pero “suele” no es “en tu caso”. Corre el pipeline con y sin él sobre el mismo set de evaluación y mira la diferencia real antes de pagar la latencia para siempre.
  • Ignorar el max_length del modelo. Si tus chunks son de 800 tokens y el reranker recorta en 512, la segunda mitad de cada chunk no se evalúa nunca. El truncamiento es silencioso: no hay excepción, solo peores rankings inexplicables.
  • Optimizar relevancia y olvidar la diversidad. Cinco chunks casi idénticos entregan la información de uno. Si tu corpus tiene contenido duplicado o muy repetitivo, MMR o un tope por documento fuente recuperan contexto real que estabas botando.

La frase para llevarse

“El retriever decide qué documentos existen para tu pregunta; el reranker decide cuáles merecen el prompt. Y ningún reranker del mundo puede rescatar el documento que el retriever nunca trajo.”

Qué viene

Módulo 9: generación, citas y cómo no alucinar. Hasta aquí todo fue retrieval — encontrar el texto correcto. Los chunks buenos ya están en la mano; falta que el modelo redacte con ellos sin inventar. Vamos a ver la anatomía completa del prompt de RAG, la instrucción de grounding que más reduce alucinaciones (y por qué los modelos se resisten tanto a decir “no sé”), tres técnicas de citación incluyendo salida estructurada en JSON, por qué el orden de los chunks en el contexto cambia la respuesta, y una tabla de síntomas para saber si el problema está en el prompt o — casi siempre — más arriba en el pipeline.

Material relacionado

RAGRerankersCross-encoderRetrievalMMR

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.