← SCRAM AI Lab

Tutoriales

NL-to-SQL con Prisma: el patrón 2-pass

Single-pass NL→SQL alucina tablas y joins. El patrón 2-pass separa intent (qué quiere) de traducción (cómo). Sonnet 4.6 para pass 1, gpt-4o-mini para pass 2.

May 21, 2026

5 lecturas

Single-pass NL→SQL alucina y vas a pagarlo en producción

El demo es seductor: usuario pregunta "dame los 10 deals más grandes del trimestre" y el LLM escupe SQL. Funciona en el demo. En producción con un schema de 80 tablas, joins de tres niveles y nombres de columnas en español mexicano (estatus, monto_neto, fecha_alta), el modelo inventa tablas que no existen, joins que no se sostienen, y filtros sintácticamente válidos pero semánticamente equivocados. Y como la SQL parsea bien, el sistema corre la query y devuelve resultados que parecen correctos pero no lo son. Eso es peor que un error.

Por qué single-pass falla

Estás pidiendo al modelo que haga tres cosas a la vez: (1) entender el intent del usuario, (2) mapearlo al schema correcto, (3) producir SQL válido. Cada paso multiplica la oportunidad de alucinar. El modelo prioriza "producir SQL que se vea correcto" sobre "asegurarse de que el intent es ese". Y el output es un blob de texto que es difícil validar sin ejecutar.

El patrón 2-pass

  • Pass 1 — Intent y query spec: el modelo recibe la pregunta y un resumen del schema, y devuelve un objeto JSON estructurado describiendo qué quiere el usuario: entidad principal, agregación, filtros, ordenamiento, límite. No SQL todavía.
  • Pass 2 — Traducción a Prisma: con el query spec validado, un segundo modelo (puede ser más barato) traduce el spec a una query de Prisma. Como Prisma es type-safe y el spec ya está acotado, este pass casi no alucina.

Por qué dos modelos distintos

Pass 1 requiere razonamiento sobre intent ambiguo y conocimiento del dominio. Aquí Sonnet 4.6 sobresale. Pass 2 es traducción mecánica de un JSON bien definido a código Prisma. gpt-4o-mini hace esto bien y cuesta una fracción. Ahorras 60-70% del costo total sin perder calidad.

El prompt de Pass 1

Eres un analista que traduce preguntas a query specs estructurados.
Schema disponible (formato resumido):
- Contact { id, name, email, score, status, ownerId, createdAt }
- Deal { id, amount, stage, probability, contactId, closeDate, createdAt }
- Pipeline { id, name }
- ... (introspección automática del Prisma schema)

Pregunta del usuario:
{userQuestion}

Responde SOLO con JSON válido siguiendo este shape:
{
  "entity": "Contact" | "Deal" | "Pipeline" | ...,
  "operation": "list" | "count" | "sum" | "avg" | "groupBy",
  "filters": [{ "field": string, "op": "eq"|"gt"|"lt"|"contains"|"in", "value": any }],
  "joins": [string],
  "orderBy": { "field": string, "direction": "asc"|"desc" } | null,
  "limit": number | null,
  "groupBy": string | null,
  "confidence": 0..1,
  "clarificationNeeded": string | null
}

Si la pregunta es ambigua, pon clarificationNeeded en texto natural.

Validación del query spec

Antes de pasar a Pass 2, valida el spec contra schema introspection. Esto atrapa hallucinations sin necesidad de ejecutar la query:

function validateSpec(spec, schema) {
  const entity = schema.models[spec.entity];
  if (!entity) return { ok: false, error: `Unknown entity: ${spec.entity}` };

  for (const f of spec.filters) {
    if (!entity.fields[f.field]) {
      return { ok: false, error: `Field ${f.field} not in ${spec.entity}` };
    }
  }
  if (spec.orderBy && !entity.fields[spec.orderBy.field]) {
    return { ok: false, error: `OrderBy field ${spec.orderBy.field} invalid` };
  }
  for (const j of spec.joins) {
    if (!entity.relations[j]) {
      return { ok: false, error: `Relation ${j} not in ${spec.entity}` };
    }
  }
  return { ok: true };
}

Si falla, retroalimenta al Pass 1 con el error y pídele que corrija. Dos intentos máximo; si no cuadra, devuelve clarification al usuario.

Pass 2: traducción a Prisma

Aquí el modelo recibe el spec validado y produce código Prisma. Como el shape está acotado y Prisma es type-safe, las opciones de error se desploman:

// Spec validado
{
  entity: "Deal",
  operation: "list",
  filters: [
    { field: "stage", op: "in", value: ["negotiation", "proposal"] },
    { field: "amount", op: "gt", value: 50000 }
  ],
  joins: ["contact"],
  orderBy: { field: "amount", direction: "desc" },
  limit: 10
}

// Pass 2 output
prisma.deal.findMany({
  where: { stage: { in: ["negotiation", "proposal"] }, amount: { gt: 50000 } },
  include: { contact: true },
  orderBy: { amount: "desc" },
  take: 10
});

Por qué Prisma y no SQL crudo

Prisma te da type-safety en el output, validación automática contra el schema, y protección contra SQL injection sin que el modelo tenga que pensarlo. Además, el output es código que tu equipo lee y mantiene, no SQL ad-hoc que nadie quiere tocar. Para queries que Prisma no soporta (window functions, CTEs complejos), aíslalas en raw queries explícitas con allowlist.

Lo que vas a aprender en producción

  • El schema cambia y rompe Pass 1: regenera el prompt cada deploy desde introspection, no lo hardcodees.
  • Usuarios usan vocabulario propio: "vendedor" en lugar de "owner", "ventas" en lugar de "deal". Mantén un glosario en el system prompt.
  • Confidence importa: cuando el spec viene con confidence < 0.7, pídele al usuario clarificación antes de ejecutar.

¿Tu NL-to-SQL actual valida contra el schema antes de ejecutar, o confía en que el SQL "se ve correcto"? Si es lo segundo, ya tienes resultados incorrectos en producción; solo no los has detectado.

nl-to-sql
prisma
ai-native-saas
← Volver a SCRAM AI Lab