Статьи Утилиты Telegram YouTube VK Видео RuTube Отзывы

Spring AI: retrieval augmented generation

Исходники

23 июня 2025

Тэги: Docker, Excel, gradle, Kotlin, PostgreSQL, Spring AI, SQL, нейросети.

Содержание

  1. Векторное хранилище
  2. Заготовка проекта
  3. Конфигурация ChatClient
  4. Работа с документами
  5. Генерация эмбеддингов
  6. Делаем запросы к LLM с использованием RAG
  7. Выводы

В статье Spring AI: пишем telegram-bot для ChatGPT мы научились работать с нейросетью в диалоговом режиме, сохраняя контекст беседы. Инструкции нейросети, которые наиболее важны для нас, мы передавали в первом системном сообщении. Помимо инструкций, в системном промте мы можем сообщать нейросети какую-то дополнительную информацию, тем самым обогащая её контекст. Но что делать, если контекст, который надо сообщить, слишком большой? Что, если вы делаете корпоративного виртуального ассистента, который должен оперировать вашей внутренней базой знаний, состоящей из десятков и сотен документов, да ещё и в разном формате?

Если пытаться всю эту базу знаний поместить в системное сообщение, то вы довольно быстро упрётесь в ширину контекстного окна, которая зависит от конкретной модели. Я уж не говорю про то, что вам нужно уметь парсить такие форматы как word, html, markdown и т.п., чтобы не расходовать токены на форматирование, которое не несёт особой смысловой нагрузки.

Как же быть в этом случае? Имеет смысл заранее обработать всю базу знаний, положить её в особое хранилище в специальном виде, который позволяет быстро подгружать эту информацию в контекст LLM (large language model или большая языковая модель). И даже не целиком, а только реально те документы, которые коррелируют с запросом.

Эту задачу можно решить с помощью RAG (retrieval augmented generation или поисковая дополненная генерация). То есть LLM перед тем, как дать ответ на запрос пользователя, выполнит поиск подходящей информации в вашем хранилище. Причём каждый документ хранится не в виде текста, а в виде массива чисел (т.н. «векторов»).

Spring AI и retrieval augmented generation

Процесс преобразования различных документов в такой векторный формат выполняется опять же с помощью LLM и называется embedding («встраивание»). Хорошая новость заключается в том, что всё это можно легко сделать с помощью Spring AI. Давайте рассмотрим пример.

Предположим, вы хотите загрузить в контекст LLM историю изменения курса доллара, чтобы затем её анализировать. Конечно, LLM в теории и так может знать курс доллара на конкретную дату. Но сама LLM – это вещь статичная. Её обучали на выборке, которая была актуальна на какую-то конкретную дату. Поэтому велика вероятность, что LLM будет «сочинять» значения курса доллара, т.е. «галлюцинировать». И вот чтобы LLM использовала реальную историю изменения курса доллара, нам нужно подгрузить её в контекст.

Векторное хранилище

Эмбеддинги хранятся в виде векторов в специальном векторном хранилище. В настоящее время есть несколько таких хранилищ, но старый добрый Postgres также поддерживает вектора, если установить расширение pgVector. На Ubuntu расширение можно поставить одной командой:

sudo apt install postgresql-XX-pgvector
# где XX - номер версии postgres, которую вы используете

После этого в самой базе нужно выполнить следующий запрос:

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(1536) -- 1536 is the default embedding dimension
);

create index on vector_store using hnsw (embedding vector_cosine_ops);

Здесь мы активируем только что установленные расширения, а затем создаём таблицу 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:

spring:
  ai:
    openai:
      api-key: ${OPEN_AI_API_KEY}
      base-url: ${OPEN_AI_BASE_URL:https://api.openai.com}
      embedding:
        options:
          model: text-embedding-3-small

Так же как и в предыдущей статье, здесь по умолчанию прописан базовый урл подключения к OpenAI, но если вы используете другую LLM с совместимым протоколом, вы можете переопределить переменную окружения OPEN_AI_BASE_URL. Также в параметре embedding.options.model мы указываем имя модели, которая будет использоваться при создании эмбеддингов (text-embedding-3-small). Эта модель отличается от той, которая обрабатывает пользовательские запросы.

Далее здесь же прописываем параметры подключения к Postgres и настраиваем vectorStore.

spring:
  datasource:
    url: ${JDBC_URL}
    username: ${JDBC_USER}
    password: ${JDBC_PASSWORD}
  vectorstore:
    pgvector:
      index-type: HNSW
      distance-function: COSINE_DISTANCE
      dimensions: 1536
      max-document-batch-size: 10000

Здесь мы указываем тип индекса (HNSW), размерность вектора в зависимости от модели (1536) и максимальное количество документов в пачке.

Конфигурация ChatClient

Давайте сразу сконфигурируем ChatClient, чтобы потом можно было выполнять запросы к LLM в диалоговом режиме.

@Configuration
class ChatClientConfig {
    @Bean
    fun chatClient(
        builder: ChatClient.Builder,
        vectorStore: VectorStore,
    ): ChatClient =
        builder
            .defaultAdvisors(
                SimpleLoggerAdvisor(),
                QuestionAnswerAdvisor(vectorStore),
            )
            .build()
}

Эта конфигурация полностью аналогично той, что мы делали в предыдущей статье. Здесь мы добавляем два общих advisor'a:

  • SimpleLoggerAdvisor для логирования запросов и ответов LLM.
  • QuestionAnswerAdvisor, который и отвечает за добавление эмбеддингов в контекст LLM. Поэтому в качестве параметра в

него мы передаём бин vectorStore, который Spring AI автоматически сконфигурирует на основании данных из application.yml.

Важно отметить, что мы здесь конфигурируем не эмбеддинг-модель, а уже модель для диалогового взаимодействия.

Работа с документами

В Spring AI есть базовый класс Document. Этот класс представляет абстракцию над документом в любом формате: файл Word, Excel, json, html, plain text и т.д. Класс содержит в себе несколько полей: собственно, само содержимое документа и метаданные вида «ключ-значение».

Парсить обычный текстовый файл слишком просто. Но мы, как истинные финансисты, создадим несколько Excel-файлов с историей курса в разбивке по месяцам. Саму историю можно взять с официального сайта Центробанка. Там же можно экспортировать эту историю в Excel. Всего выгружается 4 колонки, но чтобы не тратить токены напрасно, оставим только две из них: дату и сам курс. Примеры готовых файлов можно найти в проекте, который прилагается к данной статье.

Структура данных выглядит примерно так:

Дата         Курс доллара, руб.
3/29/2025    83.6813
3/28/2025    83.8347
3/27/2025    84.2065
3/26/2025    84.1930
3/25/2025    83.8737
...

Эти Excel файлы мы будем парсить с помощью Tika Document Reader. Он позволяет читать очень много различных «офисных» форматов. А если хотите парсить html-страницы (например, wiki или confluence), используйте spring-ai-jsoup-document-reader.

Создадим спринговый компонент CustomDocumentReader:

@Component
class CustomDocumentReader {
    fun getDocuments(resource: Resource, yearMonth: YearMonth): List<Document> {
        val tikaDocumentReader = TikaDocumentReader(resource)
        val documents = tikaDocumentReader.read()
        documents.forEach {
            it.metadata["year"] = yearMonth.year
            it.metadata["month"] = yearMonth.monthValue
        }
        return documents
    }
}

Он на вход получает Resource, т.к. Excel-файлы в нашем примере являются частью самого проекта. Но вы можете читать и внешние файлы. Также с каждым файлом передаём комбинацию месяца и года, которые будем добавлять в метаданные, чтобы потом было легче фильтровать документы в векторном хранилище. Само чтение выполняется предельно просто: передаём ресурс в TikaDocumentReader и читаем с помощью метода read().

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

Генерация эмбеддингов

Создадим спринговый сервис и назовём его RagService. В нём определим метод saveDocumentsToVectorStore(), который будет читать Excel-файлы с помощью созданного выше компонента и сохранять их в векторное хранилище с помощью embedding-модели. Название этой модели определено в application.yml (text-embedding-3-small).

@Service
class RagService(
    private val documentReader: CustomDocumentReader,
    private val vectorStore: VectorStore,
    private val chatClient: ChatClient,
) {
    fun saveDocumentsToVectorStore() {
        vectorStore.delete("year > 0")
        listOf(
            YearMonth.of(2024, 12),
            YearMonth.of(2025, 1),
            YearMonth.of(2025, 2),
            YearMonth.of(2025, 3),
        ).forEach { yearMonth ->
            val resource = ClassPathResource("${yearMonth.year}-${yearMonth.monthValue}.xlsx")
            val documents = documentReader.getDocuments(resource, yearMonth)
            vectorStore.add(documents)
        }
    }
}

Файлы хранятся в папке resources. Их имена состоят из комбинаций месяца и года. Я заранее знаю, какие файлы есть в проекте, поэтому просто захардкодил список этих комбинаций. Но вы можете сделать более гибкую логику хранения метаданных вместе с файлами.

Сначала я удаляю из хранилища старые документы (если они там имеются) с помощью метода vectorStore.delete(). В параметре мне нужно указать какое-то условия фильтрации по метаданным. Я тут указываю, что год должен быть больше нуля. По сути я удаляю все документы, у которых в метаданных установлен параметр year. Аналогичным образом мы можем и сужать контекст при поиске документов.

Затем преобразуем каждый Excel-файл в объект типа Document и сохраняем документы в векторном хранилище с помощью метода vectorStore.add().

Чтобы вызывать созданный нами метод сервисного слоя, добавим rest-контроллер.

@RestController
@RequestMapping("/rag")
class RagController(
    private val ragService: RagService,
) {
    @PutMapping
    fun saveDocumentsToVectorStore(): MessageDto {
        ragService.saveDocumentsToVectorStore()
        return MessageDto(text = "Documents saved to vector store")
    }
}

Если теперь запустим проект и выполним PUT-запрос по урлу http://127.0.0.1:8080/rag, то в нашем векторном хранилище в Postgres появится 4 новых записи. Таким образом, мы создали эмбеддинги для расширения контекста целевой LLM, с которой будем взаимодействовать в диалоговом режиме.

Делаем запросы к LLM с использованием RAG

Добавим в RagService второй метод, который будет отвечать за диалоговое взаимодействие. Он принимает запрос от пользователя в виде строки текста и возвращает ответ от LLM.

fun getAnswer(question: String): String {
    val responseFormat = ResponseFormat.builder()
        .type(ResponseFormat.Type.TEXT)
        .build()

    val chatOptions = OpenAiChatOptions.builder()
        .model(OpenAiApi.ChatModel.GPT_4_1_MINI)
        .temperature(0.0)
        .responseFormat(responseFormat)
        .build()

    // ...
}

Аналогично предыдущей статье, здесь мы настраиваем некоторые параметры запроса. А именно указываем, что формат ответа должен быть в виде простого текста без форматирования, что целевая модель – это GPT 4.1 mini, и что температура равна нулю (т.е. ответ должен быть максимально точным, без «фантазирования»).

Затем с этими параметрами вызываем chatClient, который мы сконфигурировали ранее.

return chatClient.prompt(
    Prompt(
        SystemMessage(SYSTEM_PROMPT),
        chatOptions,
    )
)
    .advisors { a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "year == 2025") }
    .user(question)
    .call()
    .content()
    ?: "Не удалось получить ответ"

Здесь мы указываем какой-то базовый системный промт (типа «отвечай коротко, без лишних слов...»), а также передаём параметр FILTER_EXPRESSION в QuestionAnswerAdvisor. Именно через этот параметр мы можем динамически сужать область поиска документов для RAG, если нам точно известны какие-то условия. В данном случае мы оставляем только ту историю курсов, которая относится к 2025 году. Эта фильтрация выполняется по метаданным.

Чтобы вызывать метод через rest, добавим ещё один эндпоинт в RagController:

@PostMapping
fun getAnswer(@RequestBody request: MessageDto): MessageDto =
    MessageDto(
        text = ragService.getAnswer(request.text),
    )

Теперь запустим проект и сделаем POST-запрос с помощью Postman.

Запрос максимального курса доллара за 2025 год

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 в облаке.


См. также


Комментарии

Добавить комментарий

×

devmark.ru