23 июня 2025
Тэги: Docker, Excel, gradle, Kotlin, PostgreSQL, Spring AI, SQL, нейросети.
В статье Spring AI: пишем telegram-bot для ChatGPT мы научились работать с нейросетью в диалоговом режиме, сохраняя контекст беседы. Инструкции нейросети, которые наиболее важны для нас, мы передавали в первом системном сообщении. Помимо инструкций, в системном промте мы можем сообщать нейросети какую-то дополнительную информацию, тем самым обогащая её контекст. Но что делать, если контекст, который надо сообщить, слишком большой? Что, если вы делаете корпоративного виртуального ассистента, который должен оперировать вашей внутренней базой знаний, состоящей из десятков и сотен документов, да ещё и в разном формате?
Если пытаться всю эту базу знаний поместить в системное сообщение, то вы довольно быстро упрётесь в ширину контекстного окна, которая зависит от конкретной модели. Я уж не говорю про то, что вам нужно уметь парсить такие форматы как word, html, markdown и т.п., чтобы не расходовать токены на форматирование, которое не несёт особой смысловой нагрузки.
Как же быть в этом случае? Имеет смысл заранее обработать всю базу знаний, положить её в особое хранилище в специальном виде, который позволяет быстро подгружать эту информацию в контекст LLM (large language model или большая языковая модель). И даже не целиком, а только реально те документы, которые коррелируют с запросом.
Эту задачу можно решить с помощью RAG (retrieval augmented generation или поисковая дополненная генерация). То есть LLM перед тем, как дать ответ на запрос пользователя, выполнит поиск подходящей информации в вашем хранилище. Причём каждый документ хранится не в виде текста, а в виде массива чисел (т.н. «векторов»).
Процесс преобразования различных документов в такой векторный формат выполняется опять же с помощью LLM и называется embedding («встраивание»). Хорошая новость заключается в том, что всё это можно легко сделать с помощью Spring AI. Давайте рассмотрим пример.
Предположим, вы хотите загрузить в контекст LLM историю изменения курса доллара, чтобы затем её анализировать. Конечно, LLM в теории и так может знать курс доллара на конкретную дату. Но сама LLM – это вещь статичная. Её обучали на выборке, которая была актуальна на какую-то конкретную дату. Поэтому велика вероятность, что LLM будет «сочинять» значения курса доллара, т.е. «галлюцинировать». И вот чтобы LLM использовала реальную историю изменения курса доллара, нам нужно подгрузить её в контекст.
Эмбеддинги хранятся в виде векторов в специальном векторном хранилище. В настоящее время есть несколько таких хранилищ, но старый добрый Postgres также поддерживает вектора, если установить расширение pgVector. На Ubuntu расширение можно поставить одной командой:
После этого в самой базе нужно выполнить следующий запрос:
Здесь мы активируем только что установленные расширения, а затем создаём таблицу vector_store с такой структурой, которую ожидает Spring AI. Здесь мы храним исходный текст в поле content, метаданные вида «ключ-значение» в формате json, а также собственно сам эмбеддинг в виде вектора. Этот вектор имеет фиксированную размерность (в данном случае 1536). Поэтому исходный документ будет преобразован в массив из 1536 чисел с плавающей точкой.
В конце накладываем на поле embedding специальный индекс типа HNSW (Hierarchical Navigable Small World). Такой индекс позволяет эффективно искать «ближайших соседей» среди векторов, т.е. информацию, которая наиболее коррелирует с запросом пользователя.
Итак, зайдём на start.spring.io и создадим заготовку проекта. В качестве языка выбираем Kotlin, тип проекта – Gradle Kotlin.
Далее переходим к зависимостям. Добавляем Spring Web, OpenAI, PgVector Database, Tika Document Reader и драйвер Postgres.
Скачиваем проект и в файле application.yml прописываем параметры подключения к LLM:
Так же как и в предыдущей статье, здесь по умолчанию прописан базовый урл подключения к OpenAI, но если вы используете другую LLM с совместимым протоколом, вы можете переопределить переменную окружения OPEN_AI_BASE_URL. Также в параметре embedding.options.model мы указываем имя модели, которая будет использоваться при создании эмбеддингов (text-embedding-3-small). Эта модель отличается от той, которая обрабатывает пользовательские запросы.
Далее здесь же прописываем параметры подключения к Postgres и настраиваем vectorStore.
Здесь мы указываем тип индекса (HNSW), размерность вектора в зависимости от модели (1536) и максимальное количество документов в пачке.
Давайте сразу сконфигурируем ChatClient, чтобы потом можно было выполнять запросы к LLM в диалоговом режиме.
Эта конфигурация полностью аналогично той, что мы делали в предыдущей статье. Здесь мы добавляем два общих advisor'a:
него мы передаём бин vectorStore, который Spring AI автоматически сконфигурирует на основании данных из application.yml.
Важно отметить, что мы здесь конфигурируем не эмбеддинг-модель, а уже модель для диалогового взаимодействия.
В Spring AI есть базовый класс Document. Этот класс представляет абстракцию над документом в любом формате: файл Word, Excel, json, html, plain text и т.д. Класс содержит в себе несколько полей: собственно, само содержимое документа и метаданные вида «ключ-значение».
Парсить обычный текстовый файл слишком просто. Но мы, как истинные финансисты, создадим несколько Excel-файлов с историей курса в разбивке по месяцам. Саму историю можно взять с официального сайта Центробанка. Там же можно экспортировать эту историю в Excel. Всего выгружается 4 колонки, но чтобы не тратить токены напрасно, оставим только две из них: дату и сам курс. Примеры готовых файлов можно найти в проекте, который прилагается к данной статье.
Структура данных выглядит примерно так:
Эти Excel файлы мы будем парсить с помощью Tika Document Reader. Он позволяет читать очень много различных «офисных» форматов. А если хотите парсить html-страницы (например, wiki или confluence), используйте spring-ai-jsoup-document-reader.
Создадим спринговый компонент CustomDocumentReader:
Он на вход получает Resource, т.к. Excel-файлы в нашем примере являются частью самого проекта. Но вы можете читать и внешние файлы. Также с каждым файлом передаём комбинацию месяца и года, которые будем добавлять в метаданные, чтобы потом было легче фильтровать документы в векторном хранилище. Само чтение выполняется предельно просто: передаём ресурс в TikaDocumentReader и читаем с помощью метода read().
Несмотря на то, что на вход подаётся один документ, на выходе мы возвращаем список документов. Это связано с тем, что слишком большие документы могут разделяться на части, чтобы они влезали в контекст LLM.
Создадим спринговый сервис и назовём его RagService. В нём определим метод saveDocumentsToVectorStore(), который будет читать Excel-файлы с помощью созданного выше компонента и сохранять их в векторное хранилище с помощью embedding-модели. Название этой модели определено в application.yml (text-embedding-3-small).
Файлы хранятся в папке resources. Их имена состоят из комбинаций месяца и года. Я заранее знаю, какие файлы есть в проекте, поэтому просто захардкодил список этих комбинаций. Но вы можете сделать более гибкую логику хранения метаданных вместе с файлами.
Сначала я удаляю из хранилища старые документы (если они там имеются) с помощью метода vectorStore.delete(). В параметре мне нужно указать какое-то условия фильтрации по метаданным. Я тут указываю, что год должен быть больше нуля. По сути я удаляю все документы, у которых в метаданных установлен параметр year. Аналогичным образом мы можем и сужать контекст при поиске документов.
Затем преобразуем каждый Excel-файл в объект типа Document и сохраняем документы в векторном хранилище с помощью метода vectorStore.add().
Чтобы вызывать созданный нами метод сервисного слоя, добавим rest-контроллер.
Если теперь запустим проект и выполним PUT-запрос по урлу http://127.0.0.1:8080/rag, то в нашем векторном хранилище в Postgres появится 4 новых записи. Таким образом, мы создали эмбеддинги для расширения контекста целевой LLM, с которой будем взаимодействовать в диалоговом режиме.
Добавим в RagService второй метод, который будет отвечать за диалоговое взаимодействие. Он принимает запрос от пользователя в виде строки текста и возвращает ответ от LLM.
Аналогично предыдущей статье, здесь мы настраиваем некоторые параметры запроса. А именно указываем, что формат ответа должен быть в виде простого текста без форматирования, что целевая модель – это GPT 4.1 mini, и что температура равна нулю (т.е. ответ должен быть максимально точным, без «фантазирования»).
Затем с этими параметрами вызываем chatClient, который мы сконфигурировали ранее.
Здесь мы указываем какой-то базовый системный промт (типа «отвечай коротко, без лишних слов...»), а также передаём параметр FILTER_EXPRESSION в QuestionAnswerAdvisor. Именно через этот параметр мы можем динамически сужать область поиска документов для RAG, если нам точно известны какие-то условия. В данном случае мы оставляем только ту историю курсов, которая относится к 2025 году. Эта фильтрация выполняется по метаданным.
Чтобы вызывать метод через rest, добавим ещё один эндпоинт в RagController:
Теперь запустим проект и сделаем POST-запрос с помощью Postman.
LLM нам отвечает, что наибольший курс доллара был 15 января 2025 года. Если мы теперь посмотрим поле content в vector_store, то убедимся, что это действительно так и значение курса совпадает до 4-х знаков после запятой. То есть нейросеть не выдумала этот курс, а действительно выполнила поиск в нашем хранилище.
Но на самом деле ещё более высокий курс был в начале декабря 2024. Давайте теперь уберём FILTER_EXPRESSION и будем искать вообще по всем документам. Тогда ответ будет другой:
Опять же, сверимся с БД и убедимся, что 3 декабря курс был ещё более высокий. То есть фильтрация по метаданным работает.
Spring AI, который только недавно получил первую стабильную версию, уже предоставляет довольно много возможностей для работы с RAG (retrieval augmented generation). Причём здесь мы не завязаны на конкретное хранилище. Сегодня мы используем pgVector, а завтра можем легко перейти на другие векторные хранилища вроде Milvus или Qdrant. Для этого перехода нам нужно будет просто подтянуть в проект другую зависимость и слегка подправить application.yml.
Сама же концепция RAG позволяет довольно просто превратить нейросеть «общего назначения» в интерактивного помощника, знакомого со спецификой конкретно вашей компании. Например, на основе корпоративной базы знаний вроде confluence.
Пример проекта содержит Dockerfile и полностью готов к деплою. А если хотите развернуть приложение в облаке буквально в один клик – воспользуйтесь услугами dockhost.ru. Более подробно читайте в статье Как быстро развернуть Spring Boot в облаке.
Kotlin, Java, Spring, Spring Boot, Spring Data, Spring AI, SQL, PostgreSQL, Oracle, H2, Linux, Hibernate, Collections, Stream API, многопоточность, чат-боты, нейросети, файлы, devops, Docker, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.