Módulo 4: Re-ranking — la segunda etapa que transforma retrieval mediocre en excelente

Cápsula 02: Cosine similarity es buena, no perfecta — el límite que justifica re-ranking

Descripción de la cápsula

Cosine similarity hizo posible RAG. Sin ella, no podríamos comparar millones de vectores en milisegundos. Hasta acá, todo lo que aprendiste sobre vector databases, embeddings y retrieval depende de cosine similarity como métrica de "qué tan parecidos son dos textos".

Pero cosine similarity tiene un límite. Mide overlap semántico entre embeddings — qué tan cerca están dos vectores en el espacio. Lo que no mide es relevancia real — si un documento responde efectivamente la pregunta del usuario. Esos dos conceptos coinciden 70-80% del tiempo, pero el 20-30% restante es donde tu RAG empieza a devolver respuestas que técnicamente "encajan" con la query pero pedagógicamente fallan.

Esta cápsula te muestra los tres límites estructurales de cosine similarity, por qué no se pueden resolver simplemente "embebiendo mejor", y por qué la solución natural es agregar una segunda etapa de scoring (re-ranking) sobre los candidatos que cosine similarity recuperó. Es el "por qué" del módulo entero — sin entender este límite, las técnicas que vienen suenan a optimización innecesaria.

Al finalizar esta cápsula serás capaz de:

  • ✅ Explicar la diferencia entre similaridad semántica y relevancia para una query
  • ✅ Identificar los tres modos de falla de cosine similarity en retrieval
  • ✅ Diferenciar arquitecturas bi-encoder (cosine) vs cross-encoder (re-ranking)
  • ✅ Calcular el impacto típico en precision (cosine alone vs con re-ranking)
  • ✅ Justificar la decisión de agregar re-ranking a un pipeline RAG existente
  • ✅ Anticipar el error de "más data soluciona retrieval" — a veces solo más data no alcanza

Tiempo estimado: 30-35 minutos


El insight: similaridad ≠ relevancia

Esta es la distinción que define todo el módulo. Vamos a hacerla concreta con un ejemplo.

Query del usuario: "¿cómo implemento autenticación OAuth2 en FastAPI?"

Tu vector database tiene tres documentos sobre OAuth2. Cosine similarity los rankea así:

Doc A — "FastAPI OAuth2 Implementation Guide"                    cosine: 0.85
        "Para implementar OAuth2 en FastAPI, usar OAuth2PasswordBearer
         desde fastapi.security..."

Doc B — "OAuth2 Authentication Overview"                         cosine: 0.83
        "OAuth2 es un framework de autorización que permite a aplicaciones
         de terceros acceso limitado a recursos de usuarios..."

Doc C — "OAuth2 Specification (RFC 6749)"                        cosine: 0.81
        "The OAuth 2.0 authorization framework enables a third-party
         application to obtain limited access to an HTTP service..."

Los tres tienen scores parecidos (0.81-0.85). Para cosine similarity, los tres son "relevantes". Pero veamos qué responde cada uno a la query real:

DocCosineRelevancia para "cómo implementar OAuth2 en FastAPI"
A0.85Alta — específico de FastAPI, código concreto
B0.83Baja — overview genérico, no menciona FastAPI
C0.81Baja — spec teórica, ninguna implementación

Si pasas los tres al LLM, dos de los tres chunks diluyen el contexto. El LLM ve mucho texto sobre OAuth2 en general y poco sobre FastAPI específicamente. La respuesta resultante va a ser correcta pero genérica — exactamente lo opuesto a lo que el usuario pidió.

Esto es el problema central: cosine similarity confundió "habla de OAuth2" con "responde cómo implementarlo en FastAPI". Son temas relacionados pero no son la misma cosa.


Los tres modos de falla de cosine similarity

Falla 1: especificidad vs generalidad

El caso del ejemplo anterior. Cuando una query es específica (FastAPI + OAuth2), pero el corpus tiene tanto documentos específicos como genéricos sobre el tema, cosine similarity tiende a rankearlos parecidos. La diferencia entre "FastAPI OAuth2 guide" y "OAuth2 overview" es ~3-5% en cosine similarity, pero es la diferencia entre "responde la pregunta" y "no la responde".

Por qué pasa: los embeddings codifican el contenido semántico, no la especificidad. Un documento técnico sobre FastAPI OAuth2 y un documento abstracto sobre OAuth2 están cerca en el espacio vectorial porque comparten vocabulario y conceptos centrales. La diferencia de especificidad (uno menciona FastAPI, otro no) se diluye.

Falla 2: keywords exactos vs paráfrasis

Query: "¿cómo uso OAuth2PasswordBearer?" (con el nombre exacto de la clase)

Documentos en el corpus:

Doc X: "OAuth2PasswordBearer is the FastAPI security class for OAuth2 password flow.
        Import it from fastapi.security..."
        cosine: 0.78  ← más bajo

Doc Y: "Para autenticación OAuth2 con username/password en FastAPI, usar el flow
        de password con la dependency adecuada del módulo de seguridad..."
        cosine: 0.83  ← más alto

El doc Y rankea más alto aunque no menciona el nombre exacto de la clase que el usuario preguntó. ¿Por qué? Porque los embeddings normalizan paráfrasis — "OAuth2PasswordBearer" y "dependency adecuada del módulo de seguridad" terminan en regiones cercanas del espacio vectorial.

Para un usuario que sabe el nombre exacto y quiere documentación de esa clase específica, el doc X es la respuesta. Cosine similarity lo entierra.

Esto se exacerba en queries con identificadores: nombres de funciones, IDs de productos, números de versión, códigos de error. Cosine similarity rara vez prioriza el match exacto sobre la paráfrasis cercana.

Falla 3: relación query-documento vs similaridad de embeddings

Esta es la más sutil. Cosine similarity compara dos embeddings que se crearon independientemente:

# Bi-encoder (lo que cosine similarity hace por debajo)
query_emb = encoder.encode("¿cómo implemento OAuth2 en FastAPI?")    # vector A
doc_emb = encoder.encode("FastAPI OAuth2 guide: usar OAuth2Password...")  # vector B

similarity = cosine(query_emb, doc_emb)  # 0.85

El encoder procesó la query y el documento por separado. El embedding del documento no sabe que iba a ser comparado con esta query específica. Es como darle a un evaluador dos textos cualesquiera y preguntarle "¿qué tan parecidos son?" sin contexto adicional.

Cross-encoders (la base del re-ranking) hacen algo distinto:

# Cross-encoder
score = cross_encoder.predict([
    ("¿cómo implemento OAuth2 en FastAPI?",
     "FastAPI OAuth2 guide: usar OAuth2Password...")
])  # 0.94 — score más alto y más calibrado

El cross-encoder analiza el par query-documento como una unidad. Internamente, atiende a cómo cada palabra de la query se relaciona con cada palabra del documento. Detecta cosas como "la query pide implementación, el documento muestra implementación" — relaciones que cosine similarity nunca puede capturar porque los embeddings nunca se "vieron" juntos.


Bi-encoder vs cross-encoder, lado a lado

┌────────────────────────────────────────────────────────────────────┐
│                                                                    │
│  BI-ENCODER (cosine similarity en retrieval)                       │
│                                                                    │
│   query  ──┬──>  encoder ──>  vector A                             │
│            │                                                       │
│   doc    ──┴──>  encoder ──>  vector B                             │
│                                                                    │
│                          score = cosine(A, B)                      │
│                                                                    │
│   ✅ Rápido (embebes docs UNA vez, queries en milisegundos)        │
│   ✅ Escala a millones de docs                                     │
│   ❌ No analiza interacción query-doc                              │
│   ❌ Modos de falla: especificidad, exactitud, relevancia          │
│                                                                    │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  CROSS-ENCODER (re-ranking)                                        │
│                                                                    │
│   query, doc ──>  encoder (procesa AMBOS juntos) ──>  score        │
│                                                                    │
│   ✅ Analiza interacción query-doc completa                        │
│   ✅ Mucho mejor precision                                         │
│   ❌ Lento (re-ejecuta el modelo por cada par)                     │
│   ❌ NO escala a millones — solo top-K candidatos                  │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

El insight pedagógico: no son técnicas competitivas, son complementarias.

Pipeline RAG con re-ranking:
  ┌─────────────────────┐    ┌──────────────────────┐    ┌──────┐
  │ Bi-encoder retrieve │ -> │ Cross-encoder rerank │ -> │ LLM  │
  │   (cosine)          │    │                      │    │      │
  │                     │    │                      │    │      │
  │  1M docs → top-50   │    │  top-50 → top-5      │    │ ...  │
  └─────────────────────┘    └──────────────────────┘    └──────┘
       Rápido                     Preciso                 Genera
       (milisegundos)             (cientos de ms)

Bi-encoder hace el primer pase masivo (rápido pero ruidoso). Cross-encoder refina los top-K candidatos (más lento pero preciso). El LLM recibe los mejores 5 chunks en lugar de 50.

Sin re-ranking: el LLM recibe top-5 directos del cosine retrieve. Si 2-3 son falsos positivos, generation se diluye.

Con re-ranking: el LLM recibe top-5 después del segundo pase. Casi todos son relevantes. Generation mejora medible.


Impacto cuantificado: por qué vale el costo extra

# Benchmark típico sobre dataset técnico
metrics = {
    "Cosine only (top-5 directo)": {
        "precision@5": 0.70,
        "false_positive_rate": 0.30,
        "latency_p95_ms": 180,
        "cost_per_query_usd": 0.0002,
    },
    "Cosine + cross-encoder rerank": {
        "precision@5": 0.91,
        "false_positive_rate": 0.09,
        "latency_p95_ms": 320,        # +140ms para re-rank
        "cost_per_query_usd": 0.0002, # cross-encoder corre local, sin costo extra
    },
    "Cosine + LLM-as-reranker": {
        "precision@5": 0.94,
        "false_positive_rate": 0.06,
        "latency_p95_ms": 850,        # +670ms (LLM API call)
        "cost_per_query_usd": 0.0008, # +$0.0006 por query
    },
}

Lecturas:

  1. Cross-encoder es la opción default. +21% precision, +140ms latencia, cero costo extra. La cápsula 03 cubre esto en detalle.

  2. LLM-as-reranker es para casos críticos. +24% precision, pero +670ms latencia y costo adicional. Usar cuando 3% de mejora justifica el trade-off (ej: legal, médico). La cápsula 04 lo cubre.

  3. El falso positivo cae 70-80%. De 1 de cada 3 docs en top-5 siendo basura, a 1 de cada 11. Eso transforma la calidad percibida del sistema más que cualquier optimización de chunking o embeddings.


Por qué "embebir mejor" no resuelve estos problemas

Una pregunta razonable: si cosine similarity falla por estos tres modos, ¿no se podría usar un modelo de embeddings mejor?

La respuesta corta: no completamente, porque los problemas son estructurales del approach bi-encoder.

Cuando un mejor embedding ayuda:

  • Modelos más nuevos (text-embedding-3-large vs ada-002) reducen ~5-10% los falsos positivos
  • Embeddings específicos del dominio (legal-bert, biobert) ayudan en su nicho
  • Embeddings multilingües reducen errores de cross-language matching

Cuando un mejor embedding NO alcanza:

  • La distinción "específico vs genérico" requiere atender a contexto que un embedding individual no captura
  • Match exacto de identificadores requiere comparación a nivel de token, no de embedding agregado
  • La relación query-documento solo se puede capturar comparando los dos en un mismo paso (cross-encoder)

Conclusión pedagógica: mejorar embeddings complementa re-ranking, no lo reemplaza. La arquitectura bi-encoder + cross-encoder es la combinación correcta — ningún componente solo alcanza la calidad de los dos juntos.


¿Cuándo agregar re-ranking?

No es siempre la respuesta. Re-ranking agrega 100-700ms de latencia y, según la técnica, costo monetario. Considerar agregarlo cuando:

Situación¿Re-rank?Por qué
MVP con <10K docs❌ NoCosine alcanza, no es problema todavía
Dataset >100K docs✅ Sí (cross-encoder)Falsos positivos se acumulan a escala
Queries muy específicas (FastAPI OAuth2)✅ SíEl modo de falla "especificidad" es severo
Queries con identificadores exactos✅ Sí (o hybrid search)El modo de falla "exactitud" es severo
SLA estricto (<200ms total)⚠️ Cross-encoder ligero o noLa latencia extra puede romper el SLA
Compliance crítico (legal, médico)✅ Sí (LLM-based)Vale el costo extra por mejor precision
Costo es restricción duraCross-encoder OK; LLM noCross-encoder local es gratis

Default razonable: empiezas sin re-ranking, mides precision sobre eval set, agregas re-ranking cuando precision@5 cae bajo 85%.


Trampas y errores comunes

Trampa 1: confundir cosine score alto con relevancia alta

El error: ves cosine 0.85 y asumes "es muy relevante". Le pasas al LLM sin filtrar.

Síntoma: respuestas del LLM son consistentemente correctas pero genéricas. No abordan el aspecto específico que el usuario preguntó.

Cómo prevenir: medir precision sobre eval set. Si recall es alto (encuentra docs sobre el tema) pero precision es bajo (los docs no responden la query específica), agregar re-ranking.

Trampa 2: optimizar el modelo de embeddings y olvidar re-ranking

El error: equipo gasta semanas comparando text-embedding-3-small vs large vs Cohere vs Voyage. Cada uno mejora marginalmente. Nadie probó re-ranking, que daría 20% de mejora con un día de trabajo.

Síntoma: Pareto de mejora vs esfuerzo terriblemente desbalanceado.

Cómo prevenir: primero re-ranking (gana mucho con poco esfuerzo), después optimización de embeddings (gana poco con mucho esfuerzo).

Trampa 3: re-rankear los top-5 directos

El error: haces cosine retrieval con n_results=5, después re-rankeas esos 5.

Síntoma: re-ranking no mejora porque los candidatos ya están filtrados por cosine. Estás re-ordenando 5 docs que probablemente son los correctos pero algunos eran falsos positivos descartados antes del re-rank.

Cómo prevenir: retrieval con n_results=20-50, re-ranking selecciona top-5 finales. La cápsula 03 cubre el patrón.

Trampa 4: comparar cosine score con cross-encoder score

El error: intentas "rankear conjuntamente" sumando cosine + cross-encoder scores.

Síntoma: los scores tienen rangos distintos (cosine ~0-1, cross-encoder logits ~-10 a 10). Sumarlos sin normalizar da rankings sin sentido.

Cómo prevenir: o normaliza ambos a [0,1] antes de combinar, o usar Reciprocal Rank Fusion (RRF) que combina rankings ignorando scores absolutos. Cubierto en M05.

Trampa 5: asumir que re-ranking "siempre mejora"

El error: agregas re-ranking sin medir. Asumes que precision sube.

Síntoma: en algunos dominios (corpus muy específico, queries muy directas) el re-ranking apenas mejora porque cosine ya estaba bien.

Cómo prevenir: siempre A/B test. Eval set fijo, medir precision@K antes y después. Si la mejora es <3%, no vale la latencia extra.

Trampa 6: re-ranking lento sin batching

El error: llamas al cross-encoder una vez por par query-documento.

Síntoma: re-rankear 50 docs tarda 5 segundos en lugar de 200ms.

Cómo prevenir: los cross-encoders procesan batches eficientemente. Pasarles la lista completa de pares de una vez:

# ❌ Lento
for doc in candidates:
    score = model.predict([(query, doc)])

# ✅ Rápido (batch)
scores = model.predict([(query, doc) for doc in candidates])

Ejercicio aplicado

Escenario: eres AI Engineer en una empresa de cloud computing. Tu sistema RAG tiene:

  • 800K chunks de documentación técnica indexada con OpenAI text-embedding-3-small
  • Cosine similarity para retrieval
  • n_results=5 que pasa directo al LLM
  • Sin re-ranking

Métricas actuales sobre eval set de 100 queries reales:

  • Precision@5: 71%
  • Recall@5: 88% (encuentra docs sobre el tema)
  • Latencia p95: 280ms total

El equipo de producto pide: "queremos precision@5 mínimo 88% para fin de mes. ¿Es alcanzable?"

Tu trabajo:

  1. Diagnostica si el problema es retrieval (recall) o relevancia (precision).
  2. Propone una solución concreta y estima el impacto.
  3. Identifica el trade-off principal y cómo lo justificarías al equipo de producto.
Solución

1. Diagnóstico

  • Recall@5 es 88% — el sistema encuentra los docs correctos en los top-5, no es problema de retrieval.
  • Precision@5 es 71% — el problema es que dentro del top-5 hay falsos positivos. Cosine similarity rankea documentos genéricos sobre el tema cerca de los específicos.

Esto es exactamente el modo de falla 1 (especificidad vs generalidad) y posiblemente 2 (keywords vs paráfrasis). El problema no es de retrieval — es de relevancia post-retrieval.

Implicación: mejorar el modelo de embeddings o ajustar chunking probablemente no resuelve esto. Lo que falla es la métrica de scoring (cosine similarity) en la etapa final.

2. Solución concreta: agregar cross-encoder re-ranking

Cambio de pipeline:

# Antes
results = collection.query(query_texts=[query], n_results=5)
top_5_chunks = results['documents'][0]
# Pasar al LLM directo

# Después
# Etapa 1: retrieval amplio
results = collection.query(query_texts=[query], n_results=30)
candidates = results['documents'][0]

# Etapa 2: re-ranking con cross-encoder
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs)

import numpy as np
top_5_idx = np.argsort(scores)[::-1][:5]
top_5_chunks = [candidates[i] for i in top_5_idx]
# Pasar al LLM

Impacto esperado (basado en benchmarks típicos):

  • Precision@5: 71% → ~89% (+18 puntos) — entra dentro del target
  • Recall@5: 88% → 90% (sube ligeramente porque ahora se consideran 30 candidatos en lugar de 5)
  • Latencia p95: 280ms → ~430ms (+150ms del re-ranker)
  • Costo: cero extra (modelo corre local)

Plan de validación:

  1. Setup en staging con el cambio.
  2. Correr eval set de 100 queries antes y después.
  3. Si precision@5 cae bajo 88%, ajustar n_results del retrieval inicial (probar 50 en lugar de 30).
  4. Si latencia rompe SLA, considerar modelo más liviano (ms-marco-TinyBERT) o re-rankear menos candidatos.

3. Trade-off principal y justificación

El trade-off: +150ms de latencia a cambio de +18% de precision.

Justificación al equipo de producto:

"Para alcanzar precision@5 de 88%, necesito agregar una etapa de re-ranking al pipeline. Esto sube la latencia p95 de 280ms a ~430ms — un 50% más, pero todavía dentro de SLA razonable para chatbot técnico. El cambio es en código y no agrega costo de infraestructura.

La alternativa sería migrar a un modelo de embeddings más caro (text-embedding-3-large), pero la mejora esperada es de ~5%, no llega a 88%, y triplica el costo de embeddings.

Mi recomendación: agregar re-ranking. Cumple el target, no agrega costo significativo, y el aumento de latencia es invisible para el usuario."

Si el equipo objeta la latencia extra:

  • Opción intermedia: re-rankear solo top-10 candidatos en lugar de 30. Latencia extra baja a ~80ms. Precision esperado ~85% (cerca pero no llega a 88%).
  • Opción agresiva: re-rankear top-50, paralelizar el batch en GPU si está disponible. Latencia ~120ms, precision ~91%.

Consideración a futuro: si el dataset crece a 5M+ chunks, considerar re-ranking con LLM (mejor calidad) para queries críticas, mantener cross-encoder para queries normales. Routing por tipo de query.


Resumen y siguiente paso

Lo que aprendiste:

  • Cosine similarity mide overlap semántico, no relevancia real para una query específica.
  • Tres modos de falla: especificidad (genérico vs específico), exactitud (paráfrasis vs keywords), relación (embeddings independientes vs interacción query-doc).
  • Bi-encoder (cosine) y cross-encoder (re-ranking) son complementarios, no competitivos. La arquitectura óptima los combina.
  • Re-ranking agrega 100-700ms de latencia a cambio de 15-25% más precision. Trade-off típicamente favorable.
  • Mejorar el modelo de embeddings ayuda marginalmente; agregar re-ranking ayuda dramáticamente. Empezar por re-ranking.
  • No siempre es necesario re-ranking — depende del dataset, del tipo de queries y del SLA.

Checkpoint: antes de avanzar, deberías poder:

  • Distinguir similaridad semántica de relevancia con un ejemplo propio.
  • Explicar por qué cosine similarity falla con queries específicas o con identificadores exactos.
  • Diagnosticar si un sistema RAG necesita re-ranking basándote en métricas (precision vs recall).

Siguiente cápsula: 03 — Cross-encoder re-ranking.

Acabas de entender el "por qué" del módulo. Ahora viene el "cómo". Cross-encoder es la técnica default — buena precision, latencia razonable, cero costo. La cápsula 03 te enseña a implementarla con sentence-transformers, comparar modelos disponibles (MiniLM L-6 vs L-12 vs TinyBERT), y benchmarkear el impacto sobre tu eval set. Es la primera mejora concreta que vas a aplicar a tu pipeline RAG.


Recursos

  1. Sentence Transformers — Cross-Encoders Documentation — Documentación oficial
  2. Pinecone — Re-ranking Explained — Comparación bi-encoder vs cross-encoder
  3. BEIR Benchmark — Comparaciones empíricas de retrieval con y sin re-ranking
  4. Khattab & Zaharia — ColBERT Paper — Late interaction como alternativa a cross-encoder
  5. Anthropic — Contextual Retrieval — Otra técnica complementaria al re-ranking
  6. MS MARCO — The dataset behind ms-marco-MiniLM — Dataset usado para entrenar los cross-encoders más populares

Tiempo estimado: 30-35 minutos Siguiente: 03-cross-encoder-reranking.md