volver al blog

Medir el retriever antes de tocar el modelo

ragevaluaciónretrieval

El fallo que se disfraza de alucinación

El usuario pregunta por la política de devoluciones y el modelo responde algo plausible, educado y falso. El primer reflejo del equipo suele ser el mismo: tocar el prompt, subir la temperatura, cambiar de modelo, añadir una instrucción en mayúsculas rogando que no se invente nada.

Casi siempre el problema estaba tres pasos antes. El fragmento correcto nunca llegó al contexto. El modelo no alucinó: respondió lo mejor que pudo con lo que le diste, que no incluía la respuesta.

Esa distinción no es filosófica, es operativa. Un fallo de recuperación y un fallo de generación se arreglan en sitios distintos, con presupuestos distintos. Y solo puedes separarlos si mides el retriever por su cuenta, sin el modelo delante tapando el resultado.

El conjunto dorado es el trabajo de verdad

Antes que ninguna métrica necesitas un conjunto de consultas con su respuesta conocida: qué documentos deberían haber salido para cada pregunta. Aquí es donde la mayoría de los proyectos se detienen, porque etiquetar es aburrido y no sale en la demo.

Se hace en una tarde si aceptas dos concesiones. La primera: 100 consultas bien elegidas valen más que 1.000 generadas a granel. La segunda: no necesitas marcar todos los documentos relevantes del corpus, solo los que sabes que lo son. Es un conjunto incompleto y está bien; lo usarás para comparar configuraciones entre sí, no para publicar un artículo académico.

De dónde sacar las consultas, por orden de utilidad:

  1. Los logs de producción, si ya tienes tráfico. Son las preguntas reales, con sus faltas de ortografía y su jerga interna.
  2. Las preguntas que el equipo de soporte contesta cada semana. Vienen con la fuente correcta incorporada, porque alguien ya la buscó a mano.
  3. Consultas sintéticas desde tus propios documentos: le pides a un LLM que genere la pregunta que ese fragmento responde. Rápido y útil para cubrir huecos, pero sesgado: el modelo tiende a copiar el vocabulario del texto, y tus usuarios no lo hacen.

Reserva un 20 % de consultas difíciles a propósito: sinónimos que tu corpus no usa, preguntas cuya respuesta está repartida en dos documentos, preguntas cuya respuesta no está en el corpus. Esas últimas son las más valiosas, porque miden si tu sistema sabe callarse.

Tres métricas, tres preguntas distintas

No son intercambiables ni hay una mejor. Cada una responde a una pregunta concreta sobre el sistema, y la que te importe depende de qué hagas después con los resultados.

MétricaPregunta que respondeCuándo es la que manda
recall@k¿Está el documento correcto entre los k primeros?Hay reranker o el modelo lee los k fragmentos enteros
MRR¿En qué posición aparece el primer acierto?Una sola respuesta correcta y el orden importa
nDCG@k¿Están los mejores arriba, con relevancia graduada?Varios documentos relevantes con distinto valor

La regla práctica: recall@k es tu techo. Si el documento correcto no está entre los k que recuperas, ningún reranker, ninguna reescritura de consulta y ningún prompt lo va a rescatar. Optimiza el techo primero; el orden dentro de los k es un problema posterior y más barato.

MRR y nDCG te dicen cuánto margen te queda una vez el techo es aceptable. Un recall@20 del 95 % con un MRR de 0,3 significa que tienes la información y la estás enterrando: ese es un problema de reranking, no de recuperación.

Cincuenta líneas y un número

La implementación no tiene misterio. Lo que importa es que sea tuya y la ejecutes en cada cambio, no que use una librería con nombre bonito.

import math

def recall_at_k(recuperados: list[str], relevantes: set[str], k: int) -> float:
    """Fracción de documentos relevantes que aparecen en el top-k."""
    if not relevantes:
        return 0.0
    aciertos = len(set(recuperados[:k]) & relevantes)
    return aciertos / len(relevantes)

def reciprocal_rank(recuperados: list[str], relevantes: set[str]) -> float:
    """1/posición del primer acierto. 0 si no hay ninguno."""
    for posicion, doc_id in enumerate(recuperados, start=1):
        if doc_id in relevantes:
            return 1.0 / posicion
    return 0.0

def ndcg_at_k(recuperados: list[str], grados: dict[str, int], k: int) -> float:
    """Relevancia graduada: 0 = irrelevante, 1 = útil, 2 = respuesta directa."""
    def dcg(ids: list[str]) -> float:
        # El descuento logarítmico penaliza cada posición que bajas.
        return sum(
            (2 ** grados.get(doc, 0) - 1) / math.log2(i + 1)
            for i, doc in enumerate(ids, start=1)
        )
    ideal = sorted(grados, key=lambda d: grados[d], reverse=True)[:k]
    denominador = dcg(ideal)
    return dcg(recuperados[:k]) / denominador if denominador else 0.0

def evaluar(consultas, retriever, k: int = 10) -> dict[str, float]:
    recalls, rrs, ndcgs = [], [], []
    for c in consultas:
        # El retriever devuelve solo ids ordenados: nada de llamar al LLM aquí.
        resultados = retriever(c["texto"], k=k)
        relevantes = {d for d, g in c["grados"].items() if g > 0}
        recalls.append(recall_at_k(resultados, relevantes, k))
        rrs.append(reciprocal_rank(resultados, relevantes))
        ndcgs.append(ndcg_at_k(resultados, c["grados"], k))
    n = len(consultas)
    return {
        f"recall@{k}": sum(recalls) / n,
        "mrr": sum(rrs) / n,
        f"ndcg@{k}": sum(ndcgs) / n,
    }

Dos detalles que se pasan por alto. El primero: la relevancia graduada con tres niveles (irrelevante, útil, respuesta directa) cuesta lo mismo de etiquetar que la binaria y hace que nDCG diga algo. Con relevancia binaria, nDCG y MRR acaban midiendo casi lo mismo. El segundo: el 2 ** grado - 1 del numerador es lo que hace que un documento de grado 2 valga el triple que uno de grado 1, no el doble. Si cambias esa fórmula, tus números dejan de ser comparables con los de cualquier otro.

Interpretar sin engañarte

Un número suelto no significa nada. Lo que informa es la comparación entre dos configuraciones sobre el mismo conjunto, y la distribución por detrás de la media.

Mira siempre la cola. Una media de recall@10 de 0,82 puede esconder que el 15 % de las consultas tiene recall 0: no es un sistema que funciona regular, es un sistema que funciona bien salvo en un grupo concreto de preguntas. Agrupa esas consultas fallidas y busca qué tienen en común. Casi siempre hay un patrón: acrónimos internos, preguntas negativas, documentos con tablas que el parser destrozó.

Y desconfía de las mejoras pequeñas. Con 100 consultas, una diferencia de dos puntos entre dos configuraciones está dentro del ruido. Si el cambio no se nota, no lo despliegues solo porque el número subió.

Qué hacer el lunes

Coge 30 preguntas de tu canal de soporte, anota a mano qué documento las responde y ejecuta tu retriever contra ellas. Con 30 basta para la primera señal: si tu recall@10 está por debajo de 0,7, cualquier trabajo que hagas sobre el prompt esta semana es tiempo mal invertido.

Después, congela ese conjunto en el repositorio y haz que la evaluación corra en CI con cada cambio del pipeline de ingesta. El valor no está en el número de hoy, sino en enterarte el día que baje sin que nadie sepa por qué.