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-rankerN óptimo típicoRazón
Cross-encoder local20-30Procesamiento rápido, vale tener más candidatos
Cohere Rerank25-50Costo lineal por documento; balance precision/cost
LLM rerank (GPT-4o-mini)10-20Cada par cuesta dinero; ser más selectivo
LLM rerank (GPT-4)5-10Caro 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:

  1. Sobre el eval set, plotear distribución de scores de docs relevantes vs irrelevantes.
  2. Encontrar el punto donde se separan claramente las dos distribuciones.
  3. 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:

  1. Métrica de protección: monitor de precision@K continuo sobre eval set. Si baja, alerta.
  2. A/B testing antes de deployar: comparar versión optimizada vs actual sobre eval set fijo.
  3. 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):

SprintOptimizaciónEsfuerzoImpacto p95 esperado
Día 1Bajar n_results de 50 a 202 horas850ms → 640ms
Día 2-3Caché Redis con TTL + warming1 día(mejora throughput, p95 marginal)
Día 4-5Skip dinámico de rerank1 día640ms → 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_results del 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

  1. Caching Strategies for ML Systems (Uber Eng) — Patrones de caché en ML production
  2. Redis Best Practices for Caching — Patrones de caché distribuido
  3. Speed up sentence-transformers inference — Tips de batching y optimización
  4. Goodhart's Law (Wikipedia) — Por qué optimizar lo que mides puede romper lo que no mides
  5. The Four Golden Signals (Google SRE) — Latency, traffic, errors, saturation
  6. Asyncio vs Threading vs Multiprocessing in Python — Decidir el modelo de concurrencia correcto

Tiempo estimado: 25-30 minutos Siguiente: 07-technique-comparison-2.md