Módulo 4: Evaluación y Chunking Strategies

Métricas de Retrieval: Midiendo Performance de RAG

Descripción de la cápsula

"¿Funciona mi sistema RAG?" Sin métricas, solo tienes intuición. Las métricas de retrieval (nDCG, MRR, Recall@K, Precision@K) te permiten medir objetivamente qué tan bien tu sistema encuentra documentos relevantes.

En esta cápsula aprenderás las métricas principales de retrieval, cómo calcularlas en Python, qué significa cada score, y cuándo usar cada métrica. También implementarás un evaluator completo.

Al final, podrás evaluar y optimizar tus sistemas RAG con métricas rigurosas.


Métricas principales

1. Recall@K

Definición:

De todos los documentos relevantes, ¿qué porcentaje está en los top-K resultados?

Fórmula:

Recall@K = (Docs relevantes en top-K) / (Total docs relevantes)

Ejemplo:

# Query: "Python installation"
# Docs relevantes totales: [doc_3, doc_7, doc_12]  (3 relevantes)
# Top-5 retrieved: [doc_1, doc_3, doc_5, doc_7, doc_9]

# Docs relevantes en top-5: [doc_3, doc_7]  (2 de 3)
Recall@5 = 2 / 3 = 0.67 (67%)

Interpretación:

  • Recall@5 = 1.0 → Todos los docs relevantes están en top-5 ✅
  • Recall@5 = 0.67 → Falta 1 doc relevante
  • Recall@5 = 0.0 → Ningún doc relevante en top-5 ❌

2. Precision@K

Definición:

De los top-K resultados, ¿qué porcentaje es relevante?

Fórmula:

Precision@K = (Docs relevantes en top-K) / K

Ejemplo:

# Top-5 retrieved: [doc_1, doc_3, doc_5, doc_7, doc_9]
# Docs relevantes: [doc_3, doc_7]  (2 de 5)

Precision@5 = 2 / 5 = 0.40 (40%)

Interpretación:

  • Precision@5 = 1.0 → Todos los top-5 son relevantes ✅
  • Precision@5 = 0.40 → 40% de top-5 son relevantes
  • Precision@5 = 0.0 → Ninguno de top-5 es relevante ❌

3. F1@K

Definición:

Media armónica de Recall@K y Precision@K.

Fórmula:

F1@K = 2 × (Precision@K × Recall@K) / (Precision@K + Recall@K)

Ejemplo:

Recall@5 = 0.67
Precision@5 = 0.40

F1@5 = 2 × (0.40 × 0.67) / (0.40 + 0.67) = 0.50

Interpretación: Balance entre Recall y Precision.


4. MRR (Mean Reciprocal Rank)

Definición:

Posición del PRIMER documento relevante.

Fórmula:

RR = 1 / posición_primer_relevante
MRR = promedio de RR para múltiples queries

Ejemplo:

# Query 1:
# Top-5: [doc_1, doc_3✓, doc_5, doc_7✓, doc_9]
# Primer relevante: posición 2
RR_1 = 1/2 = 0.50

# Query 2:
# Top-5: [doc_2, doc_4, doc_6✓, doc_8, doc_10]
# Primer relevante: posición 3
RR_2 = 1/3 = 0.33

# Query 3:
# Top-5: [doc_1✓, doc_3, doc_5, doc_7, doc_9]
# Primer relevante: posición 1
RR_3 = 1/1 = 1.0

MRR = (0.50 + 0.33 + 1.0) / 3 = 0.61

Interpretación:

  • MRR = 1.0 → Siempre doc relevante en posición 1 ✅
  • MRR = 0.5 → Promedio en posición 2
  • MRR < 0.2 → Docs relevantes muy abajo ❌

5. nDCG@K (Normalized Discounted Cumulative Gain)

Definición:

Como Recall pero penaliza docs relevantes que están más abajo en el ranking.

Fórmula:

DCG@K = Σ (relevance_i / log2(position_i + 1))
nDCG@K = DCG@K / IDCG@K  (normalizado 0-1)

Ejemplo:

# Top-5 retrieved: [doc_1, doc_3, doc_5, doc_7, doc_9]
# Relevance scores:
#   doc_1: 0 (no relevante)
#   doc_3: 1 (relevante)
#   doc_5: 0
#   doc_7: 1 (relevante)
#   doc_9: 0

# DCG@5:
DCG = (0/log2(2)) + (1/log2(3)) + (0/log2(4)) + (1/log2(5)) + (0/log2(6))
    = 0 + 0.63 + 0 + 0.43 + 0
    = 1.06

# IDCG@5 (ideal ranking: todos relevantes al principio):
IDCG = (1/log2(2)) + (1/log2(3)) + ...
     = 1.63

# nDCG@5:
nDCG = 1.06 / 1.63 = 0.65

Interpretación:

  • nDCG@5 = 1.0 → Ranking perfecto ✅
  • nDCG@5 = 0.65 → Ranking aceptable
  • nDCG@5 < 0.3 → Ranking malo ❌

Implementación en Python

Clase RetrievalEvaluator:

import numpy as np
from typing import List, Dict

class RetrievalEvaluator:
    """Evaluador de métricas de retrieval"""
    
    def __init__(self, k: int = 5):
        """
        Args:
            k: Cantidad de docs a considerar (top-K)
        """
        self.k = k
    
    def recall_at_k(self, retrieved: List[int], relevant: List[int]) -> float:
        """
        Recall@K
        
        Args:
            retrieved: IDs de docs retrieved (ordenados por score)
            relevant: IDs de docs relevantes (ground truth)
        
        Returns:
            Recall score [0, 1]
        """
        if not relevant:
            return 0.0
        
        retrieved_k = set(retrieved[:self.k])
        relevant_set = set(relevant)
        
        intersection = retrieved_k & relevant_set
        
        return len(intersection) / len(relevant_set)
    
    def precision_at_k(self, retrieved: List[int], relevant: List[int]) -> float:
        """Precision@K"""
        retrieved_k = set(retrieved[:self.k])
        relevant_set = set(relevant)
        
        intersection = retrieved_k & relevant_set
        
        return len(intersection) / self.k if self.k > 0 else 0.0
    
    def f1_at_k(self, retrieved: List[int], relevant: List[int]) -> float:
        """F1@K"""
        precision = self.precision_at_k(retrieved, relevant)
        recall = self.recall_at_k(retrieved, relevant)
        
        if precision + recall == 0:
            return 0.0
        
        return 2 * (precision * recall) / (precision + recall)
    
    def mrr(self, retrieved: List[int], relevant: List[int]) -> float:
        """Mean Reciprocal Rank (para 1 query)"""
        for i, doc_id in enumerate(retrieved):
            if doc_id in relevant:
                return 1.0 / (i + 1)
        
        return 0.0
    
    def ndcg_at_k(self, retrieved: List[int], relevant: List[int]) -> float:
        """
        nDCG@K (relevance binaria: 1 o 0)
        
        Args:
            retrieved: IDs retrieved (ordenados)
            relevant: IDs relevantes
        """
        relevant_set = set(relevant)
        
        # DCG (Discounted Cumulative Gain)
        dcg = 0.0
        for i, doc_id in enumerate(retrieved[:self.k]):
            relevance = 1 if doc_id in relevant_set else 0
            dcg += relevance / np.log2(i + 2)  # i+2 porque i empieza en 0
        
        # IDCG (Ideal DCG - todos relevantes al principio)
        idcg = 0.0
        for i in range(min(len(relevant), self.k)):
            idcg += 1.0 / np.log2(i + 2)
        
        if idcg == 0:
            return 0.0
        
        return dcg / idcg
    
    def evaluate_all(self, retrieved: List[int], relevant: List[int]) -> Dict:
        """Calcular todas las métricas"""
        return {
            'recall@k': self.recall_at_k(retrieved, relevant),
            'precision@k': self.precision_at_k(retrieved, relevant),
            'f1@k': self.f1_at_k(retrieved, relevant),
            'mrr': self.mrr(retrieved, relevant),
            'ndcg@k': self.ndcg_at_k(retrieved, relevant)
        }

# Uso
evaluator = RetrievalEvaluator(k=5)

# Ejemplo
retrieved = [1, 3, 5, 7, 9, 11, 13]  # IDs retrieved (ordenados por score)
relevant = [3, 7, 12]  # IDs relevantes (ground truth)

metrics = evaluator.evaluate_all(retrieved, relevant)

print("Métricas de Retrieval:")
for metric, value in metrics.items():
    print(f"  {metric}: {value:.3f}")

Output:

Métricas de Retrieval:
  recall@k: 0.667  (2 de 3 relevantes en top-5)
  precision@k: 0.400  (2 de 5 retrieved son relevantes)
  f1@k: 0.500  (balance)
  mrr: 0.500  (primer relevante en posición 2)
  ndcg@k: 0.756  (ranking aceptable)

Evaluar múltiples queries

Batch evaluation:

def evaluate_multiple_queries(evaluator, test_cases):
    """
    Evaluar múltiples queries
    
    Args:
        test_cases: Lista de dicts con keys 'retrieved' y 'relevant'
    
    Returns:
        Dict con métricas promedio
    """
    all_metrics = []
    
    for case in test_cases:
        metrics = evaluator.evaluate_all(case['retrieved'], case['relevant'])
        all_metrics.append(metrics)
    
    # Promediar
    avg_metrics = {}
    for key in all_metrics[0].keys():
        avg_metrics[key] = np.mean([m[key] for m in all_metrics])
    
    return avg_metrics

# Test cases
test_cases = [
    {
        'query': 'Python installation',
        'retrieved': [1, 3, 5, 7, 9],
        'relevant': [3, 7, 12]
    },
    {
        'query': 'List comprehensions',
        'retrieved': [2, 4, 6, 8, 10],
        'relevant': [4, 6]
    },
    {
        'query': 'Django tutorial',
        'retrieved': [1, 2, 3, 4, 5],
        'relevant': [1, 3]
    }
]

evaluator = RetrievalEvaluator(k=5)
avg_metrics = evaluate_multiple_queries(evaluator, test_cases)

print("Métricas promedio (3 queries):")
for metric, value in avg_metrics.items():
    print(f"  {metric}: {value:.3f}")

Output:

Métricas promedio (3 queries):
  recall@k: 0.722
  precision@k: 0.533
  f1@k: 0.600
  mrr: 0.667
  ndcg@k: 0.812

Cuándo usar cada métrica

Recall@K:

✅ RAG systems (quieres capturar TODOS los docs relevantes)
✅ Cuando miss un doc relevante es costoso
✅ Ejemplo: Legal search (NO puedes perder casos relevantes)

Meta típica: Recall@5 > 0.85

Precision@K:

✅ Cuando ruido (docs irrelevantes) es costoso
✅ LLM context window limitado (solo quieres lo relevante)
✅ Ejemplo: Chatbot (respuestas concisas)

Meta típica: Precision@3 > 0.80

F1@K:

✅ Balance entre Recall y Precision
✅ Cuando ambos importan igual
✅ Métrica única para comparar sistemas

Meta típica: F1@5 > 0.75

MRR:

✅ Cuando solo el PRIMER resultado importa
✅ Ejemplo: FAQ search (usuario solo ve #1)
✅ Web search (Google)

Meta típica: MRR > 0.8

nDCG@K:

✅ Cuando ranking importa (posición de relevantes)
✅ RAG con múltiples docs retrieved
✅ Métrica más completa (considera posición)

Meta típica: nDCG@5 > 0.85

Ejercicios

Ejercicio 1: Calcular Recall@K

retrieved = [1, 2, 3, 4, 5]
relevant = [3, 6, 9]

# ¿Cuál es Recall@5?
Ver solución
retrieved_k = set(retrieved[:5])  # {1, 2, 3, 4, 5}
relevant_set = set(relevant)       # {3, 6, 9}

intersection = retrieved_k & relevant_set  # {3}

recall = len(intersection) / len(relevant_set)
# recall = 1 / 3 = 0.33

print(f"Recall@5: {recall:.2f}")  # 0.33

Interpretación: Solo 1 de 3 docs relevantes está en top-5.


Ejercicio 2: Calcular nDCG@5

retrieved = [1, 3, 5, 7, 9]  # IDs retrieved
relevant = [3, 7]             # IDs relevantes

# ¿Cuál es nDCG@5?
Ver solución
import numpy as np

# DCG
dcg = 0.0
for i, doc_id in enumerate(retrieved):
    relevance = 1 if doc_id in relevant else 0
    dcg += relevance / np.log2(i + 2)

# doc_1: 0/log2(2) = 0
# doc_3: 1/log2(3) = 0.63
# doc_5: 0/log2(4) = 0
# doc_7: 1/log2(5) = 0.43
# doc_9: 0/log2(6) = 0
# dcg = 1.06

# IDCG (2 relevantes al principio)
idcg = 1/np.log2(2) + 1/np.log2(3)
# idcg = 1.0 + 0.63 = 1.63

# nDCG
ndcg = dcg / idcg
print(f"nDCG@5: {ndcg:.3f}")  # 0.650

Resumen

Qué aprendiste:

  • Recall@K: % de relevantes en top-K
  • Precision@K: % de top-K que son relevantes
  • F1@K: Balance Recall/Precision
  • MRR: Posición del primer relevante
  • nDCG@K: Ranking-aware (mejor métrica)

Conceptos clave:

  1. nDCG@K mejor métrica general (considera ranking)
  2. Recall crítico para RAG (capturar todos relevantes)
  3. MRR para single-result systems (FAQ)

Recursos adicionales

  1. Information Retrieval Metrics - Microsoft Research
  2. nDCG Explained - Wikipedia
  3. Evaluation Metrics - Stanford NLP

En la siguiente cápsula

Cápsula 05: Crear Evaluation Datasets

Aprenderás:

  • Formatos de evaluation datasets
  • Synthetic data generation
  • Human annotation
  • Quality assurance

De métricas a datasets de evaluación.


Módulo 4 - Embeddings Deep Dive Guide Métricas de retrieval: midiendo RAG objetivamente