
Spring AI позволяет работать с нейросетью в диалоговом режиме, сохраняя контекст беседы. Инструкции нейросети, которые наиболее важны для нас, мы обычно передаём в первом системном сообщении. Помимо инструкций, в системном промте мы можем сообщать нейросети какую-то дополнительную информацию, тем самым обогащая её контекст. Но что делать, если контекст, который надо сообщить, слишком большой? Что, если вы делаете корпоративного виртуального ассистента, который должен оперировать вашей внутренней базой знаний, состоящей из десятков и сотен документов, да ещё и в разном формате?
Если пытаться всю эту базу знаний поместить в системное сообщение, то вы довольно быстро упрётесь в ширину контекстного окна, которая зависит от конкретной модели. Я уж не говорю про то, что вам нужно уметь парсить такие форматы как word, html, markdown и т.п., чтобы не расходовать токены на форматирование, которое не несёт особой смысловой нагрузки.
Как же быть в этом случае? Имеет смысл заранее обработать всю базу знаний, положить её в особое хранилище в специальном виде, который позволяет быстро подгружать эту информацию в контекст LLM (large language model). И даже не целиком, а только реально те документы, которые коррелируют с запросом.
Эту задачу можно решить с помощью RAG (retrieval augmented generation или поисковая дополненная генерация). То есть LLM перед тем, как дать ответ на запрос пользователя, выполнит поиск подходящей информации в вашем хранилище. Причём каждый документ хранится не в виде текста, а в виде массива чисел (т.н. "векторов").
Процесс преобразования различных документов в такой векторный формат выполняется опять же с помощью 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). Такой индекс позволяет эффективно искать "ближайших соседей" среди векторов, т.е. информацию, которая наиболее коррелирует с запросом пользователя.
Заготовка проекта
Итак, зайдём на Spring Initializr и создадим заготовку проекта. В качестве языка выбираем 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
.

LLM нам отвечает, что наибольший курс доллара был 15 января 2025 года. Если мы теперь посмотрим поле content
в vector_store
, то убедимся, что это действительно так и значение курса совпадает до 4-х знаков после запятой. То есть нейросеть не выдумала этот курс, а действительно выполнила поиск в нашем хранилище.
Но на самом деле ещё более высокий курс был в начале декабря 2024. Давайте теперь уберём FILTER_EXPRESSION
и будем искать вообще по всем документам. Тогда ответ будет другой:

Опять же, сверимся с БД и убедимся, что 3 декабря курс был ещё более высокий. То есть фильтрация по метаданным работает.
Выводы
Spring AI, который только недавно получил первую стабильную версию, уже предоставляет довольно много возможностей для работы с RAG (retrieval augmented generation). Причём здесь мы не завязаны на конкретное хранилище. Сегодня мы используем pgVector, а завтра можем легко перейти на другие векторные хранилища вроде Milvus или Qdrant. Для этого перехода нам нужно будет просто подтянуть в проект другую зависимость и слегка подправить application.yml
.
Сама же концепция RAG позволяет довольно просто превратить нейросеть "общего назначения" в интерактивного помощника, знакомого со спецификой конкретно вашей компании. Например, на основе корпоративной базы знаний вроде confluence.
Пример работы с RAG доступен на github, а ещё больше статей по разработке на Java, Kotlin и Spring вы найдёте на моём сайте.
apcs660
Что если приблизить задачу к реальному сценарию?
кролим репозиторий документов с метаданными и контентом (тот же PDF). Контент в среднем 10к символов но может быть максимум до 10М символов текст. В контекст точно не влезет.
При этом поиск делаем как по метаданным, так и по контенту.
Имена полей в метаданных тоже необходимо маппить в NLP, к примеру dateModified дата изменения документа и тд..
Прогнать тест на 100м документов репозитории и посмотреть, заодно сравнить milvus и постгресс.
И было бы неплохо дополнить до гибридного RAG с поиском в векторном хранилище и в базе или люсин индексе, вроде моделей для люсин хватает в отличие от SQL...
Я бы сейчас для тренировки полепил такой поиск.
если получится уложиться во время поиска (после конверсии в вектора, без учета llm) до 10 секунд на 100м документах на обычном железе, то вполне неплохо.
devmark Автор
В данной статье я делал упор не столько на вопросы производительности и выбор наиболее быстрого векторного хранилища, сколько на то, что Spring AI позволяет нам уже здесь и сейчас с минимальным количеством кода использовать RAG. Поэтому взял pgVector, т.к. большинство проектов на Spring-стеке использует postgres.
Касательно того, что документ может не влезть в контекст, в Spring AI есть такой класс как TokenTextSplitter. Он позволяет разбивать исходный документ на несколько частей. Параметры такого разбиения можно гибко настраивать.
А вы какое векторное хранилище предпочитаете использовать?
apcs660
Собственно, в продакшн никакого пока не использовал. В саббатикале сейчас, после нескольких лет работы без нормального отпуска (два три дня когда не дергают), и нагоняю сейчас.
RAG смотрю так как это соприкасается с темой прошлой работы - миграция репозиториев документов и поисковые движки (в том числе самописные) на базе люсин. Типичные объемы - от 100 до 500 млн документов в одном репозитории. В начале этого года была миграция у клиента на 1.5 млрд документов (перед моим уходом). Т.е примерно понимаю что люди использовали в корпоративной среде (банки и страховые) для поиска документов и что ожидают.
PostgreSQL практически не касался больше 20 лет (в первом стартапе его использовали, затем формально перешли на Oracle), хотя база хорошая (по какой причине от нее активно воротили нос - не могу объяснить, возможно проблема моего окружения принимающего решения и выборка клиетов такая). MySQL к примеру, тащили (наверное из за стоимости в облаке Amazon), хотя как база это откровенное непонятно что.
В домашних условиях прототипирую кое что из положенного на полку и не доделанного, но большие объемы не прогнать ни на локальной сетке (квантированная модель, на 16ГБ чтобы входила), покупать через прокладки или напрямую доступ у OpenAI для теста хотя бы 10М документов (а лучше 100, 200) - дороговато. А без больших тестов оценить сложно на что это похоже.
Если к примеру, я могу хотя бы от 50-100 тысяч документов в час кролить включая создание embedding векторов (а он понадобится не один, так понимаю, если обрабатываем контент со скользящим окном), то уже можно с натяжкой этим методом пользоваться у более менее крупного клиента. Подобные скорости кролинга с контентом типичны и для on premises репозиториев, типа FileNET, и у облачных провайдеров контента типа Box или Sharepoint (если удавалось договориться и уменьшить тротлинг).
Пользователи хотят:
Скорость поиска - до 10 секунд, лучше до 5.
Скорость обновления (документ изменился, поиск тоже, счет на минуты). Иногда приходилось обновлять индекс одновременно с репозиторием (preemptive update), у того же Box, к примеру, события приходят с задержкой от секунд до десятков минут (а это "не есть хорошо"...)
Еще и security втащить. Хотя это совсем не "кошерный" вариант, но если H2 использует Lucene для полнотекствого поиска, то почему бы не всунуть индекс для ускорения join в Lucene? Без join получается денормализованные записи, при изменении общих полей (security) могут быть огромные по объему обновления. Лучше этого избегать, конечно в NoSQL, включая люсин. В Postgres большой плюс - это уже нормальная база с join. Что делать если нужен обычный поиск по полям метаданных, векторный поиск по контенту (семантическое сходство, по словам или по тексту) и join сверху?
У меня пока недостаточно опыта для оценки, какое железо для нужной производительности потребуется для RAG, чтобы LLM модели с достаточной скоростью (и точностью) создавали вектора. Не получится ли что проще будет плюнуть на RAG и создать поиск старыми способами а РАГ оставить для чат ботов...
devmark Автор
Проверка быстродействия RAG на объёмах 50-100 тысяч документов - эта тема заслуживает отдельной статьи! Но мне кажется если мы говорим в основном про поиск документов, то RAG действительно не самое оптимальное и далеко не дешёвое решение.
Вы верно подметили про RAG и чат-ботов, потому что порой интересно даже не поиск информации "как есть" в документе, а вывод из существующей информации новых фактов.
Например, где-то среди тысяч документов в описании конкретного процесса всего 1 раз упоминается, что "... согласованием занимается Иванов (начальник отдела документооборота)...", чат-бот нам сможет ответить и кто такой Иванов (в контексте именно данной компании), и кто является начальником отдела документооборота.
apcs660
RAG было бы полезно использовать, если цена конечно (и быстродействие) позволят.
Поправлю условия по количеству документов: не 50-100 тысяч, а 50-100 тыс в час, добавление. Всего для мелких организаций (небольшой банк и тд) достаточно 10 млн документов. Для более менее крупных контор типично от 100 до 500 млн.
Еще вопрос по RAG - предположим что контент большой, в разы больше контекста LLM модели. Мы создаем вектора и тд. А вопрос будет касаться содержимого начала и конца документа, те разных векторов и у них совпадение по отдельности будет так себе. Т.е RAG хорошо будет работать когда контекст почти того же размера или больше чем размер контента документа и непонятно как работать когда контент в разы больше... И следующее - поиск по метаданным, наверное, через RAG гонять не нужно, достаточно сформулировать запрос по метаданным в языке запроса который LLM "по зубам"
Интересно, что можно сделать с графовой базой, если уйти от векторов сразу в knoledge graph, RDF, knowledge graph DB. Ссылки:
https://migalkin.github.io/posts/2023/03/27/post/
https://migalkin.github.io/kgcourse2021/lectures/lecture1
PS понятно что фантазии пока, но я на саббатикале, и могу себе позволить "подурить" на непрактичные темы. Хочется иногда забыть чем занимался на работе.
Сейчас LocalAI ставлю, попробую с локальной сеткой квантированной embedding прогнать, наверное печальная будет скорость... подозреваю что неприемлимая
devmark Автор
50-100 тысяч документов в час... тут явно ведь не про базу знаний речь идёт?)
apcs660
Не база знаний, обычные бизнес документы, договора, ипотечные всякие, страховые кейсы, медицинские (из того чего касался). 50-100 тыс это скорость кролинга документов в час, как правило, причина торможения это обработка контента. Без контента типичная скорость около 1 млн /час. Еще важно тротлинг применять и сажать репозиторий в рабочее время (по ночам основная работа). Всего документов, к примеру, 10 миллионов - это детский объем на самом деле. С ними работают примерно 500 пользователей (активных сессий), это включает поиск в индесе, редактирование документов в репозитории, оттуда обновленные документы добавляются кролером в индекс (перезаписывая старую версию), и тд, по кругу. Удаление документов из репозитория - событие которое отрабатывает кролер и удаляет запись из индеса. Если идет массовое обновление или реиндексация, то при максимальной скорости кролинга в 100 тыс/час (к примеру) и 10 млн документов, нам понадобится 100 часов, это почти неделя, но в реальности две или три, так как активный кролинг нагружает репозиторий и придется кролить в нерабочее время. А теперь представим что документов не 10 млн а 200 или 300 млн - это обычный индекс в нормальном банке или страховой. Значит, документы желательно кешировать (хоть в файловой системе или в какой то базе которая хороша как кеш, cassandra или что то подобное, главное чтобы скорость записи не падала сильно при росте объема). С подобной базой кешем рекролинг будет быстрее, уже 2,3, 5 миллионов документов в час на обычном PC. Значит изменять индексы и заменять можно шустрее. Был клиент у которого индекс кролился 4 месяца, кеш он по религиозным мотивам (иначе понять не могу) не ставил (типа, некому будет обслуживать а архитекторы не подумали и пропустили мимо ушей предупреждения) - в итоге на индекс молился. Позже вскрылись баги кое какие, в типах люсин, и пришлось вместо обновленного код и рекролинга делать обходные патчи в логике в поисковике. Сейчас этим индусы занимаются, не знаю что там и как. Думаю все хорошо, деньги есть.
Посмотрел эластик, с ним мало имел дело, после 5й версии не касался его, он сильно изменился, оброс и заматерел, скачал исходники, надо посмотреть его лучше. Он мне раньше нравился, видно по уровню кода что команда сильная. Попробовал localAI с моделью отсюда https://www.elastic.co/search-labs/blog/localai-for-text-embeddings и сразу воспроизвел баг (да что ж такое) https://github.com/mudler/LocalAI/issues/4529 и https://github.com/mudler/LocalAI/issues/3886
четко по статье все поставил и поймал проблему, видать когда статью писали, бага не было. С другой моделью попроще запустил
Вектор выплюнула, замерю попозже на пачке и на разных моделях.
но это пожалуй, для обучения, не более, скорость низкая на домашней игровой карточке.
Пардон за много букв...