Ir al contenido

Reranking: el segundo filtro

Tu retriever — dense o hybrid — está optimizado para recall: traer todos los chunks relevantes en una lista corta. No está optimizado para precisión: ordenarlos perfectamente por relevancia.

Si pedís top-4, los 4 chunks que devuelve incluyen los relevantes pero también algo de ruido. Si pedís top-20, casi seguro están todos los relevantes — pero ahora el problema es que 16 son ruido.

Reranking resuelve esto en dos pasos:

  1. Retrieve wide: traer top-20 candidatos rápido (dense + hybrid).
  2. Rerank: usar un modelo más fino (y más caro) para reordenar los 20 y quedarte con los top-4.

Tradeoff: latencia ×2, calidad típicamente +10–20%. Es la mejor relación esfuerzo/ganancia que vas a encontrar en Nivel 3.

Un modelo cross-encoder (BGE-reranker, mxbai-rerank, Cohere Rerank) toma (query, chunk) como input y devuelve un score de relevancia. Mucho más preciso que comparar embeddings independientes porque el modelo ve query y chunk juntos y puede cruzar señales.

Tradeoff: rapidísimo (~50ms por par) pero requiere instalar @xenova/transformers + onnxruntime-node y descargar un modelo (~270 MB). En el curso lo dejo como avanzado: el patrón está descrito al final, son ~30 líneas extras a tu pipeline.

Le mandás los N candidatos al LLM con un prompt que pide ordenarlos. Cero setup nuevo — usa el mismo llama3.2:3b que ya tenés. Más lento (~150-300ms) y menos preciso que un cross-encoder, pero funciona y enseña el patrón.

Vive en packages/01-vercel-ai-sdk/src/retrievers/rerank.ts. La idea es simple: numerar los candidatos, pasárselos al LLM, parsear el JSON.

rerank.ts: prompt
const prompt = `Sos un evaluador de relevancia. Para la PREGUNTA y los PASAJES de abajo, asignale a cada pasaje un score entre 0 y 1 (1 = totalmente relevante, 0 = irrelevante).
Respondé EXACTAMENTE con este JSON, sin texto adicional, sin bloques de código:
[{"id": 1, "score": 0.9}, {"id": 2, "score": 0.4}, ...]
PREGUNTA:
${query}
PASAJES:
${numbered}
JSON:`;

Tres detalles:

  • Score continuo 0-1, no labels. Más expresivo: el LLM puede decir “este es claramente más relevante que este otro pero ambos son OK”.
  • JSON cerrado: simplifica el parser. El regex captura el primer [...].
  • Temperatura 0: queremos consistencia, no creatividad.

Los LLMs chicos (3B params) a veces devuelven JSON malformado, prosa antes/después, o se olvidan de un id. El parser hace lo razonable:

rerank.ts: parser
const match = data.response.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (!match) {
// Fallback: keep original order, score 0.5 across the board
return candidates.slice(0, topK).map((c) => ({ chunk: c.chunk, score: 0.5 }));
}
let parsed: Array<{ id: number; score: number }>;
try {
parsed = JSON.parse(match[0]);
} catch {
return candidates.slice(0, topK).map((c) => ({ chunk: c.chunk, score: 0.5 }));
}
const seen = new Set<number>();
const ranked: RerankHit[] = [];
for (const item of parsed) {
if (typeof item.id !== 'number' || /* ... */ seen.has(item.id)) continue;
// ... map to chunk, push
}

Si el LLM falla feo, el binario sigue funcionando con el ranking original (top-K del retriever). No mata la query — solo no aporta el lift del rerank.

Ventana de terminal
pnpm vercel:query:adv "<pregunta>" --rerank

Internamente:

  1. Retrieve dense (o hybrid si pasás --hybrid también) con RETRIEVE_K = 20 en vez de los 4 default.
  2. Pasa los 20 candidatos al LLM con el prompt de arriba.
  3. Filtra al top-4 por score del rerank.
  4. Genera la respuesta con esos 4.

Combinable con todas las otras flags del nivel.

Cuando midas el impacto del rerank, retrieval@k no alcanza: rerank no cambia QUÉ chunks tenés (siguen siendo los del top-20 del retriever) — cambia el ORDEN dentro del top-K.

Para medir reordenamiento querés nDCG@K (Normalized Discounted Cumulative Gain): puntúa más alto cuando los chunks correctos están ARRIBA, no solo cuando aparecen.

El harness del Nivel 2 (06-evals) por ahora solo computa retrieval@k. Si querés agregar nDCG, está documentado al final de este capítulo cómo. Para uso típico, faithfulness y answer-relevance van a moverse cuando rerank funciona — tu RAG pasa a tener chunks más enfocados arriba → menos ruido → menos alucinación.

Workflow recomendado: corré la misma pregunta del eval set con y sin --rerank, compará contexts y faithfulness. O — mejor — corré el harness completo:

Ventana de terminal
pnpm --filter @rag-lab/06-evals run eval -- --only vercel-ai-sdk --top-k 4
# (no rerank — baseline)
# Para comparar con rerank, por ahora hay que invocar query.adv.ts
# directamente con cada pregunta del golden set. La integración del
# rerank en el adapter del harness es un ejercicio que queda pendiente
# (requiere modificar query.fn.ts para aceptar opciones avanzadas).

Apéndice: cómo cambiar a cross-encoder real

Sección titulada «Apéndice: cómo cambiar a cross-encoder real»

Si querés reemplazar LLM-as-reranker con un cross-encoder real:

  1. pnpm add @xenova/transformers onnxruntime-node en packages/01-vercel-ai-sdk.
  2. Crear src/retrievers/rerank-ce.ts con la misma signature (query, candidates, topK) → RerankHit[].
  3. Cargar el modelo: await AutoModel.from_pretrained('Xenova/bge-reranker-v2-m3') y AutoTokenizer.from_pretrained(...).
  4. Para cada candidato: tokenizar (query, candidate.preview), pasar al modelo, leer el logit como score.
  5. Sort por score, slice top-K.

Patrón completo: ~30 líneas. La primera vez baja el modelo (~270 MB) — después cachea local. Latencia: 30-80ms por par contra 200-400ms del LLM-as-reranker.

  • Latencia crítica (< 500ms total). Rerank duplica latencia mínimo.
  • Top-K = 1. Si solo querés el chunk más probable, retrieve te lo da. Rerank empieza a aportar desde top-3 en adelante.
  • Corpus muy chico (< 100 chunks). El retriever ya está cerca del techo; rerank es overkill.

Tenés un retriever fino (dense + hybrid) y un filtro fino (rerank). Pero hay otra dimensión: la pregunta misma. Si tu pregunta tiene varios temas, una sola búsqueda no alcanza. El siguiente capítulo: multi-query.