Ir al contenido

Datasets sintéticos: el LLM se inventa preguntas

Tu golden set tiene 12 entries. Querés 100 para tener más señal estadística. ¿Las escribís vos a mano? Te lleva una tarde.

Otra opción: que el LLM las invente. Es rápido (5 minutos), escalable (mañana querés 500, las pedís) y no requiere cerebro humano.

Spoiler: tiene un costo. Los datasets sintéticos meten un sesgo específico que infla las métricas falsamente. Vamos a generarlos, ver el sesgo en vivo, y decidir cuándo se usan y cuándo no.

La idea es simple:

  1. Cargás el corpus.
  2. Lo chunkeás (con el mismo chunker que usás en producción).
  3. Para cada chunk, mandás al LLM: “leé este chunk y escribí una pregunta cuya respuesta esté contenida acá. Devolvé también la respuesta extraída del chunk.”
  4. Parseás el JSON y armás un EvalCase con expected_filenames apuntando al archivo de origen del chunk.
  5. Repetís hasta tener N entries.

El comando del curso:

Ventana de terminal
pnpm --filter @rag-lab/06-evals run synth -- --n 20

Tarda ~3 minutos para 20 entries (1 LLM call por entry). Output en packages/06-evals/golden/synthetic.json.

Vive en packages/06-evals/src/synth.ts:

synth.ts: prompt template
const PROMPT_TEMPLATE = `Generás pares de pregunta + respuesta sintéticos para evaluar un sistema RAG.
Te paso un fragmento (CHUNK) de un documento técnico en español. Tenés que escribir
UNA pregunta cuya respuesta esté contenida en ese chunk, y la respuesta tomada del chunk.
Respondé EXACTAMENTE con este JSON (sin texto adicional, sin bloques de código):
{"question": "...", "answer": "..."}
La pregunta:
- en español
- específica al contenido del chunk (no genérica)
- no incluye frases como "según el texto" o "en este chunk"
La respuesta:
- en español
- extracto fiel o paráfrasis del chunk
- 1 a 3 oraciones, un solo párrafo
CHUNK:
`;

Tres detalles del prompt que importan:

  • específica al contenido del chunk (no genérica): sin esto, el LLM tiende a inventar preguntas tipo “¿qué se discute en este texto?” — inútiles.
  • no incluye frases como "según el texto": el modelo es vago por defecto. Eliminamos las muletillas que hacen las preguntas no-naturales.
  • JSON cerrado, no markdown: si el LLM devuelve ```json ... ```, el parser falla. El prompt explícito pide JSON pelado.
synth.ts: parser
const match = data.response.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
const parsed = JSON.parse(match[0]) as Partial<SynthResult>;
if (typeof parsed.question === 'string' && typeof parsed.answer === 'string') {
return { question: parsed.question, answer: parsed.answer };
}
return null;
} catch {
return null;
}

Tres cosas que pasan en producción y por eso el parser es así:

  • El LLM mete prosa antes o después del JSON. El regex captura el primer {...} y descarta el resto.
  • El JSON viene mal formado (comilla suelta, comma trailing). El try/catch lo maneja silencioso — la entry se descarta y el script sigue.
  • Faltan campos. El check explícito de tipos rechaza la entry.

El script imprime cuántas se descartaron al final. Para n=20 esperá perder 1-3 entries por parseo fallido. Si perdés más de la mitad, algo anda mal con el modelo o el prompt.

Acá está la parte que importa entender. Mirá un par de ejemplos generados sobre el corpus del curso:

{
"id": "synth-clean-architecture-2",
"question": "¿Qué establece la regla de dependencia respecto a las direcciones permitidas para las dependencias del código fuente?",
"expected_answer": "La regla de dependencia establece que las dependencias del código fuente sólo pueden apuntar hacia adentro.",
"expected_filenames": ["clean-architecture.md"]
}

Y el chunk de origen:

La regla de dependencia establece que las dependencias del código fuente sólo pueden apuntar hacia adentro. Nada en una capa interior puede saber absolutamente nada sobre algo en una capa exterior…

¿Notás algo? La pregunta usa el mismo vocabulario que el chunk. “Regla de dependencia”, “dependencias del código fuente”, “direcciones permitidas” — todo aparece literal o casi literal.

Cuando un usuario real hace esa pregunta, no la formula así. Diría algo como:

“¿En qué dirección apuntan las dependencias en Clean Architecture?”

Diferentes palabras, mismo significado. Y los embeddings densos son mejores capturando significado idéntico que parafraseado. Para una pregunta sintética, el embedding va a matchear el chunk de origen casi perfecto. Score altísimo. Retrieval@k = 1.0 trivialmente.

Tu retriever no es así de bueno — es que el dataset está sesgado a su favor.

A pesar del sesgo, tienen lugar:

  • Smoke tests post-deploy. Despues de un cambio de infra (docker rebuild, dependency upgrade), querés saber si el RAG sigue respondiendo coherente. Un eval de 50 sintéticos en 5 minutos es ideal — si el promedio cae mucho, algo se rompió.
  • Tests de regresión. Cambiaste el chunker. ¿Las respuestas cambiaron de forma sospechosa? Un sintético constante (con --seed fijo para reproducibilidad) te da una baseline barata.
  • Coverage check. ¿Tu retriever encuentra TODOS los archivos del corpus o hay alguno “muerto”? Generá 1 sintético por chunk y mirá si retrieval@k = 1.0 para todos. Si alguno está sistemáticamente bajo, ese archivo tiene problemas (encoding, idioma, formato).
  • Benchmark publicado. Si ponés un número en un blog post o comparación, querés golden curado.
  • Decisión de tuning. Si vas a cambiar TOP_K de 4 a 6 basado en métricas, las métricas tienen que venir del golden, no del sintético.
  • A/B test contra otra config. Mismo argumento.

Generá 20 sintéticos:

Ventana de terminal
pnpm --filter @rag-lab/06-evals run synth -- --n 20 --seed 42

Después, corré dos evals:

Ventana de terminal
# Con el golden curado a mano
pnpm --filter @rag-lab/06-evals run eval -- --only vercel-ai-sdk
# Con los sintéticos
pnpm --filter @rag-lab/06-evals run eval -- --only vercel-ai-sdk \
--dataset packages/06-evals/golden/synthetic.json

Ya sabés medir. Sabés cuál métrica significa qué. Sabés cuándo confiar en los números y cuándo no.

Ahora viene la parte que hace que valga la pena haber armado todo esto: comparar configuraciones. ¿Qué pasa cuando bajás TOP_K de 4 a 2? ¿Cuando subís el chunk size? ¿Cuando cambiás el modelo de embeddings? Eso es el último capítulo del Nivel 2.