Text-to-SQL AI агенты — Полный гайд | QantCore
🗃️

Text-to-SQL AI агенты (NL2SQL)

Как создать AI-агента, который принимает вопросы на русском языке и генерирует точные SQL-запросы. Разбор схемы БД, few-shot промптинг, валидация синтаксиса, защита от SQL-инъекций и умная обработка ошибок.

intermediate ⏱ 15 мин
NL Query "Топ-5 клиентов по выручке" NL2SQL Engine Schema Parser Query Generator (LLM) Schema Context DDL + Few-Shot Examples Validation & Sanitization Syntax check / Injection guard / EXPLAIN 🛠 PostgreSQL / MySQL / SQLite / BigQuery read-only user / query timeout / result limit Result + Explanation Data + NL summary

# 1. Как работает NL2SQL: от вопроса к запросу

Text-to-SQL (NL2SQL) — это задача преобразования вопроса на естественном языке в корректный SQL-запрос. Современные LLM (GPT-4, Claude) справляются с этой задачей на уровне junior-аналитика, если предоставить им полный контекст: DDL-схему таблиц, описания колонок на русском, примеры запросов (few-shot) и бизнес-правила. Ключевая сложность не в генерации SQL как таковой, а в том, чтобы запрос был семантически верным: правильные JOIN'ы, корректные агрегации, фильтры по нужным полям.

-- Пример NL2SQL: вопрос на русском → SQL

-- Вопрос: "Покажи топ-5 клиентов по сумме заказов за последний квартал"
SELECT
    c.name AS "Клиент",
    SUM(o.total_amount) AS "Сумма заказов",
    COUNT(o.id) AS "Количество заказов"
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
WHERE o.created_at >= DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '3 months')
GROUP BY c.id, c.name
ORDER BY "Сумма заказов" DESC
LIMIT 5;
    

# 2. Системный промпт: схема БД, правила и few-shot примеры

Качество NL2SQL на 80% зависит от промпта. В системном сообщении мы передаём: полную DDL-схему (CREATE TABLE), описания таблиц и колонок на русском языке, 5-10 примеров «вопрос → SQL» (few-shot), бизнес-правила и ограничения (только SELECT, лимит 1000 строк, запрет на определённые таблицы). Такой промпт превращает общую LLM в специализированного SQL-ассистента для конкретной базы данных.

# nl2sql_prompt.py — построение системного промпта для NL2SQL
def build_system_prompt(schema_ddl: str, descriptions: dict) -> str:
    return f"""Ты — SQL-ассистент для аналитической базы данных.
Отвечай ТОЛЬКО SQL-запросом (без Markdown-форматирования).
СУБД: PostgreSQL 15.

== СХЕМА БАЗЫ ДАННЫХ ==
{schema_ddl}

== ОПИСАНИЯ ТАБЛИЦ И КОЛОНОК ==
{descriptions}

== ПРАВИЛА ==
1. Только SELECT-запросы. Запрещены INSERT/UPDATE/DELETE/DROP/ALTER.
2. Всегда добавляй LIMIT 100, если пользователь не указал иное.
3. Используй кириллические алиасы для колонок (AS "Название").
4. Для фильтрации по датам используй ISO-формат: '2026-06-15'.
5. Агрегации: SUM, AVG, COUNT, MAX, MIN — явно указывай GROUP BY.
6. Если вопрос неоднозначен — уточни, какие колонки использовать.

== ПРИМЕРЫ (FEW-SHOT) ==
Вопрос: Сколько заказов в этом месяце?
SQL: SELECT COUNT(*) AS "Количество заказов"
FROM orders WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE);

Вопрос: Покажи клиентов из Москвы с заказами больше 100 000 руб
SQL: SELECT c.name AS "Клиент", c.city AS "Город",
       SUM(o.total_amount) AS "Сумма"
FROM customers c JOIN orders o ON c.id = o.customer_id
WHERE c.city = 'Москва'
GROUP BY c.id, c.name, c.city
HAVING SUM(o.total_amount) > 100000
ORDER BY "Сумма" DESC
LIMIT 100;

Вопрос: Средний чек по месяцам за 2025 год
SQL: SELECT TO_CHAR(created_at, 'YYYY-MM') AS "Месяц",
       ROUND(AVG(total_amount), 2) AS "Средний чек"
FROM orders WHERE created_at BETWEEN '2025-01-01' AND '2025-12-31'
GROUP BY "Месяц" ORDER BY "Месяц";
"""
    

# 3. Извлечение схемы из реальной БД

Для автоматического построения промпта нам нужно программно извлечь DDL из базы данных. Для PostgreSQL используем information_schema и pg_catalog, для MySQL — SHOW CREATE TABLE. Результат кэшируется (схема меняется редко), а описания таблиц и колонок можно хранить в отдельной конфигурации или извлекать из COMMENT'ов, если они заполнены.

# schema_extractor.py — извлечение схемы из PostgreSQL
import asyncpg
from typing import Dict

async def extract_schema(conn: asyncpg.Connection, schema: str = "public") -> Dict:
    # Получаем список таблиц
    tables = await conn.fetch("""
        SELECT table_name
        FROM information_schema.tables
        WHERE table_schema = $1 AND table_type = 'BASE TABLE'
        ORDER BY table_name
    """, schema)

    schema_info = {"tables": {}, "ddl": ""}

    for (table_name,) in tables:
        # Получаем колонки с типами и комментариями
        columns = await conn.fetch("""
            SELECT
                c.column_name,
                c.data_type,
                c.is_nullable,
                c.column_default,
                pgd.description AS comment
            FROM information_schema.columns c
            LEFT JOIN pg_catalog.pg_description pgd
                ON pgd.objoid = (quote_ident($1) || '.' || quote_ident(c.table_name))::regclass
                AND pgd.objsubid = c.ordinal_position
            WHERE c.table_schema = $1 AND c.table_name = $2
            ORDER BY c.ordinal_position
        """, schema, table_name)

        schema_info["tables"][table_name] = [
            {"name": col["column_name"], "type": col["data_type"],
             "nullable": col["is_nullable"] == "YES",
             "default": col["column_default"],
             "comment": col["comment"] or ""}
            for col in columns
        ]

        # Генерируем DDL
        ddl = f"CREATE TABLE {table_name} (\n"
        col_defs = []
        for col in columns:
            nullable = "" if col["is_nullable"] == "YES" else " NOT NULL"
            comment = f" -- {col['comment']}" if col["comment"] else ""
            col_defs.append(f"  {col['column_name']} {col['data_type']}{nullable}{comment}")
        ddl += ",\n".join(col_defs) + "\n);\n"
        schema_info["ddl"] += ddl + "\n"

    return schema_info
    

# 4. Валидация SQL: синтаксис, безопасность и EXPLAIN

LLM может сгенерировать синтаксически некорректный или опасный SQL. Перед выполнением обязательна многоуровневая валидация: (1) проверка синтаксиса через EXPLAIN (без фактического выполнения для тяжёлых запросов), (2) проверка на запрещённые ключевые слова (DROP, INSERT, UPDATE, DELETE, TRUNCATE), (3) проверка на SQL-инъекции — экранирование пользовательского ввода, если он попадает в запрос. Для продакшена — выполнение запроса под read-only пользователем с жёстким statement_timeout.

# sql_validator.py — валидация и безопасное выполнение SQL
import re
import asyncpg

FORBIDDEN_KEYWORDS = [
    r'\bDROP\b', r'\bINSERT\b', r'\bUPDATE\b', r'\bDELETE\b',
    r'\bTRUNCATE\b', r'\bALTER\b', r'\bCREATE\b', r'\bGRANT\b',
    r'\bREVOKE\b', r'\bCOPY\b', r'\bEXECUTE\b', r'\bVACUUM\b'
]

def validate_sql(sql: str) -> tuple[bool, str]:
    """Возвращает (валиден, сообщение_об_ошибке)."""
    sql_upper = sql.upper()

    # 1. Проверка запрещённых ключевых слов
    for pattern in FORBIDDEN_KEYWORDS:
        if re.search(pattern, sql_upper):
            return False, f"Запрос содержит запрещённую операцию: {pattern}"

    # 2. Проверка на SELECT
    if not sql_upper.strip().startswith("SELECT") and \
       not sql_upper.strip().startswith("WITH") and \
       not sql_upper.strip().startswith("EXPLAIN"):
        return False, "Разрешены только SELECT/WITH/EXPLAIN запросы"

    # 3. Проверка на наличие LIMIT
    if "LIMIT" not in sql_upper:
        return False, "Добавьте LIMIT для ограничения выборки"

    return True, "OK"

async def safe_execute_sql(conn: asyncpg.Connection, sql: str,
                          timeout_ms: int = 10000) -> dict:
    """Выполняет SQL с таймаутом и возвращает результат или ошибку."""
    is_valid, error = validate_sql(sql)
    if not is_valid:
        return {"status": "validation_error", "message": error}

    try:
        # Сначала EXPLAIN для проверки синтаксиса
        await conn.execute(f"EXPLAIN {sql}", timeout=timeout_ms / 1000)

        # Затем выполняем сам запрос
        rows = await conn.fetch(sql, timeout=timeout_ms / 1000)
        columns = [k for k in rows[0].keys()] if rows else []
        data = [[str(v) for v in row.values()] for row in rows]
        return {"status": "ok", "columns": columns, "rows": data, "count": len(rows)}
    except asyncpg.exceptions.PostgresSyntaxError as e:
        return {"status": "syntax_error", "message": str(e)}
    except Exception as e:
        return {"status": "execution_error", "message": str(e)}
    

# 5. Основной цикл NL2SQL-агента

Агент принимает вопрос пользователя, генерирует SQL, валидирует его и выполняет. Если запрос выполнился с ошибкой — агент анализирует ошибку и автоматически исправляет SQL (self-correction loop). После успешного выполнения результаты форматируются в читаемый вид, и добавляется краткое пояснение на естественном языке.

# nl2sql_agent.py — основной цикл Text-to-SQL агента
import json
from openai import OpenAI

client = OpenAI()

async def nl2sql_agent(user_question: str, conn, schema_info: dict) -> dict:
    system_prompt = build_system_prompt(
        schema_info["ddl"],
        "\n".join(
            f"Таблица {t}: {', '.join(c['name'] + ' — ' + c['comment'] for c in cols if c['comment'])}"
            for t, cols in schema_info["tables"].items()
        )
    )

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_question}
    ]

    for attempt in range(3):  # до 3 попыток исправления
        response = client.chat.completions.create(
            model="gpt-4", messages=messages, temperature=0.1
        )
        sql = response.choices[0].message.content.strip()

        # Убираем Markdown-форматирование, если модель его добавила
        if sql.startswith("```"):
            sql = sql.split("\n")[1:-1]
            sql = "\n".join(sql)

        result = await safe_execute_sql(conn, sql)

        if result["status"] == "ok":
            # Генерируем пояснение на естественном языке
            explanation = client.chat.completions.create(
                model="gpt-4", temperature=0.3,
                messages=[
                    {"role": "system", "content": "Кратко поясни результат SQL-запроса на русском, 1-2 предложения."},
                    {"role": "user", "content": f"Запрос: {sql}\nРезультат: {json.dumps(result['rows'], ensure_ascii=False)}"}
                ]
            )
            return {
                "sql": sql,
                "columns": result["columns"],
                "rows": result["rows"],
                "explanation": explanation.choices[0].message.content
            }

        # Self-correction: добавляем ошибку в контекст и просим исправить
        messages.append({"role": "assistant", "content": sql})
        messages.append({"role": "user", "content":
            f"Ошибка выполнения: {result['message']}. Исправь запрос."})

    return {"error": "Не удалось сгенерировать корректный SQL за 3 попытки"}
    

# 6. Продвинутые техники: join-рекомендации и семантическая валидация

Базовая NL2SQL-система иногда ошибается в JOIN'ах — выбирает не ту таблицу или забывает связать таблицы. Продвинутые техники включают: (1) граф связей таблиц (foreign key graph) в промпте — модель явно видит, как соединять таблицы; (2) семантическую валидацию — проверяем, что колонки в SELECT действительно существуют в указанных таблицах, а JOIN-условия ссылаются на реальные внешние ключи; (3) кэширование успешных запросов — повторяющиеся вопросы не требуют вызова LLM.

# advanced_nl2sql.py — граф связей и семантическая валидация
import asyncpg

async def build_join_graph(conn: asyncpg.Connection) -> str:
    """Извлекает foreign keys и строит граф связей для промпта."""
    fks = await conn.fetch("""
        SELECT
            tc.table_name AS from_table,
            kcu.column_name AS from_column,
            ccu.table_name AS to_table,
            ccu.column_name AS to_column
        FROM information_schema.table_constraints tc
        JOIN information_schema.key_column_usage kcu
            ON tc.constraint_name = kcu.constraint_name
        JOIN information_schema.constraint_column_usage ccu
            ON ccu.constraint_name = tc.constraint_name
        WHERE tc.constraint_type = 'FOREIGN KEY'
          AND tc.table_schema = 'public'
    """)

    graph_lines = ["== СВЯЗИ МЕЖДУ ТАБЛИЦАМИ (FOREIGN KEYS) =="]
    for fk in fks:
        graph_lines.append(
            f"{fk['from_table']}.{fk['from_column']} → {fk['to_table']}.{fk['to_column']}"
        )
    return "\n".join(graph_lines)

# Кэш запросов: LRU-словарь вопрос → SQL
from functools import lru_cache
import hashlib

query_cache = {}

def cache_key(question: str) -> str:
    return hashlib.md5(question.lower().strip().encode()).hexdigest()

def get_cached(question: str) -> str | None:
    return query_cache.get(cache_key(question))

def set_cache(question: str, sql: str):
    key = cache_key(question)
    if len(query_cache) > 1000:
        query_cache.pop(next(iter(query_cache)))  # простой LRU
    query_cache[key] = sql
    
✅ Итог

Мы создали Text-to-SQL агента, который принимает вопросы на русском языке и возвращает точные SQL-запросы с результатами и пояснениями. Ключевые компоненты: автоматическое извлечение схемы БД, few-shot промптинг для точности, многоуровневая валидация и защита от инъекций, self-correction при ошибках и кэширование частых запросов. Дальнейшие шаги: интеграция с визуализацией (автоматические графики по результатам), поддержка диалогового режима с уточняющими вопросами, и файн-тюнинг open-source моделей (Llama, Qwen) на доменных данных для снижения стоимости инференса.