Привет! Меня зовут Михаил Куляскин, я инженер по машинному обучению в команде продуктивизации ИИ в X5 Tech. Недавно я выступал с докладом на крупной конференции CodeFest в Новосибирске, по которому и написана данная статья. В ней я расскажу о нашем опыте построения сервиса text2sql — интеллектуального помощника, который позволяет получать доступ к данным из баз по запросу на естественном языке. Такой сервис особенно актуален для крупных компаний с развитой аналитической культурой и большим объемом данных: он позволяет менеджерам и аналитикам запрашивать нужную информацию в виде таблицы, графика или конкретного ответа, не прибегая к помощи специалистов по SQL.
Мы подробно разберём:
в чём состоит проблема и зачем нужен такой сервис;
что вообще такое text2sql;
как устроен пайплайн обработки пользовательских запросов;
как мы валидируем качество работы системы;
какие подходы к генерации SQL-кода мы пробовали и какие показали наилучшие результаты;
как визуализируем ответы для пользователя;
и, наконец, покажем несколько примеров работы нашего приложения.
Проблематика

Представьте себе ситуацию, вы — директор направления. В перерыве между совещаниями заказываете кофе, как вдруг в рабочий чат прилетает срочный запрос от руководства: «На сколько вырос средний чек в Москве за год? Нужны данные к закрытию квартала!»
Обычный сценарий:
Прерываете обед, идёте к аналитикам.
Ждёте, пока они соберут данные.
Кофе остывает, дедлайн горит.
С Text2SQL:
Пишете в бота: «Средний чек Москва, 2025 vs 2024, динамика в%» → Через 15 секунд получаете точный ответ с трендом. Profit!
Звучит, конечно, классно, но что же такое text2sql, и что нужно, чтобы реализовать такого бота?
Что такое text2sql?
text2sql — это преобразование текстового запроса в SQL‑код, результат выполнения которого отвечал бы на вопрос пользователя. В машинном обучении такая задача называется sequence‑to‑sequence.
Что нужно, чтобы её решить при помощи LLM:
объединяем пользовательский запрос с описанием схемы базы данных в один промпт;
просим LLM сгенерировать правильный SQL;
определяем тип визуализации;
выполняем запрос в БД;
выдаём результаты пользователю в чате.

Начнём мы с базы данных:

На этапе MVP мы сосредоточились на одной витрине — клиентских чеках. Это таблица с ~150 колонками и несколькими миллиардами строк. Работа с одной таблицей позволяет быстрее получить работающее решение без необходимости поддержки join-операций. Тем не менее, наша целевая архитектура ориентирована на работу с произвольным числом таблиц, поэтому заложили соответствующую гибкость с самого начала.
Основным источником знаний для LLM является инструкция с описанием тестовой таблицы. В неё могут быть включены:

Есть разные способы текстового представления схем. На рисунке показаны примеры баз данных в виде схемы DDL, MAC-SQL и M-схемы:

DDL — наиболее часто используемый способ описания схемы базы данных, однако в нём, как правило, отсутствуют пояснения к таблицам и столбцам, а также примеры значений. Это затрудняет работу LLM, особенно при наличии схожих по названию полей. В одной из статей (A preview of XiYanSQL: A Multi-Generator Ensemble Framework for Text-to-SQL) мы нашли альтернативный подход — MAC-SQL Schema, который позже был доработан авторами в более компактную и информативную M-Schema. Отличия:
Типы данных: обязательны, чтобы модель могла корректно применять агрегации и фильтрации.
Первичные ключи: явно маркируются для сохранения связей при join’ах.
Описание столбцов: в отличие от MAC-SQL, M-Schema обращается напрямую к БД и вытаскивает подробности (описания, примеры, типы).
Косметика: улучшена читаемость схемы для моделей.
Пайплайн обработки запросов пользователей
Прежде чем передавать описание схемы вместе с пользовательским запросом в LLM для генерации SQL, важно учесть возможные сложности с самим запросом. Примеры:

«Привет, что ты умеешь?» — нецелевой запрос, SQL тут не нужен.
«Сумма РТО по торговой сети» — в X5 несколько торговых сетей, нужно уточнение.
«Покажи статистику за январь 2025 года» — непонятно, какие метрики интересуют.
«Проникновение КЛ в РТО для лояльных» — термин может быть неочевиден для модели.
«Начисленные баллы по заводу H495» — терминология может идти из устаревших систем (в данном случае «завод» означает «магазин»).
С учётом масштаба компании и разнообразия формулировок такие кейсы необходимо обрабатывать. Если с последними двумя примерами помогает подробная документация, то первые три требуют дополнительной фильтрации и логики обработки на этапе препроцессинга.

Для этого мы:
получаем историю запросов пользователя;
перефразируем запрос;
проверяем его релевантность;
при необходимости задаем пользователю уточняющий вопрос;
и, наконец, отправляем запрос в text2sql пайплайн.

Наша цель — не просто построить вопросно-ответную систему, а создать полноценного ассистента, который учитывает контекст и историю диалога. Например, если после первого запроса пользователь пишет: «А сгруппируй не по кластерам, а по магазинам», нам необходимо объединить предыдущие сообщения с новым уточнением. Для этого мы получаем историю из backend, определяем, является ли текущий запрос продолжением, и, если да — формируем краткое суммарное описание диалога. В противном случае передаём исходный запрос без изменений.
Пример промпта:
Ты - ассистент аналитик в большой продуктовой retail компании.
Тебе будет дана история аналитических сообщений [message_history], состоящая из запросов пользователя [user_request] и ответов [response] на его запросы. Ты должен понять на основе [message_history], является ли последний запрос пользователя [user_last_request] продолжением/уточнением, или же он является отдельным/самостоятельным.
1. Если ты считаешь, что [user_last_request] является самостоятельным, то перепиши [user_last_request], минимально исправляя грамматические ошибки.
2. Если ты считаешь, что [user_last_request] является продолжением, то в этом случае твоя задача - перефразировать [user_last_request] так, чтобы получился один лаконичный вопрос, который полностью сохраняет смысл [user_last_request] и при этом учитывает контекст из предыдущих сообщений [message_history].
Важность контекста предыдущих сообщений [message_history] убывает по мере появления новых запросов пользователя, то есть самые старые сообщения влияют на [user_last_request] меньше, чем предпоследние запросы.
Цель перефразированного запроса - отправить аналитический запрос в базу данных и получить из неё ответ [response].

Запросы пользователей часто бывают короткими и недостаточно информативными для корректного преобразования в SQL. Чтобы не пытаться интерпретировать каждый ввод вроде: «Привет, что ты умеешь?», мы внедрили несколько механизмов предварительной проверки. Для приветствий возвращается короткая подсказка. Если вопрос выходит за рамки доступной витрины (например, «Продажи вертолетов»), пользователь получает вежливый отказ. В случае, когда для генерации SQL не хватает деталей, модель может задать уточняющий вопрос — например, про период, метрику или регион. Сейчас эту логику обрабатывает LLM, но мы рассматриваем возможность использования более лёгких моделей на этом этапе.
Пример промпта для проверки на общий запрос:
Проанализируй запрос пользователя и определи, является ли он запросом к базе данных или общим запросом.
Запрос считается запросом к базе данных, если он:
1. Запрашивает любую информацию о данных (статистика, метрики, показатели).
2. Содержит ссылки на конкретные данные или их характеристики.
3. Подразумевает получение аналитической информации.
Запрос считается общим, если он:
1. Является приветствием или прощанием.
2. Содержит общие фразы без запросов конкретных данных.
3. Является повседневным запросом (как дела, что ты умеешь и т. д.).
4. Не связан с анализом данных.
Для проверки на соответствие контексту таблицы:
Ты - опытный аналитик данных. Твоя задача - проанализировать
запрос пользователя и определить, относится ли он к контексту
предоставленной базы данных.
Тебе будет предоставлен запрос пользователя,
краткое описание базы данных, документация к
базе данных и схема базы данных.
Запрос считается релевантным, если он:
1. Запрашивает информацию о розничных продажах.
2. Касается данных о клиентах магазина.
3. Относится к программе лояльности.
4. Связан с операциями магазина.
5. Соответствует данным в таблицах.
Запрос считается нерелевантным, если он:
1. Запрашивает данные из другой области (например, автомобили, недвижимость).
2. Касается информации, которой нет в базе данных.
3. Не связан с розничной торговлей.
4. В таблицах отсутствует такая информация.
Сначала подумай, что именно ты видишь в этом запросе,
и как каждый элемент уникально соотносится со схемой базы данных.
На основе этого сделай вывод о релевантности запроса к этой базе данных.
Проверка на необходимость задать уточняющий запрос:
Проанализируй запрос пользователя к базе данных, описание базы данных и схему базы данных и определи, достаточно ли в нем информации, чтобы сформировать sql запрос на его основе. Твоя задача - определить, нужен ли какой-либо уточняющий вопрос к пользователю.
Задавай уточняющий запрос только если запрос не может быть выполнен на основе имеющейся информации.
После всех этапов подготовки мы переходим к самой генерации SQL-кода.

Сначала проводится предобработка запроса, например, расшифровка аббревиатур. Затем мы объединяем подсказки, описание схемы базы данных, текст вопроса и передаём это всё в LLM. Подсказки включают правила расчёта аналитических метрик, извлечённые из документации к витрине, а также few-shot примеры — о них подробнее расскажем чуть позже. На выходе модель генерирует SQL-запрос, который проходит статическую проверку на синтаксическую корректность. Если всё в порядке, код передаётся на следующий этап пайплайна.
Валидация качества работы системы
Важным элементом любой ML-системы является валидация качества. В задаче text2sql это означает проверку того, насколько корректный SQL-код генерирует модель. Этот этап нетривиален, поэтому остановимся на нём подробнее.
SQL |
Тип ответа |
Сложность |
Желаемый ответ |
|
Средний чек клиентов в Москве за сентябрь 2023 года |
SELEST AVG (chtck_value) AS avg_check FROM db WHERE city = “Moscow“ AND date BETWEEN “2023-09-01“ AND “2023-09-30" |
Текст |
Easy |
В Москве за сентябрь 2023 года средний чек клиентов составил 12345 рублей |
Определите, сколько клиентов в каждом сегменте использовали более 3 сервисов, и какой средний ARPU у этих клиентов |
SELEST segment_person, COUNT (DISTINCT client_id) AS clients_using-services, AVG (arpu) FROM db WHERE cnt_services > 3 GROUP BY segment_person |
Таблица |
Medium |
Таблица с колонками: segment_person, COUNT (DISTINCT client_id), AVG (arpu) |
Покажите, как менялась сумма продаж по дням в январе 2021 года для магазина с id-store_id_1 |
SELECT date AS transaction_date, SUM (turnover) AS total_sales FROM db WHERE date BETWEEN “2021-01-01“ AND “2021-01-31“ AND store_id=“_id_1“ GROUP BY date ORDER BY date |
Lineplot |
Easy |
График lineplot со значениями по осям: x: transaction_date y: total_sales |
… |
… |
… |
… |
… |
Для оценки мы собрали небольшой валидационный датасет (~200 примеров) на тестовой таблице. В него вошли текстовые запросы, соответствующие эталонные SQL-запросы, экспертная оценка сложности (easy/medium/hard) и желаемый формат ответа: текст, таблица или график.
Например, вопрос: «Средний чек клиентов в Москве за сентябрь 2023 года» — простой, ему соответствует SQL-запрос, который аналитик в Х5 написал бы вручную. Формат ответа в реальном общении был бы текст: «Средний чек составил N рублей». Важно, чтобы такие примеры отражали реальные задачи от потенциальных пользователей — это позволяет валидировать и улучшать систему на максимально прикладных кейсах.

После сбора разметки мы смогли приступить к измерению метрик качества. В существующих бенчмарках по text2sql, таких как Spider и Bird, используется метрика Execution Accuracy (EX) — доля запросов, результат выполнения которых совпадает с эталонным SQL. Совпадением считается либо полное соответствие таблиц, либо совпадение по ключевым полям.
Однако в отличие от бенчмарков, в реальных витринах уровень сложности схем выше, а формулировки пользователей — менее формальные и более разнообразные. Например, если эталонный ответ содержит две колонки, а сгенерированный SQL возвращает те же две плюс одну лишнюю (например, store_name
и store_id
), такой ответ в EX уже считается неверным, хотя фактически он корректен. Поэтому мы решили использовать более лояльную метрику — совпадение хотя бы одной колонки. Она хорошо коррелирует с ручной разметкой и на практике даёт более адекватную оценку.
Метрика считается путём попарного сравнения отсортированных колонок. У этого подхода есть ограничения: например, если модель вывела долю вместо процента — получим false negative; или если пользователь задал общий запрос, допускающий несколько корректных SQL-вариантов, — мы тоже получим ошибку. Сузить валидацию до строго детерминированных случаев можно, но это не даст реалистичной картины: в боевых условиях запросы как раз и будут неточными и плохо структурированными.

Дополнительно мы используем подход LLM-as-a-judge. На вход модели (в нашем случае — GPT-4o) подаются два SQL-запроса: эталонный и сгенерированный. Модель должна оценить, направлены ли они на получение одного и того же результата (1 — да, 0 — нет), и кратко пояснить своё решение. Это позволяет не только автоматизировать оценку, но и ускорить ручную валидацию при необходимости. По нашим экспериментам, такая метрика показывает сопоставимые результаты с Execution Accuracy (EX).
В качестве вспомогательного способа оценки мы также пробовали сравнивать SQL-запросы по структуре, разложив их на абстрактные синтаксические деревья (AST). Этот подход хорошо выявляет синтаксическое сходство, но плохо коррелирует с реальной семантикой запроса и ручной разметкой.
В итоге, несмотря на ограничения, комбинация EX и LLM-оценок даёт вполне адекватное представление о качестве работы системы.
Эксперименты с различными подходами генерации SQL кода

Задача text2sql является довольно популярной, поэтому мы решили исследовать доступные статьи, готовые библиотеки и различные подходы генерации и LLM модели. Нашли на Papers With Code лидерборд с метриками различных авторов на бенчмарке SPIDER и попробовали применить некоторые из них к нашим данным и сравнить между собой.

На графике показаны модели и их показатели усреднённых оценок EX и LLM судьи.
Прежде всего мы хотели сравнить качество различных opensource моделей, и после этого развернуть лучшую в нашем внутреннем контуре для дальнейших экспериментов. Мы взяли часть размеченного датасета (из 50 запросов, для ускорения этого этапа) и протестировали несколько моделей в самой простой архитектуре.
Самой слабой моделью оказался sqlcoder-8b — llama, дообученная на решение задачи text2sql. Она выдавала много галлюцинаций, например, несуществующие колонки, и путала похожие по названиям поля между собой.
Самая сильная модель — Deepseek R1, рассуждающая модель — имеет увеличенное время генерации, но зато хорошее качество и полезный ризонинг, который можно использовать для улучшения промптов. Однако есть один большой минус — нужно 8 — 10 nvidia H100 на один инстанс такой модельки, что неоправданно много, ведь качество становится лучше всего в 1,8 раз, а железа нужно в 5 раз больше.
В итоге мы выбрали Qwen2.5-72B — модель, занявшую второе место по качеству, но требующую существенно меньше ресурсов для развёртывания.

Перед тем как пробовать архитектурные решения, предложенные в статьях, мы решили протестировать коробочное решение — Vanna Framework. Идея в том, что вы добавляете в векторное хранилище все ваши схемы, документацию к ним и примеры корректных запросов. Каждый пользовательский запрос векторизуется, и на основе эмбеддинга подбирается наиболее подходящая комбинация схем, документации и few-shot примеров. Эти элементы подставляются в промпт, после чего LLM генерирует SQL-запрос, который затем выполняется в базе данных. Пользователь получает ответ в виде таблицы или графика. Если результат оказался верным, его можно добавить обратно в векторное хранилище для будущего использования — таким образом формируется своего рода обучающая выборка.
Мы получили довольно неплохие результаты: показатели оказались выше, чем при использовании той же модели qwen2.5-72b без фреймворка. Однако на большем валидационном датасете (150 запросов) точность составляет около 50%, что всё ещё далеко от желаемого качества.
Основные минусы коробочного решения — ограниченные возможности кастомизации. Нам важно было иметь контроль над поведением системы: учитывать весь контекст диалога, уточнять неполные запросы, не генерировать код на приветственные сообщения, расширить способы отображения результатов и, в идеале, иметь механизм обработки и «починки» ошибок выполнения. Всё это оказалось затруднительно в рамках коробочного подхода. Поэтому мы решили двигаться дальше и тестировать другие гипотезы.

Одним из ключевых решений, рассмотренных нами, стала архитектура из статьи PET-SQL, которая занимает предпоследнюю строчку в лидерборде. Основные идеи подхода заключаются в следующем.
Во-первых, используется механизм few-shot примеров: наиболее часто встречающиеся пары запрос — SQL сохраняются в отдельное векторное хранилище. Запросы пользователей преобразуются в эмбеддинги с помощью модели multilingual-e5-large
, после чего для каждого нового запроса извлекаются наиболее близкие примеры. Эти примеры подставляются в промпт. Такой подход помогает модели лучше улавливать логические паттерны, характерные для конкретных метрик, и, как следствие, снижает количество ошибок в вычислениях.
Во-вторых, предлагается поэтапная генерация SQL. На первом этапе в промпт подаётся вся схема базы данных целиком, и модель генерирует предварительный SQL-запрос (PreSQL). На его основе проводится schema linking — процедура отбора наиболее релевантных таблиц и колонок. Это важно, поскольку многие LLM ограничены по размеру контекстного окна, и полные схемы с десятками таблиц и сотнями полей могут либо не влезать, либо вносить избыточный шум в генерацию. В статье schema linking реализован не через классический RAG-подход (векторный поиск по описаниям), а проще — отбором только тех таблиц и колонок, которые уже использовались в сгенерированном PreSQL.
На следующем этапе формируется финальный SQL-запрос (FinSQL) — уже на основе сокращённой схемы. При этом используется подход cross-consistency, который подробнее рассмотрим в следующем эксперименте.

Мы провели серию экспериментов, чтобы оценить влияние отдельных компонентов архитектуры PET-SQL. На первом этапе мы протестировали только добавление few-shot примеров на небольшом валидационном датасете. Примеры подбирались таким образом, чтобы избежать утечек ответов — в выборку включались лишь запросы с аналогичной структурой, но не дублирующие оригинальный SQL. Это дало прирост по сравнению с бейзлайном — коробочным решением Vanna Framework.
На следующем этапе мы добавили компонент schema-linking, предполагающий сокращение схемы на основе PreSQL. Однако результаты оказались хуже, чем при использовании только few-shot примеров. Несмотря на улучшение по сравнению с Vanna, итоговая точность снизилась.
На основе этих результатов мы сделали два вывода. Во-первых, few-shot примеры действительно работают и приносят ощутимую пользу — мы продолжаем использовать этот подход. Во-вторых, schema-linking в предложенном виде оказался неэффективным. В процессе анализа мы наткнулись на статью с характерным названием The Death of Schema Linking, где авторы поднимают основную проблему этого метода: в ходе агрессивного сокращения схемы могут отбрасываться важные поля, необходимые для генерации корректного запроса. В результате риск ошибки оказывается выше, особенно для больших моделей. Авторы делают вывод, что schema-linking может быть полезен в основном в случае маленьких моделей с ограниченным контекстом, тогда как более крупные модели лучше справляются с длинным контекстом, и для них такой подход скорее вреден.

Рассмотрим следующий компонент, который мы пропустили при разборе PET-SQL, — генерацию нескольких SQL-кандидатов с последующим выбором наилучшего. Этот подход можно рассматривать как аналог ансамблирования, только применительно к LLM. Идея была подробно описана в статье XiYanSQL, занимающей первую строчку в лидерборде.
Сначала, как и в PET-SQL, используется schema-linking. Далее начинается генерация множества SQL-кандидатов. Существует два способа получения разнообразных вариантов:
Self-consistency — увеличение параметра
temperature
в одной и той же модели, чтобы получить разнообразные, но потенциально разумные варианты SQL.Мульти-модельный подход — использование нескольких различных моделей (например, Qwen, LLaMA, DeepSeek и др.) для генерации независимых SQL-кандидатов.
На следующем этапе каждый SQL-кандидат проходит процедуру refinement — перепроверку и корректировку. После этого все варианты передаются в отдельную модель, которую авторы дообучили на задачу выбора наилучшего SQL. Модель оценивает кандидатов по нескольким критериям: соответствие исходному вопросу, синтаксическая корректность и логическая обоснованность.
Подход оказался весьма эффективным — он показал высокие результаты как на общедоступных бенчмарках, так и в рамках наших внутренних тестов.

Рассмотрим результаты применения описанного выше подхода генерации SQL-кандидатов с последующим выбором наилучшего. В нашей реализации для финального выбора использовалась модель Qwen2.5-72B, принимающая решение на основе исходного пользовательского запроса, текста сгенерированного SQL и результатов его выполнения.
На первом этапе мы исследовали вариант с одной моделью (Qwen-72B), где увеличили температуру до 0,7 и сгенерировали по 3 и 5 SQL-кандидатов на каждый запрос. Уже на этом шаге средняя точность выросла до 80%, при этом вариант с пятью кандидатами показал лишь незначительное улучшение по сравнению с тремя.
Затем мы перешли к мульти-модельной генерации, в рамках которой использовали схему «одна модель — один кандидат». Эксперименты с тремя моделями от одного разработчика показали наименьший прирост. Наилучший результат — до 86% точности — был достигнут при использовании трёх моделей, существенно отличающихся как по архитектуре, так и по происхождению: Qwen-72B, дистиллированной версии DeepSeek на базе LLaMA и LLaMA-70B. Такой разнородный ансамбль обеспечил максимальное разнообразие кандидатов и, как следствие, высокое качество финального выбора.
Тем не менее, у этого подхода есть существенные недостатки. Во-первых, требуется одновременно запускать несколько крупных моделей, что предполагает значительное потребление VRAM. Во-вторых, суммарное время генерации заметно возрастает: необходимо трижды сформировать SQL (каждый раз с большим промптом и объёмным выводом), затем провести пост-проверку каждого варианта и, наконец, выполнить процедуру выбора лучшего кандидата.
В результате мы пришли к выводу, что, несмотря на высокую точность, этот подход слишком ресурсоёмкий, чтобы использовать его для всех входящих запросов. Поэтому на текущем этапе мы отложили его использование до момента, когда сможем эффективно классифицировать запросы по сложности и применять его точечно — только к действительно сложным случаям.

Одним из побочных экспериментов стало исследование возможности генерации ORM-кода на SQLAlchemy вместо прямого SQL. Мы не нашли ранее опубликованных работ, в которых предпринималась бы попытка реализации подобного подхода, поэтому решили протестировать его самостоятельно. Основная мотивация заключалась в том, чтобы избавиться от зависимости от конкретного SQL-диалекта и обеспечить универсальность работы с различными СУБД за счёт генерации промежуточного SQLAlchemy-кода, который затем можно компилировать в нужный SQL.
Тем не менее, эксперимент показал значительно худшие результаты по сравнению с генерацией сырого SQL. Во-первых, корректность выполнения кода сильно зависела от версии библиотеки SQLAlchemy. Поскольку неясно, на каких версиях была обучена модель, даже переход на более старые версии не всегда помогал — возникали ошибки несовместимости. Во-вторых, модели часто игнорировали инструкции и вместо генерации отдельных SQL-запросов выдавали полноценные Python-скрипты с подключением к БД, что усложняло последующую обработку. Наконец, сам по себе пайплайн генерации SQL через ORM оказался менее устойчивым — из-за дополнительного этапа преобразования увеличилось количество возможных точек отказа.
Исходя из полученных результатов, мы пришли к выводу, что генерация SQLAlchemy-кода на текущем этапе нецелесообразна, и решили сосредоточиться на генерации «сырого» SQL, который обеспечивает большую стабильность и предсказуемость исполнения.

Таким образом, мы продолжаем активно экспериментировать и исследовать новые подходы к генерации SQL-запросов с использованием LLM. На текущем этапе мы остановились на следующей архитектуре: в качестве основной модели используем Qwen-72B, в промпт подаём отобранные few-shot примеры эталонных запросов, а после генерации проводится этап самопроверки (self-refinement), где модель перепроверяет корректность и логичность своего вывода. Такой подход позволил достичь около 76% точности на наших бенчмарках. При этом стоит отметить, что фактическое качество системы с учётом механизма доуточнения от пользователя может быть существенно выше — особенно в реальных сценариях, когда пользователь может скорректировать или дополнить исходный запрос.

В реальной среде выполнения генерация SQL может завершаться неудачно по ряду причин:
Галлюцинации
Модель использует несуществующие таблицы или колонки, которых нет в схеме. Это происходит, если модель «догадалась» по названию, но в реальности такой сущности нет.Синтаксические ошибки диалекта
Например, модель генерирует SQL для PostgreSQL, а исполняемая база — ClickHouse. Это может касаться как типов данных (VARCHAR
vsString
), так и специфических операторов (LIMIT
...OFFSET
vsLIMIT
n, m и т.д.).-
Логические ошибки SQL
Запрос синтаксически корректен, но содержит неправильную логику, например:WHERE
идёт послеGROUP BY
неверная вложенность подзапросов
использование агрегатов без
GROUP BY
, где они обязательны
-
Ошибки исполнения (runtime)
Код может не выполниться из-за:деления на ноль
приведения несовместимых типов
NULL-значений, с которыми не была предусмотрена работа

Для работы с подобными кейсами в нашей системе реализован механизм автоматической перегенерации SQL-запроса при возникновении ошибки выполнения. Как это работает:
Если запрос упал, наш сервис получает traceback
вместе с оригинальным SQL. Затем мы повторяем этап генерации, добавляя в prompt сообщение об ошибке и SQL-код, вызвавший её. Это позволяет модели учесть контекст неудачной попытки и скорректировать запрос.
Процесс повторяется до N раз (где N — настраиваемый параметр), и на практике такой подход часто приводит к успешному исправлению ошибки. Однако он увеличивает время отклика, поскольку включает несколько итераций генерации и проверки.
Как мы визуализируем данные для пользователя

После успешной генерации и выполнения SQL-запроса возникает вопрос: в каком виде показать результат пользователю? Мы реализовали механизм автоматического выбора формата представления данных на основе анализа запроса и сгенерированного SQL.
LLM оценивает контекст и выбирает между тремя форматами:
Текстовый ответ,
Таблица,
График (lineplot или barplot).
Зачем это нужно:
Если пользователь задаёт запрос вроде «Какие продажи были в прошлом месяце?», модель отдаёт краткий текстовый ответ:
«Продажи в прошлом месяце составили X рублей».
Если же запрос предполагает:
анализ динамики метрики во времени — используется lineplot,
сравнение показателей между группами — выбирается barplot.
Во всех остальных случаях система отображает результат в табличной форме, что остаётся универсальным и удобным вариантом представления данных.
Проанализируй пользовательский запрос к базе данных и SQL-запрос, который будет выполнен, чтобы определить наиболее подходящий формат вывода данных.
Учитывай количество столбцов в результате SQL-запроса, при выборе типа визуализации. Если выбирается график, в результате должно быть как минимум два столбца.
Пользователь может не указать явно тип визуализации в запросе — ты должен определить его самостоятельно.
Ответь только одним словом из списка: text, plot, table.
Используй следующие правила:
text:
- Если пользователь запрашивает короткий ответ, объяснение или краткое резюме (например, «Какова численность населения города X?»).
- Когда нужно вывести одно значение или небольшое количество информации.
- Если информация требует подробного описания или пояснения в виде предложений и абзацев.
- Если размер результирующей таблицы, скорее всего, будет небольшим.
plot:
- Если нужно показать изменение данных во времени или по другой непрерывной шкале (например, график продаж по месяцам).
- Для визуализации взаимосвязей между переменными.
- Когда важно показать распределение данных, выявить выбросы или аномалии.
- Если нужно визуально сравнить величины разных показателей.
- Если таблица содержит много строк и мало столбцов (например, один столбец — дата, второй — значения).
table:
- Если необходимо вывести конкретные строки таблицы.
- Если ни один из других типов визуализации не подходит.
Если LLM выбирает оптимальный тип ответа — текст, то сгенерированный SQL отправляется на бэкенд, где он выполняется в базе данных. Полученный результат возвращается обратно в модель с запросом сформировать человекочитаемый ответ на основе данных. Этот текст затем отображается пользователю в чате.
Такой подход отлично работает в сценариях, где объем данных небольшой — например, когда результатом является одна, две или несколько строк. В этом случае модель легко обрабатывает данные и формирует информативный и понятный ответ.
Проанализируй запрос пользователя, sql запрос, который он выполнил и результат, полученный из базы данных, и напиши человекочитаемый ответ на русском языке, основанный на данных, возвращенных в результате.
В случае выбора визуализации в виде графика процесс немного сложнее. Сначала модель определяет конкретный тип графика — на данный момент поддерживаются lineplot, barplot и pieplot.
Проанализируй пользовательский запрос и SQL-запрос, чтобы определить наиболее подходящий тип диаграммы.
Ответь только одним словом из списка: line, bar, pie.
Используй следующие правила:
line (линейная диаграмма):
- Если нужно показать изменения во времени, тренды, динамику, последовательные изменения.
bar (столбчатая диаграмма):
- Если нужно показать распределение непрерывной переменной в зависимости от категориальной.
pie (круговая диаграмма):
- Если нужно отобразить состав или доли разных категорий в наборе данных.
Затем LLM выбирает, какие данные использовать для осей графика, а также формирует заголовок для визуализации.
В результате мы получаем все необходимые параметры и данные, чтобы отрисовать график на фронтенде и представить информацию пользователю в наглядном и удобном виде.
Проанализируй пользовательский запрос, SQL-запрос, который будет выполнен, и тип диаграммы, чтобы определить наиболее подходящие столбцы для осей X и Y.
Сгенерируй описательное название диаграммы на основе пользовательского запроса и содержания SQL-запроса.
Если выбран тип ответа — таблица, SQL-запрос выполняется, и полученная таблица выводится напрямую в чат без дополнительной обработки.
Примеры работы приложения

На скриншотах показано, как выглядит взаимодействие с нашим чат-ботом:
В первом примере задаётся вопрос: «Покажи РТО с НДС за вчера» — бот возвращает ответ в виде числового значения (значение на скриншоте затемнено).
В последнем примере запрос сложнее: нужно показать динамику продаж за апрель по конкретному адресу. В этом случае пользователю выводится график с трендом, наглядно отображающий данные.

Поддержка нескольких таблиц — реализовать автоматический роутинг запросов между разными таблицами базы данных.
Обработка запросов с джоинами — обеспечить корректную генерацию и выполнение сложных SQL с объединениями таблиц.
Внедрение спекулятивного декодинга — разделение запросов по сложности и маршрутизация их на разные модели или подходы генерации для оптимизации качества и скорости.
Подготовка и дообучение собственной модели — собрать большой датасет и обучить внутреннюю 7/32b LLM для повышения точности и независимости от внешних сервисов.
Оптимизация пайплайна — замена LLM на более легкие модели на некоторых этапах для снижения затрат и ускорения отклика.
Работа с произвольными Excel файлами — разработка функционала для обработки и аналитики данных из разных форматов Excel.
Эксперименты с schema-linking на основе RAG — изучение гибридных подходов к сокращению схемы и повышению качества генерации.

Заключение
Современные языковые модели способны эффективно обрабатывать до 90% запросов средней и низкой сложности (easy/medium) на реальных базах данных при наличии полной и подробной документации. Однако полностью заменить аналитиков им пока не под силу — зато они отлично справляются с рутинными задачами и большинством ad-hoc запросов. Создать первый прототип такой системы за месяц - два оказывается вполне достижимой задачей.
Если вам понравилась тема данной статьи и вы хотите еще больше технических подробностей, то приглашаю вас послушать моего коллегу Джала Антонова на конференции AiConf X 2025 26 сентября 2025 c докладом: «Готовим Text2SQL на «Пятёрку»: выжимаем максимум из опенсорсных моделей» про то, как мы дообучали собственные модели и внедряли агентский подход.
Комментарии (0)
Ivan22
23.09.2025 15:46этой идее уже лет 8 в продакшен решениях. Уже давно в BI тулы встроены возможности визуализации данных в ответ на запрос обычным текстом, например вот
https://youtu.be/lssvoeE2cqU?t=37
или в PowerBi тут:
Shambala
23.09.2025 15:46С тоской вспоминаю свою молодость в рекрутинге, где под подсчет нулей в ЗП специалистов с приставкой BI можно было сладко заснуть.
Ivan22
23.09.2025 15:46ох уже эти сказки конечно. Я в BI с 2005-го, не помню времен когда BI хоть как-то принципиально отличался от любой разработки
shai_hulud
Когда-то SQL дизайнили как язык для менеджеров, что бы они сами могли доставать данные из БД для анализа. Мечта тех дизайнеров наконец-то исполнилась, теперь менеджеры смогут запрашивать данные, но не факт что будут :)