Módulo 4: Re-ranking — la segunda etapa que transforma retrieval mediocre en excelente
Cápsula 06: Trade-offs y optimizaciones operativas del re-ranking
Descripción de la cápsula
Las cápsulas anteriores cubrieron qué técnica de re-ranking elegir (cross-encoder, LLM, Cohere). Pero hay decisiones operativas que afectan la performance independientemente de qué técnica uses: cuántos candidatos pasar al re-ranker, cuándo re-rankear vs cuándo confiar en el retrieval directo, qué cachear, cómo paralelizar.
Estas optimizaciones pueden mejorar 30-50% la latencia o reducir 70% el costo sin cambiar la técnica subyacente. En sistemas en producción, son lo que separa "re-ranking funciona pero es lento" de "re-ranking es rápido y barato". Esta cápsula te da el playbook operativo para sacarle el máximo a la técnica que elijas.
Al finalizar esta cápsula serás capaz de:
- ✅ Calcular el
n_resultsóptimo del retrieval inicial según el tipo de re-ranker - ✅ Diseñar caché de resultados de re-ranking para queries repetidas
- ✅ Aplicar score thresholds para descartar resultados de baja relevancia
- ✅ Identificar queries donde re-ranking no aporta valor (skip dinámico)
- ✅ Paralelizar correctamente cuando el re-ranker lo permite
- ✅ Anticipar la trampa: optimizar latencia rompiendo accuracy en silencio
Tiempo estimado: 25-30 minutos
Optimización 1: el n_results correcto del retrieval inicial
La pregunta clave: ¿cuántos candidatos pasar del retrieval al re-ranker?
cosine retrieval re-ranker
[corpus] ────────────────> [top-N candidates] ───────> [top-K final]
N = ? K = 5
Demasiado bajo (N=10): pierdes docs buenos antes del rerank
Demasiado alto (N=200): re-ranking se vuelve lento y caro
Sweet spot: N = 20-50 (dependiendo del corpus y técnica)
Cómo encontrar el N óptimo empíricamente
# benchmark_n_results.py
import time
import statistics
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")
def benchmark_n_results(query, eval_set, n_values=[5, 10, 20, 50, 100, 200]):
"""
Mide precision@5 vs latency para distintos valores de n_results del retrieval.
"""
results = []
for n in n_values:
latencies = []
precisions = []
for item in eval_set:
# Retrieval con N candidatos
start = time.perf_counter()
candidates = collection.query(
query_texts=[item["query"]], n_results=n
)['documents'][0]
# Re-rank
pairs = [(item["query"], doc) for doc in candidates]
scores = reranker.predict(pairs)
# Top 5 final
import numpy as np
top_5_idx = np.argsort(scores)[::-1][:5]
top_5_docs = [candidates[i] for i in top_5_idx]
latencies.append((time.perf_counter() - start) * 1000)
# Precision: ¿están los expected_doc_ids en top 5?
# (asumes que tu eval set tiene mapping doc → id)
hits = sum(1 for doc in top_5_docs if doc in item["expected_chunks"])
precisions.append(hits / 5)
results.append({
"n_results": n,
"p95_latency_ms": sorted(latencies)[int(len(latencies) * 0.95)],
"avg_precision_at_5": statistics.mean(precisions),
})
return results
Output típico (cross-encoder local, dataset técnico):
n_results= 5: p95=210ms, precision@5=0.74
n_results= 10: p95=240ms, precision@5=0.85
n_results= 20: p95=320ms, precision@5=0.91 ← sweet spot
n_results= 50: p95=580ms, precision@5=0.92 ← retornos decrecientes
n_results=100: p95=1100ms, precision@5=0.92 ← desperdicio
n_results=200: p95=2200ms, precision@5=0.92 ← inutilizable
Lecturas:
- De N=5 a N=20: precision sube 17 puntos. Cada candidato extra paga.
- De N=20 a N=50: precision sube solo 1 punto. Latencia casi dobla. Mal trade.
- N>50: retornos cero, latencia explota.
Sweet spot por técnica:
| Re-ranker | N óptimo típico | Razón |
|---|---|---|
| Cross-encoder local | 20-30 | Procesamiento rápido, vale tener más candidatos |
| Cohere Rerank | 25-50 | Costo lineal por documento; balance precision/cost |
| LLM rerank (GPT-4o-mini) | 10-20 | Cada par cuesta dinero; ser más selectivo |
| LLM rerank (GPT-4) | 5-10 | Caro y lento, solo para los más probables |
Regla: empezar en N=20, ajustar empíricamente sobre eval set.
Optimización 2: caché de resultados
En producción, las queries no son aleatorias. Hay un patrón Pareto: ~20% de queries únicas representan ~80% del tráfico. Cachear resultados de queries repetidas es la optimización con mejor ratio mejora/esfuerzo.
Implementación con LRU cache (in-memory)
# rerank_cache.py
import hashlib
from functools import lru_cache
from typing import Tuple
def query_cache_key(query: str, n_candidates: int) -> str:
"""Key estable para caché basada en query y configuración."""
raw = f"{query.strip().lower()}|{n_candidates}"
return hashlib.md5(raw.encode()).hexdigest()
# Cache con tamaño configurable
_RERANK_CACHE: dict[str, list] = {}
_MAX_CACHE_SIZE = 10_000
def cached_rerank_pipeline(query: str, top_k: int = 5):
"""Pipeline completo con caché de resultados de re-ranking."""
cache_key = query_cache_key(query, n_candidates=20)
# Cache hit
if cache_key in _RERANK_CACHE:
return _RERANK_CACHE[cache_key][:top_k]
# Cache miss: ejecutar pipeline completo
candidates = collection.query(query_texts=[query], n_results=20)['documents'][0]
reranked = cross_encoder_rerank(query, candidates, top_k=top_k * 2)
# Guardar en caché (hasta el límite)
if len(_RERANK_CACHE) >= _MAX_CACHE_SIZE:
# Evict más viejo (FIFO simple, en producción usar LRU real)
_RERANK_CACHE.pop(next(iter(_RERANK_CACHE)))
_RERANK_CACHE[cache_key] = reranked
return reranked[:top_k]
Cuándo invalidar el caché
El caché se vuelve incorrecto si:
- Cambia el contenido de la collection (insertas documentos nuevos): los resultados antiguos pueden estar omitiendo docs nuevos relevantes.
- Cambia el modelo de embeddings (re-ingest): los rankings completos cambian.
- Cambia el modelo de re-ranking (upgrade de Cohere v3 a v4, por ejemplo).
def invalidate_cache(reason: str):
global _RERANK_CACHE
print(f"Invalidating rerank cache: {reason}")
_RERANK_CACHE.clear()
# En el pipeline de ingestion
def ingest_documents(...):
collection.add(...)
invalidate_cache(f"Added {n} new documents")
Cache distribuido (Redis) para múltiples instancias
LRU in-memory funciona para un solo proceso. Si tienes múltiples instancias de tu API, cada una tiene su propio caché y los hits son raros. Solución: caché distribuido.
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def cached_rerank_redis(query: str, top_k: int = 5, ttl_seconds: int = 3600):
cache_key = f"rerank:{query_cache_key(query, n_candidates=20)}"
# Cache hit
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)[:top_k]
# Cache miss
candidates = collection.query(query_texts=[query], n_results=20)['documents'][0]
reranked = cross_encoder_rerank(query, candidates, top_k=top_k * 2)
# Guardar con TTL (auto-invalida después de ttl_seconds)
redis_client.setex(cache_key, ttl_seconds, json.dumps(reranked))
return reranked[:top_k]
Beneficio típico de cachear:
- Hit rate típico en sistemas con tráfico real: 40-70%.
- Latencia en hits: ~5ms (lookup de Redis) vs 320ms (pipeline completo).
- Reducción de costo en sistemas con LLM rerank: hasta 70% menos llamadas a OpenAI.
Optimización 3: score thresholds para descartar basura
Re-rankear no garantiza que todos los top_k sean relevantes. A veces los top-5 incluyen 1-2 docs con scores muy bajos que el LLM no debería ver.
# Sin threshold (deja pasar todo)
top_5 = reranker.rerank(query, candidates, top_k=5)
# Con threshold (filtra resultados de baja relevancia)
all_reranked = reranker.rerank(query, candidates, top_k=20)
top_relevant = [doc for doc in all_reranked if doc.score > 0.5]
# Si quedan menos de 5 después del threshold, está OK
# Mejor pasar 3 docs relevantes al LLM que 5 con 2 basura
Cómo elegir el threshold:
- Sobre el eval set, plotear distribución de scores de docs relevantes vs irrelevantes.
- Encontrar el punto donde se separan claramente las dos distribuciones.
- Setear el threshold ahí, validar que precision sube y recall no cae demasiado.
import matplotlib.pyplot as plt
def find_threshold(eval_set):
relevant_scores = []
irrelevant_scores = []
for item in eval_set:
candidates = collection.query(query_texts=[item["query"]], n_results=20)['documents'][0]
reranked = reranker.rerank(item["query"], candidates, top_k=20)
for doc in reranked:
if doc.document in item["expected_chunks"]:
relevant_scores.append(doc.score)
else:
irrelevant_scores.append(doc.score)
# Histograma comparativo
plt.hist(relevant_scores, alpha=0.5, label="Relevant", bins=20)
plt.hist(irrelevant_scores, alpha=0.5, label="Irrelevant", bins=20)
plt.legend()
plt.show()
# Donde las dos distribuciones se cruzan = threshold candidato
Threshold típico (cross-encoder MS MARCO): 0.4-0.6 Threshold típico (Cohere relevance score): 0.5-0.7 Threshold típico (LLM rerank 0-10): 5.0-7.0
Optimización 4: skip dinámico — cuando NO re-rankear
No todas las queries necesitan re-ranking. Si el cosine retrieval inicial ya es muy confiado (top-3 con scores mucho más altos que el resto), re-rankear es desperdicio.
def needs_reranking(retrieval_scores: list[float], threshold_gap: float = 0.15) -> bool:
"""
Decide si vale re-rankear basándose en la distribución de scores.
Si el gap entre el top-3 y top-10 es grande, el retrieval es confiado y
re-rankear no va a cambiar mucho. Si el gap es chico, hay ambigüedad y
re-rankear vale.
"""
if len(retrieval_scores) < 10:
return False # no hay suficientes candidatos
# Distancia coseno: menor = más similar
top_3_avg = sum(retrieval_scores[:3]) / 3
top_10_avg = sum(retrieval_scores[:10]) / 10
gap = top_10_avg - top_3_avg
return gap < threshold_gap
def smart_rerank(query: str, top_k: int = 5):
results = collection.query(query_texts=[query], n_results=20)
candidates = results['documents'][0]
distances = results['distances'][0]
if needs_reranking(distances):
# Re-rankear porque hay ambigüedad
return cross_encoder_rerank(query, candidates, top_k=top_k)
else:
# Skip rerank — el retrieval ya es confiado
return [(doc, dist) for doc, dist in zip(candidates[:top_k], distances[:top_k])]
Beneficio: ~20-30% de queries pueden saltearse el re-ranking sin pérdida medible de calidad. Ahorra latencia y costo.
Riesgo: definir threshold_gap mal puede saltarse re-ranking en queries que sí lo necesitaban. Validar empíricamente sobre eval set.
Optimización 5: paralelización correcta
Cross-encoder local: usar batch nativo
# ❌ Mal: secuencial (Python loop)
for doc in candidates:
score = reranker.predict([(query, doc)])
# ✅ Bien: batch nativo
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs, batch_size=32)
El batch_size interno de sentence-transformers aprovecha vectorización GPU/CPU. La diferencia entre secuencial y batch es 10-50x.
LLM rerank: ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
def parallel_llm_rerank(query, candidates, top_k=5, workers=5):
def score_one(doc):
return llm_rerank_pair(query, doc).score
with ThreadPoolExecutor(max_workers=workers) as executor:
scores = list(executor.map(score_one, candidates))
paired = sorted(zip(candidates, scores), key=lambda x: -x[1])
return paired[:top_k]
5-10 workers paralelos transforman 1500ms en ~300ms para rerankear 20 candidatos. Las llamadas son I/O bound (esperan a OpenAI), paralelizar es trivial.
Cohere Rerank: ya viene paralelizado server-side
Cohere recibe los N documentos en una sola llamada API y los procesa en paralelo internamente. No hace falta paralelizar del lado del cliente.
# Una sola llamada con todos los candidatos
response = co.rerank(query=query, documents=candidates, top_n=top_k)
La trampa: optimizar latencia rompiendo accuracy en silencio
Goodhart's Law aplicado: cuando optimizas n_results=10 para bajar latencia, puedes estar bajando precision sin verlo en el dashboard.
# Optimización agresiva
n_results = 5 # bajo, "para ahorrar"
top_k_rerank = 3 # bajo, "para ahorrar"
score_threshold = 0.7 # alto, "para ser estricto"
# Resultado: latencia mejora 40%, precision baja 8% sin alertar
Mitigación:
- Métrica de protección: monitor de precision@K continuo sobre eval set. Si baja, alerta.
- A/B testing antes de deployar: comparar versión optimizada vs actual sobre eval set fijo.
- Métricas compuestas: "latency × (1 / precision)" — empeora si cualquiera de las dos se degrada.
Trampas y errores comunes
Trampa 1: cachear sin invalidar al ingestar nuevos docs
El error: caché de re-ranking activo. Insertan 1000 docs nuevos. El caché sigue devolviendo resultados que no consideran los nuevos docs.
Síntoma: docs nuevos relevantes nunca aparecen en queries cacheadas. El sistema parece no aprovechar la nueva información.
Cómo prevenir: invalidar el caché en cada ingestion (o al menos al final del batch). Si la ingestion es continua, usar TTL corto (5-15 min) en lugar de cache permanente.
Trampa 2: threshold global cuando los scores tienen rangos distintos por query
El error: seteas threshold=0.5 global. Pero algunas queries son ambiguas (todos los scores entre 0.3 y 0.5) y otras son específicas (scores entre 0.6 y 0.9).
Síntoma: queries ambiguas devuelven cero resultados (todos por debajo del threshold). Queries específicas no filtran nada (todos por encima).
Cómo prevenir: usar threshold relativo en lugar de absoluto:
# Threshold relativo: solo descartar docs significativamente peores que el top-1
top_score = reranked[0].score
relevant = [doc for doc in reranked if doc.score > top_score * 0.7]
Trampa 3: skip de rerank que se vuelve permanente
El error: implementas "skip dinámico" con una lógica que descarta el rerank cuando el retrieval es "confiado". Por bug en la lógica, casi siempre considera el retrieval confiado, y el rerank casi nunca corre.
Síntoma: las métricas operativas no cambian (rerank latency, costo de Cohere caen) pero precision baja silenciosamente.
Cómo prevenir: instrumentar rerank_skipped_count y rerank_executed_count. Monitor que el ratio sea ~20-30%, no >70%.
Trampa 4: caché en memoria sin tamaño máximo
El error:
_CACHE = {} # crece infinitamente
Síntoma: memoria de la app crece sin límite, OOM eventualmente.
Cómo prevenir: siempre con MAX_SIZE y política de eviction (LRU típicamente).
Trampa 5: paralelizar cross-encoder con threads
El error: uses ThreadPoolExecutor para llamar a cross_encoder.predict con un par cada vez.
Síntoma: no acelera. Cross-encoder local es CPU-bound; threads no ayudan, GIL los serializa.
Cómo prevenir: para cross-encoder local, usar el batch_size interno, no threads. Para llamadas a APIs (LLM, Cohere), threads sí ayudan (I/O bound).
Trampa 6: medir latencia solo del rerank
El error: mediste que cross-encoder rerank tarda 150ms. Asumes que ese es el costo total.
Síntoma: en producción, la latencia es 400ms. Olvidaste contar el retrieval previo, el embedding de la query y la generación.
Cómo prevenir: medir end-to-end desde que llega la query del usuario hasta que se devuelve la respuesta. Cada componente del pipeline debe loguear su latencia.
Ejercicio aplicado
Escenario: sistema RAG en producción con estas métricas baseline:
- Pipeline: cosine retrieval
n_results=50→ cross-encoder rerank → top-5 al LLM - Latencia p95 actual: 850ms
- Precision@5: 89%
- Costo: ~$0/query (cross-encoder local)
- Volumen: 100K queries/día
El equipo de producto pide: "bajemos latencia a <500ms p95 sin sacrificar precision."
Tu trabajo: propone tres optimizaciones específicas en orden de prioridad, estima el impacto de cada una, y especifica cómo validarlas.
Solución
Optimización 1: bajar n_results de 50 a 20
Hipótesis: según benchmarks típicos, n=50 vs n=20 da retornos decrecientes severos. Probablemente la precision se mantiene casi igual.
Impacto esperado:
- Latencia rerank: ~360ms → ~150ms (mitad de candidatos)
- Latencia total p95: 850ms → ~640ms
- Precision: probablemente sin cambio (<1% de delta)
Validación: correr eval set con n=10, 20, 30, 50. Si precision con n=20 ≥ 88%, deployar. Costo: 1 hora de trabajo.
Optimización 2: agregar caché LRU para queries repetidas
Hipótesis: sistema con 100K queries/día probablemente tiene 30-50% de queries repetidas (Pareto típico).
Impacto esperado:
- En queries cacheadas (hit): latencia ~5ms
- Hit rate esperado: 40%
- Latencia promedio ponderada: 0.6 * 640ms + 0.4 * 5ms = 386ms p50, ~640ms p95
- p95 NO mejora directamente (los cache misses siguen igual), PERO el throughput agregado del sistema mejora 40%
Variante: priorizar p95 con caché distribuido + warming proactivo
# Pre-calentar caché con queries top del último día
def warm_cache_with_top_queries():
top_queries = get_top_queries_from_logs(limit=1000)
for query in top_queries:
cached_rerank_pipeline(query) # llena el cache
Eso garantiza que las queries más comunes están siempre cacheadas, mejorando p95.
Validación: medir hit rate real durante 1 semana. Si está >30%, vale el costo de Redis ($10-50/mes managed).
Optimización 3: skip dinámico para queries con retrieval confiado
Hipótesis: ~25% de queries tienen retrieval donde top-3 es claramente mejor que top-10. Re-rankear no aporta valor.
def should_skip_rerank(distances):
if len(distances) < 10:
return False
return distances[9] - distances[2] < 0.10 # gap pequeño = confiado
Impacto esperado:
- 25% de queries saltean rerank → 0ms en lugar de ~150ms
- Latencia promedio: pequeña mejora
- p95 de queries que SÍ rerankean: sin cambio
Validación crítica: verificar que skip no afecta precision. Setear monitor de "precision en queries skipped" vs "precision en queries rerankeadas". Si la diferencia es >2%, ajustar threshold.
Plan de implementación (en orden):
| Sprint | Optimización | Esfuerzo | Impacto p95 esperado |
|---|---|---|---|
| Día 1 | Bajar n_results de 50 a 20 | 2 horas | 850ms → 640ms |
| Día 2-3 | Caché Redis con TTL + warming | 1 día | (mejora throughput, p95 marginal) |
| Día 4-5 | Skip dinámico de rerank | 1 día | 640ms → 580ms |
Si después de las 3 optimizaciones todavía estamos sobre 500ms:
- Considerar cross-encoder más liviano (
ms-marco-TinyBERT-L-2-v2): -50ms latency, -2% precision. - Optimizar la generación con LLM (típicamente 60% del tiempo total): caching de respuestas para queries idénticas, modelo más pequeño para queries simples (Haiku/4o-mini en lugar de GPT-4).
- Si nada alcanza, renegociar SLA con producto: 600ms es razonable para chatbot técnico, sub-500ms es estricto.
Métricas a monitorear durante el rollout:
- Latency p50/p95/p99 (no solo p95)
- Precision@5 sobre eval set diario
- Cache hit rate
- Skip rerank rate (debe estar 20-30%)
- Costo por query (debería bajar marginalmente con caché)
Plan de rollback: feature flag para cada optimización. Si métrica de protección (precision) cae más de 2%, rollback automático.
Resumen y siguiente paso
Lo que aprendiste:
- El
n_resultsdel retrieval inicial tiene retornos decrecientes después de 20-30 candidatos. Sweet spot empírico depende del re-ranker. - Caché de resultados con LRU/Redis aprovecha el patrón Pareto de queries repetidas (40-70% hit rate típico).
- Score thresholds permiten descartar resultados de baja relevancia. Threshold óptimo se encuentra empíricamente sobre eval set.
- Skip dinámico (saltarse re-ranking cuando el retrieval ya es confiado) ahorra 20-30% del costo sin pérdida de precision.
- Paralelización: batch nativo para cross-encoder local, ThreadPoolExecutor para LLM/Cohere, ninguna para Cohere (lo hace server-side).
- Optimizar latencia sin medir precision lleva a Goodhart's Law: la métrica mejora pero la calidad cae en silencio. Siempre métricas de protección.
Checkpoint: antes de avanzar, deberías poder:
- Encontrar el
n_resultsóptimo para tu pipeline con un benchmark sobre eval set. - Implementar caché LRU/Redis con invalidación correcta.
- Diseñar skip dinámico de re-ranking con métricas de protección.
Siguiente cápsula: 07 — Comparación de técnicas de re-ranking.
Cubrimos las tres técnicas (cross-encoder, LLM, Cohere) y las optimizaciones operativas. La cápsula 07 es la consolidación: decision framework para elegir la técnica correcta dado un contexto, benchmarks lado a lado, y un flowchart accionable que vas a usar cuando empiezes un nuevo proyecto. Es la cápsula que vuelves a consultar mes a mes.
Recursos
- Caching Strategies for ML Systems (Uber Eng) — Patrones de caché en ML production
- Redis Best Practices for Caching — Patrones de caché distribuido
- Speed up sentence-transformers inference — Tips de batching y optimización
- Goodhart's Law (Wikipedia) — Por qué optimizar lo que mides puede romper lo que no mides
- The Four Golden Signals (Google SRE) — Latency, traffic, errors, saturation
- Asyncio vs Threading vs Multiprocessing in Python — Decidir el modelo de concurrencia correcto
Tiempo estimado: 25-30 minutos Siguiente: 07-technique-comparison-2.md