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:
- Los logs de producción, si ya tienes tráfico. Son las preguntas reales, con sus faltas de ortografía y su jerga interna.
- Las preguntas que el equipo de soporte contesta cada semana. Vienen con la fuente correcta incorporada, porque alguien ya la buscó a mano.
- 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étrica | Pregunta que responde | Cuá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é.