Módulo 4: Re-ranking — la segunda etapa que transforma retrieval mediocre en excelente
Cápsula 04: LLM-based re-ranking — la opción cara cuando 3% de precision extra justifica el costo
Descripción de la cápsula
Cross-encoder es el default razonable para re-ranking. Pero existen casos donde su accuracy no alcanza: legal donde una respuesta incorrecta tiene consecuencias regulatorias, médico donde el costo de un falso positivo es clínico, financiero donde la información citada se audita. En esos casos vale subir un peldaño y usar el modelo más sofisticado disponible — un LLM de propósito general — como evaluador de relevancia.
LLM-based re-ranking no es magia. Toma cada par (query, documento) candidato y le pide a un LLM que asigne un score de relevancia. El modelo "lee" cada par como un humano lo haría: comprende la pregunta, lee el documento, evalúa si responde, devuelve un número. Es más caro y más lento que cross-encoder, pero gana 3-5% extra de precision en los casos difíciles donde cross-encoder fallaba.
Esta cápsula te enseña cuándo elegirlo, cómo implementarlo de forma robusta (incluyendo cómo evitar las trampas obvias del prompt mal diseñado), y cómo justificar el costo extra con números.
Al finalizar esta cápsula serás capaz de:
- ✅ Identificar los tres escenarios donde LLM re-ranking se justifica sobre cross-encoder
- ✅ Implementar LLM re-ranking con OpenAI usando structured outputs
- ✅ Diseñar el prompt de evaluación correctamente (criterios explícitos, escala calibrada)
- ✅ Calcular el costo total mensual del cambio para un volumen dado
- ✅ Anticipar la trampa más cara: scoring inconsistente entre llamadas (alta varianza)
- ✅ Diferenciar "LLM como reranker" (esta cápsula) de "LLM como retriever" (que NO recomendamos)
Tiempo estimado: 30-35 minutos
Cuándo elegir LLM re-ranking sobre cross-encoder
Cross-encoder local es la opción default por buenas razones: gratis, rápido (~150ms), 90%+ precision en casos típicos. LLM re-ranking solo se justifica si al menos uno de estos tres factores aplica:
Factor 1: dominio donde 3% extra de precision tiene impacto desproporcionado
| Dominio | Costo de un falso positivo |
|---|---|
| Asesoría legal | Información incorrecta citada en proceso judicial |
| Diagnóstico médico | Recomendación basada en literatura no aplicable al caso |
| Compliance financiero | Decisión regulatoria con datos parcialmente fuera de contexto |
| Información sobre seguridad de productos | Recomendación que omite advertencias críticas |
En estos contextos, pasar de 91% (cross-encoder) a 94% (LLM-based) significa reducir falsos positivos un 33%. Si tu producto procesa 10,000 queries por mes en un contexto crítico, esos 300 queries menos con respuesta dudosa pueden ser la diferencia entre "sistema confiable" y "sistema con responsabilidad legal".
Factor 2: queries semánticamente complejas que cross-encoder maneja peor
Cross-encoders están entrenados sobre datasets como MS MARCO — queries cortas, factuales, en inglés. Funcionan excelente para esos casos. Pero degradan cuando las queries son:
- Multi-paso o de razonamiento: "¿qué documentos discuten X considerando la restricción Y?"
- Comparativas: "¿cuál es la diferencia entre A y B según los autores Z?"
- Hipotéticas: "si tuviera escenario X, ¿qué documentos serían aplicables?"
- En idiomas no-ingleses: español jurídico, portugués técnico, etc.
Los LLMs grandes (GPT-4, Claude) razonan mejor sobre esas queries porque su entrenamiento cubre más diversidad lingüística y patrones de razonamiento.
Factor 3: el costo es trivial vs el valor del producto
Si tu producto cobra $500/mes por usuario y el costo extra de LLM re-ranking es $20/mes por usuario, el cálculo es obvio. Si cobras $5/mes, el costo extra puede ser prohibitivo.
Cálculo del costo extra:
# Costo aproximado por query con LLM re-ranking
# (asume reranking de top-20 candidatos con GPT-4o-mini)
QUERIES_PER_DAY = 1000
TOP_K_TO_RERANK = 20
TOKENS_PER_PAIR = 500 # query + chunk + prompt overhead
TOKENS_OUT_PER_PAIR = 5 # solo el score numérico
GPT_4O_MINI_INPUT_PER_MILLION = 0.15 # USD
GPT_4O_MINI_OUTPUT_PER_MILLION = 0.60 # USD
input_tokens_per_query = TOP_K_TO_RERANK * TOKENS_PER_PAIR
output_tokens_per_query = TOP_K_TO_RERANK * TOKENS_OUT_PER_PAIR
cost_per_query_input = (input_tokens_per_query / 1_000_000) * GPT_4O_MINI_INPUT_PER_MILLION
cost_per_query_output = (output_tokens_per_query / 1_000_000) * GPT_4O_MINI_OUTPUT_PER_MILLION
cost_per_query_total = cost_per_query_input + cost_per_query_output
monthly_cost = cost_per_query_total * QUERIES_PER_DAY * 30
print(f"Costo por query: ${cost_per_query_total:.5f}")
print(f"Costo mensual ({QUERIES_PER_DAY} queries/día): ${monthly_cost:.2f}")
Output:
Costo por query: $0.00154
Costo mensual (1000 queries/día): $46.20
Lectura: ~$46/mes en re-ranking para 30K queries. Si tu producto genera más valor que eso, la decisión es trivial. Si no, cross-encoder.
Implementación correcta con structured outputs
La implementación naive (la que dejaba la versión vieja de esta cápsula) tiene tres problemas: prompts ambiguos, parsing frágil de la respuesta, scoring inconsistente. Vamos a hacerla correctamente.
Setup con structured outputs
# llm_reranker.py
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List
import os
import time
class RelevanceScore(BaseModel):
score: float = Field(
ge=0.0, le=10.0,
description="Relevance score from 0 to 10"
)
reasoning: str = Field(
description="One-sentence justification for the score"
)
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
SYSTEM_PROMPT = """You are an expert relevance evaluator for retrieval systems.
Given a user query and a candidate document, score how well the document answers
the query on a scale of 0 to 10:
- 10: Document directly and completely answers the query with specific details
- 7-9: Document answers the query but with some gaps or generality
- 4-6: Document is on-topic but doesn't directly answer this specific query
- 1-3: Document mentions related concepts but isn't useful for this query
- 0: Document is completely unrelated
Be strict. Most documents should score 4-7. Reserve 9-10 for genuinely excellent matches.
Reserve 0-2 for clearly wrong matches.
Return JSON with score (number 0-10) and reasoning (one sentence)."""
def llm_rerank_pair(query: str, document: str, model: str = "gpt-4o-mini") -> RelevanceScore:
"""Score a single (query, document) pair."""
response = client.beta.chat.completions.parse(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f"Query: {query}\n\nDocument:\n{document[:1500]}",
},
],
response_format=RelevanceScore,
temperature=0.1, # baja para consistencia
)
return response.choices[0].message.parsed
Por qué response_format=RelevanceScore: garantiza que el LLM devuelva JSON parseable con los campos exactos. Sin esto, el modelo a veces responde con texto libre y el parsing falla.
Por qué temperature=0.1: re-ranking necesita consistencia. Si la misma query+doc da scores 8.5 y 6.2 en runs distintos, el ranking se vuelve inestable. Temperature baja (no 0 exacto, que bloquea structured outputs en algunos modelos) reduce esa varianza.
Por qué la guía explícita en el prompt: sin criterios claros, los modelos tienden a dar scores inflados (todo entre 7-9). El sistema queda mal calibrado y todos los docs parecen relevantes.
Función completa de re-ranking
import numpy as np
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
@dataclass
class RerankedDoc:
document: str
score: float
reasoning: str
original_rank: int
def llm_rerank(
query: str,
documents: List[str],
top_k: int = 5,
model: str = "gpt-4o-mini",
parallel_workers: int = 5,
) -> List[RerankedDoc]:
"""
Re-rank a list of candidate documents using an LLM.
Calls are parallelized via ThreadPoolExecutor.
"""
def score_one(args):
original_rank, doc = args
try:
result = llm_rerank_pair(query, doc, model=model)
return RerankedDoc(
document=doc,
score=result.score,
reasoning=result.reasoning,
original_rank=original_rank,
)
except Exception as e:
print(f"Failed to score doc {original_rank}: {e}")
return RerankedDoc(
document=doc,
score=0.0, # fallback: trata como irrelevante
reasoning=f"scoring failed: {e}",
original_rank=original_rank,
)
args = list(enumerate(documents))
with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
scored_docs = list(executor.map(score_one, args))
# Ordenar por score descendente
scored_docs.sort(key=lambda d: -d.score)
return scored_docs[:top_k]
Uso end-to-end
# pipeline_with_llm_rerank.py
import chromadb
from chromadb.utils import embedding_functions
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.getenv("OPENAI_API_KEY"),
model_name="text-embedding-3-small"
)
client_chroma = chromadb.PersistentClient(path="./chroma_db")
collection = client_chroma.get_collection("docs", embedding_function=openai_ef)
query = "How does HNSW handle 10M+ vectors with limited RAM?"
# Etapa 1: retrieval amplio
results = collection.query(query_texts=[query], n_results=20)
candidates = results['documents'][0]
# Etapa 2: re-ranking con LLM
start = time.perf_counter()
top_5 = llm_rerank(query, candidates, top_k=5, model="gpt-4o-mini")
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"Re-ranking took {elapsed_ms:.0f}ms (with parallel workers)")
print(f"\nTop 5 after LLM re-rank:")
for i, doc in enumerate(top_5, 1):
print(f"\n#{i} (score: {doc.score}, was rank #{doc.original_rank+1} before)")
print(f" Reasoning: {doc.reasoning}")
print(f" Doc: {doc.document[:120]}...")
Output típico:
Re-ranking took 1240ms (with parallel workers)
Top 5 after LLM re-rank:
#1 (score: 9.0, was rank #3 before)
Reasoning: Discusses HNSW memory scaling specifically with examples for 10M+ vectors.
Doc: HNSW graph memory scales linearly with M parameter. For 10M vectors with M=16...
#2 (score: 8.5, was rank #1 before)
Reasoning: Covers HNSW tuning for memory but focuses on smaller datasets (<1M).
Doc: Tuning HNSW parameters for production: lower M values reduce memory at cost...
#3 (score: 7.0, was rank #5 before)
Reasoning: Mentions IVF+PQ as alternative to HNSW for memory-constrained large datasets.
Doc: When HNSW exceeds available RAM, consider IVF+PQ which compresses vectors...
Nota los movimientos de ranking: el doc #3 original (que cosine puso tercero) sube a #1 después del re-ranking porque es el más específico sobre el caso "10M+ vectors with limited RAM". El doc #1 original (más general sobre HNSW tuning) baja a #2.
Diseñando el prompt: lo que hace la diferencia
El prompt es la pieza más sensible. Tres elementos críticos:
1. Criterios de scoring explícitos
❌ Mal: "Score how relevant this is on a scale of 0-10."
✅ Bien: "10: directly answers with specifics. 7-9: answers with gaps. 4-6: on-topic
but doesn't answer this specific query. 1-3: related concepts only. 0: unrelated."
Sin criterios, el LLM inventa su propia escala (típicamente inflada hacia 7-9).
2. Calibración de severidad
✅ "Be strict. Most documents should score 4-7. Reserve 9-10 for genuinely excellent matches."
Los LLMs por default son "amables" — todo merece 8/10. Pedir explícitamente severidad mejora la separación entre buenos y malos candidatos.
3. Razonamiento corto obligatorio
✅ Pedir reasoning: "score (number) + reasoning (one sentence)"
Forzar al LLM a justificar el score brevemente mejora la calidad del score (chain-of-thought implícito) y te da auditabilidad — puedes revisar por qué un doc específico se rankeó alto/bajo.
Prompt para queries en español
Si tu corpus y queries son en español, ajustar el prompt:
SYSTEM_PROMPT_ES = """Eres un evaluador experto de relevancia para sistemas de retrieval.
Dada una consulta del usuario y un documento candidato, evalúa qué tan bien el documento
responde la consulta en una escala de 0 a 10:
- 10: El documento responde directa y completamente la consulta con detalles específicos
- 7-9: El documento responde la consulta pero con algunas lagunas o generalidad
- 4-6: El documento está en el tema pero no responde esta consulta específica
- 1-3: El documento menciona conceptos relacionados pero no es útil para esta consulta
- 0: El documento es completamente irrelevante
Sé estricto. La mayoría de documentos deberían puntuar entre 4 y 7. Reserva 9-10 para
matches genuinamente excelentes. Reserva 0-2 para matches claramente equivocados.
Devuelve JSON con score (número 0-10) y reasoning (una oración justificando el score)."""
Nota que mantengo "JSON con score y reasoning" en español para que el modelo no mezcle idiomas en la salida.
Trampas y errores comunes
Trampa 1: temperature=0 con structured outputs
El error:
response = client.beta.chat.completions.parse(
...,
response_format=RelevanceScore,
temperature=0,
)
Síntoma: algunos modelos lanzan error porque temperature=0 deshabilita el sampling necesario para structured outputs.
Cómo prevenir: usar temperature=0.1 (suficientemente bajo para consistencia, suficientemente alto para que structured outputs funcione).
Trampa 2: llamadas secuenciales sin paralelismo
El error:
for doc in candidates:
score = llm_rerank_pair(query, doc) # secuencial
Síntoma: re-rankear 20 candidatos tarda 20 × 800ms = 16 segundos. Inutilizable en producción.
Cómo prevenir: ThreadPoolExecutor con 5-10 workers. Las llamadas son I/O bound (esperan a OpenAI), paralelizar es trivial. Latencia total baja a ~1.5 segundos.
Trampa 3: prompt sin criterios concretos
El error: prompt minimalista del estilo "Score this from 0-10".
Síntoma: todos los docs reciben scores entre 6 y 9. La separación entre relevantes e irrelevantes desaparece. El re-ranking no mejora porque casi todos quedan empatados.
Cómo prevenir: criterios explícitos + instrucción de severidad (cubierto arriba).
Trampa 4: pasar documentos enormes al LLM
El error:
content=f"Query: {query}\n\nDocument:\n{document}" # documento de 10K caracteres
Síntoma: costos por query se disparan (más tokens), latencia sube, y a veces el LLM se distrae con partes irrelevantes del documento.
Cómo prevenir: truncar documento a 1500-2000 caracteres. Si tus chunks son más grandes, considera chunkearlos antes de re-rankear, o tomar solo los primeros N caracteres. La info crítica suele estar al inicio del chunk.
content=f"Query: {query}\n\nDocument:\n{document[:1500]}"
Trampa 5: usar GPT-4 para reranking de chunks pequeños
El error: eliges GPT-4 (modelo grande) para todos los reranks "para máxima calidad".
Síntoma: costos son 10x más caros que GPT-4o-mini. La calidad de re-ranking entre los dos modelos es similar (5-10% diferencia), no justifica el 10x de costo.
Cómo prevenir: GPT-4o-mini es el sweet spot calidad/costo para re-ranking. GPT-4 solo para casos críticos donde el 5-10% extra de precision se justifica.
Trampa 6: no manejar fallos de la API
El error: una llamada falla con timeout/rate limit. El score queda como None. El sort posterior crashea.
Síntoma: un fallo en una de las 20 llamadas paralelas hace fallar el re-ranking entero.
Cómo prevenir: try/except en cada llamada con fallback razonable (score=0 = "trato como irrelevante, queda al final"). El pipeline continúa con los 19 docs scoreados correctamente.
try:
result = llm_rerank_pair(query, doc)
score = result.score
except Exception as e:
print(f"Failed to score doc: {e}")
score = 0.0 # fallback
Ejercicio aplicado
Escenario: eres Tech Lead en una empresa de jurisprudencia digital (búsqueda de casos legales). El sistema actual:
- 150K casos legales chunkeados, indexados con OpenAI text-embedding-3-large
- Cosine similarity + cross-encoder re-ranking (
ms-marco-MiniLM-L-12-v2) - 5,000 queries/día, principalmente abogados consultando precedentes
- Métricas actuales: precision@5 = 88%, latencia p95 = 600ms
El cliente premium pide: "Para casos críticos (litigios millonarios), necesitamos precision@5 ≥ 94%. Estamos dispuestos a pagar más por queries marcadas como 'high-stakes'."
Tu trabajo:
- Diseña la solución: ¿reemplazas cross-encoder con LLM-rerank? ¿Combinas los dos? ¿Routing por tipo de query?
- Estima costo extra mensual.
- Identifica un riesgo no obvio de tu solución.
Solución
1. Diseño de la solución: routing por importancia de query
No tiene sentido aplicar LLM re-ranking a todos los queries (5K/día = $230/mes solo en re-ranking, sin contar embeddings y generation). Mejor: routing condicional según marcador del cliente.
def rerank(query: str, candidates: list[str], stakes: str = "normal") -> list:
"""
Re-rank con tier según importancia de la query.
stakes:
- "normal": cross-encoder (default, cubre 95% de queries)
- "high": cross-encoder + LLM rerank en cascada (premium)
"""
if stakes == "normal":
# Pipeline actual
return cross_encoder_rerank(query, candidates, top_k=5)
elif stakes == "high":
# Cascada: cross-encoder primero, después LLM sobre top-10
cross_top_10 = cross_encoder_rerank(query, candidates, top_k=10)
cross_docs = [item.document for item in cross_top_10]
# LLM-rerank sobre los 10 mejores del cross-encoder
return llm_rerank(query, cross_docs, top_k=5, model="gpt-4o")
Por qué cascada en lugar de LLM directo:
- Cross-encoder primero filtra los obviamente irrelevantes (10 mejores de 30 candidatos).
- LLM rerank solo sobre los 10 finalistas — menos llamadas, mismo resultado.
- Si LLM falla, fallback al ranking del cross-encoder.
Ejemplo de uso:
@app.post("/search")
async def search(query: str, stakes: str = "normal"):
# Validar permiso para "high stakes" (solo plan premium)
if stakes == "high" and not user.is_premium:
stakes = "normal"
# Pipeline
candidates = vector_db.query(query, n_results=30)
top_5 = rerank(query, candidates, stakes=stakes)
answer = llm_generate(query, top_5)
return answer
2. Estimación de costo extra
Asumiendo:
- 10% de queries de clientes premium son marcadas "high stakes" → 500 queries/día
- Re-ranking con GPT-4o (modelo más caro pero de mejor calidad para casos legales)
- 10 candidatos a re-rankear por query (después del cross-encoder)
- ~600 tokens por par (query legal + chunk + prompt)
DAILY_HIGH_STAKES = 500
PAIRS_PER_QUERY = 10
TOKENS_IN_PER_PAIR = 600
TOKENS_OUT_PER_PAIR = 30
# GPT-4o pricing (mayo 2026)
INPUT_PER_MILLION = 2.50 # USD
OUTPUT_PER_MILLION = 10.00 # USD
input_tokens_daily = DAILY_HIGH_STAKES * PAIRS_PER_QUERY * TOKENS_IN_PER_PAIR
output_tokens_daily = DAILY_HIGH_STAKES * PAIRS_PER_QUERY * TOKENS_OUT_PER_PAIR
cost_input = (input_tokens_daily / 1_000_000) * INPUT_PER_MILLION
cost_output = (output_tokens_daily / 1_000_000) * OUTPUT_PER_MILLION
daily_cost = cost_input + cost_output
monthly_cost = daily_cost * 30
print(f"Daily cost: ${daily_cost:.2f}")
print(f"Monthly cost: ${monthly_cost:.2f}")
Output:
Daily cost: $9.00
Monthly cost: $270.00
$270/mes extra para 15,000 queries high-stakes/mes. Si el cliente premium paga $500-1000/mes por la feature, el margen es saludable.
Plan de validación de precision:
- Construir golden set de 50 queries legales high-stakes con ground truth (anotadas por abogados senior).
- Medir precision@5 con el pipeline actual (88%) vs cascada cross-encoder + LLM (esperado 94-96%).
- Si llega a 94%, deployar. Si no, iterar el prompt o subir a GPT-4 (más caro, solo si necesario).
3. Riesgo no obvio: latencia y experiencia de usuario
El cambio aumenta latencia de queries high-stakes:
- Pipeline actual: ~600ms p95
- Pipeline con LLM rerank en cascada: ~600ms (cross-encoder) + ~1500ms (LLM) = ~2100ms p95
1.5 segundos extra son muy notables para el usuario. Los abogados están acostumbrados a Google y Westlaw que responden <500ms. Saltar a 2 segundos puede generar percepción de "el sistema está lento" aunque la calidad sea mejor.
Mitigación:
- UX explícita: cuando el usuario marca "high stakes", mostrar mensaje "Análisis profundo en curso (puede tardar unos segundos)..." — convierte la espera en feature, no bug.
- Streaming: mostrar los resultados del cross-encoder primero (rápido), después actualizar con los del LLM rerank cuando lleguen. UX progresiva.
- Async / background: para queries muy críticas, ofrecer modo "análisis exhaustivo" que tarda 5-10s pero usa GPT-4 con razonamiento extendido. Diferenciación de producto.
Otro riesgo más sutil: cobrar por queries puede modificar comportamiento del usuario. Si abogados saben que cada query high-stakes cuesta más, pueden:
- Sub-utilizarlo para casos donde sí lo necesitan (pierdes calidad de servicio).
- Sobre-utilizarlo y discutir factura.
Mitigación: integrar en plan premium con "queries high-stakes ilimitadas", no cobrar por query individual. Más simple para el cliente, más previsible para ti.
Resumen y siguiente paso
Lo que aprendiste:
- LLM re-ranking gana 3-5% extra de precision sobre cross-encoder a costa de 5-10x más latencia y costo monetario.
- Solo se justifica en tres escenarios: dominios críticos (legal/médico/financiero), queries semánticamente complejas, o cuando el costo es trivial vs el valor del producto.
- Implementación correcta requiere: structured outputs (parsing seguro), prompt con criterios explícitos y severidad calibrada, paralelismo via ThreadPoolExecutor, manejo de fallos.
- GPT-4o-mini es el sweet spot calidad/costo. GPT-4 solo si el 5% extra justifica el 10x de costo.
- Cascada cross-encoder → LLM es más eficiente que LLM solo: el cross-encoder filtra primero, el LLM refina los finalistas.
- Routing por tipo de query (normal vs high-stakes) permite ofrecer LLM rerank como feature premium sin disparar costos generales.
Checkpoint: antes de avanzar, deberías poder:
- Calcular el costo mensual de agregar LLM re-ranking para un volumen dado.
- Diseñar un prompt con criterios explícitos y severidad calibrada.
- Identificar cuándo cross-encoder es suficiente vs cuándo LLM-rerank se justifica.
Siguiente cápsula: 05 — Cohere Rerank API.
Cubrimos cross-encoder (default) y LLM-based (premium). La cápsula 05 cubre la tercera opción: managed rerankers como Cohere Rerank — APIs especializadas que no son LLMs generales pero ofrecen calidad cercana sin requerir infrastructure local. Es la opción intermedia: mejor que cross-encoder local en algunos casos, más barata que LLM, sin necesidad de mantener modelos.
Recursos
- OpenAI — Structured Outputs — Documentación oficial de structured outputs con Pydantic
- OpenAI — Pricing Calculator — Precios actualizados
- Anthropic — Claude as a Reranker — Caso de uso de Claude para re-ranking
- LlamaIndex — LLM Reranker Implementation — Patrón completo en LlamaIndex
- Why Use an LLM for Reranking? (Pinecone) — Comparación de métodos con benchmarks
- BEIR Benchmark — Reranking Comparisons — Resultados empíricos de distintos rerankers
Tiempo estimado: 30-35 minutos Siguiente: 05-cohere-rerank.md