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:
- nDCG@K mejor métrica general (considera ranking)
- Recall crítico para RAG (capturar todos relevantes)
- MRR para single-result systems (FAQ)
Recursos adicionales
- Information Retrieval Metrics - Microsoft Research
- nDCG Explained - Wikipedia
- 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