В любой крупной компании есть повторяющийся сценарий. Аналитик сидит над экспериментом или моделью, строит А/В тесты и дашборды, и в этот момент приходит бизнес и просит быстро посмотреть «продажи жвачки за вчера». Аналитик переключается, пишет запрос, отдаёт результат, а через десять минут прилетает почти такой же вопрос. Потом ещё один и ещё. День заканчивается, а свои задачи стоят и покрываются ржавчиной.
Так выглядит ручной доступ к данным. Бизнес понимает, что нужные данные в хранилище есть, но не всегда может их быстро получить. А аналитик становится «бутылочны�� горлышком», через которое проходит большой поток запросов.
Мы хотели упростить работу с данными. Чтобы пользователь задавал вопрос, сервис превращал его в SQL, выполнял в хранилище и возвращал аналитический ответ. Чтобы рутина уходила в инструмент, а аналитик переставал быть «бутылочным горлышком».
Вроде ничего нового, но в реальной инфраструктуре вскрывается много интересного: специфическая лексика, свойственная каждому из направлений бизнеса, громоздкие витрины, опыт работы с легаси системами и поведение LLM, которую сначала надо надрессировать.
Я Джал Антонов, ведущий ML-инженер X5 Tech. В этой статье по мотивам моего выступления на AIConf расскажу, как мы продолжаем собирать свой Text2SQL. Что работало, что ломалось, что пришлось выкинуть, где помогли векторные индексы, почему промпты разрослись до десятков тысяч токенов и как мы всё это решали.
Требования к системе Text2SQL
Прежде чем писать код, мы сформулировали базовые требования. Ничего экзотического, только то, что делает инструмент рабочим внутри большой компании.
|
Требование |
Суть |
Что это даёт |
|
Интеграция с корпоративным мессенджером |
Работает как обычный чат-бот без отдельных интерфейсов |
Пользователь пишет запрос и сразу получает ответ, никаких лишних шагов |
|
Быстрое время ответа |
До 15 секунд на весь цикл генерации и выполнения SQL |
Инструмент остаётся рабочим, пользователи не уходят обратно к аналитикам |
|
Удобочитаемый результат |
Текст, таблица или график в зависимости от структуры данных |
Пользователь получает понятный ответ без ручного форматирования |
Так система закрывает большую часть запросов от менеджеров.
Прежде чем изобретать велосипед, мы посмотрели на существующие решения Text2SQL. Один из самых популярных продуктов на рынке — Vanna.ai.
Его главный плюс – быстрое поднятие MVP. Достаточно передать описание колонок, их типы и примеры, выбрать LLM-модель и можно получить первый рабочий прототип.
Но это коробочный продукт, который сложно кастомизировать. А в нашем случае важны перефразирование и уточнение запроса, которые трудно встроить поверх готового решения. Поэтому мы решили сделать свой Text2SQL.
Базовый подход
Для первого прототипа мы выбрали работу с одной витриной данных по клиентским чекам. В ней около 150 колонок и больше миллиарда строк данных, но довольно простая структура. Работа осуществляется с одной таблицей без дополнительных JOIN операций. Такой выбор снижает количество ошибок на старте и позволяет быстро собрать рабочий MVP. На нём проще проверить идею Text2SQL в реальной инфраструктуре и показать ценность концепции.
Схема работы выглядела так:

На вход сервису приходит пользовательский запрос. Мы обогащаем его общим описанием таблицы и подаём в Text2SQL. Модель возвращает валидный SQL запрос. Бэкенд выполняет этот запрос в базе данных, собирает результат и отправляет его пользователю. Логика на этом уровне простая.
Теперь посмотрим, что происходит внутри Text2SQL.

Запрос проходит несколько этапов:
-
приводим запрос к полной формулировке (перефразируем);
-
проверяем, относится ли он к данным;
-
задаём уточнение, если не хватает параметров;
-
формируем промпт для модели;
-
генерируем SQL;
-
выполняем его и выбираем формат ответа для пользователя.
Теперь детальнее разберём каждый из описанных этапов.
Этапы прохождения запроса
Перефразирование запроса
Наша задача была научиться вести диалог с пользователем.
В этом примере цель перефразирования проста. Нужно собрать разрозненные сообщения пользователя в один полный аналитический запрос: «Сумма РТО кластер Самара за май 2025».
Проверка связи запроса с данными
На следующем этапе сервис должен заниматься только аналитическими запросами.
Мы сразу отбрасываем: приветствия, общие разговоры и вопросы про погоду. Это экономит ресурсы и не нагружает пайплайн генерации SQL.
Уточнение пользовательского запроса
Пользователи часто формулируют запросы так, как привыкли внутри компании. Поэтому из текста не всегда понятно, что они хотят получить.
Например, в запросе «Продажи торговой сети Пятёрочка в разрезе территорий» нет периода, поэтому сервис спрашивает уточнение. Ещё в витрине может не быть такого подразделения: «Выполнение плана РТО за 15 мая 2025 года в дивизионе Нижнекамск по кластерам». Тогда мы ищем наиболее близкое значение и возвращаем вопрос в формате: «Вы имели ввиду дивизион Казань Нижнекамск?».
Такой простой механизм существенно снижает количество потенциальных ошибок в нашем пайплайне и повышает точность финального SQL.
Формируем промпт
Посмотрим, как он выглядит.

Наш промпт собран из нескольких частей.
-
общее описание таблицы и её назначения;
-
подсказки по аналитическим метрикам, которые приняты в компании;
-
few-shot примеры — пары «запрос пользователя плюс эталонный SQL для похожих задач».
Промпт должен дать модели достаточно контекста, чтобы она могла сгенерировать SQL, который отвечает на вопрос пользователя. При работе с нашей таблицей по клиентским чекам промпт доходил до 20 тысяч токенов. Для одной таблицы это заметно.
Генерируем SQL
После того, как запрос пользователя перефразирован и при необходимости уточнён, наступает ключевой этап преобразования текстового запроса в SQL. Мы хотим сгенерировать не просто синтаксически верный, но и семантически точный запрос, который корректно решит поставленную задачу. Сам процесс генерации состоит из нескольких шагов:
1. Вызов LLM: Подготовленный промпт отправляется в языковую модель. Мы запрашиваем только чистый SQL-код без дополнительных объяснений.
2. Валидация: Сгенерированный запрос проверяется на базовые синтаксические ошибки и очевидные логические проблемы.
3. Выполнение: После проверки SQL запускается на пользовательской витрине, а результат передаётся для подготовки финального ответа.
Однако на практике первый же запрос может оказаться неудачным. Возвращать пользователю сырую ошибку из БД плохо. Это испортит весь опыт взаимодействия. Поэтому мы реализовали механизм повторной генерации. Если выполнение SQL завершается с ошибкой, то в новый промпт для модели, помимо исходного контекста, добавляется сообщение об ошибке и инструкция по её исправлению. Это позволяет решить большинство проблем на втором или третьем ретрае, и пользователь об этом даже не узнает. Но из-за того, что модель не всегда может исправить ошибку, мы ограничили число попыток тремя. Если все они оказываются неудачными, система сообщает пользователю, что не может ответить на его вопрос.
Определение типа визуализации данных
Наша задача не заканчивается на корректном SQL. Пользователь должен получить ответ в удобном виде.

Для этого с помощью LLM определяем необходимый тип ответа. Если в результате выполнения SQL сгенерировалось одно число или небольшой набор чисел, формируем текстовый ответ. Модель помогает собрать короткую человеческую формулировку поверх сырого результата.
Если запрос про динамику или сравнение — выбираем график. LLM определяет его тип, например, bar plot, line plot или pie plot, а также колонки для осей. Дальше бэкенд строит график и отправляет его пользователю.
Универсальный вариант — это таблица. Мы используем её, когда ни текст, ни график не подходят. Например, когда нужно вывести список магазинов или набор показателей.
Теперь разберём, как мы оцениваем, что сервис становится лучше, а не просто меняется.
Валидация данных
Для оценки качества мы опирались на метрику Execution Accuracy из бенчмарков BIRD и SPIDER. Логика простая. Есть два SQL. Первый — эталонный, его написал аналитик. Второй — сгенерировала модель. Мы выполняем оба запроса и сравниваем получившиеся DataFrame. Если они совпали полностью или по набору колонок, мы проставляем 1. Если нет, ставим 0.

Но Execution Accuracy может быть излишне строгой для реальных запросов. Поэтому мы решили рассмотреть другие метрики.
AST Similarity (Abstract Syntax Tree) сравнивает только структуру SQL. В этом случае мы не смотрим на результаты выполнения, а анализируем сам синтаксис. Такой подход помогает понять, насколько генерация отклоняется от эталона формально, даже если итоговые DataFrame совпадают или незначительно различаются.
Классическая проблема такой метрики в том, что два совершенно разных SQL могут выдавать один и тот же верный результат.

Ещё одна метрика — LLM as a judge.
В этом случае мы просим модель оценить, насколько сгенерированный SQL действительно отвечает на исходный вопрос. Но на вход подаём не только SQL, а и результаты выполнения обоих запросов. Такой формат позволяет увидеть расхождения, которые не фиксируются через синтаксис или сравнение DataFrame.
Вдохновившись статьёй: «SQL-of-Thought: Multi-agentic Text-to-SQL with Guided Error Correction», мы дополнительно ввели классификацию ошибок. Разбили ошибки на группы: синтаксис, значения, фильтрация, агрегация. Это помогает понять, где именно ломается пайплайн и какие части требуют доработки.

Конечно, без сложностей не обошлось, поэтому мы начали улучшать наш сервис.
Проблемы базового подхода
Мы выделили основные ограничения и потенциальные точки роста для нашего пайплайна:
-
масштабирование на новые таблицы;
-
проблема галлюцинаций и безопасности;
-
много нерелевантной информации на вход LLM;
-
низкое качество генерации SQL для сложных запросов;
-
зависимость от большой LLM;
-
низкая гибкость и адаптивность пайплайна.
Команда хотела масштабироваться на новые таблицы, но добавление каждой обходилось слишком дорого. Это означало много ручной работы аналитиков и недели настройки.
Параллельно оставалась проблема галлюцинаций модели. Промпт разрастался, работал неэффективно, а генерация SQL для сложных запросов давала около 70% качества, что всё ещё оставалось достаточно низким для сложных и нестандартных запросов. Формирование сложных формул и метрик аллоцируется на LLM, а не считается заранее в аналитической БД. Поэтому если хотите получать ответы быстрее — переносите все расчёты в БД.
Кроме того, наша изначальная LLM была на 72 млрд параметров, а мы хотели пойти в сторону более лёгких и быстрых решений.
Наконец, наш изначальный пайплайн был недостаточно гибким.
Со всем этим можно было бороться.
Масштабирование на новые таблицы
Для каждой новой таблицы аналитикам приходилось заполнять довольно большой набор данных. В обязательный перечень входили: общее описание таблицы, описание колонок, их типы, примеры значений и подсказки по расчёту метрик. Самой трудоёмкой частью была подготовка few-shot примеров, потому что они напрямую влияют на качество генерации.
По нашим расчётам, на добавление новой таблицы уходило около двух недель. И это только первый прогон. После него качество обычно было низким, и следовали дополнительные итерации доработки вместе с аналитиками. Нужно было уточнять описания полей, самой таблицы и подсказок для решения багов.
Ещё мы добавили поддержку четырёх новых таблиц, помимо изначальной по клиентским чекам.

Пока мы работаем с ними отдельно и не поддерживаем JOIN-запросы.
Проблема галлюцинаций и промпт-инъекций пользователей
Во время тестирования выяснилось, что на некоторые запросы пользователей вроде «Продажи BMW за вчера» или «Покажи EBITDA за первое полугодие», наша LLM, даже не имея данных в таблице, пытается ответить и сгенерировать SQL. Иногда даже придумывает несуществующие колонки. Плюс мы не хотели обрабатывать запросы пользователя с выполнением SQL кода или опасные запросы по удалению данных из таблицы.
Чтобы останавливать такие случаи на входе, мы добавили модель классификации, которая фильтровала странные и некорректные запросы пользователя в начале пайплайна. Если же модель всё-таки пропускала такой запрос, на помощь приходил механизм Value Retriever.

Его задача – найти в таблице точные значения для фильтров, упомянутых пользователем. Алгоритм такой: сначала мы извлекаем из запроса упоминания сущностей (например, «макрорегион Москва 1001» или «торговая сеть Пятёрочка»). Затем, зная по схеме, какие колонки в таблице соответствуют этим типам сущностей, мы ищем, есть ли точное или максимально близкое совпадение среди реальных значений в этих колонках. Если значение не найдено, система не додумывает его, а уточняет у пользователя, предлагая наиболее похожие значения из нужной колонки. Параллельно ищем аналитические метрики и добавляем их в формулы вместе со значениями в наш промпт генерации. Таким образом, SQL-запрос генерируется только на основе реально существующих данных.
Например, на запрос пользователя «Продажи в Пятёрочке в Ростове» генерировался такой код.
SQL, который модель генерировала до добавления Value Retriever:
SELECT SUM(sales) AS total_sales
FROM table
WHERE city = 'Ростов' and retail_chain = ‘Пятерочка’
Проблема в том, что модель не знает реальных значений в базе. В колонке city действительно есть город Ростов, но в формате «Ростов-на-Дону». Поэтому запрос выше вернёт пустой результат, хотя данные существуют.
Теперь с Value Retriever всё иначе:
-
Из запроса извлекается сущность
cityсо значением ‘Ростов’. -
Value Retriever проверяет, есть ли в колонке
cityточное совпадение. Не находит. -
Ищем наиболее близкое значение и находим
'Ростов-на-Дону'.
SELECT SUM(sales) AS total_sales
FROM table
WHERE city = 'Ростов-на-Дону'
AND retail_chain = 'Пятерочка'
Такой подход дал нам +5% по метрике Execution Accuracy, а также помог убрать неоднозначности в пользовательском вопросе.
Много нерелевантной информации на вход LLM
Для решения этой проблемы мы добавили Column Retriever. Это классический векторный поиск.

Мы векторизуем запрос пользователя и ищем в витрине колонки, ближе всего подходящие смыслу запроса. В результате в промпт попадают не все поля подряд, а только те, что могут реально пригодиться.
Так мы сократили контекст примерно в два раза. Промпт на 20 тысяч токенов ужался до 10 тысяч без потери качества.
Низкое качество генерации SQL для сложных запросов
Это классическая проблема всех Text2SQL решений. Если смотреть на результаты бенчмарка BIRD, видно, что текущие SOTA решения пока заметно уступают человеку. По этой классификации разрыв превышает двенадцать процентных пунктов. Это показывает, что задача ещё далека от решения.

Но прогресс есть. Недавнее решение пробило потолок в 80%. Мы пробовали внедрить идеи из топовых статей этого бенчмарка и начали с улучшения few-shot. Для этого ищем наиболее подходящие под пользовательский запрос примеры векторным поиском, но зачастую в ТОП попадают few-shot с одинаковыми названиями городов или периодов. Оказалось, что модель цепляется за совпадение по словам, а не по структуре запроса. Поэтому при поиске такие запросы надо маскировать и искать по маске. Это повышает качество поиска few-shot на 7%.
Мы также попробовали подход из статьи OpenSearch-SQL: Enhancing Text-to-SQL with Dynamic Few-shot and Consistency Alignment. Авторы предлагают использовать Chain-of-Thought (CoT) few-shot.

Мы пробовали добавлять небольшой reasoning. Перечислять все колонки, значения, которые используются в фильтре WHERE, представлять SQL в более простом формате и подавать сам эталонный SQL. Но этот вариант не принёс никакого выигрыша в метриках.
Тогда мы проверили ещё одну гипотезу из Chain-of-Thought. Авторы публикации предлагали строить план выполнения SQL кода командой EXPLAIN, чтобы подавать его как reasoning в наши few-shot. Но напрямую использовать результаты из EXPLAIN не получается, потому что LLM его не понимает. Поэтому авторы идеи дополнительно преобразовывали его в человекочитаемый текст и только затем подавали reasoning’ом во few-shot. Но такой подход также не дал нам никакого выигрыша.
Третья идея из Chain-of-Thought (CoT) – это классический алгоритм Divide-and-Conquer. Надо разбить сложный запрос на несколько подзапросов, обработать их отдельно и суммировать финальный результат. В нашем случае и это не дало прироста метрик.
Планы
Мы ещё попробуем потестировать его, когда начнём работать с более сложными зап��осами и поддерживать JOIN. Потому что авторы статьи утверждают, что именно на них идея хорошо работает.
Поэтому мы решили проверить ещё одну гипотезу и сравнили нашу текущую модель Qwen (32 млрд параметров) с другими LLM.

Оказалось, что GPT5 обгоняет нашу модель всего на четыре процентных пункта. Это примерно тот же уровень, что у последних версий Qwen и открытой модели gpt-oss (120 млрд параметров). Мы также проверили Think2sql, которая обучена специально под Text2SQL, но она показала совсем низкое качество.
Всё это указывает на то, что следующий апгрейд LLM сам по себе вряд ли даст значительный прирост.
Зависимость от большой LLM
Наша изначальная модель (72 млрд параметров) давала приемлемое качество, но была тяжела по ресурсам. Поэтому мы хотели перейти на более лёгкий вариант, но без потерь. Для этого собрали датасет из ~1200 пар пользовательских запросов, эталонный SQL и дообучили на нём LoRA-адаптер с помощью более лёгкой модели (8 млрд параметров).

Хотя для витрины с клиентскими чеками удалось приблизиться к качеству большой модели, для новой таблицы по магазинам дообученный адаптер уже давал заметно худшие результаты. Основная проблема такого подхода в том, что для каждой новой таблицы требовалась обширная и дорогая ручная разметка. Это сотни пар «вопрос — эталонный SQL». Качество же адаптера оставалось непредсказуемым и сильно зависело от специфики данных, сложности таблицы и разнообразности пользовательских запросов. Масштабировать такой подход на десятки витрин стало бы очень сложной задачей. Поэтому мы решили отказаться от дообучения моделей и вернуться к совершенствованию архитектуры самого пайплайна.
Что планируем делать дальше
Мы хотим поднять качество генерации на текущих витринах и расширить поддержку на большее число таблиц. Параллельно рассматриваем работу с JOIN-запросами. Ещё одна идея – это шаблонизация пользовательских запросов. В этом случае не придётся заново генерировать SQL для каждого нового пользовательского вопроса. А также в наших планах – поэкспериментировать с агентским подходом. Хочется, чтобы сервис умел вести более живой диалог, уточнять контекст и возвращаться к предыдущим вопросам — почти как живой аналитик.
Заключение
Большие модели становятся умнее, но не точнее. Поэтому всё чаще говорят, что LLM — это мощный двигатель без настроенного карбюратора. Потенциал огромный, но без фильтров, ограничений и правильной среды работает непредсказуемо, и легко уходит в сторону.
Нет одной модели, которая решает всё. Работает только связка идей: векторный поиск, уточнения, ограничение контекста, нормальные few-shot и строгая валидация. Этот набор даёт стабильный результат и упрощает жизнь аналитикам. А качество зависит не столько от размеров LLM, сколько от подготовки витрин, понятных метрик и подконтрольных шагов пайплайна.
В конечном счёте, качество решения зависит не столько от размера LLM, сколько от тщательной подготовки данных, продуманной архитектуры и большой работы по описанию и разметке данных.
В итоге выигрывает не тот, кто ставит самую большую модель, а тот, кто минимизирует неопределённость на входе и исключает хаос на выходе.
А чтобы узнать больше о трендах в IT-отрасли, следите за обновлениями в календаре «Онтико» и узнавайте всё о ближайших конференциях!
Автор: a_dzhal


