Привет! Меня зовут Бромбин Андрей, и сегодня я разберу на практике, что такое RAG-системы и как они помогают улучшать поиск. Покажу, как использовать Spring AI, векторные базы данных и LLM. Ты получишь теорию и пример реализации на Java и Spring Boot — от идеи до работающего сервиса. Без сложных формул — только чёткие объяснения и код.

О чём эта статья?
Ответим на следующие вопросы:
Что такое векторные базы данных и почему они незаменимы для приложений с ИИ.
Что такое embeddings и почему без них RAG-системы теряют свою силу.
Как реализовать сервис для хранения и поиска знаний по смыслу с помощью Spring Boot, Spring AI и Qdrant.
Как реализовать сервис для подкреплённой генерации ответов LLM с помощью Spring AI.
Способы улучшить RAG: как повысить точность и полезность ответов.
Как собрать всё вместе в работающую систему, о которой можно честно написать, например, в резюме.
Проблема и решение: зачем нужен RAG?
Представьте: у вас есть LLM (большая языковая модель), которая умеет отлично генерировать текст, но без контекста не знает, что именно хочет пользователь. Допустим, сотрудник спрашивает: «Как исправить ошибку X в системе Y?».
Если у модели нет доступа к внутренним инструкциям, тикетам и документации, она либо выдумает ответ (галлюцинация), либо честно признается: «Не знаю». RAG решает эту проблему, добавляя модели нужный контекст.

RAG решает эту проблему, комбинируя два этапа:
Retrieval (поиск): Находит релевантные данные в базе знаний с помощью векторного поиска. Это быстрее и точнее, чем поиск по ключевым словам, так как учитывает семантику.
Augmented Generation (подкреплённая генерация): Передаёт найденные данные LLM, чтобы она сгенерировала точный и полезный ответ.
Первый ключ к успеху RAG — это эмбеддинги и векторные базы данных. Давайте разберём, как это работает, пошагово внедрив её в систему инцидентов. Зелёным цветом я выделил Retrieval блок, который будет отвечать за поиск данных, а синим — блок Agumented Generation для подкреплённой генерации ответа.

Когда использовать RAG?
RAG хорошо работает там, где много неструктурированных данных: документы, тикеты, регламенты. Он даёт быстрый доступ к актуальной информации без дообучения модели — это дорого и трудоёмко. Ещё RAG снижает риск галлюцинаций LLM, потому что подсовывает ей релевантный контекст.
Основные понятия
Что такое embeddings?
Эмбеддинги — это числовые векторы, которые описывают смысл текста. Например:
«Поезд прибыл на станцию» = [0.27, -0.41, 0.88, ...]
«Поезд подъехал к платформе» = [0.26, -0.40, 0.87, ...]
«База данных легла под нагрузкой» = [-0.11, 0.17, -0.56, ...]
«БД ушла в закат на пике» = [-0.08, 0.16, -0.51, ...]
Эти векторы близки по расстоянию, так как их смысл похож. Расстояние между векторами измеряется метриками, например, такими как:
Косинусное сходство:
, где A и B — векторы. Значение от -1 (противоположный смысл) до 1 (идентичный смысл).
Евклидово расстояние:
, где меньшее расстояние означает большую схожесть.
Как создаются эмбеддинги? Модель, например BERT, обучается на огромном массиве текстов, чтобы улавливать контекст и смысл слов. Текст сначала разбивается на токены — слова или их части. Эти токены проходят через слои трансформера, и на выходе получается векторное представление.
Зачем нужны векторные базы данных?
Векторные базы данных хранят специальные числовые представления объектов — эмбеддинги. Их можно представить как точки на карте: близкие точки означают схожий смысл, а далёкие друг от друга — разный. Благодаря этому поиск работает не по буквальному совпадению слов, а по смыслу. Эмбеддинги создаются один раз и сохраняются, поэтому нужный фрагмент находится быстро и без лишних вычислений. Это делает векторные БД удобным инструментом для работы с LLM и большими массивами данных.

Основные составляющие конечной JSON-подобной структуры в векторной БД:
ID — уникальный идентификатор объекта.
Vector (Embedding) — сам вектор, который хранится в специальной структуре для быстрого поиска ближайших соседей.
Payload (Metadata) — дополнительные данные в формате ключ-значение (часто JSON): путь к файлу, тип документа, автор, дата и т.д.
Оригинальный текст — сам фрагмент или ссылка на внешний источник.
Про безопасность: для обеспечения безопасности документов в векторных хранилищах, в первую очередь, следует использовать встроенные механизмы аутентификации, а также управление API-ключами. Кроме того, для разграничения доступа можно присваивать каждому документу метку access_level
или access_role
, определяющую, кто имеет право на просмотр, либо кластеризировать данные по уровням доступа.

Как работает поиск: данные переводятся в векторы и индексируются. Запрос пользователя также конвертируется в вектор и база ищет самые близкие к нему по смыслу. Алгоритмы вроде HNSW (обход многоуровневого графа) или IVF (обход векторных кластеров) делают это очень быстро — даже при миллионах записей.
Реализация на Java и Spring Ai
Для работы с RAG на Java и Spring AI нужны несколько библиотек. В проект необходимо добавить следующие зависимости:
pom.xml
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-services</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.grpc</groupId>
<artifactId>spring-grpc-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-qdrant</artifactId>
</dependency>
// остальные базовые: lombok, spring-boot и тд
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.grpc</groupId>
<artifactId>spring-grpc-dependencies</artifactId>
<version>${spring-grpc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Превращаем текст в вектор
Spring AI предоставляет готовые реализации для интеграции с популярными моделями эмбеддингов через интерфейс EmbeddingModel. Среди них: OpenAiEmbeddingModel
, OllamaEmbeddingModel
, PostgresMlEmbeddingModel
и другие из пакетов spring-ai-starter-model-<modelName>
. Это избавляет от ручной реализации методов call()
или embed()
, предлагая единый API для генерации векторов.
Для русского языка мы в каждый момент времени ориентируемся на актуальные бенчмарки. Сейчас хороший выбор — модель ru-en-RoSBERTa на Hugging Face: она обучена на русском и английском и подходит для двуязычных задач.
Конфиг клиента для модели RoSBERTa
@Configuration
@FieldDefaults(level = AccessLevel.PRIVATE)
public class RosbertaClientConfig {
@Value("${huggingface.token}")
String hfToken;
@Value("${huggingface.rosberta.url}")
String rosbertaUrl;
@Bean
public RestClient ruEnHuggingFaceRestClient() {
if (hfToken == null || hfToken.isBlank()) {
throw new IllegalStateException("huggingface token is not set");
}
return RestClient.builder()
.baseUrl(rosbertaUrl)
.defaultHeader("Authorization", "Bearer " + hfToken)
.build();
}
}
Класс RosbertaEmbeddingModel
расширяет абстрактный класс и переопределяет его методы для работы с моделью. В call
формируется запрос к API Hugging Face: текст запроса дополняется префиксом "search_query"
, упаковывается в payload и отправляется POST-запросом через RestClient
. В ответ приходит массив чисел (эмбеддинг). Метод embed
делегирует работу методу call
, передавая текст из документа. При необходимости модель можно развернуть локально, а не использовать облачный API.
Класс RosbertaEmbeddingModel
@Service
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class RosbertaEmbeddingModel extends AbstractEmbeddingModel {
RestClient restClient;
@Override
public @NotNull EmbeddingResponse call(@NotNull EmbeddingRequest request) {
var payload = Map.of(
"inputs", "search_query: " + request.getInstructions().get(0),
"parameters",
Map.of("pooling_method", "cls", "normalize_embeddings", true)
);
List<Double> responseList = restClient.post()
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
float[] floats = convertDoubleListToFloatArray(responseList);
return new EmbeddingResponse(List.of(new Embedding(floats, 0)));
}
@Override
public @NotNull float[] embed(@NotNull Document document) {
return call(new EmbeddingRequest(List.of(document.getFormattedContent()), null))
.getResults().getFirst().getOutput();
}
}
Теперь можно проверить результат через Postman, отправив запрос к нашему API:

Сохранение в векторное хранилище
Поднимем Qdrant локально с помощью docker-compose.yml
:
docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:latest
restart: always
container_name: qdrant
ports:
- 6333:6333
- 6334:6334
expose:
- 6333
- 6334
- 6335
configs:
- source: qdrant_config
target: /qdrant/config/production.yaml
volumes:
- ./qdrant_data:/qdrant/storage
configs:
qdrant_config:
content: |
log_level: INFO
Ранее мы подключили пакеты Spring AI и gRPC — его использует Qdrant для общения с клиентом. Для конфигурации есть два варианта: задать её вручную или использовать автоконфигурацию. Spring умеет автоматически настраивать QdrantVectorStore
, если в application.yml
указать параметры вида:»
spring:
ai:
vectorstore:
qdrant:
host: <qdrant host>
port: <qdrant grpc port>
api-key: <qdrant api key>
collection-name: <collection name>
use-tls: false
initialize-schema: true
Ручная настройка несложна и пригодится нам, так как мы используем не стандартную реализацию пакетного EmbeddingModel
, а собственную — RosbertaEmbeddingModel
, которую мы указываем в @Bean QdrantVectorStore
.
class QdrantConfig
@Slf4j
@Configuration
@FieldDefaults(level = AccessLevel.PRIVATE)
public class QdrantConfig {
@Value("${qdrant.host:localhost}")
String qdrantHost;
@Value("${qdrant.port:6334}")
int qdrantPort;
@Value("${qdrant.collection-name:incidents}")
String collectionName;
@Value("${qdrant.api-key:}")
String apiKey;
@Bean
@Primary
public QdrantClient qdrantClient() {
QdrantGrpcClient.Builder builder = QdrantGrpcClient
.newBuilder(qdrantHost, qdrantPort, false);
if (apiKey != null && !apiKey.trim().isEmpty()) {
builder.withApiKey(apiKey);
}
return new QdrantClient(builder.build());
}
@Bean
@Primary
public QdrantVectorStore qdrantVectorStore(QdrantClient qdrantClient,
RosbertaEmbeddingModel rosbertaEmbeddingModel) {
QdrantVectorStore voStore = QdrantVectorStore
.builder(qdrantClient, rosbertaEmbeddingModel)
.collectionName(collectionName)
.initializeSchema(true)
.build();
return voStore;
}
}
На завершающем этапе мы формируем документ с метаданными и сохраняем его. При сохранении текст преобразуется в embedding с помощью указанной EmbeddingModel
в конфигурации.
Класс сохранения документа в векторное хранилище Qdrant
@Slf4j
@Service
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class IncidentEmbeddingService {
QdrantVectorStore qdrantVectorStore;
public void storeIncident(String text, List<String> tags) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("tags", tags);
metadata.put("timestamp", System.currentTimeMillis());
Document document = new Document(text, metadata);
qdrantVectorStore.doAdd(List.of(document));
}
}
После вызова метода открываем Qdrant Web UI и проверяем данные:

Обогатим хранилище данными тем же способом. Чтобы граф был понятнее, оставим самые важные связи, построив остовое дерево.

Поиск похожих по семантике данных
Создали эндпоинт /incidents/similar
, который принимает текстовый запрос и возвращает список документов из векторного хранилища. Параметр limit
задаёт максимальное количество результатов.
Эндпоинт для поиска семантически похожих документов
@PostMapping(path = "/incidents/similar", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Document>> getSimilarIncidents(
@RequestBody String query,
@RequestParam(defaultValue = "3") int limit) {
try {
List<Document> responseDocuments = incidentEmbeddingService
.searchSimilarIncidents(query, limit);
return ResponseEntity.ok(responseDocuments);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
Метод searchSimilarIncidents
собирает запрос (SearchRequest
) с текстом и topK
. Система превращает текст в эмбеддинг и сравнивает его с векторами в базе. Внутри Qdrant для оценки близости используется косинусное расстояние — угол между векторами.
Метод поиска семантически похожих документов в Qdrant
public List<Document> searchSimilarIncidents(String query, Integer limit) {
SearchRequest searchRequest = SearchRequest.builder()
.query(query)
.topK(limit)
//.filterExtension("key == 'value'")
//.similarityThreshold(0.6)
.build();
return qdrantVectorStore.similaritySearch(searchRequest);
}
При запросе к API мы получаем k документов, наиболее близких по смыслу. Для каждого документа доступны метрики: distance
— косинусное расстояние до запроса (чем меньше, тем ближе по смыслу) иscore = 1 - distance
. Эти показатели можно использовать для ранжирования результатов.

Минимальное значение косинусного сходства не всегда значит лучший результат. Поэтому мы увеличиваем выборку — берём k ближайших соседей и улучшаем поиск с помощью RAG Fusion. О нём подробнее будет в разделе про повышение качества поиска.
Подкреплённая генерация ответа
Spring AI поддерживает работу с разными LLM, например, OpenAI, Gemini, LLaMA, Amazon AI и другие. Вместо того чтобы писать специфичный код для каждой модели, можно использовать интерфейсы ChatModel
и ChatClient
и переключать модели через конфигурацию. Это упрощает RAG-сценарии и работу с несколькими поставщиками одновременно. Из российских LLM-моделей не без труда подключаются GigaChat и YandexGPT.
Конфигурация
Для работы с Open AI достаточно добавить соответствующую зависимость в pom.xml
:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
Далее нужно указать ключевые параметры: модель, температуру генерации, лимит токенов в ответе, API-ключ и URL для вызовов.
spring:
ai:
openai:
api-key: sk-<OPEN_AI_API_KEY>
base-url: <OPEN_AI_API_URL>
chat:
completions-path: /v1/chat/completions
options:
model: gpt-5
temperature: 1
max-completion-tokens: 1000
Параметр temperature
в LLM (в том числе в OpenAI) управляет степенью «случайности» или креативности генерации текста. При temperature = 0
модель работает детерминировано и выбирает самый вероятный вариант. При temperature > 1
генерация становится более свободной — появляются нестандартные, иногда неожиданные ответы.
Не все модели поддерживают параметр temperature
, например, gpt-5 вернул мнеUnsupported value. Only the default (1) value is supported
.
Завершающий этап — создание бинаChatClient
, который будет использоваться для работы с LLM.
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultAdvisors(
new SimpleLoggerAdvisor()
)
.build();
}
}
Полный цикл: от обработки запроса до ответа LLM
Процесс включает четыре шага:
Предобработка: приводим вопрос в удобный для анализа вид — нормализуем текст, исправляем ошибки, убираем лишние символы, уточняем формулировки. Также создаём
n
альтернативных вариантов вопроса для дальнейшей работы.Трансформация и поиск: преобразуем запросы в векторы и ищем
k
наиболее близких документов. В результате имеемn × k
кандидатов.Ранжирование: оцениваем найденные документы по релевантности, удаляем дубликаты, формируем упорядоченный список.
Подкреплённая генерация: передаём отобранный контекст в LLM, которая на основе него составляет ответ для пользователя.

Проще всего попросить LLM нормализовать запрос пользователя, передав системный промпт, где указываем требования к нормализации, семантическому уплотнению, контекстуализации и формату вывода.
Системный промпт и метод предобработки запроса
private static final String PREPROCESSED_SYSTEM_PROMPT =
"Ты специалист по семантической оптимизации пользовательских вопросов для поиска по эмбеддингам. "
+ "Преобразуй входной ВОПРОС по правилам:\n"
+ "1) Нормализация:\n"
+ " - Удали спецсимволы (оставь только !?.,)\n"
+ " - Убери лишние пробелы и переносы строк\n"
+ " - Приведи все кавычки к виду \\\"\\\"\n"
+ " - Расшифруй сокращения: «н-р» → «например», «т.д.» → «и так далее»\n"
+ "2) Семантическое уплотнение:\n"
+ " - Сохрани ключевые термины, числа, имена собственные без изменений\n"
+ " - Удали стоп-слова («очень», «просто», «ну») и вводные фразы («кстати», «в общем»)\n"
+ " - Устрани повторы, сделай формулировку точной и ёмкой\n"
+ " - Заменяй местоимения на конкретные референсы (напр. «он» → «алгоритм авторизации»)\n"
+ "3) Контекстуализация:\n"
+ " - Добавь недостающие уточнения в [квадратных скобках], если это повышает однозначность\n"
+ " - Делай вопрос самодостаточным: «Как он работает?» → «Как работает алгоритм авторизации?»\n"
+ "Формат вывода:\n"
+ " - Сначала выведи ТОЛЬКО итоговый очищенный и уточнённый вопрос (без комментариев)\n"
+ " - Если вопрос состоит из нескольких смысловых частей, раздели их пустой строкой\n"
+ " - Затем выведи 3 альтернативные формулировки, сохраняя смысл:\n"
+ "Вывод ТОЛЬКО в JSON с полями:\n"
+ "{\\\"normalized\\\": \\\"<строка>\\\",\\\"alternatives\\\": [\\\"<строка>\\\",\\\"<строка>\\\",\\\"<строка>"]}"
+ "Без пояснений и текста вне JSON";
public PreprocessedQuestion preprocessQuestion(String question) {
String raw = chatClient.prompt(new Prompt(
List.of(new SystemMessage(PREPROCESSED_SYSTEM_PROMPT),
new UserMessage(question))
)).call().content();
// Парсим ответ LLM на основной вопрос и варианты
return extractVariants(raw);
}
Для получения семантически похожих документов для каждого из вариантов запросов достаточно вызвать раннее реализованный метод — searchSimilarDocuments(variant, topK)
. Далее необходимо ранжировать полученные документы. Простой способ — убрать дубликаты и отсортировать по убыванию score
.
Метод ранжирования
private List<Document> rankDocuments(List<Document> documents) {
Map<String, Document> uniqueDocs = documents.stream()
.collect(Collectors.toMap(
Document::getId,
d -> d,
(d1, d2) -> d1.getScore() >= d2.getScore() ? d1 : d2
));
List<Document> ranked = uniqueDocs.values().stream()
.sorted(Comparator.comparingDouble(Document::getScore).reversed())
.toList();
return ranked;
}
Финальный этап — передать вопрос и контекст в LLM, взамен получив ответ.
Системный промпт и метод подкреплённой генерации
String AG_SYSTEM_PROMPT = """
Ты эксперт по написанию лаконичных и понятных ответов для пользователей.
На вход получаешь:
1) Вопрос пользователя.
2) Контекст, состоящий из релевантных документов.
Задача:
- Сформулировать ответ на вопрос, опираясь на предоставленный контекст.
- Если информации недостаточно — честно сообщи об этом.
- Ответ должен быть ясным, структурированным и по возможности кратким.
- Не добавляй лишние комментарии.
""";
public String generateAnswerFromContext(String userQuestion, String context) {
SystemMessage systemMessage = new SystemMessage(AG_SYSTEM_PROMPT);
String userContent = "Вопрос пользователя: " + userQuestion
+ "\n\nКонтекст документов:\n" + context;
UserMessage userMessage = new UserMessage(userContent);
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
String response = chatClient.prompt(prompt).call().content();
return response.strip();
}
Результаты
Сделаем несколько запросов и посмотрим, как ведёт себя система. Для начала — вопрос к LLM без RAG-составляющей:

Теперь сделаем запрос на api реализованной RAG-системы:

Посмотрим, как ведёт себя модель, когда у неё есть весь нужный контекст:

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

Предобработка и очистка пользовательского вопроса
Первый шаг — нормализовать вопрос: убрать «шум», исправить опечатки, провести лемматизацию. Это даёт более точный запрос и повышает релевантность поиска.
Часто применяют подход RAG Fusion: просим LLM предложить несколько вариантов очищенного запроса и по каждому выполняем семантический поиск. В результате получаем выборку размером [число предобработанных запросов] × [число релевантных документов]
, которую можно ранжировать и объединять в общий контекст.
В больших системах на этапе предобработки также классифицируют запросы: например, как просьбу, вопрос или жалобу, либо по отделам (бухгалтерия, HR). В нашей Java-реализации такую метаинформацию мы сохраняли в ключе tags
.
Ансамбли
Помимо ансамблирования разных формулировок запроса, используют и ансамбли из нескольких LLM или нескольких сервисов-ретриверов. В нашем простом примере была связка «трансформер — вектор — векторное хранилище» (Dense Retriever). Также можно подключать и традиционные методы поиска (Sparse Retriever), где релевантность оценивается по частоте вхождений терминов в документ.

Дополнительный шаг поиска — динамическое обучение
Прежде чем выдавать клиенту финальный ответ, можно сгенерировать несколько черновых вариантов на основе ранжированных документов и использовать их как подсказки для LLM. Это позволяет модели опираться на конкретные примеры и давать более релевантный ответ. На этом этапе полезно применять zero-shot, few-shot и похожие методики.
Оценка результата
Оценка RAG — задача непростая: важно считать, сколько из извлечённых документов действительно релевантные, и насколько ответ опирается на них.
Для извлечения precision и recall
. Для генерации — faithfulness/groundedness
, совпадение с эталоном или оценка результата другой LLM-моделью.
Кроме того, смотрят порог уверенности по logits
— распределение вероятностей для токенов в сгенерированном тексте. Это помогает понять, насколько модель уверена в ответе, и при низкой уверенности подставлять честное сообщение: «На данный вопрос не найден ответ».
Заключение
Мы разобрали, как собрать простую RAG-систему на Java и Spring AI: от теории до кода, от нормализации запросов и поиска векторных представлений до ансамблирования и повышения качества ответов. Теперь у вас есть рабочие примеры и понимание принципов, которые можно расширять под свои задачи.

Надеюсь, статья оказалась полезной и вдохновляющей. Если у вас есть вопросы, идеи или замечания — делитесь ими в комментариях. Буду рад конструктивной критике: вместе мы сделаем наш путь ещё интереснее и полезнее.
Присоединяйтесь к моему Telegram-каналу, чтобы не пропустить следующие материалы.
До встречи в новой статье!
© 2025 ООО «МТ ФИНАНС»
Комментарии (3)
federini123
28.08.2025 16:14Самое интересное впереди начинается на прикладном уровне оценки и оптимизации работы. Для погружения статья отличная, покрывает всю просветительскую базовую базу
ekzes
Очень интересно, здорово, что на пальцах объяснена математика векторного поиска по тексту. И здорово, когда у компании файлы базы знаний находятся в txt формате или хотя бы адекватно сделан pdf. Правда куча геморроя начинается, когда начинаешь использовать OCR для документов, особенно когда в ПДФках есть таблицы :) Так что если автор решит проблему адекватной векторизации ПДФ, буду раз почитать)