Reclasificación en RAG: Codificadores cruzados vs. evaluadores de LLM y su latencia
Cómo un reordenador de segunda etapa corrige el orden de los candidatos en RAG, comparando codificadores cruzados con jueces basados en LLM, los costos de latencia, los modos de fallo y cómo evaluarlos.
Fuentes: Reranking for RAG: Cross-Encoders, LLM Rerankers, and Latency Tradeoffs
El fragmento correcto estaba en los resultados, solo que demasiado abajo
Imagínese un sistema RAG cuya búsqueda híbrida finalmente ha comenzado a funcionar correctamente. El almacén de vectores está activo, el índice de palabras clave funciona, la lógica de fusión está implementada, y el fragmento que contiene la respuesta ahora aparece en algún lugar del conjunto de candidatos. Aun así, el modelo sigue generando una respuesta mediocre.
Los registros explican por qué. Un desarrollador preguntó cómo depurar un error PAYMENTS_API_TIMEOUT en el entorno de pruebas. La búsqueda híbrida devolvió veinte candidatos: un resumen general sobre autenticación, una nota de incidente desactualizada del año anterior, una página genérica sobre lógica de intentos, y, en el octavo lugar, el fragmento del manual de operaciones del entorno de pruebas con la solución exacta. El prompt solo tenía espacio para tres fragmentos. El correcto estaba demasiado abajo, por lo que el modelo construyó su respuesta a partir del material equivocado, ya que el material adecuado no cabía.
La recuperación encontró las pruebas pero no las colocó en primer lugar. La recuperación se refiere a la capacidad de recordar, mientras que el reordenamiento se centra en la precisión dentro de un límite estricto de recursos.
Un buen conjunto de candidatos por sí solo no es suficiente. Lo que realmente lee el modelo depende del orden de dicho conjunto, y ese orden es el cuello de botella.
Qué hace la etapa de reordenamiento
La primera pasada está diseñada para ser rápida y amplia. Una consulta en una base de datos vectorial o un índice de palabras clave debe abarcar millones de registros en milisegundos; por eso estos sistemas dependen de aproximaciones e índices precalculados, y recuperan intencionadamente más de lo necesario.
El reclasificado añade una segunda etapa que selecciona un pequeño subconjunto de resultados y los examina con más detenimiento. Es más lento pero más preciso, y nunca accede a la base de datos. Todo lo que devuelva la primera pasada, generalmente los veinte o cincuenta primeros resultados, es recalculado mediante un modelo más potente y preciso.
La primera pasada genera candidatos, y el reclasificado decide cuáles de ellos merecen un lugar dentro de la ventana de contexto limitada.
Por qué la primera pasada no puede completar la tarea
Si el modelo de incrustación es bueno, ¿no debería el mejor fragmento aparecer en primer lugar? No de forma fiable, ya que la recuperación en la primera pasada es, por su propia naturaleza, imprecisa.
La puntuación de palabras clave como BM25 depende de la superposición exacta de términos. Una página de referencia extensa que menciona un código de error cincuenta veces suele superar a un manual breve paso a paso que solo lo menciona dos veces, ya que el algoritmo interpreta la frecuencia como relevancia.
La recuperación de vectores presenta ruidos de otra manera. Las embeddings miden la cercanía semántica al colocar los conceptos en un espacio de alta dimensión. Ante una pregunta de solución de problemas, estas muestran fragmentos relacionados con los sistemas mencionados. Las descripciones generales tienden a encontrarse muy cerca de procedimientos específicos en ese espacio. Un fragmento que describe la arquitectura de facturación podría tener una puntuación más alta que la orden exacta para reiniciar el proceso de facturación. El modelo sabe que ambos temas están relacionados con la facturación, pero no puede distinguir entre una instrucción operativa y una descripción teórica.
La recuperación híbrida combina las listas léxicas y semánticas en un conjunto completo de candidatos que incluye las pruebas necesarias. También incluye duplicados, documentos obsoletos, resúmenes generales y fragmentos que solo son parcialmente relevantes.
La recuperación en la primera pasada nunca debe considerarse como la última capa de clasificación. Funciona como un embudo, y la reclasificación es el filtro en la parte inferior.
Dos formas de reordenar
Una vez que existen los candidatos, es necesario ordenarlos. Dos enfoques se han convertido en estándar. Uno utiliza cross-encoders, modelos especializados entrenados para evaluar cuán relevante es un documento para una consulta. El otro emplea grandes modelos de lenguaje, donde un modelo como Claude o GPT recibe la consulta y el documento y se le pregunta cuán útil es el documento.
Ambos mejoran significativamente la calidad del ranking en comparación con el primer proceso. Se diferencian en latencia, costo, determinismo y los requisitos técnicos que exigen.
Cross-encoders
Un cross-encoder es un modelo transformer, aunque funciona de manera diferente a los modelos de embedding en una base de datos vectorial.
Un modelo de embedding estándar pasa la consulta a través de la red para obtener un vector, pasa el documento para obtener otro y compara los dos mediante similitud coseno. Este es el diseño de bi-encoder, y es rápido porque cada vector de documento puede calcularse por adelantado.
Un cross-encoder no genera vectores separados. Une la consulta y el documento en una sola cadena y ejecuta todo el proceso a través de la red de una sola vez. Por lo tanto, la autoatención puede analizar las palabras de la consulta y las palabras del documento juntas en cada capa, observando cómo se relacionan los términos en el contexto. El resultado es una única puntuación de relevancia.
Este diseño destaca en decisiones de relevancia de alto nivel de detalle. Los resultados son predecibles y estables, con una dispersión mucho menor que la que se obtiene al solicitarle algo a un modelo de lenguaje, y prefiere de manera fiable un fragmento operativo específico en lugar de un resumen amplio.
Aquí hay una función de reclasificación desarrollada sobre una API de codificador cruzado alojada.
import cohere
import time
def rerank_cross_encoder(
query: str,
candidates: list[dict],
top_n: int = 5,
model: str = "rerank-v4.0-pro",
) -> list[dict]:
"""
The practical default for second-stage ranking.
Passes the query and candidate texts to a dedicated cross-encoder model.
"""
co = cohere.ClientV2()
# Extract just the text content for the API call
documents = [c["content"] for c in candidates]
resp = co.rerank(
model=model,
query=query,
documents=documents,
top_n=top_n,
)
# Reattach the original metadata and the new score
reranked = []
for r in resp.results:
original_chunk = candidates[r.index]
reranked.append({
**original_chunk,
"rerank_score": r.relevance_score
})
return reranked
En entornos de producción, un codificador cruzado es adecuado para los diez a cincuenta candidatos principales. Los equipos lo utilizan por defecto en búsquedas de documentación para ingenieros, bases de conocimiento de soporte técnico y copilotos internos.
El principal costo es la velocidad. Un codificador cruzado no puede escanear toda una base de datos; espera a la primera pasada, recibe los candidatos a través de una API o un modelo local y luego espera los resultados, lo que añade un salto de red y un paso de cálculo.
Reclasificadores de LLM
La alternativa es un modelo de lenguaje de uso general que actúa como juez. Un prompt estricto contiene la consulta del usuario y un fragmento de un candidato, y el modelo devuelve una puntuación.
Los reordenadores de LLM manejan bien las sutilezas. Un codificador cruzado busca una relevancia semántica profunda, mientras que un LLM también puede verificar si un procedimiento está completo, razonar sobre evidencias contradictorias y separar los fragmentos con pasos reales de aquellos que solo contienen información de contexto. El prompt puede indicarle que penalice la vaguedad o favorezca los fragmentos que incluyen ejemplos de código.
Este prompt para el juez utiliza la interfaz de cliente de Anthropic.
import anthropic
JUDGE_PROMPT = """\
You are a strict relevance judge. Given a user query and a candidate document chunk,
rate how well the chunk answers the query on a scale of 0 to 10.
Respond with ONLY a JSON object in this exact format:
{"score": <int>, "reason": "<one short sentence>"}
Query: {query}
Chunk: {chunk}"""
async def rerank_llm(
query: str,
candidates: list[dict],
top_n: int = 5,
) -> list[dict]:
"""
Expensive special forces. Uses an LLM to reason about nuance and completeness.
"""
client = anthropic.AsyncAnthropic()
scored = []
for c in candidates:
resp = await client.messages.create(
model="claude-opus-4-6",
max_tokens=128,
messages=[{
"role": "user",
"content": JUDGE_PROMPT.format(query=query, chunk=c["content"]),
}],
)
import json
try:
result = json.loads(resp.content[0].text)
scored.append({
**c,
"rerank_score": result["score"],
"reason": result.get("reason", "")
})
except (json.JSONDecodeError, KeyError):
# Fallback if the model fails to follow JSON instructions
scored.append({**c, "rerank_score": 0, "reason": "parse_error"})
# Sort by the LLM-assigned score descending
scored.sort(key=lambda x: x["rerank_score"], reverse=True)
return scored[:top_n]
Se le pide al modelo que indique una razón junto con la puntuación. Escribir primero una justificación funciona como un proceso de razonamiento secuencial y estabiliza el número final.
Ellos ganan su sustento con consultas de alto valor, en sistemas de bajo tráfico donde la precisión de la respuesta justifica una espera, y en corpus complejos donde la relevancia depende de matices en las políticas y no solo de una coincidencia temática.
Los costos son elevados. Se realiza una llamada completa al LLM para cada fragmento candidato, y aunque las llamadas se ejecutan de forma concurrente, la respuesta más lenta determina el resultado. El costo es mucho mayor que el de un codificador cruzado, y la salida es menos determinista.
El efecto del reordenamiento en la latencia
Una etapa adicional cambia la forma en que funciona el sistema. Implica llamadas adicionales al modelo, serialización adicional de los datos de texto y tiempo adicional en la red.
A los usuarios no les importa cómo se organiza la recuperación de información ni perciben las métricas de recordación. Lo que notan es la calidad de las respuestas y el tiempo de respuesta. Un reordenador teóricamente excelente resulta inútil si hace que el asistente sea demasiado lento como para ser confiable.
Este complemento mide el tiempo que tarda una función de reordenamiento.
def rerank_with_timing(
rerank_fn: callable,
query: str,
candidates: list[dict],
top_n: int = 5,
) -> tuple[list[dict], float]:
"""
Measure the exact cost of the reranking stage.
"""
t0 = time.perf_counter()
results = rerank_fn(query, candidates, top_n)
latency_ms = (time.perf_counter() - t0) * 1000
return results, latency_ms
Enviar veinte candidatos a una API de codificador cruzado rápido puede añadir entre 150 y 300 milisegundos, lo cual suele ser un precio razonable a cambio de un mejor contexto. Enviar cincuenta a un modelo LLM puede añadir de dos a cuatro segundos. Sumando otros tres segundos para la generación, el usuario espera siete segundos, lo cual supera el límite aceptable en una interfaz de chat.
La cantidad de candidatos sometidos a reclasificación necesita ajuste. Los diez mejores son rápidos, pero pueden pasar por alto la información relevante cuando la primera pasada tiene dificultades. Los cincuenta mejores capturan casi todo, pero agotan todo el tiempo de latencia disponible. Para un codificador cruzado en producción, los veinte mejores suelen ser el punto óptimo.
Cuándo vale la pena reclasificar
Añada un sistema de reclasificación cuando la recuperación en la primera pasada sea imprecisa, pero normalmente encuentre las pruebas necesarias.
Si las huellas muestran que el fragmento de evidencia exacto aparece con frecuencia entre los candidatos pero en el puesto diez o quince, el reclasificador reparará el sistema de inmediato. Está justificado cuando los documentos generales superan rutinariamente a los procedimientos precisos, y cuando la calidad de la respuesta depende en gran medida de contar con los tres mejores fragmentos en la solicitud, por lo que la precisión prevalece sobre la mera recuperación de información.
Los manuales de operaciones, los asistentes de políticas internas y las herramientas de resolución de problemas casi siempre necesitan un reclasificador. Elegir el comando correcto en lugar de uno cercano puede determinar si un incidente se resuelve o se convierte en una interrupción del servicio.
Cuando el reclasificador es un exceso
Con un corpus muy pequeño, omítalo. Una base de conocimientos de cincuenta páginas puede integrarse completa y ser consultada mediante búsqueda vectorial.
También es inútil cuando la primera pasada ya es casi perfecta. En un sistema con muchas búsquedas donde BM25 coloca de forma fiable la referencia API correcta en el primer lugar, un codificador cruzado solo ralentiza el proceso sin cambiar el resultado.
Un presupuesto de latencia muy ajustado, como en un asistente de voz o en la completación de código en línea, no deja espacio para una etapa adicional. Ajuste la primera pasada y acepte cierto nivel de ruido. Y cuando el fragmento adecuado suele aparecer ya en el primer o segundo lugar, la clasificación no es lo que lo impide.
Modos de fallo a vigilar
Los reclasificadores corrigen el orden pero introducen sus propios problemas. Esté atento a estos después del despliegue.
Prejuicio por resúmenes amplios. Algunos codificadores cruzados se entrenan con datos que premian respuestas exhaustivas. Un resumen genérico puede obtener una puntuación más alta que un fragmento de código pequeño y preciso, ya que el resumen comparte más vocabulario con la consulta.
Hacinamiento por duplicados. Si el corpus contiene tres versiones de una misma guía de implementación, la primera pasada probablemente recuperará las tres. El reclasificador considera que cada una responde bien a la consulta y les asigna una puntuación alta. Los tres primeros espacios en la ventana de contexto entonces contienen la misma información, dejando fuera el siguiente dato distinto. Una herramienta como esta elimina los duplicados casi idénticos.
def dedupe_candidates(
candidates: list[dict],
similarity_threshold: float = 0.85,
) -> list[dict]:
seen_tokens: list[set[str]] = []
deduped = []
for c in candidates:
tokens = set(c["content"].lower().split())
is_dup = False
for s in seen_tokens:
# Calculate simple Jaccard similarity
overlap = len(tokens & s) / max(len(tokens | s), 1)
if overlap >= similarity_threshold:
is_dup = True
break
if not is_dup:
deduped.append(c)
seen_tokens.append(tokens)
return deduped
Preferencia por documentos obsoletos. Un codificador cruzado solo lee el texto y no tiene idea de en qué año se encuentra. Un manual actualizado de 2022 puede obtener mejores puntuaciones que uno elaborado apresuradamente en 2026, aun cuando el documento más antiguo esté completamente desactualizado. Los ajustes de metadatos aplicados después de la reclasificación modifican las puntuaciones en función de la actualidad y el estado del documento.
from datetime import datetime, timezone
def apply_metadata_boost(
candidates: list[dict],
freshness_halflife_days: int = 90,
) -> list[dict]:
now = datetime.now(timezone.utc)
boosted = []
for c in candidates:
score = c.get("rerank_score", 0.0)
# Hard penalty for superseded documentation
if c.get("status") == "superseded":
score *= 0.4
# Gradual decay for older documents
updated = c.get("updated_at")
if updated:
age_days = (now - updated).days
decay_factor = max(0.5, 1 - age_days / (freshness_halflife_days * 2))
score *= decay_factor
boosted.append({**c, "rerank_score": score})
# Sort again based on the adjusted scores
boosted.sort(key=lambda x: x["rerank_score"], reverse=True)
return boosted
Explosión de latencia. La calidad mejora después del despliegue, pero de vez en cuando el sistema se detiene durante cinco segundos. Esto suele ocurrir cuando aumenta drásticamente la cantidad de candidatos o cuando la API externa de reclasificación funciona mal. Siempre se debe incluir una solución de respaldo con tiempo de espera que omita el reclasificador y devuelva los resultados de la primera pasada cuando la segunda etapa dure demasiado.
Evaluación adecuada de un reclasificador
Los equipos a menudo miden lo incorrecto, comprobando si el fragmento adecuado aparece en algún lugar de los veinte primeros resultados. La recuperación en la primera pasada ya cubre eso.
La pregunta más relevante es si el reclasificador elevó lo suficientemente alto al fragmento correcto como para que sea significativo. Eso implica medir la relevancia en los primeros 1 y 3 resultados, la completitud de las respuestas y la supresión de duplicados, además de hacer un seguimiento del sobrecargo de latencia y del costo por consulta.
Construya un conjunto de herramientas de evaluación que compare la línea de base de primera pasada simple con el codificador cruzado y el reordenador de LLM, utilizando conjuntos idénticos de candidatos.
from dataclasses import dataclass
@dataclass
class RerankEvalCase:
query: str
expected_substring: str
category: str # e.g., "identifier", "procedure", "troubleshooting"
def eval_reranking(
cases: list[RerankEvalCase],
retrieve_fn: callable,
rerank_fns: dict[str, callable | None],
top_n: int = 3,
) -> dict:
"""
Compare multiple reranking strategies against a baseline.
Measures hit rate at top-N and tracks latency overhead.
"""
results = {}
for name, rerank_fn in rerank_fns.items():
hits = 0
total_latency = 0.0
by_type: dict[str, dict] = {}
for case in cases:
# Get the exact same starting candidates for every strategy
candidates = retrieve_fn(case.query)
if rerank_fn is not None:
reranked, lat = rerank_with_timing(
rerank_fn, case.query, candidates, top_n
)
total_latency += lat
else:
# Baseline: just take the top-N from first-pass retrieval
reranked = candidates[:top_n]
# Check if the expected evidence made it into the final prompt window
top_contents = [r["content"] for r in reranked]
found = any(case.expected_substring in c for c in top_contents)
hits += int(found)
# Track metrics by query category
by_type.setdefault(case.category, {"hit": 0, "total": 0})
by_type[case.category]["total"] += 1
by_type[case.category]["hit"] += int(found)
total = len(cases)
results[name] = {
"hit_rate": hits / total if total else 0,
"avg_latency_ms": total_latency / total if total else 0,
"by_type": {
t: {**v, "rate": v["hit"] / v["total"]}
for t, v in by_type.items()
},
}
return results
El conjunto de pruebas debe incluir varios tipos de consultas: aquellas con muchos identificadores donde cuenta la cadena exacta, aquellas que siguen un procedimiento donde cuenta el orden de los pasos, y aquellas de resolución de problemas donde los síntomas indican las causas raíz.
Los números muestran con precisión qué obtiene el reordenador. Si un codificador cruzado aumenta la tasa de resultados entre los 3 mejores del 60 % al 85 % en 200 milisegundos, debería utilizarse. Si el reordenador basado en LLM alcanza el 88 % en tres segundos, es probable que el codificador cruzado siga siendo el mejor opción. El reordenamiento debe justificarse con respuestas finales mejores dentro de un presupuesto de latencia realista.
Una pila por defecto sensata
Una línea de base fiable para un sistema de producción sigue una secuencia específica. Divida los documentos mediante particionamiento consciente de la estructura. Utilice una recuperación híbrida para recopilar de veinte a treinta candidatos. Elimine los textos casi idénticos mediante deduplicación. Evalúe el resto con un codificador cruzado para determinar su relevancia semántica. Aplique un refuerzo de metadatos que penalice a los documentos obsoletos. Luego, tome los tres a cinco fragmentos principales de la lista final ordenada y utilícelos para crear la instrucción.
def two_stage_retrieve(
query: str,
retrieve_fn: callable,
top_k: int = 20,
top_n: int = 5,
) -> tuple[list[dict], dict]:
"""
The complete production pipeline for second-stage ranking.
"""
t0 = time.perf_counter()
# Stage 1: Fast hybrid retrieval
candidates = retrieve_fn(query)[:top_k]
# Clean up the candidate pool
candidates = dedupe_candidates(candidates)
# Stage 2: Cross-encoder rerank
# We score slightly more than top_n to allow metadata boosts to reorder the edges
score_limit = min(top_n * 2, len(candidates))
reranked = rerank_cross_encoder(query, candidates, top_n=score_limit)
# Apply business logic for freshness and status
final = apply_metadata_boost(reranked)[:top_n]
latency = (time.perf_counter() - t0) * 1000
trace = {
"query": query,
"first_pass_count": len(candidates),
"post_rerank_count": len(reranked),
"final_count": len(final),
"latency_ms": latency,
}
return final, trace
El método predeterminado funciona porque cada herramienta realiza la tarea para la que está diseñada. La recuperación se encarga de la recuperación de candidatos, el reordenamiento gestiona el orden de los candidatos, y los codificadores cruzados ofrecen la mejor relación calidad-latencia para esa segunda etapa.
Una advertencia: no se debe añadir un reordenador simplemente para corregir datos defectuosos. Con un particionamiento incorrecto, solo ordenará correctamente los fragmentos defectuosos. Si faltan metadatos, no podrá adivinar qué documento está obsoleto. Corrija primero la ingestión y la extracción.
Lo que el reordenamiento aún deja sin resolver
Un reordenador se asegura de que la evidencia más sólida del conjunto recuperado llegue al modelo. Lo que no puede indicarle es si una búsqueda vectorial fue realmente la opción adecuada. Tome como ejemplo una pregunta sobre cuántos usuarios están activos en una región específica: dirigirla a un índice de documentación es incorrecto, ya que esa cifra se encuentra en una base de datos SQL.
La calidad de la recuperación de información tiene diferentes niveles. La primera fase localiza el texto y la segunda lo organiza, pero antes de ambas se toma una decisión sobre dónde buscar. Dirigir una solicitud entre búsquedas vectoriales, consultas SQL y búsquedas web en tiempo real, según la intención del usuario, es el nivel que precede a todo lo anterior.