DEV Community

Adrian
Adrian

Posted on Originally published at adrianmoreno-dev.com

Voice-to-SQL: hablarle a tu base de datos con un LLM (y por qué la seguridad es lo difícil)

Las preguntas sobre datos casi nunca las hace quien sabe SQL. Las hace alguien de negocio, marketing o dirección, que tiene que pedírselas a un analista y esperar. Voice-to-SQL ataca justo esa fricción: que cualquiera pueda preguntarle a la base de datos hablando, y obtener la respuesta al instante.

El problema

Quería una demo que tradujera lenguaje natural —por voz o texto— a SQL, lo ejecutara sobre una base de datos real y devolviera la tabla, mostrando el SQL generado. Tres requisitos no negociables: que la voz no costara dinero, que fuera seguro dejar que un LLM tocara la base de datos, y que la respuesta llegara en menos de un segundo.

La voz: en el navegador, no en el backend

La tentación es mandar el audio al servidor y transcribirlo con Whisper o un servicio de pago. Lo descarté: añade latencia, coste y complejidad. En su lugar uso la Web Speech API, que viene integrada en Chrome, Edge y Safari. La transcripción ocurre en el dispositivo del usuario; el backend solo recibe texto. Coste de transcripción: cero. El precio a pagar es la dependencia del navegador, un compromiso razonable para una demo web.

De texto a SQL: el esquema es el prompt

El corazón del sistema es la conversión texto→SQL con un LLM (Qwen3.8 27B vía Groq, por su latencia muy baja). La clave no es el modelo, sino lo que se le da: junto a la pregunta, le paso el esquema exacto de la base de datos —tablas, columnas, tipos, valores posibles de los enums y un par de reglas del dialecto SQLite—. Sin ese contexto, el modelo inventa columnas. Con él, genera JOINs y GROUP BY correctos a la primera.

La parte difícil: que sea seguro

Aquí está el verdadero reto de ingeniería. Dar a un LLM una conexión a tu base de datos es una receta para el desastre: una pregunta mal interpretada (o malintencionada) podría acabar en un DROP TABLE. La regla de oro es no confiar nunca en el SQL generado.

Por eso, antes de ejecutar nada, el SQL pasa por un sanitizador que:

  • Solo acepta sentencias que empiecen por SELECT o WITH.

  • Rechaza por lista negra INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, PRAGMA, ATTACH y REPLACE.

  • Bloquea el encadenamiento de varias sentencias con punto y coma (un clásico vector de inyección).

  • Impone un tope absoluto de 100 filas por respuesta.

Es una defensa en capas, deliberadamente conservadora: prefiero rechazar una consulta legítima rara antes que permitir una destructiva. El LLM propone; el sanitizador dispone.

La base de datos demo

Para que las preguntas tengan sentido, generé con un script una base SQLite que imita el back-office de una empresa SaaS: 200 clientes (país, sector, plan, churn), 8 productos (planes y add-ons) y 5.000 ventas del último año con su aporte al MRR. Así se pueden hacer preguntas analíticas de verdad —ingresos por país, MRR por plan, churn— y no juguetes.

Resultado

De la pregunta a la tabla, en torno a medio segundo (la mayor parte es la generación del LLM; SQLite ejecuta en local en milisegundos). Y cada respuesta enseña el SQL que se generó, lo que convierte la demo en algo didáctico además de útil.

Qué aprendí

Que en un sistema "LLM + base de datos", el modelo es la parte fácil. Lo que de verdad decide si esto se puede poner delante de un usuario es la capa de seguridad y la calidad del contexto (el esquema) que le das al modelo. La inteligencia genera el SQL; la ingeniería hace que sea seguro ejecutarlo.


Publicado originalmente en adrianmoreno-dev.com.

Top comments (1)

Collapse
 
_firelinks profile image
Mike Dabydeen •

El sanitizador de texto es la capa que yo dejaría al final, no al principio. SQLite ya trae controles que actúan sobre lo que el motor va a ejecutar, no sobre cómo se ve el texto, y en Python cada uno es una línea:

  1. Abrir la base en solo lectura: sqlite3.connect("file:demo.db?mode=ro", uri=True). Así cualquier escritura que se escape del filtro falla en el motor.
  2. conn.set_authorizer(...): SQLite consulta esa función por cada acción al preparar la sentencia, así que puedes permitir solo SQLITE_SELECT, SQLITE_READ y SQLITE_FUNCTION, y además limitar qué tablas y columnas se pueden leer. Eso importa en cuanto la base real tenga datos personales que no todos deberían ver.
  3. conn.set_progress_handler(...): un SELECT puede hacer daño sin escribir nada. Un CROSS JOIN entre tus 5.000 ventas y 200 clientes ya es un millón de filas, y un WITH RECURSIVE sin condición de salida no termina. El tope de 100 filas no ayuda si el trabajo ocurre antes del LIMIT. Con el handler cortas la consulta después de cierto número de pasos o de un presupuesto de tiempo.

Con eso la lista negra pasa a ser un filtro para devolver un mensaje claro, y la seguridad deja de depender de que el texto se parezca a algo peligroso. ¿Cómo aplicas hoy el tope de 100 filas: agregando LIMIT al SQL o cortando en el fetch?