DEV Community

Cover image for Cómo conversar con tu base de datos usando IA: un generador de SQL a partir de lenguaje natural
JhonyVargas
JhonyVargas

Posted on

Cómo conversar con tu base de datos usando IA: un generador de SQL a partir de lenguaje natural

Artículo técnico — Unidad 3, Base de Datos II

Introducción

Una de las barreras más comunes entre las personas de negocio y sus datos es
el propio SQL: saber que existe la información no sirve de mucho si hay que
escribir un JOIN para llegar a ella. En los últimos años, los modelos de
lenguaje (LLMs) han empezado a cerrar esa brecha con la tarea conocida como
Text-to-SQL: el usuario escribe una pregunta en lenguaje natural y el
modelo genera la consulta SQL correspondiente.

En este artículo muestro una implementación pequeña pero completa de este
patrón, usando un modelo público de Hugging Face, una base de datos SQLite de
ejemplo y una capa de validación que evita que la IA ejecute algo peligroso.
El código completo está disponible en el repositorio público enlazado al
final.

Arquitectura

pregunta (ingles)
   -> modelo T5 afinado en WikiSQL (Hugging Face)
   -> SQL "crudo" generado por el modelo
   -> adaptacion (nombre de tabla real + comillas en valores) y validacion (solo SELECT)
   -> ejecucion en modo solo-lectura sobre SQLite
   -> resultado tabular
Enter fullscreen mode Exit fullscreen mode

La base de datos de ejemplo simula las ventas de una tienda de tecnología,
con una tabla ventas que incluye producto, categoría, precio, cantidad,
ciudad, cliente y fecha:

CREATE TABLE ventas (
    id INTEGER PRIMARY KEY,
    product TEXT NOT NULL,
    category TEXT NOT NULL,
    price REAL NOT NULL,
    quantity INTEGER NOT NULL,
    city TEXT NOT NULL,
    customer TEXT NOT NULL,
    sale_date TEXT NOT NULL
)
Enter fullscreen mode Exit fullscreen mode

El modelo: Hugging Face + T5 afinado en WikiSQL

Para generar SQL usé mrm8488/t5-base-finetuned-wikiSQL, un modelo T5
disponible en Hugging Face y entrenado sobre el dataset WikiSQL. Se usa así:

from transformers import T5ForConditionalGeneration, T5Tokenizer

MODEL_NAME = "mrm8488/t5-base-finetuned-wikiSQL"

class TextToSQL:
    def __init__(self, model_name: str = MODEL_NAME):
        self.tokenizer = T5Tokenizer.from_pretrained(model_name)
        self.model = T5ForConditionalGeneration.from_pretrained(model_name)

    def generate(self, question: str) -> str:
        prompt = f"translate English to SQL: {question} </s>"
        features = self.tokenizer([prompt], return_tensors="pt")
        output_ids = self.model.generate(
            input_ids=features["input_ids"],
            attention_mask=features["attention_mask"],
            max_length=128,
        )
        return self.tokenizer.decode(output_ids[0], skip_special_tokens=True)
Enter fullscreen mode Exit fullscreen mode

Para la pregunta how many products were sold in Madrid, el modelo entrega
algo como:

SELECT COUNT(product) FROM table WHERE city = Madrid
Enter fullscreen mode Exit fullscreen mode

Dos detalles a resolver antes de poder ejecutar esto: la tabla genérica
table (así es como WikiSQL nombra sus tablas de entrenamiento) y el valor
Madrid sin comillas.

La parte que casi nadie muestra: el modelo no conoce tu esquema

Al probar el modelo con preguntas reales aparecieron tres problemas que no se
ven en los ejemplos "de juguete" que suelen circular en tutoriales:

  1. A veces omite los paréntesis de las funciones de agregación: SELECT COUNT Product FROM table WHERE City = Madrid en vez de SELECT COUNT(Product) FROM table WHERE City = 'Madrid'.
  2. El modelo no sabe que nuestra tabla se llama ventas ni que sus columnas son product, category, price, quantity, city, customer y sale_date. WikiSQL entrena con miles de tablas distintas, así que el modelo "inventa" un nombre de columna plausible a partir de las palabras de la pregunta (city a veces sale como City, otras como Location).
  3. No siempre respeta la capitalización real de los datos: generó City = barcelona (minúscula) cuando en la base de datos el valor guardado es Barcelona. Si se ejecuta tal cual, la consulta no falla, pero devuelve 0 filas silenciosamente — el peor tipo de error porque parece que funcionó.

Es tentador tomar el string que devuelve el modelo y pasarlo directo a
cursor.execute(). Es también la forma más rápida de abrir una puerta a
inyección SQL, a que el modelo "alucine" un DROP TABLE, o a que un falso
negativo silencioso pase como resultado correcto. Por eso textsql/sanitize.py
hace cuatro cosas antes de que cualquier SQL toque la base de datos:

1. Reparar los paréntesis faltantes en las funciones de agregación:

_AGG_NO_PARENS = re.compile(
    r"\b(COUNT|SUM|AVG|MIN|MAX)\s+(?!\()([A-Za-z_]\w*)(?:\s+[A-Za-z_]\w*)*\s+FROM",
    re.IGNORECASE,
)

def _fix_missing_parens(sql: str) -> str:
    return _AGG_NO_PARENS.sub(lambda m: f"{m.group(1)}({m.group(2)}) FROM", sql)
Enter fullscreen mode Exit fullscreen mode

2. Renombrar la tabla genérica y poner comillas en los valores de
texto sueltos (igual que en la primera versión).

3. Normalizar columnas con un diccionario de sinónimos, ya que el modelo
no ve el esquema real:

COLUMN_SYNONYMS = {
    "product": "product", "item": "product",
    "category": "category", "type": "category",
    "price": "price", "cost": "price", "amount": "price",
    "quantity": "quantity", "qty": "quantity",
    "city": "city", "location": "city", "town": "city",
    "customer": "customer", "client": "customer", "buyer": "customer",
    "date": "sale_date", "sale": "*", "sales": "*",
}
Enter fullscreen mode Exit fullscreen mode

Con esto, COUNT Sale (una columna que no existe) termina traduciéndose a
COUNT(*), que es exactamente lo que alguien quiere decir con "cuántas
ventas hubo".

4. Comparar sin distinguir mayúsculas, para no devolver falsos negativos
silenciosos:

def _case_insensitive_compare(sql: str) -> str:
    return _STRING_EQUALITY.sub(
        lambda m: f"LOWER({m.group(1)}) = LOWER('{m.group(2)}')", sql
    )
Enter fullscreen mode Exit fullscreen mode

Y finalmente, validar que lo único que se ejecute sea un SELECT, sin
comandos encadenados ni palabras clave destructivas:

FORBIDDEN_KEYWORDS = re.compile(
    r"\b(insert|update|delete|drop|alter|attach|detach|pragma|create|replace|vacuum)\b",
    re.IGNORECASE,
)

def validate_select_only(sql: str) -> None:
    if not sql.strip().upper().startswith("SELECT"):
        raise UnsafeQueryError("Solo se permiten consultas SELECT generadas por el modelo.")
    if ";" in sql:
        raise UnsafeQueryError("No se permiten multiples sentencias en una misma consulta.")
    if FORBIDDEN_KEYWORDS.search(sql):
        raise UnsafeQueryError("La consulta generada contiene palabras clave no permitidas.")
Enter fullscreen mode Exit fullscreen mode

Como defensa adicional, la conexión a SQLite se abre en modo solo-lectura
(file:ventas.db?mode=ro), de forma que incluso si algo se filtrara por la
validación, la base de datos no podría modificarse.

De la terminal a una demo web con Streamlit

Con el motor ya funcionando, envolverlo en una interfaz web con Streamlit
toma menos de 30 líneas:

import streamlit as st
from textsql.db import TABLE_NAME, run_query
from textsql.model import TextToSQL
from textsql.sanitize import adapt_sql, validate_select_only

st.title("🗄️ Pregunta a tu base de datos en lenguaje natural")

question = st.text_input("Escribe tu pregunta en ingles")

if st.button("Generar y ejecutar") and question:
    sql = adapt_sql(engine.generate(question), TABLE_NAME)
    st.code(sql, language="sql")
    validate_select_only(sql)
    st.dataframe(run_query(sql))
Enter fullscreen mode Exit fullscreen mode

El resultado es una página donde se escribe, por ejemplo, what is the total
price where city is Barcelona
, se ve el SQL generado y la tabla de
resultados en tiempo real.

Resultados y limitaciones

Con la tabla ventas de ejemplo (120 filas sintéticas) y la capa de
normalización en su lugar, preguntas como estas se ejecutan correctamente de
principio a fin:

"how many products were sold in city Madrid"
  -> SELECT COUNT(product) FROM ventas WHERE LOWER(city) = LOWER('Madrid')

"how many sale in category Phones"
  -> SELECT COUNT(*) FROM ventas WHERE LOWER(category) = LOWER('Phones')

"what is the customer where product is Smartwatch"
  -> SELECT customer FROM ventas WHERE LOWER(product) = LOWER('Smartwatch')
Enter fullscreen mode Exit fullscreen mode

Aún así, el prototipo tiene límites claros:

  • Si la pregunta usa una palabra que no está en COLUMN_SYNONYMS, la consulta puede fallar al ejecutarse (se muestra el error en pantalla en vez de arriesgarse a ejecutar algo incorrecto).
  • Preguntas que requieren varias tablas o subconsultas (WikiSQL solo entrena con una tabla por ejemplo).
  • Preguntas en español: el modelo fue entrenado en inglés, así que hay que traducir la pregunta antes de enviarla (una extensión natural del proyecto sería añadir un paso de traducción automática).

Estas limitaciones son, en sí mismas, una buena lección: los modelos
Text-to-SQL de hoy son excelentes asistentes para consultas exploratorias
sencillas, pero todavía necesitan una capa de validación humana o automática
antes de confiar en ellos para producción.

Conclusión

Combinar una base de datos SQL con un modelo de IA no requiere infraestructura
compleja: con un modelo público de Hugging Face, unas 100 líneas de Python y
una capa de sanitización cuidadosa, es posible construir un prototipo
funcional de "conversación con la base de datos" en una tarde. El reto
interesante no está en generar el SQL, sino en decidir cuánto confiar en él
antes de ejecutarlo.

Referencias

  • Cómo comunicarse con cualquier base de datos mediante IA: crea tu propio extractor de datos con consultas SQL.
  • Texto a SQL — Hugging Face.
  • Cómo crear un generador de consultas SQL a partir de texto con Streamlit y Hugging Face (Medium), por Kuhelidey.
  • Código completo: https://github.com/JhonyVargas/text-to-sql-ia

Top comments (0)