Por qué los primeros 5 resultados necesitan tratamiento especial
Todo lo que hemos construido hasta ahora (BM25, bi-encoders, ColBERT, fusión híbrida) sacrifica profundidad de puntuación por velocidad, devolviendo entre 100 y 1000 candidatos bajo la suposición de que los documentos relevantes se encuentran en algún lugar de ese conjunto. Esa suposición suele ser correcta, pero un pipeline RAG no alimenta los 1000 candidatos al LLM. Alimenta los primeros 5, quizás 10, y si los documentos verdaderamente relevantes están en las posiciones 40 y 73, nunca llegan al modelo.
Sin embargo, una vez que tenemos un conjunto manejable de candidatos de la primera fase, podemos permitirnos ejecutar una función de puntuación mucho más costosa sobre cada uno. Un cross-encoder, por ejemplo, lee la consulta y el documento conjuntamente a través de cada capa del transformer y genera una única puntuación de relevancia, que tiende a ser mucho más precisa que cualquier cosa que un bi-encoder pueda producir (porque los bi-encoders nunca permiten que los tokens de la consulta y del documento se atiendan mutuamente). Puntuar 100 candidatos de esta manera toma una fracción de segundo en una GPU, mientras que puntuar millones tomaría horas. Esta estructura de dos fases (recall barato primero, precisión costosa después) es lo que la comunidad de recuperación de información llama reranking.
En la práctica, el recuperador de primera etapa devuelve $K$ candidatos (frecuentemente $K = 100$), el reranker repuntúa todos los $K$ contra la consulta, y solo los primeros $k$ (frecuentemente $k = 5$) pasan a la aplicación posterior.
Cómo los Cross-Encoders puntúan la relevancia
Un cross-encoder concatena la consulta y el documento en una única secuencia ([CLS] consulta [SEP] documento [SEP]) y la pasa a través de un transformer. Dado que cada token de la consulta atiende a cada token del documento a través de todas las capas, el modelo captura interacciones de grano fino que la codificación independiente pierde por completo. Una cabeza de clasificación o regresión sobre la representación del [CLS] genera entonces una puntuación escalar de relevancia.
El entrenamiento de estos modelos típicamente se basa en pérdidas por pares sobre datos etiquetados con relevancia. Dada una consulta, un documento relevante y un documento no relevante, queremos que el modelo asigne una puntuación más alta al relevante. La pérdida RankNet expresa esto como entropía cruzada binaria sobre la diferencia de puntuaciones.
Aquí $s_{d^+}$ y $s_{d^-}$ son las puntuaciones del reranker para los documentos relevante y no relevante, y $\sigma$ es la sigmoide. Minimizar esta pérdida empuja al modelo a ampliar el margen entre las puntuaciones positivas y negativas.
MS MARCO proporciona datos de entrenamiento convenientes para esta configuración, ya que cada consulta tiene un pasaje positivo anotado mientras que cada otro pasaje sirve como negativo implícito. Los negativos difíciles extraídos de los primeros resultados de BM25 son particularmente valiosos porque son documentos que "parecen relevantes" y se posicionan bien bajo un recuperador barato pero no son realmente relevantes, por lo que entrenar contra ellos obliga al modelo a aprender las distinciones que más importan en la frontera entre relevante e irrelevante.
¿Y si formulamos el reranking como generación de texto?
Los cross-encoders funcionan bien, pero requieren entrenar una cabeza de clasificación y un ciclo dedicado de fine-tuning. Nogueira et al. (2020) observaron que los modelos sequence-to-sequence ya saben cómo responder preguntas de sí/no, así que ¿por qué no simplemente hacer una? Su modelo, monoT5, toma un prompt de la forma "Query: <consulta> Document: <documento> Relevant:" y genera un único token, ya sea "true" o "false". La puntuación de relevancia es simplemente la log-probabilidad de generar "true".
Dado que T5 está preentrenado en tareas diversas de sequence-to-sequence, ya tiene una sólida comprensión del lenguaje, y la formulación true/false encaja naturalmente en lo que ya sabe hacer. Un efecto secundario atractivo es que la salida es una probabilidad calibrada: un documento con $\log P(\text{true}) = -0.1$ es mucho más confiadamente relevante que uno con $\log P(\text{true}) = -3.5$, lo que facilita establecer umbrales de relevancia mínima.
El fine-tuning sigue la misma receta de datos que los cross-encoders (MS MARCO, pasajes positivos mapeados a "true", negativos a "false"). A pesar de la simplicidad de esta configuración, monoT5-3B logró resultados estado del arte en el ranking de pasajes de MS MARCO cuando fue publicado, y variantes más pequeñas como monoT5-220M siguen siendo líneas base competitivas.
¿Puede un LLM reordenar una lista completa de una vez?
Tanto los cross-encoders como monoT5 puntúan documentos uno a la vez (pointwise), lo que significa que nunca comparan candidatos entre sí. Sun et al. (2023) propusieron RankGPT, que adopta un enfoque diferente: entregar al LLM la consulta y una lista numerada de 20 pasajes, y luego pedirle que genere una permutación que represente el orden de relevancia. Un prompt similar al siguiente cumple la tarea.
"Te proporcionaré 20 pasajes. Ordénalos por relevancia respecto a la consulta. Genera solo los números de pasaje de más a menos relevante, separados por comas. Consulta: [consulta] Pasajes: [1] [texto1] [2] [texto2] ... Ranking:"
El LLM genera algo como "3, 7, 1, 12, ..." y tenemos un reranking completo. Debido a que el modelo ve todos los candidatos simultáneamente, puede resolver empates y cuasi-empates que los puntuadores pointwise no detectan. Sin embargo, el costo es elevado: encajar 20 documentos completos en el contexto puede consumir fácilmente decenas de miles de tokens por consulta, y ejecutar esto a escala se acumula rápidamente.
Una variante de ventana deslizante reduce este costo en cierta medida. En lugar de ordenar todos los $K$ candidatos a la vez, deslizamos una ventana de tamaño $w$ (digamos 20) sobre la lista de abajo hacia arriba, reordenando dentro de cada ventana y dejando que los documentos más relevantes asciendan con cada pasada.
También existe un punto intermedio entre pointwise y listwise. Podemos puntuar cada candidato individualmente preguntando al LLM "¿Es este documento relevante para la consulta? Responde sí o no" y usando la log-probabilidad de "sí" como puntuación (esencialmente monoT5 con un backbone LLM). Esto es más barato que el reranking listwise pero pierde la capacidad de comparar candidatos entre sí.
La siguiente simulación ilustra por qué el reranking importa, mostrando cómo el NDCG@k mejora a medida que pasamos de un recuperador de primera etapa barato (BM25) a través de un bi-encoder hasta un reranker que concentra los documentos relevantes cerca de la parte superior de la lista.
import math, json
import js
# Simulate cross-encoder vs first-stage retriever quality
# at different positions in the ranked list
def ndcg_at_k(relevances, k):
"""
relevances: list of 0/1 labels in rank order (1=relevant)
k: cutoff
"""
dcg = sum(rel / math.log2(i + 2) for i, rel in enumerate(relevances[:k]))
ideal = sorted(relevances, reverse=True)
idcg = sum(rel / math.log2(i + 2) for i, rel in enumerate(ideal[:k]))
return dcg / idcg if idcg > 0 else 0.0
# Simulate ranked lists from different systems for 50 queries
# We'll use synthetic relevance patterns
import random
random.seed(42)
def simulate_rankings(n_relevant=5, list_size=20, precision_boost=0):
"""
Returns a ranked list of 0/1 relevance labels.
Higher precision_boost = more relevant docs near the top.
"""
# Place n_relevant relevant docs; higher precision_boost -> lower average rank
all_docs = [0] * list_size
positions = sorted(random.sample(range(list_size), n_relevant))
# Apply boost: shift relevant docs toward top
boosted = [max(0, p - precision_boost + random.randint(-1, 1)) for p in positions]
boosted = [min(p, list_size - 1) for p in boosted]
for p in boosted:
all_docs[p] = 1
return all_docs
n_queries = 30
k_values = [1, 3, 5, 10]
bm25_ndcg = {k: [] for k in k_values}
bienc_ndcg = {k: [] for k in k_values}
rerank_ndcg = {k: [] for k in k_values}
for _ in range(n_queries):
bm25_rels = simulate_rankings(n_relevant=5, list_size=20, precision_boost=0)
bienc_rels = simulate_rankings(n_relevant=5, list_size=20, precision_boost=3)
rerank_rels = simulate_rankings(n_relevant=5, list_size=20, precision_boost=8)
for k in k_values:
bm25_ndcg[k].append(ndcg_at_k(bm25_rels, k))
bienc_ndcg[k].append(ndcg_at_k(bienc_rels, k))
rerank_ndcg[k].append(ndcg_at_k(rerank_rels, k))
mean_bm25 = [round(sum(bm25_ndcg[k])/n_queries, 3) for k in k_values]
mean_bienc = [round(sum(bienc_ndcg[k])/n_queries, 3) for k in k_values]
mean_rerank = [round(sum(rerank_ndcg[k])/n_queries, 3) for k in k_values]
plot_data = [
{
"title": "NDCG@k: BM25 vs Bi-Encoder vs Reranker (Simulated)",
"x_label": "k (cutoff)",
"y_label": "Mean NDCG@k",
"x_data": [str(k) for k in k_values],
"lines": [
{"label": "BM25", "data": mean_bm25, "color": "#f59e0b"},
{"label": "Bi-Encoder", "data": mean_bienc, "color": "#3b82f6"},
{"label": "Reranker", "data": mean_rerank, "color": "#10b981"},
]
}
]
js.window.py_plot_data = json.dumps(plot_data)
Quiz
Pon a prueba tu comprensión del reranking en recuperación.
¿Por qué se usa un cross-encoder como reranker en lugar de como recuperador de primera etapa?
En la pérdida por pares RankNet $-\log \sigma(s_{d^+} - s_{d^-})$, ¿qué aprende el modelo?
monoT5 calcula las puntuaciones de relevancia:
¿Cuál es la principal limitación práctica del enfoque de reranking listwise de RankGPT?
En un pipeline RAG típico de dos etapas con $K=100$ candidatos de primera etapa y $k=5$ resultados finales, ¿dónde opera el reranker?