Привет! Меня зовут Бромбин Андрей, и сегодня я разберу на практике, что такое 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 ООО «МТ ФИНАНС»
ссылка на оригинал статьи https://habr.com/ru/articles/924100/
Добавить комментарий