Разрабатывая новую систему отчетности, возник вопрос – а не добавить ли нам AI как-нибудь куда-нибудь? Не, ну а что – это модно, стильно, да и вообще - ни один нормальный новый проект не может вынести отсутствия AI. Задача не выглядела сильно сложной. Прокинуть вопрос пользователя из интерфейса на бэк – стандартная задача, Ollama устанавливается парой кликов, скачать и запустить модель – это 2 команды, а то и одна, если хорошо постараться. Доступ к модели возможен по web api из бэка на C#, документация по Ollama есть… Что тут может пойти не так, верно?

Этап первый «Не, ну попробуем»
В целом, этот ваш AI внедрять планировалось в разрабатываемую систему отчетности для крупного госзаказчика. Предыдущая версия системы отчётности была рассчитана на десятки тысяч пользователей при одновременном онлайне в несколько тысяч человек. Сложная предметная область, тысячи таблиц в БД, многие из которых содержат миллионы и десятки миллионов записей, а некоторые - сотни миллионов.
Бэк разрабатываемой системы – web api на c#. Фронт – react. Хотелось посмотреть, насколько AI-ассистент может помочь с анализом информации отчета, что он в принципе может и каковы пределы его возможностей на текущий момент. Например, если пользователь попросит выявить аномалии в отчете – они будут выявлены или нет? А если нужно будет сопоставить данные 2 отчетов – справится ли? Или если пользователю нужно будет подсказать, каким отчетом воспользоваться, то получится ли указать на правильный?
Последний вопрос вызывал особые опасения, поскольку было понятно сразу – просто обычная LLM модель явно немного не в курсе того, какие отчеты есть у нас в системе. Тем не менее, первая пара вопросов казалась логически возможной для решения.
Система отчетности проектировалось как локальное решение с изоляцией данных, поэтому облачные APIнам не подходили даже в теории. В качестве сервиса инференса LLM была выбрана Ollama, т.к. ее легко установить и она не требовательна к ресурсам. Итого, первая версия выглядела так:

В UI части системы добавилось окно чата, через которое пользователь вводит свой вопрос. К вопросу цепляется контекст, который включает данные отчета и историю переписки пользователя, и все это улетает на бэк. Бэк подготавливает данные, берет из настроек Settings.AIOptions параметры вроде названия модели и пересылает в Ollama через HttpClient примерно в таком духе:
var requestData = new
{
model = Settings.AIOptions.ModelName,
messages = chatHistory,
stream = false,
keep_alive = 60
};
var stringRequest = JsonSerializer.Serialize(requestData);
HttpResponseMessage response =
await new HttpClient() { Timeout = new TimeSpan(0, 0, Settings.AIOptions.MaxWaitTimeSeconds) }
.PostAsync(Settings.AIOptions.ApiURL,
new StringContent(stringRequest, Encoding.UTF8, "application/json"));
return await response.Content.ReadFromJsonAsync(typeof(AIAnswer)) as AIAnswer;
В принципе, даже как-то удивительно, но Ollama без особых проблем начала принимать запросы и отвечать. Ну, или просто что-то уже забылось.
Этап второй «Эй, ты опять все переврал!»

Дальше началось тестирование того, что получилось и подбор моделей. Первой моделью была Mistral и это было некоторое огорчение – у нее в принципе с русским языком на тот момент было не очень хорошо и из-за этого она не подходила.
Следующая модель – gemma3. Вот у нее с русским все хорошо, отвечала на вопросы очень живым, насыщенным языком, отвечала подробно и детально. Жаль только, что в половине случаев неправильно, но зато как!
Очередная модель – qwen3. У нее есть некоторые проблемы с языком – любит начинать отвечать на английском (а изредка даже на китайском), но почти исключить риск этого помогает системный промпт. Зато в плане анализа данных – очень даже неплохо. Произвести операции над данным, сгруппировать, отсортировать, отфильтровать, посчитать какую-нибудь метрику – с такими задачами справлялась относительно легко. С более абстрактными задачами, вроде выявления аномалий, справлялась хуже, но на какие-то мысли пользователя могла навести. Сопоставление данных 2 отчетов – вот тут начались проблемы.
Ollama скрытно управляет окном контекста. Ей можно передавать большое количество текста, она безропотно съест все… И отбросит большую часть, ничем не намекнув на это. Сопоставление данных 2 отчетов предполагает необходимость анализа большого числа данных, и тут qwen3 начинал сходить с ума – модель была рассуждающая, она вычисляла результат, начинала перепроверять себя, у нее ничего не сходилось, она снова пересчитывала и в итоге выдавала что-то слабо похожее на правильный результат.
После этого попробовали еще несколько моделей, но qwen3 осталась лидером. В целом, результат работы моделей понравился. Так же учли, что в будущем все равно модели будут развиваться, рано или поздно они выйдут на нужный уровень по анализу данных, поэтому было принято решение развивать дальше это направление в системе отчетности.
Этап третий «Все усложняется»

Итак, направление развития было признано перспективным, но для нормального использования требовалось решить проблемы:
1. Ollama не подходит для использования как сервис инференса LLM в системе со множеством пользователем. Она просто не может параллельно выполнять запросы, все запросы добавляются в очередь и считаются один за другим. Так же Ollama реализует свой интерфейс REST API, в то время множество других сервисов реализуют OpenAI-совместимый REST API. Закладываясь под совместимый api можно было бы облегчить миграцию на другой сервис при необходимости.
2. Нужно считать токены в передаваемой в LLM информации и управлять размером контекста. Без этого не получится совместить вместе историю переписки между LLM и пользователем, отчетные данные и прикладные данные.
3. Модели нужны прикладные данные. Нормативные документы, описание отчетов системы… Мало ли что может понадобиться, возможно даже описание каких-то таблиц БД или справочников. В общем, нужно делать RAG, чтобы эти данные добавить.
Выбор сервиса инференса
С первой проблемой все было более-менее понятно – нужно попробовать разные сервисы инференса. В докере по очереди были подняты и протестированы 3 OpenAI-совместимых движка – vLLM, TGI и LMDeploy. По скорости работы показались примерно одинаковыми, по функционалу тоже. В итоге был выбран vLLM как наиболее популярное решение. Был переработан под него api вызова из бэка, был добавлен стриминг ответа, чтобы пользователь не грустил, пока LLM додумывает ответ до конца.
Управление контекстом
Со второй проблемой ситуация была хуже. В теории, для .net есть Microsoft.ML.Tokenizers, который должен бы считать токены. Зачем бы еще нужна была библиотека с таким названием, да? Но, к сожалению, свободно считать токены через нее не получилось – вроде бы и создается BpeTokenizer, вроде бы и считает что-то, да вот цифры не складываются. Вроде как, чтобы точно посчитать число токенов для qwen3, нужна поддержка tokenizer.json, а ее в BpeTokenizer нет... В общем, как бы ни хотелось посчитать токены средствами самого бэка, легко и просто это сделать что-то не получилось, а подключать сторонние пакеты или делать что-то сложное очень не хотелось. Тут начали как-то даже завидовать питонистам. Ладно, все равно уже решили использовать vLLM, а у него есть отдельный endpoint для подсчета токенов, так что сделали подсчет токенов через него. По скорости, кстати, примерно аналогично с BpeTokenizer.
Когда с подсчетом токенов разобрались, наступила очередь организации модели управления контекстом. Разделяем контекст на три части: часть под информацию из отчетов, часть под информацию из будущей RAG системы, остальное – под историю переписки между LLM и пользователем. Перед тем, как отправить очередной вопрос LLM проверяем, не требуется ли сгенерировать summary:
var availableTokensCount =
_model.ModelMaxTokens - (_reportInfoContextTokens ?? 0) - (_ragContextTokens ?? 0);
var usedTokens = await _tokenizer.GetTokensCount(JoinToString(_historyMessages)) + _lastMessageTokens;
return usedTokens + _model.MessageMaxTokens > availableTokensCount * 0.9;
Если ответ от LLM может заполнить окно контекста более чем на 90% - просим LLM сгенерировать краткий пересказ предыдущей беседы.
Третья проблема… Ну, она явно достойна отдельного этапа или двух. Или трех?.. Не, надеюсь, 2 будет достаточно.
Этап четвертый «R.A.G.»

Это, наверное, самая непростая часть системы. Понятно, что дать модели прикладные данные – это естественный и нужный шаг. Но какие именно данные потребуются? Какие дадут значительное увеличение пользовательского опыта, какие бесполезны и только мешать будут?
Заполнение хранилища
Было решено сделать 3 области – данные из документов, данные из метаданных и кода самой системы отчетности и данные из БД. Данные из БД впоследствии разделились на описание БД (структуру таблиц) и разного рода справочники.
С документами все просто – нарезаем на чанки, генерируем embedding, кладем в векторную БД.
Для остальных источников документы вначале нужно было сгенерировать. На этом этапе в качестве результат генерации был просто текст. Анализировалась метаинформация отчетной системы, в исходном коде искалась информация об отчетах, регулярными выражениями вытаскивались списки полей отчета, фильтры и прочее, и генерировался текстовый документ с описанием всего этого. Для БД проще – структуру таблиц и справочники довольно легко получить и выгрузить.
В качестве инструмента для нарезки чанков был выбран docling, в качестве векторной БД Qdrant. Все это поднято в отдельных контейнерах.
Примерно так выглядит процесс заполнения векторной БД:

Извлечение данных из хранилища
Дальше нужно было как-то извлечь информацию из векторной БД. Тут, наверное, был самый долгий этап, за который было испробовано множество разных подходов, описать которые даже как-то затруднительно. Долго, нужно рыться в истории, да и… Не хочется. Итоговый процесс получился такой:
1. LLM анализирует вопрос пользователя и историю чата, поскольку вопрос может быть размазан по переписке и ключевые понятия, по которым имеет смысл искать что-то в векторной БД, может быть совсем не в последнем сообщении от пользователя.
Заставляем LLM вернуть json файл с жестко заданной структурой
{
["guided_json"] = new
{
type = "object",
properties = new
{
Columns = new { type = "string" },
Rows = new
{
type = "array",
items = new { type = "string" }
},
Comments = new { type = "string" }
},
required = new[] { "Columns", "Rows", "Comments" }
}
}
Где:
- FindedKeywords - ключевые слова для поиска информации по запросу
- EnreachedRequest - переформулированный и расширенный с помощью FindedKeywords оригинальный запрос пользователя.
Впоследствии, EnreachedRequest показал свою бесполезность – из-за словесной шелухи результаты поиска обычно были хуже, чем по FindedKeywords.
2. Получаем embedding по FindedKeywords.
3. В реляционной БД у нас хранится список актуальных коллекций векторной БД, на старте приложения они кэшируются и обновляются из БД при очередном запросе если прошло определенное время с прошлого обновления. Это дает возможность гибко настраивать новые коллекции и отключать поиск по старым.
По этому списку коллекций мы вытягиваем порции чанков из векторной БД. Вытягиваем пачками до тех пор, пока векторная похожесть не снизится меньше пороговой (ну, или не превысим общий лимит числа чанков).
4. Выбираем самые релевантные чанки среди всех найденных, делаем из реранкинг, и все результаты реранкинга, которые превышают определенный score, добавляем в результат пока не заполнится максимальный размер контекста под RAG (или кончатся релевантные чанки).
5. Подмешиваем найденную информацию к запросу пользователя и отправляем LLM. Подмешиваем – это добавляем сообщение с ролью system и текстом вроде "Была найдена следующая информация в базе знаний:…».
Вот так это выглядит в виде схемы:

Этап пятый «R.A.G. 2: Повторение — мать учения»

Признаться, в предыдущем этапе я смухлевал, сразу выдав, какой итоговый процесс получился в итоге. На самом деле, изначально reranker-а не было, процесс был проще, но результаты поиска по RAG были так себе. Процесс улучшения был итеративным: попробовать по-другому раскладывать – протестировать, попробовать по-другому искать – протестировать. Потом повторить еще раз, еще раз, еще раз и так далее, пока поиск по RAG не стал приносить удовлетворительные результаты.
Особую боль доставлял поиск по информации о самой системе. В итоговой версии сначала собирается информация об отчетах в текстовом виде, потом прогоняется через LLM с просьбой описать, о чем этот отчет, потом на основании всего этого генерируется документ в разметке markdown, и уже он режется на чанки и кладется в векторное хранилище.
Так же сложно сказать, что этот этап по-настоящему закончен. Например, можно попробовать улучшить механизм поиска новых чанков в векторном хранилище - порог минимальной релевантности для продолжения поиска по коллекции можно сделать динамическим, нет смысла искать новые чанки меньшей релевантности, чем минимально релевантный чанк из top N релевантных чанков, где N – максимальное число чанков, которые отправим на переранжирование. Или можно попробовать отказаться от отдельных контейнеров под каждую задачу и оркестровку через бэк, и сделать контейнер с конвейером через langchain.
Заключение

В рамках эксперимента по внедрению AI в отчетность удалось построить рабочую архитектуру: LLM отвечает на вопросы, RAG работает и позволяет обогащать контекст. Теперь, если пользователь, например, составит отчет за текущий год и предшествующий, он мог бы не только сам "глазами" искать нюансы и различия, но и поручить это LLM.
При этом остается множество направлений для развития – оптимизировать текущие алгоритмы и архитектуру, добавить возможность генерировать презентации или документы по результатам анализа, добавить возможность автоматически генерировать графики в разных разрезах, и т.д.
Касательно границ применимости такого решения в продуктовой среде есть 2 ограничения:
1. Ресурсы
Чем больше данных нужно проанализировать, тем больший размер контекста требуется, тем больший размер видеопамяти требуется, тем дольше ждать завершения анализа.
Для примера, отчет на 100 строк с 10 колонками занимает примерно 18000 токенов. Это довольно много, особенно с учетом того, что так же нужно выделить часть контекста под информацию из RAG и под историю переписки.
2. Пользователи
Эффективное взаимодействие с AI требует навыков: нужно уметь разграничить, с чем AI может помочь, а с чем нет; как лучше сформулировать вопрос; что проще и эффективнее сделать самому; чему можно верить, а что стоит отбросить… Ухудшает ситуацию то, что возможности AI постоянно меняются и плывут. На практике, использование даже идеального ассистента в рабочих задачах потребовало бы определенного набора навыков, а AI далеко не идеален.
В итоге LLM может проанализировать отчет, может сопоставить данные, можно добавить множество разных прикладных данных и эффективно искать их и подмешивать в контекст. Однако, чтобы все это можно было использовать в продуктовой среде, требуются ресурсы для большого контекста моделей (хотя, конечно, было бы странно ожидать другого) и гораздо более тонкий момент – потребность пользователей в новом инструменте анализа, готовность с ним разбираться и использовать его.