- BrainTools - https://www.braintools.ru -

AI без Python: как исправить документацию и внедрить RAG в JVM-стеке

Меня зовут Дмитрий Вдовин, я техлид команды Budget Tool. Мы отвечаем за продукт, через который в банке проходят процессы планирования и контроля расходов. Это внутренняя система, в которой формируются бюджеты, согласуются изменения и фиксируются расходы по направлениям. У нас много терминов, правил и нюансов. Например, чем OPEX отличается от CAPEX, зачем нужны кост-центры и группы расходов, что такое аллокация и реаллокация, как заполнять бюджет.

Даже для опытных пользователей системы (продукт-оунеры, техлиды, CTO, руководители уровня B-1, сотрудники кост-менеджмента) это не всегда просто, тем более для новых. Значительная часть времени уходит не на работу в системе, а на поиск информации в разрозненных источниках: Excel-таблицы, письма, локальные заметки или уточнение деталей у коллег. Отсюда и появилась идея AI-ассистента как удобного способа получать ответы в одном месте, обычным человеческим языком.

Python почти стандарт для AI-проектов, но мы, как и большинство продуктовых команд в банке, используем JVM-стек: Kotlin, Java, Spring Boot. Поэтому осознанно выбрали развивать AI-ассистента в уже знакомом стеке. Это не просто техническое предпочтение. Мы хотели сохранить поддержку и масштабируемость внутри команды и  не привлекать новые компетенции, которых у нас пока нет.

Наш опыт [1] может быть полезен командам, которые работают в JVM-среде и хотят внедрить AI без перехода на другой стек.

Тем более, что наши условия, думаю, знакомы многим: небольшая команда, без ML-инженеров и большого опыта внедрения AI, но с желанием разобраться и сделать рабочее решение.

Для разработки AI-сервисов у нас было три основные библиотеки:

Spring AI [2] — проект из экосистемы Spring;

Koog [3] — опенсорс-фреймворк от JetBrains, больше нацеленный на создание AI-агентов;

LangChain4j [4] — опенсорс-проект, который вобрал в себя принципы из Python-библиотек LangChain, Haystack и LlamaIndex.

На данный момент мы остановились на Spring AI, чтобы оставаться в единой экосистеме. Несмотря на то, что это ещё молодой проект, где не всё идеально и периодически возникает желание от него отказаться, он активно развивается, и недавно вышла вторая версия в превью.

RAG и векторная база данных

AI без Python: как исправить документацию и внедрить RAG в JVM-стеке - 1

Первой задачей было научить ассистента отвечать на простые вопросы по работе в системе и объяснять внутренние термины. Когда нужно работать с узкоспециализированными знаниями компании, самый прямой путь — это RAG, то есть генерация ответа на основе найденного контекста.

Схема простая. Мы заранее разбиваем документы на фрагменты, так называемые чанки, и сохраняем в специальном представлении в виде векторов. Когда пользователь задаёт вопрос AI-ассистенту, система ищет релевантные чанки текста и передаёт их в модель. Ответ формируется только на основе найденного контекста. Подробнее о подходе можно почитать в статье «RAG: учим искусственный интеллект работать с новыми данными» [5].

В качестве хранилища для чанков мы использовали PostgreSQL с расширением pgVector. Да, это не совсем «честная» векторная база, и в других условиях логичнее было бы выбрать Milvus или Qdrant. Но не хотелось тратить время и ресурсы на их разворачивание и дальнейшую поддержку силами команды. Нам был нужен быстрый PoC, поэтому взяли расширение pgVector в DBaaS.

Мы выбирали не по метрикам производительности, а по скорости запуска и возможности быстро проверить гипотезу.

Первая проблема: данные

Первый прототип собрали быстро, буквально из десяти строк кода. Векторный поиск тоже заработал без сложностей.

В минимальном виде, при использовании Spring AI, код будет выглядеть так:

@Service
class RagService(
    private val chatClientBuilder: ChatClient.Builder,
    private val vectorStore: VectorStore,
) {

     fun generateAnswer(userText: String) {
        
         val response = chatClientBuilder.defaultAdvisors(SimpleLoggerAdvisor()).build()
             .prompt()
             .options(
                 OpenAiChatOptions
                     .builder()
                     .temperature(0.1)
                     .build(),
                 )
             .advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
             .user(userText)
             .call()
             .chatResponse()
     }
}

Чтобы это заработало, нужно в application.yaml прописать необходимые данные для конфигурации.

spring:
   datasource:
     url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
     password: postgres
  ai:
     openai:
       api-key: ${LLM_API_KEY} # ваш ключ, для апи
       base-url: ${LLM_BASE_URL} # базовый урл до llm
       chat:
         options:
           model: ${LLM_CHAT_MODEL:openai/gpt-oss-120b} # модель для примера
       embedding:
         options:
           model: ${LLM_EMBEDDING_MODEL:qwen3-embedding-06b} # модель для примера
     vectorstore:
       pgvector:
         index-type: HNSW
         distance-type: COSINE_DISTANCE
         dimensions: 1024
         max-document-batch-size: 10000 # Optional: Maximum number of documents per batch

Также потребуется установить расширение pgVector и создать необходимую структуру таблиц.

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS vector_store (
	id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
	content text,
	metadata json,
	embedding vector(1024)
);
CREATE INDEX ON vector_store USING HNSW (embedding vector_cosine_ops);

Подробнее про настройку PostgreSQL [6].

У вектора dimensions везде должна быть одинаковая размерность и определяться моделью эмбеддингов.

index-type:HNSW указывает, как искать векторы, а distance-type: COSINE_DISTANCE как определять их «похожесть».

Как Spring AI собирает RAG через Advisor

Хочу ещё пару слов сказать про .advisors(QuestionAnswerAdvisor.builder(vectorStore).build())

В Spring AI многое завязано на Advisor. Это механизм, который позволяет перехватывать и менять запросы и ответы при работе с LLM. Он помогает выносить типовые Generative AI-сценарии в переиспользуемые компоненты, преобразовывать данные до и после обращения к модели и упрощать поддержку таких интеграций. 

И QuestionAnswerAdvisor — это простой Advisor, который доступен в Spring AI из «коробки» для построения RAG-систем.

Он устроен так:

● ищет подходящие чанки данных в векторной базе данных;

● объединяет их в единый контекст;

● подставляет в шаблон промпта;

● делает запрос к LLM с этим промптом.

Внутри этого Advisor зашит такой шаблон-промпта:

{query}

Context information is below, surrounded by ---------------------

---------------------
{question_answer_context}
---------------------

Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.

Конечно, его можно переопределить и использовать свой шаблон, но для RAG лучше вообще взять другой Advisor.

..advisors(
     RetrievalAugmentationAdvisor
         .builder()
         .documentRetriever(
             VectorStoreDocumentRetriever
                 .builder()
                 .similarityThreshold(0.3)
                 .topK(10)
                 .vectorStore(vectorStore)
                 .build(),
         ).queryAugmenter(
             ContextualQueryAugmenter
                 .builder().allowEmptyContext(false).build(),
         )
         .build(),
)

Такой Advisor даёт больше гибкости и настроек. Мы можем переопределить базовый промпт-шаблон и указать topK — сколько максимально документов нужно вернуть, и определить threshold по схожести документов с вопросом, чтобы отсечь нерелевантные результаты.

Алгоритм такой:

● Система находит самые похожие документы (у которых similar >= установленного threshold)

● Сортирует их по similarity

● Берёт первые K штук

И ещё этот Advisor даёт возможность добавить pre-retrieval. Например, добавить перефразирование входящего запроса, а также post-retrieval – постобработку найденных документов, например обогатить их чем-то. Подробнее про этот Advisor [7].

Почему RAG упирается не в код, а в данные

Мы загрузили первые несколько документов в базу и получили ответы очень низкого качества.

Дело было в следующем:

● страницы в Confluence имели разную структуру;

● некоторые страницы содержали только картинки и ссылки на файлы;

● часть информации была устаревшей;

● далеко не всё было описано во внутренней базе, многое хранилось просто «в головах людей».

В итоге AI-ассистенту просто негде было брать подходящий контекст для корректных ответов. Поэтому, прежде чем переходить к технической части, сначала пришлось решить организационную задачу.

Аналитики и продукт-оунер нашей команды оформили отдельные страницы по работе с системой. После этого мы договорились о едином формате: логические блоки и заголовки второго уровня и попросили бизнес собрать на отдельных страницах определения, термины и частые вопросы. Фактически мы начали готовить документацию с расчётом на то, что её будет читать не только человек, но и AI-ассистент.

Подход «скормить всё, что есть, и пусть AI сам разберётся» — заведомо проигрышный.

Решение в лоб

Мы начали искать рабочий вариант обработки данных и обратились к Spring AI. У него из коробки есть TokenTextSplitter, который разбивает текст на чанки заданного размера. При этом он работает не просто по длине текста, а по токенам, стараясь находить место для разделения в конце предложения. В документации сказано так: разбивает текст на фрагменты на основе количества токенов с использованием кодировки CL100K_BASE.

Пример кода для разбиения текста с помощью TokenTextSplitter.

val splitter = TokenTextSplitter.builder()
     .withChunkSize(1000)
     .build()

splitter.apply(listOf(Document.Builder().text("some text").build()))

Но стандартное решение быстро показало свой основной минус. Определения и правила в документах часто оказывались распределены по нескольким чанкам, и не все из них попадали в итоговую выборку. В результате модель получала неполный контекст и давала неполные ответы.

Мы разбивали текст на небольшие чанки, около 400 символов. Поэтому попробовали их увеличить, но в выборку начало попадать много «лишнего». Пришлось искать баланс, чтобы чанк был достаточно маленьким для точного поиска и достаточно большим, чтобы сохранять смысл. Значение в 400 символов нельзя назвать универсальным. Но в нашем случае оно лучше подходило для сохранения баланса, учитывая специфику данных в нашей базе знаний. Мы нашли его экспериментальным путём.

Перекрытие

Чтобы исправить разрывы контекста, мы воспользовались рекомендованными подходами для разбиения документов на чанки. Первым обычно называют перекрытие. Идея простая. Конец одного чанка частично повторяется в начале следующего. Это снижает риск потери контекста на границе разбиения. Но проблема в том, что в Spring AI у TokenTextSplitter нет встроенного функционала для разбиения с перекрытием. Поэтому нам помог langchain4j, в котором есть DocumentSplitter с поддержкой overlap. Затаскивать в проект langchain4j только ради разбиения с перекрытием, наверное, не совсем хорошее решение. Но нам было нужно быстро опробовать свою идею и добиться приемлемого качества.

val splitter = DocumentSplitters.recursive(400, 50)
splitter.split(document)

У этого метода сигнатура:

recursive(int maxSegmentSizeInChars, int maxOverlapSizeInChars)

где,

maxSegmentSizeInChars – максимальный размер чанка в символах.

maxOverlapSizeInChars – максимальный размер перекрытия в символах.

Сначала   он пытается разбить документ на абзацы и поместить как можно больше абзацев в один чанк. Если какие-то абзацы оказываются слишком длинными, они рекурсивно разбиваются на предложения, затем на слова и, при необходимости, на отдельные символы до тех пор, пока не поместятся в чанк.

После использования метода качество ответов улучшилось. Модель начала чаще получать полный фрагмент определения или инструкции, но появилась другая проблема. В один чанк могли попадать фрагменты из разных логических блоков документа. Смысл начинал смешиваться, и поиск возвращал куски текста, которые формально релевантны, но логически относятся к разным разделам.

Секции как единица смысла

Поэтому мы обратились к внутренней структуре нашей базы. Посмотрели на данные в Confluence и выделили уже существующую естественную единицу смысла — секцию. Логические блоки на страницах были разделены заголовками уровня h2. Главное было не смешать их при разделении.

Мы начали делить документ на секции, а если секция оказывалась слишком большой, дополнительно разбивали её на чанки. Это дало заметный прирост качества, но полностью проблему не решило. Поиск не всегда возвращал все части секции, необходимые для корректного ответа.

Например, у нас была инструкция, состоящая из множества пунктов. В итоговую выборку могли попасть несколько первых и последних пунктов, а центральная часть инструкции в выборку не попадала.

Мы пробовали разные варианты:

● добавлять название секции в каждый чанк;

● забирать соседние чанки (один–два выше и ниже);

● расширять итоговый контекст.

В какой-то момент контекст становился «грязным». Появлялись повторы и лишние фрагменты, которые мешали модели сфокусироваться на сути вопроса.

Финальное решение

AI без Python: как исправить документацию и внедрить RAG в JVM-стеке - 2

Поэтому мы стали искать другой подход и в итоге пришли к более простому решению.

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

После векторного поиска алгоритм выглядел так:

● взять id секции каждого найденного чанка;

● оставить иникальный Set из секций;

● забрать из базы полный текст каждой секции.

fun searchWithAllSectionContent(
     userText: String,
     topK: Int = 6,
     threshold: Double = 0.3,
): List<Document> {
     val documents = searchDocuments(userText, topK, threshold)

     val sectionUids =
         documents
             .map { UUID.fromString(it.metadata["sectionUid"] as String) }
             .toSet()

     val documentsWithSection =
         sectionUids.map {
             val section = pageSectionRepository.findByUid(it)
             Document
                 .builder()
                 .text("nn### ${section.title} n ${section.content}")
                 .build()
         }

     return documentsWithSection
   }

Поиск оставался точным на уровне чанков, а контекст формировался как логически цельный блок. Это убрало разрывы инструкций и сделало поведение [8] ассистента более стабильным и предсказуемым.

Время выходить в прод

Но чтобы понять, какие вопросы пользователи задают на практике и насколько хорошо с ними справляется наш ассистент, нужно было как можно быстрее перейти от гипотез к реальной эксплуатации. Поэтому для каждого запроса мы сформировали обращение к LLM из трёх частей:

● история переписки

● найденный контекст из базы знаний

● системный промпт

AI без Python: как исправить документацию и внедрить RAG в JVM-стеке - 3

За первые полгода пользователи отправили более 1500 сообщений и создали свыше 250 чатов. Эти цифры важны не сами по себе, а как объём данных для анализа.

AI без Python: как исправить документацию и внедрить RAG в JVM-стеке - 4

С одной стороны, это не самые большие показатели, но важно учитывать специфику. Это внутренний продукт, рассчитанный на ограниченный круг сотрудников. В самом начале сообщений было больше, что, скорее всего, связано с внутренней «рекламой» нового функционала в компании.

Параллельно мы собрали дашборд в Grafana, где отслеживали:

● вопросы пользователей и ответы AI-ассистента;

● частые темы и повторяющиеся запросы;

● случаи, когда ассистент не находил релевантный контекст или отвечал неуверенно.

AI без Python: как исправить документацию и внедрить RAG в JVM-стеке - 5

Это и стало нашим главным источником обратной связи. Вместо абстрактных оценок качества мы получили реальные примеры диалогов. В результате ассистент стал для нас не только пользовательским инструментом, но и своеобразным «датчиком качества» нашей базы знаний и документации.

Почему данные всё ещё важнее модели

Я ни разу не упоминал конкретную LLM-модель. Это не случайно. На этом этапе для нашей задачи не было принципиальной разницы между условными qwen-32b и qwen-420b. Мы сознательно ограничили модель. Она не должна была опираться на собственные знания. Её задача сводилась к поиску ответа в предоставленных данных и формированию текста по заданным правилам. И практика показала, что качество ответов в первую очередь зависит от качества и структуры базы знаний, а не от размера модели.

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

Этот процесс нельзя автоматизировать полностью. Его может выполнить только владелец предметной области. Поэтому без участия бизнеса устойчиво повышать качество ответов не получится. Зато после этого ассистент, без каких-либо изменений в коде, начинал отвечать лучше.

Заключение

Когда мы начинали, казалось, что основная сложность будет в выборе модели, библиотеках и архитектуре, особенно с учётом того, что наш стек отличается от привычного для AI-проектов. Но на практике самым трудоёмким оказался не код и не интеграция с LLM.

Ассистент не создаёт знания. Он лишь отражает реальную степень зрелости документации. В этом смысле внедрение AI-ассистента стало для нас не только пользовательской функцией, но и инструментом проверки процессов. Каждый некорректный ответ указывал не на ограничение модели, а на пробел в описании предметной области. RAG оказался не добавлением магии, а способом структурировать и зафиксировать знания внутри команды.

Чем точнее описана предметная область, тем стабильнее работает ассистент. А дальше можно усложнять архитектуру, добавлять инструменты и маршрутизацию, развивать тестирование, выстраивать обратную связь и внедрять автоматизацию.

А как вы контролируете качество ответов своих ассистентов? Используете автоматические проверки, ручную модерацию, набор тестовых сценариев или полагаетесь на обратную связь? Пишите в комментариях.

Автор: vdovin_ds

Источник [9]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/27571

URLs in this post:

[1] опыт: http://www.braintools.ru/article/6952

[2] Spring AI: https://docs.spring.io/spring-ai/reference/index.html

[3] Koog: https://docs.koog.ai/

[4] LangChain4j: https://docs.langchain4j.dev/

[5] «RAG: учим искусственный интеллект работать с новыми данными»: https://yandex.cloud/ru/blog/posts/2025/05/retrieval-augmented-generation-basics

[6] Подробнее про настройку PostgreSQL: https://docs.spring.io/spring-ai/reference/api/vectordbs/pgvector.html

[7] Подробнее про этот Advisor: https://docs.spring.io/spring-ai/reference/api/retrieval-augmented-generation.html#_retrievalaugmentationadvisor

[8] поведение: http://www.braintools.ru/article/9372

[9] Источник: https://habr.com/ru/companies/raiffeisenbank/articles/1012666/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1012666

www.BrainTools.ru

Rambler's Top100