volver al blog

Texto a SQL en producción: por qué la demo funciona y el piloto no

llmdatosarquitectura

La consulta que sale bien a la primera

Conectas un LLM a la base de datos, le pasas el esquema y preguntas “¿cuántos pedidos hubo el mes pasado?”. Devuelve un SELECT correcto, con su WHERE bien puesto. La demo dura tres minutos y convence a todo el mundo.

Dos semanas después alguien pregunta “¿cuántos clientes activos tenemos?” y el sistema responde 4.812. La cifra es plausible, el SQL es válido y el número está mal. Porque en tu empresa “activo” significa que ha comprado en los últimos 90 días y no ha solicitado la baja, y eso no está escrito en ninguna parte del esquema.

Nadie se da cuenta. Ese es el problema.

El error no es de sintaxis

Un modelo actual escribe SQL sintácticamente válido casi siempre. Si el fallo fuera ese, lo verías: la base de datos devolvería un error y se acabó.

Los fallos que importan son silenciosos y vienen de tres sitios.

El esquema es opaco. Columnas como st, flg_2 o dt_alta no le dicen nada al modelo. Tampoco a los humanos, pero los humanos preguntan a alguien.

Las reglas de negocio no están en el esquema. Qué cuenta como cliente activo, si los pedidos cancelados suman a la facturación o si los pedidos internos se excluyen. Todo eso vive en la cabeza del equipo de datos o en una consulta que alguien guardó en Notion.

Los JOIN duplican filas. Unir pedidos con líneas de pedido y sumar el importe del pedido infla el total. El resultado no falla, solo es más grande de lo que debería. Y un número inflado en un dashboard es indistinguible de un número correcto.

La regla práctica: si el sistema no puede explicar de dónde sale la cifra, la cifra no vale. Devolver siempre el SQL junto a la respuesta no es un detalle de interfaz: es el control que le queda a quien lee.

Cuánto esquema le das al modelo

Esta es la primera decisión de arquitectura y condiciona todo lo demás.

EnfoqueQué le das al modeloFunciona cuandoSe rompe cuando
Esquema completo en el promptEl DDL enteroMenos de 30 tablas y nombres legiblesEl esquema crece: coste, latencia y ruido en el contexto
Recuperación de esquemaLas 5–15 tablas relevantes, con descripciones y valores de ejemploHay cientos de tablas pero cada pregunta toca pocasLa pregunta cruza dominios y falta una tabla clave
Capa semánticaMétricas y dimensiones ya definidas, no tablasLas preguntas se repiten y el negocio tiene definiciones cerradasAlguien pregunta algo que no está en el catálogo

El error habitual es empezar por el primero y no revisarlo nunca. Funciona en la demo porque la demo usa una base de datos de juguete.

La recuperación de esquema es RAG aplicado a metadatos: indexas la descripción de cada tabla y columna, recuperas las relevantes para la pregunta y montas un DDL reducido. Lo que más mejora la precisión no es el modelo, son las descripciones. Una línea por columna escrita por alguien que conoce el dato vale más que cambiar a un modelo más caro.

La capa semántica es la opción aburrida y la que mejor envejece. Si el 80 % de las preguntas son variaciones de 15 métricas, define esas métricas una vez y deja que el modelo solo elija filtros y agrupaciones. Reduces la superficie de error a casi nada.

Ejecutar sin confiar

El SQL que genera el modelo es entrada no confiable. Da igual lo bueno que sea el prompt: la validación va en código, antes de tocar la base de datos.

import sqlglot
from sqlglot import exp

MAX_FILAS = 1000

def validar(sql: str, tablas_permitidas: set[str]) -> str:
    """Devuelve el SQL saneado o lanza excepción. Nunca ejecuta."""
    arbol = sqlglot.parse(sql, dialect="postgres")

    # Una sola sentencia: corta de raíz el "; DROP TABLE". Ojo, un CTE con
    # efectos secundarios (WITH d AS (DELETE ... RETURNING ...)) sí pasa este
    # filtro: de eso se encarga el rol de solo lectura, no el analizador.
    if len(arbol) != 1:
        raise ValueError("Se esperaba una única sentencia")

    consulta = arbol[0]
    if not isinstance(consulta, exp.Select):
        raise ValueError("Solo se permiten consultas SELECT")

    # El modelo no decide a qué tablas accede. Lo decide esta lista, que sale
    # de los permisos de la sesión, no de la petición del cliente.
    # (find_all también devuelve los alias de CTE: añádelos al conjunto
    # permitido si generas consultas con WITH.)
    usadas = {t.name for t in consulta.find_all(exp.Table)}
    if not usadas.issubset(tablas_permitidas):
        raise ValueError(f"Tablas no autorizadas: {usadas - tablas_permitidas}")

    # Tope duro de filas, aunque el modelo se olvide del LIMIT.
    return consulta.limit(MAX_FILAS).sql(dialect="postgres")

Analizar el árbol sintáctico no sustituye las defensas de la base de datos; las complementa. Debajo hacen falta tres cosas más: un rol de solo lectura con acceso únicamente a las vistas expuestas, un statement_timeout agresivo y una réplica separada de la de producción. Con eso, el daño de una consulta maliciosa se queda, en la práctica, en una respuesta lenta.

Y no aceptes filtros que vengan del cliente. Si el frontend puede inyectar un tenant_id en el prompt, has construido un formulario para consultar los datos de otro.

Evaluar: resultados, no cadenas de texto

Comparar el SQL generado con un SQL de referencia no sirve. Hay 10 formas de escribir la misma consulta y solo importa una cosa: si devuelve el número correcto.

Lo que se mide es la precisión de ejecución: ejecutas la consulta generada y la de referencia y comparas los conjuntos de resultados, sin tener en cuenta el orden de las filas ni el nombre de las columnas.

Monta un golden set de 50 a 100 preguntas reales con su resultado esperado, escritas por el equipo que usa los datos. Después vigila dos métricas:

  • Precisión de ejecución: porcentaje de respuestas con el conjunto correcto.
  • Tasa de abstención: porcentaje de preguntas en las que el sistema dice que no puede responder.

La segunda importa tanto como la primera. Un sistema que acierta el 85 % y se abstiene en el 15 % restante es útil. Uno que acierta el 90 % e inventa el otro 10 % es una fuente de malas decisiones, porque nadie sabe si el número que tiene delante es de los buenos. Deja que el modelo responda “no tengo esa información” y trátalo como un buen resultado en la evaluación, no como un fallo.

Cada consulta que un analista corrige a mano es un caso de prueba nuevo y, casi siempre, una descripción de columna que falta.

Cuándo no montar esto

Texto a SQL no es la solución cuando las preguntas son siempre las mismas: ahí un dashboard es más rápido, más barato y no se equivoca. Tampoco cuando el resultado alimenta un proceso automático sin nadie que lo revise, porque el modo de fallo es un número plausible.

Encaja bien en el hueco intermedio: exploración interna, preguntas de cola larga que nadie va a convertir en informe y gente que sabe lo suficiente del negocio como para oler un número raro.

El resumen accionable

El cuello de botella de texto a SQL no es el modelo, son los metadatos. Antes de cambiar de proveedor, escribe una línea de descripción por columna en las tablas que de verdad se consultan.

Si empiezas hoy, el orden que funciona es este: expón vistas curadas en lugar de tablas crudas, valida el SQL con un analizador antes de ejecutarlo, muestra siempre la consulta junto a la respuesta y monta el golden set de evaluación antes de abrirlo a nadie.

Y si al probarlo descubres que el equipo no se pone de acuerdo en qué es un cliente activo, enhorabuena: acabas de encontrar el problema real, y no lo va a resolver un LLM.