# Evaluación de RAG — RAGAS y métricas de producción

Guía completa para medir, diagnosticar y mejorar la calidad de pipelines RAG antes
y después de su despliegue en producción.

---

## RAGAS — métricas fundamentales

RAGAS (Retrieval Augmented Generation Assessment) evalúa el pipeline RAG en cuatro
dimensiones independientes.

| Métrica | Qué mide | Umbral mínimo | Síntoma si es bajo |
|---------|----------|--------------|-------------------|
| `faithfulness` | ¿La respuesta está respaldada por el contexto? | > 0.80 | El modelo alucina datos no presentes |
| `answer_relevancy` | ¿La respuesta responde la pregunta? | > 0.75 | Respuestas genéricas o fuera de tema |
| `context_precision` | ¿Los chunks recuperados son relevantes? | > 0.70 | El retriever trae ruido |
| `context_recall` | ¿Se recuperó suficiente contexto? | > 0.70 | Respuestas incompletas por falta de contexto |

### Instalación

```bash
pip install ragas datasets langchain-openai
```

### Dataset de evaluación mínimo

El dataset requiere al menos: pregunta, respuesta generada, contextos recuperados,
y ground truth (respuesta correcta esperada).

```python
from datasets import Dataset

# Formato requerido por RAGAS
datos_evaluacion = {
    "question": [
        "¿Cuántos días de vacaciones corresponden al primer año?",
        "¿Cuál es el proceso para solicitar home office?",
        "¿Qué cobertura incluye el seguro médico?",
    ],
    "answer": [
        "Según la política de la empresa, los empleados reciben 12 días de vacaciones...",
        "Para solicitar home office debes completar el formulario HR-045...",
        "El seguro cubre consultas médicas, hospitalización y medicamentos genéricos...",
    ],
    "contexts": [
        ["...texto del chunk recuperado 1...", "...texto del chunk recuperado 2..."],
        ["...texto del chunk recuperado 3..."],
        ["...texto del chunk recuperado 4...", "...texto del chunk recuperado 5..."],
    ],
    "ground_truth": [
        "Los empleados con menos de un año de antigüedad reciben 12 días de vacaciones proporcionales.",
        "El proceso requiere llenar el formulario HR-045 y aprobación del gerente directo con 5 días de anticipación.",
        "El seguro cubre consultas con médicos de la red, hospitalización hasta 30 días y medicamentos del cuadro básico.",
    ],
}

dataset = Dataset.from_dict(datos_evaluacion)
```

### Ejecutar evaluación completa

```python
from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# RAGAS requiere un LLM para calcular las métricas
resultado = evaluate(
    dataset=dataset,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
    llm=ChatOpenAI(model="gpt-4o-mini"),
    embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),
)

print(resultado)
# {'faithfulness': 0.87, 'answer_relevancy': 0.82, 'context_precision': 0.79, 'context_recall': 0.74}

# Exportar a DataFrame para análisis
df = resultado.to_pandas()
df.to_csv("reporte_ragas.csv", index=False)
```

### Construir el dataset de evaluación desde cero

```python
import json
from pathlib import Path

def generar_dataset_evaluacion(
    corpus_path: str,
    num_preguntas: int = 50,
    llm: ChatOpenAI = None,
) -> Dataset:
    """
    Genera preguntas y ground truths automáticamente desde el corpus.
    Revisar manualmente el 20% de los pares generados antes de usar.
    """
    from ragas.testset.generator import TestsetGenerator
    from ragas.testset.evolutions import simple, multi_context, reasoning

    loader = PyPDFLoader(corpus_path)
    documentos = loader.load()

    generador = TestsetGenerator.from_langchain(
        generator_llm=llm,
        critic_llm=llm,
        embeddings=OpenAIEmbeddings(),
    )

    testset = generador.generate_with_langchain_docs(
        documentos,
        test_size=num_preguntas,
        distributions={simple: 0.5, multi_context: 0.3, reasoning: 0.2},
    )
    return testset.to_dataset()
```

---

## Hallucination detection

Las alucinaciones ocurren cuando el modelo genera información no respaldada en el
contexto recuperado. Detectarlas antes de producción es crítico.

### Síntomas de alucinación

- `faithfulness` < 0.7 en RAGAS.
- La respuesta menciona datos numéricos, fechas o nombres que no aparecen en los chunks.
- La respuesta dice "según el documento" pero el chunk no contiene esa información.

### Validación post-generación

```python
from langchain_core.prompts import PromptTemplate

PLANTILLA_VERIFICACION = PromptTemplate.from_template("""
Dado el siguiente contexto y la respuesta generada, determina si CADA afirmación
de la respuesta está respaldada textualmente en el contexto.

Contexto:
{contexto}

Respuesta generada:
{respuesta}

Responde únicamente con JSON:
{{
  "respaldada": true/false,
  "afirmaciones_sin_respaldo": ["lista de afirmaciones no encontradas en el contexto"]
}}
""")

async def verificar_faithfulness(contexto: str, respuesta: str, llm) -> dict:
    cadena = PLANTILLA_VERIFICACION | llm
    resultado = await cadena.ainvoke({"contexto": contexto, "respuesta": respuesta})
    return json.loads(resultado.content)
```

### Mitigación de alucinaciones

1. **Temperatura = 0**: obligatorio para tareas factuales.
2. **Prompt con instrucción explícita**: "Responde solo con información del contexto.
   Si no está en el contexto, di exactamente: 'No encontré información sobre esto'."
3. **Aumentar k en el retriever**: recuperar más chunks (4→8) reduce el riesgo de
   que la información relevante no esté en el contexto.
4. **Reranking**: usar un modelo de reranking (Cohere Rerank, BGE Reranker) para
   ordenar los chunks por relevancia antes de pasar al LLM.

```python
# Ejemplo con Cohere Rerank
from langchain_cohere import CohereRerank
from langchain.retrievers import ContextualCompressionRetriever

reranker = CohereRerank(model="rerank-multilingual-v3.0", top_n=4)
retriever_con_rerank = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 10}),
)
```

---

## A/B testing de configuraciones RAG

Metodología para comparar variantes de configuración de forma sistemática.

### Variables a probar

| Variable | Valores a comparar | Métrica de impacto |
|----------|-------------------|-------------------|
| chunk_size | 500, 1000, 1500 | context_precision, context_recall |
| overlap | 0, 100, 200 | context_recall |
| k (retriever) | 2, 4, 6, 8 | faithfulness, answer_relevancy |
| Modelo embedding | text-embedding-3-small vs voyage-3 | context_precision |
| Modelo LLM | gpt-4o-mini vs gpt-4o | faithfulness, answer_relevancy |

### Framework de comparación

```python
from dataclasses import dataclass
from ragas import evaluate

@dataclass
class ConfigRAG:
    chunk_size: int
    chunk_overlap: int
    k_retriever: int
    modelo_embedding: str
    modelo_llm: str

def evaluar_configuracion(config: ConfigRAG, dataset: Dataset) -> dict:
    """Evalúa una configuración RAG y retorna sus métricas."""
    embeddings = OpenAIEmbeddings(model=config.modelo_embedding)
    llm = ChatOpenAI(model=config.modelo_llm, temperature=0)

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=config.chunk_size,
        chunk_overlap=config.chunk_overlap,
    )
    # Re-indexar con la nueva configuración
    chunks = splitter.split_documents(documentos_corpus)
    vs = PGVector.from_documents(chunks, embeddings, connection_string=DATABASE_URL,
                                  collection_name=f"test_{config.chunk_size}")

    # Generar respuestas con esta configuración
    cadena = crear_cadena_rag(vs.as_retriever(search_kwargs={"k": config.k_retriever}), llm)
    respuestas = [cadena.invoke(q) for q in dataset["question"]]

    # Evaluar
    dataset_con_respuestas = dataset.map(lambda x, i: {**x, "answer": respuestas[i]},
                                          with_indices=True)
    resultado = evaluate(dataset_con_respuestas,
                         metrics=[faithfulness, answer_relevancy, context_precision])
    return {
        "config": config,
        "faithfulness": resultado["faithfulness"],
        "answer_relevancy": resultado["answer_relevancy"],
        "context_precision": resultado["context_precision"],
        "score_global": (resultado["faithfulness"] + resultado["answer_relevancy"]
                         + resultado["context_precision"]) / 3,
    }

# Comparar múltiples configuraciones
configuraciones = [
    ConfigRAG(500, 50, 4, "text-embedding-3-small", "gpt-4o-mini"),
    ConfigRAG(1000, 200, 4, "text-embedding-3-small", "gpt-4o-mini"),
    ConfigRAG(1000, 200, 6, "text-embedding-3-small", "gpt-4o-mini"),
]
resultados = [evaluar_configuracion(c, dataset_eval) for c in configuraciones]
mejor = max(resultados, key=lambda r: r["score_global"])
```

---

## Métricas de producción

Una vez en producción, monitorear continuamente para detectar degradación.

### Latencia

```python
import time
import structlog

logger = structlog.get_logger()

async def query_rag_con_telemetria(pregunta: str, cadena) -> dict:
    inicio = time.perf_counter()

    respuesta = await cadena.ainvoke(pregunta)

    latencia_ms = (time.perf_counter() - inicio) * 1000
    logger.info(
        "query_rag",
        latencia_ms=round(latencia_ms, 1),
        longitud_respuesta=len(respuesta),
        pregunta_hash=hashlib.md5(pregunta.encode()).hexdigest()[:8],
    )

    return {"respuesta": respuesta, "latencia_ms": latencia_ms}
```

### Indicadores clave de producción

| Métrica | Objetivo | Alerta si |
|---------|----------|-----------|
| Latencia p50 | < 2 s | > 3 s |
| Latencia p95 | < 5 s | > 8 s |
| Tokens por query (promedio) | < 2000 | > 4000 |
| Tasa de "No encontré información" | < 15% | > 25% |
| Tasa de rechazo (guardrails) | < 2% | > 5% |

### Detección de degradación

Ejecutar RAGAS en una muestra aleatoria del 5% de las queries de producción
cada semana. Si `faithfulness` baja más de 0.05 puntos respecto a la línea base,
investigar cambios en el corpus o en el modelo.

```python
# Registrar muestra aleatoria para re-evaluación periódica
import random

async def query_con_muestreo(pregunta: str, cadena, tasa_muestreo: float = 0.05):
    resultado = await query_rag_con_telemetria(pregunta, cadena)

    if random.random() < tasa_muestreo:
        # Guardar para evaluación asíncrona posterior
        await cola_evaluacion.enqueue({
            "pregunta": pregunta,
            "respuesta": resultado["respuesta"],
            "timestamp": datetime.utcnow().isoformat(),
        })

    return resultado
```
