Medir el retriever exige preguntas que nadie ha escrito
El consejo de medir el recall antes de tocar el prompt es correcto y casi nadie lo sigue. No por pereza: por aritmética. Para calcular recall@10 necesitas pares de pregunta y fragmento correcto, y un conjunto decente arranca en unos 150 pares. A cinco minutos por par entre leer, redactar y verificar, son 12 horas y media de alguien que conoce el dominio. Ese alguien nunca tiene 12 horas.
Así que el proyecto sale a producción sin línea base y el equipo evalúa a ojo durante meses. Cada cambio de chunking es una corazonada con factura.
Hay una salida intermedia que funciona mejor de lo que su mala fama sugiere: generar el conjunto desde el propio corpus. No sustituye al tráfico real, pero convierte “esto parece mejor” en un número que se mueve.
Invertir la dirección: del fragmento a la pregunta
En producción el flujo va de la pregunta al fragmento. Para fabricar datos de evaluación se recorre al revés: coges un fragmento que ya está indexado, le pides a un modelo que escriba la pregunta que ese fragmento respondería y te guardas el par.
La etiqueta sale gratis. Sabes cuál es el fragmento correcto porque es el que usaste de semilla. Eso es exactamente lo que hace caro el etiquetado manual y lo que aquí desaparece.
Hay una trampa evidente en el planteamiento y conviene nombrarla ya: el modelo está mirando la respuesta cuando escribe la pregunta. Tiende a producir preguntas que calcan el vocabulario del documento, y ese es justo el caso fácil para un retriever. Sin un filtro delante, el conjunto mide lo que ya sabes que funciona. Volvemos a ello en dos secciones.
Un generador mínimo
Nada de frameworks. Son 40 líneas y conviene que las entiendas enteras, porque cada decisión de aquí sesga la métrica de después.
import json
import random
from openai import OpenAI
cliente = OpenAI()
PROMPT = """Eres un empleado que consulta la documentación interna.
Lee el fragmento y escribe una sola pregunta que este fragmento responda por completo.
Reglas:
- Formula la pregunta como la escribiría alguien que no ha leído el fragmento.
- No copies frases literales del fragmento ni su terminología exacta.
- Nada de "según el documento" ni referencias al texto.
- Si el fragmento no contiene información suficiente, responde exactamente: DESCARTAR
Fragmento:
{texto}"""
def generar_pares(fragmentos, n=200, semilla=7):
# Muestreo aleatorio, no los primeros N: los primeros fragmentos de un
# corpus suelen ser portadas, índices y avisos legales, y ninguno de los
# tres representa lo que la gente pregunta.
random.seed(semilla)
muestra = random.sample(fragmentos, min(n, len(fragmentos)))
pares = []
for frag in muestra:
r = cliente.chat.completions.create(
model="gpt-5.6-mini",
messages=[{"role": "user", "content": PROMPT.format(texto=frag["texto"])}],
temperature=0.7, # variedad: a 0 salen 200 preguntas clónicas
)
pregunta = r.choices[0].message.content.strip()
if pregunta.upper().startswith("DESCARTAR"):
continue
pares.append({"pregunta": pregunta, "chunk_id": frag["id"]})
return pares
if __name__ == "__main__":
fragmentos = json.load(open("chunks.json"))
with open("eval_bruto.jsonl", "w") as f:
for p in generar_pares(fragmentos):
f.write(json.dumps(p, ensure_ascii=False) + "\n")
Dos detalles que parecen menores y no lo son. El DESCARTAR evita que un
fragmento de tabla de contenidos genere una pregunta imposible, y son más
frecuentes de lo que crees. Y la temperatura a 0,7 existe porque con muestreo
codicioso el modelo cae en una plantilla única —«¿Cuál es el procedimiento
para…?»— y acabas midiendo el retriever contra un solo tipo de frase.
El filtro es el trabajo de verdad
El fichero que acabas de escribir no es un conjunto de evaluación todavía. Es materia prima. Lo que lo convierte en dato utilizable es descartar, y hay tres pasadas que valen la pena.
Ida y vuelta. Pasa cada pregunta por tu propio retriever. Si el fragmento semilla no aparece en el top 50, el par es sospechoso: o la pregunta es ambigua, o el fragmento no la respondía de verdad. No lo descartes en silencio, apártalo a una lista y léela. Esa lista es el informe de fallos más honesto que vas a tener.
Unicidad. Si la pregunta la responden bien otros nueve fragmentos del corpus, la etiqueta de “correcto” es mentira y el recall que midas será pesimista sin motivo. Para corpus con mucha redundancia, acepta varios fragmentos válidos por pregunta y calcula el recall contra el conjunto.
Fuga de vocabulario. Mide el solapamiento léxico entre pregunta y fragmento. Si el 80 % de las palabras con carga semántica de la pregunta aparecen literalmente en el fragmento, el par es demasiado fácil y lo aprobaría hasta un BM25. Un umbral por encima del cual descartas, o mejor, un aviso al modelo para que reformule, arregla buena parte del sesgo que anunciábamos al invertir la dirección.
De 200 pares brutos suelen sobrevivir entre 120 y 150. Es un descarte sano; un filtro que no descarta no está filtrando.
Qué preguntas meter, y en qué proporción
Un conjunto formado solo por preguntas factuales de un párrafo mide un tercio de tu sistema. Esta es una distribución que aguanta bien en documentación corporativa:
| Tipo | Proporción | Qué pone a prueba | Cómo se genera |
|---|---|---|---|
| Factual de un fragmento | 50 % | Recuperación base | Un fragmento como semilla |
| De síntesis | 20 % | Recall multifragmento | Dos o tres fragmentos del mismo documento |
| Con jerga del usuario | 15 % | Desajuste de vocabulario | Reescribir la pregunta con sinónimos del negocio |
| Negativa | 10 % | Que el sistema sepa callarse | Pregunta plausible sin respuesta en el corpus |
| Con filtro temporal | 5 % | Metadatos y versiones | ”…en la política vigente de 2026” |
Las negativas son las que más rendimiento dan por línea escrita. Un RAG que nunca ha sido medido contra preguntas sin respuesta responde a todas, y ese es el fallo que llega a soporte.
Lo que un conjunto sintético no te dice
Conviene ser claro con los límites, porque el número es cómodo y engaña.
Tus preguntas sintéticas son gramaticales, completas y están bien escritas. Las de tus usuarios son de tres palabras, tienen erratas y a veces son dos preguntas en una. El recall@10 que midas aquí será optimista frente al de producción, y la diferencia no suele ser pequeña.
Tampoco captura la distribución real de intención: en un corpus de 1.000 documentos el grueso del tráfico suele concentrarse en unas pocas decenas, y el muestreo aleatorio reparte por igual. Si sabes cuáles son, sobrerrepresenta sus fragmentos en la muestra.
Y no mide la calidad de la respuesta generada. Mide recuperación, que es una condición necesaria y nada más.
Lo que sí te da, desde el primer día, es una medida comparable entre dos configuraciones. Para decidir si el chunking tardío mejora tu caso, eso basta.
Por dónde empezar
Genera 200 pares esta tarde, filtra hasta que te queden 120, congélalos en un fichero versionado en el repositorio y mide tu configuración actual. Ese número es tu línea base, aunque sea optimista.
A partir de ahí, la regla que importa: cada pregunta real que llegue por soporte, por un voto negativo o por una queja en una reunión entra en el conjunto y desplaza a una sintética. En seis meses el conjunto es mayoritariamente real y el andamio sintético habrá cumplido su función, que era permitirte medir antes de tener con qué.
Un conjunto imperfecto disponible hoy vale más que el perfecto que nadie va a etiquetar nunca.