La búsqueda por vectores acierta el conjunto, falla el orden
Montas un RAG, la recuperación funciona y el sistema responde bien. Hasta que un día una pregunta clara devuelve una respuesta vaga. Miras los chunks que llegaron al contexto y el bueno estaba ahí, pero en el puesto ocho, por debajo de siete fragmentos que solo se parecían de lejos. El modelo leyó primero el ruido y contestó con lo que tenía a mano.
Ese es el punto ciego de la búsqueda por vectores: es excelente encontrando candidatos plausibles y mediocre ordenándolos. Recupera el conjunto correcto, pero el orden que te entrega no es el orden que necesitas. Y en un RAG el orden manda, porque el modelo pondera más lo que aparece arriba y porque el contexto tiene un límite: si el fragmento clave se cae del top-k, deja de existir.
Por qué el vector no puede afinar el orden
La búsqueda semántica compara un vector de la pregunta con un vector de cada chunk, calculados por separado y de antemano. Eso es justo lo que la hace rápida: los embeddings de tus documentos ya están en el índice, así que consultar es una comparación de distancias y poco más. Escala a millones de fragmentos sin despeinarse.
El precio de esa velocidad es que la pregunta y el chunk nunca se miran a la vez. Cada uno se comprime en un vector a solas, sin saber del otro. Los matices que distinguen “cómo instalar el driver” de “cómo desinstalar el driver” se difuminan en ese promedio. Para traer diez candidatos razonables sobra; para decidir cuál de esos diez responde de verdad a la pregunta, se queda corta.
Bi-encoder contra cross-encoder
El reranker resuelve exactamente eso. Es un segundo modelo que recibe la pregunta y un chunk juntos, en el mismo pase, y devuelve una puntuación de relevancia. Al ver los dos textos a la vez capta la interacción entre palabras que el embedding pierde. La diferencia con la búsqueda por vectores es de arquitectura, no de ajuste:
| Bi-encoder (búsqueda vectorial) | Cross-encoder (reranker) | |
|---|---|---|
| Cómo puntúa | Vector de la pregunta vs vector del chunk, por separado | Pregunta y chunk juntos en una sola pasada |
| Precálculo | Sí: los embeddings viven en el índice | No: puntúa en el momento de la consulta |
| Velocidad | Muy rápida, escala a millones | Lenta, una inferencia por candidato |
| Precisión del orden | Aproximada | Alta |
| Su papel | Traer los candidatos (recall) | Ordenar los candidatos (precisión) |
No compiten: se complementan. El bi-encoder hace la criba a gran escala y el cross-encoder afina el orden de los pocos que pasan. A esto se le llama pipeline de recuperación en dos fases, y es el patrón que uso por defecto en cuanto la calidad del orden empieza a importar.
Cómo encaja en el pipeline
La idea cabe en una frase: recupera de más y quédate con menos. Pides al vector más candidatos de los que vas a usar (por ejemplo, veinticinco), el reranker los puntúa uno a uno y te quedas con los cinco mejores para el contexto. La clave está en la brecha entre las dos cifras: ahí es donde el reranker rescata el chunk que el vector había enterrado.
from sentence_transformers import CrossEncoder
# El reranker se carga una vez, al arrancar el servicio, no por petición.
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
def recuperar(pregunta: str, indice, k_final: int = 5) -> list[dict]:
# Fase 1: el vector trae de más. Barato y con buen recall.
candidatos = indice.buscar(pregunta, top_k=25)
# Fase 2: el cross-encoder ve pregunta y chunk JUNTOS y puntúa cada par.
pares = [(pregunta, c["contenido"]) for c in candidatos]
puntuaciones = reranker.predict(pares)
# Reordenar por la puntuación del reranker, no por la distancia del vector.
for candidato, score in zip(candidatos, puntuaciones):
candidato["relevancia"] = float(score)
candidatos.sort(key=lambda c: c["relevancia"], reverse=True)
# Al contexto solo van los mejores. El resto se descarta.
return candidatos[:k_final]
Dos detalles que marcan la diferencia en producción. El primero: carga el modelo una sola vez al arrancar, nunca dentro de la función que atiende cada petición; instanciarlo por consulta convierte los milisegundos en segundos. El segundo: ordena por la puntuación del reranker y olvida la distancia del vector, que ya cumplió su función en la fase uno. Mezclar las dos señales suele empeorar el resultado.
El coste es latencia, y se paga por candidato
El reranker no sale gratis. Cada candidato es una inferencia completa del cross-encoder, así que el coste crece con el número de candidatos que le pasas. Reordenar veinticinco fragmentos no es lo mismo que reordenar doscientos: lo segundo dispara la latencia sin apenas mejorar la respuesta.
Ahí está el mando que de verdad controlas. Cuantos más candidatos entren a la fase dos, más probable es que el chunk correcto esté entre ellos, pero más tardas. En la práctica, recuperar entre veinte y cincuenta candidatos y quedarte con tres o cinco cubre la mayoría de casos. Si necesitas ir más allá, un reranker alojado como servicio (Cohere Rerank, Jina y similares) te quita el modelo de encima a cambio de una llamada de red y su factura. Mídelo con tus datos antes de decidir: la cifra buena es la tuya, no la del blog de nadie.
Cuándo merece la pena y cuándo no
El reranking no es obligatorio. Es la respuesta a un síntoma concreto: el vector trae el chunk correcto, pero no lo pone arriba. Si tu recall ya es malo (el fragmento bueno ni siquiera aparece entre los candidatos), un reranker no te salva, porque solo reordena lo que le llega; el problema está antes, en el chunking o en el propio embedding. Reordenar basura da basura ordenada.
Merece la pena cuando la base documental es grande y variada, cuando muchos fragmentos se parecen entre sí y el matiz decide, o cuando ya has medido que el recall es bueno pero la precisión del orden no. No merece la pena en un corpus pequeño donde casi todo cabe en el contexto, ni cuando la latencia es sagrada y cada milisegundo cuenta, ni antes de haber medido nada. Añadir un reranker “por si acaso” es complejidad que no has justificado.
Por dónde empezar
Si ya tienes un RAG que recupera de forma decente pero responde peor de lo que debería, prueba esto antes de tocar el modelo generativo. Recupera veinticinco candidatos en lugar de cinco, mete un cross-encoder pequeño como ms-marco-MiniLM entre la recuperación y el prompt, y quédate con los cinco mejores. Reutiliza el conjunto de preguntas de prueba que ya usabas para medir el chunking y compara: mira si el chunk correcto sube de posición y si la respuesta mejora.
El reranker es de esas piezas que no se ven en la demo y se notan en producción. No arregla un RAG roto; afina uno que casi funciona. Y esa distancia entre “casi” y “bien” es, muchas veces, la única que el usuario percibe.