Cómo testear un sistema RAG: métricas, datasets y pruebas que detectan fallas reales
Un sistema RAG puede parecer convincente mucho antes de ser confiable. Hacés una pregunta, recibís una respuesta razonable, quizá hasta con una cita, y es fácil concluir que está listo. El problema aparece cuando cambia una consulta, un documento se actualiza, el reranker baja un resultado importante o el modelo decide completar un vacío con una afirmación plausible.
Un RAG no se valida porque “suena bien”. Se valida cuando podés demostrar que recupera la evidencia correcta, que responde usando esa evidencia y que no empeora cuando modificás el pipeline.
Por qué testear un RAG es distinto
Una respuesta incorrecta puede tener varias causas:
- El documento necesario no estaba en el índice.
- El documento estaba, pero el chunking separó el dato de su contexto.
- El retriever no lo incluyó entre los primeros resultados.
- El reranker lo bajó demasiado.
- El LLM recibió buena evidencia, pero la ignoró o agregó información no respaldada.
- El sistema debería haberse abstenido, pero respondió con seguridad.
Evaluar solamente la respuesta final mezcla todos esos problemas. Si una respuesta falla, necesitás saber si corregir embeddings, metadata, chunking, top-k, prompt o generación.
Las tres capas que hay que medir
La forma más útil de pensar una evaluación RAG es separarla en tres niveles.
Retrieval. ¿Los documentos o chunks correctos aparecen entre los resultados? Esta capa se puede evaluar sin llamar al modelo generativo.
Generación. Dado un contexto determinado, ¿la respuesta es correcta, relevante y fiel a la evidencia? Acá se prueba si el LLM usa bien lo que recibió.
Sistema completo. ¿La combinación de retrieval, reranking, prompt y generación funciona bajo condiciones parecidas a producción, con una latencia y costo aceptables?
Esa separación ahorra muchísimo tiempo de debugging. Si el recall cae, no tiene sentido ajustar el prompt. Si el contexto es correcto pero la respuesta inventa datos, no hace falta reconstruir el índice.
Qué medir en retrieval
Las métricas tradicionales de búsqueda siguen siendo fundamentales.
- Recall@k: mide si el documento relevante aparece entre los primeros k resultados.
- Precision@k: mide cuánto ruido llega al contexto del LLM.
- MRR: premia que el primer resultado correcto esté arriba.
- nDCG: resulta útil cuando hay varios documentos relevantes y distintos niveles de relevancia.
- Cobertura: mide qué porcentaje de preguntas realmente tiene evidencia recuperable en el corpus.
En la práctica, un RAG suele priorizar recall antes que precision. El reranker puede ordenar candidatos que ya recuperaste, pero no puede recuperar un documento que nunca entró en el top-k.
Por ejemplo: si la política de cancelación está en el puesto 12 y tu pipeline solo pasa cinco chunks al LLM, no importa qué tan bueno sea el modelo. La evidencia no llegó.
Qué medir en la respuesta
Una respuesta puede ser fluida, relevante y aun así ser incorrecta. Por eso conviene medir dimensiones separadas:
- Faithfulness o groundedness: las afirmaciones de la respuesta se pueden respaldar con el contexto recuperado.
- Answer relevancy: la respuesta contesta realmente la pregunta.
- Correctitud factual: coincide con una respuesta de referencia cuando existe una.
- Calidad de citas: señala la fuente correcta y suficientemente específica.
- Abstención: cuando no hay evidencia, el sistema lo reconoce en vez de inventar.
La abstención merece atención especial. Una respuesta como “No encontré información suficiente en la documentación disponible” puede ser mucho mejor que una respuesta precisa en apariencia, pero falsa.
Empezá con un dataset pequeño y realista
No necesitás miles de ejemplos para encontrar fallas importantes. Un buen punto de partida son entre 50 y 100 consultas reales o representativas.
Cada caso debería incluir:
Pregunta
Respuesta esperada, si aplica
Documento o chunks relevantes
Tipo de consulta
Resultado esperado: responder o abstenerseEl dataset debería contener preguntas simples, consultas ambiguas, preguntas que requieren combinar dos documentos, términos exactos como códigos o nombres de producto, contenido desactualizado y casos sin respuesta.
Los datos sintéticos sirven para arrancar o aumentar cobertura, pero no deberían ser la única fuente. Las consultas reales suelen tener ambigüedad, errores de escritura y vocabulario que ningún generador de preguntas reproduce del todo bien.
Un test reproducible con DeepEval
DeepEval permite tratar las evaluaciones como tests: definís un caso, métricas y umbrales mínimos. Es una opción cómoda para integrar en un proyecto Python.
from deepeval import assert_test
from deepeval.metrics import (
AnswerRelevancyMetric,
ContextualRecallMetric,
FaithfulnessMetric,
)
from deepeval.test_case import LLMTestCase
case = LLMTestCase(
input="¿Cuál es el plazo de cancelación?",
actual_output="Podés cancelar hasta 30 días antes del inicio.",
expected_output=(
"La cancelación se permite hasta 30 días antes del inicio."
),
retrieval_context=[
"Las reservas pueden cancelarse sin cargo hasta "
"30 días antes del inicio."
],
)
assert_test(
case,
metrics=[
ContextualRecallMetric(threshold=0.8),
FaithfulnessMetric(threshold=0.9),
AnswerRelevancyMetric(threshold=0.8),
],
)Este test revisa tres cosas distintas. ContextualRecallMetric detecta si el contexto contiene la evidencia necesaria; FaithfulnessMetric detecta afirmaciones que no están sustentadas; y AnswerRelevancyMetric revisa si la salida responde la consulta.
Los umbrales no son universales. Un asistente interno de bajo riesgo puede tolerar otro nivel que un sistema para soporte legal, financiero o médico. Lo importante es elegirlos explícitamente y versionarlos junto con el dataset.
El mismo caso con Ragas
Ragas está especialmente orientado a evaluar aplicaciones RAG. Su API actual permite puntuar cada interacción con un modelo evaluador.
import asyncio
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from ragas.metrics.collections import ContextRecall, Faithfulness
async def evaluar_respuesta():
evaluator_llm = llm_factory(
"gpt-4o-mini",
client=AsyncOpenAI(),
)
pregunta = "¿Cuál es el plazo de cancelación?"
contexto = [
"Las reservas pueden cancelarse sin cargo hasta "
"30 días antes del inicio."
]
respuesta_esperada = (
"La cancelación se permite hasta 30 días antes del inicio."
)
respuesta_rag = (
"Podés cancelar hasta 30 días antes del inicio."
)
context_recall = ContextRecall(llm=evaluator_llm)
faithfulness = Faithfulness(llm=evaluator_llm)
recall = await context_recall.ascore(
user_input=pregunta,
retrieved_contexts=contexto,
reference=respuesta_esperada,
)
fidelity = await faithfulness.ascore(
user_input=pregunta,
response=respuesta_rag,
retrieved_contexts=contexto,
)
print(f"Context recall: {recall.value:.2f}")
print(f"Faithfulness: {fidelity.value:.2f}")
asyncio.run(evaluar_respuesta())ContextRecall pregunta si el contexto recuperado contiene la evidencia necesaria para producir la respuesta de referencia. Faithfulness toma la respuesta generada y verifica si cada afirmación se puede inferir del contexto. Los dos scores van de 0 a 1: un número bajo apunta a una falla distinta y, por lo tanto, a un arreglo distinto.
DeepEval y Ragas resuelven problemas similares. Elegí una para empezar, definí un conjunto de casos representativos y hacé que cada cambio pase por las mismas pruebas. Sumar herramientas antes de tener un dataset sólido rara vez mejora la calidad.
Observabilidad: detectar degradaciones antes de que lleguen al usuario
Los tests responden si una versión nueva es mejor o peor sobre un conjunto conocido. La observabilidad responde otra pregunta: ¿qué está ocurriendo en producción ahora mismo?
OpenTelemetry permite registrar trazas y métricas de cada etapa del pipeline: extracción, limpieza, chunking, embeddings, upsert al vector store, retrieval y generación. El punto no es guardar todo. Es poder explicar una degradación sin adivinar.
Este ejemplo instrumenta el proceso de indexación. Asume que chunk_document, embed y upsert_vectors son funciones de tu aplicación y que el SDK de OpenTelemetry ya está configurado para exportar datos por OTLP.
import time
from opentelemetry import metrics, trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("rag.indexer")
meter = metrics.get_meter("rag.indexer")
chunks_indexed = meter.create_counter(
"rag.indexing.chunks",
description="Cantidad de chunks enviados al vector store",
)
embedding_latency = meter.create_histogram(
"rag.embedding.duration",
unit="s",
description="Latencia de cada lote de embeddings",
)
indexing_failures = meter.create_counter(
"rag.indexing.failures",
description="Errores durante el proceso de indexación",
)
def index_document(document):
with tracer.start_as_current_span("rag.index_document") as root_span:
root_span.set_attribute("rag.document.id", document.id)
root_span.set_attribute("rag.document.source", document.source)
chunks = chunk_document(document.text)
root_span.set_attribute("rag.chunk.count", len(chunks))
try:
with tracer.start_as_current_span("rag.create_embeddings") as span:
span.set_attribute("gen_ai.operation.name", "embeddings")
span.set_attribute(
"gen_ai.request.model",
"text-embedding-3-large",
)
span.set_attribute("rag.embedding.batch_size", len(chunks))
started_at = time.perf_counter()
vectors = embed(chunks)
elapsed = time.perf_counter() - started_at
span.set_attribute(
"gen_ai.embeddings.dimension.count",
len(vectors[0]),
)
embedding_latency.record(
elapsed,
attributes={"model": "text-embedding-3-large"},
)
with tracer.start_as_current_span("rag.upsert_vectors") as span:
span.set_attribute("rag.vector_store", "my-vector-store")
span.set_attribute("rag.vector.count", len(vectors))
upsert_vectors(document.id, chunks, vectors)
chunks_indexed.add(
len(chunks),
attributes={"source": document.source},
)
except Exception as error:
root_span.record_exception(error)
root_span.set_status(Status(StatusCode.ERROR, str(error)))
indexing_failures.add(
1,
attributes={"stage": "index_document"},
)
raiseEl span padre, rag.index_document, muestra el tiempo total por documento. Los spans hijos separan la creación de embeddings del guardado en el vector store. Si una indexación se vuelve lenta, podés saber si el problema fue el proveedor de embeddings, un batch demasiado grande o la base vectorial.
El histograma de latencia permite alertar ante degradaciones; los contadores muestran throughput y errores; los atributos facilitan filtrar por modelo, fuente o etapa. OpenTelemetry define convenciones para operaciones de embeddings y atributos como modelo y dimensiones, por lo que conviene usarlas cuando estén disponibles. Consultá la documentación de instrumentación y las convenciones GenAI.
No registres documentos completos, embeddings, prompts sin filtrar ni datos personales como atributos de una traza. Usá IDs, hashes y metadata de baja cardinalidad. En producción, enviá la telemetría a un OpenTelemetry Collector y desde ahí al backend que uses; es el patrón recomendado para exportar trazas y métricas. OpenTelemetry explica cómo configurar exporters.
Convertir la evaluación en una red de seguridad
Cada cambio de embeddings, chunking, modelo, índice, top-k, reranker o prompt debería ejecutar el mismo dataset de evaluación. Compará los resultados con un baseline, definí qué caídas bloquean un despliegue y revisá manualmente los casos que empeoran.
No alcanza con un promedio. Un cambio puede elevar el score global y al mismo tiempo romper las preguntas que más importan: políticas de devolución, condiciones comerciales, documentación técnica o requisitos de compliance.
La calidad de un RAG no es una propiedad fija del modelo. Es una disciplina continua: medir antes de desplegar, observar después de desplegar y convertir cada falla real en un nuevo caso de evaluación.
La pregunta no es si tu RAG respondió bien hoy. Es si podés demostrar que el próximo cambio no rompió las respuestas que importan.