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

О чём эта статья?

Ответим на следующие вопросы:

  • Что такое векторные базы данных и почему они незаменимы для приложений с ИИ.

  • Что такое embeddings и почему без них RAG-системы теряют свою силу.

  • Как реализовать сервис для хранения и поиска знаний по смыслу с помощью Spring Boot, Spring AI и Qdrant.

  • Как реализовать сервис для подкреплённой генерации ответов LLM с помощью Spring AI.

  • Способы улучшить RAG: как повысить точность и полезность ответов.

  • Как собрать всё вместе в работающую систему, о которой можно честно написать, например, в резюме.

Проблема и решение: зачем нужен RAG?

Представьте: у вас есть LLM (большая языковая модель), которая умеет отлично генерировать текст, но без контекста не знает, что именно хочет пользователь. Допустим, сотрудник спрашивает: «Как исправить ошибку X в системе Y?».

Если у модели нет доступа к внутренним инструкциям, тикетам и документации, она либо выдумает ответ (галлюцинация), либо честно признается: «Не знаю». RAG решает эту проблему, добавляя модели нужный контекст.

Он ничего не стёр, он просто блефует — он вообще не в курсе про наш payment service
Он ничего не стёр, он просто блефует — он вообще не в курсе про наш payment service

RAG решает эту проблему, комбинируя два этапа:

  1. Retrieval (поиск): Находит релевантные данные в базе знаний с помощью векторного поиска. Это быстрее и точнее, чем поиск по ключевым словам, так как учитывает семантику.

  2. Augmented Generation (подкреплённая генерация): Передаёт найденные данные LLM, чтобы она сгенерировала точный и полезный ответ.

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

Структурная схема интеграции RAG-системы
Структурная схема интеграции RAG-системы

Когда использовать 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, ...]

Эти векторы близки по расстоянию, так как их смысл похож. Расстояние между векторами измеряется метриками, например, такими как:

  • Косинусное сходство: cos(θ) = (A·B)/(|A|·|B|), где A и B — векторы. Значение от -1 (противоположный смысл) до 1 (идентичный смысл).

  • Евклидово расстояние: √Σ(Ai - Bi)², где меньшее расстояние означает большую схожесть.

Как создаются эмбеддинги? Модель, например BERT, обучается на огромном массиве текстов, чтобы улавливать контекст и смысл слов. Текст сначала разбивается на токены — слова или их части. Эти токены проходят через слои трансформера, и на выходе получается векторное представление.

Зачем нужны векторные базы данных?

Векторные базы данных хранят специальные числовые представления объектов — эмбеддинги. Их можно представить как точки на карте: близкие точки означают схожий смысл, а далёкие друг от друга — разный. Благодаря этому поиск работает не по буквальному совпадению слов, а по смыслу. Эмбеддинги создаются один раз и сохраняются, поэтому нужный фрагмент находится быстро и без лишних вычислений. Это делает векторные БД удобным инструментом для работы с LLM и большими массивами данных.

Векторное пространство IT-компетенций и их расположение по семантике
Векторное пространство IT-компетенций и их расположение по семантике

Основные составляющие конечной JSON-подобной структуры в векторной БД:

  • ID — уникальный идентификатор объекта.

  • Vector (Embedding) — сам вектор, который хранится в специальной структуре для быстрого поиска ближайших соседей.

  • Payload (Metadata) — дополнительные данные в формате ключ-значение (часто JSON): путь к файлу, тип документа, автор, дата и т.д.

  • Оригинальный текст — сам фрагмент или ссылка на внешний источник.

Про безопасность: для обеспечения безопасности документов в векторных хранилищах, в первую очередь, следует использовать встроенные механизмы аутентификации, а также управление API-ключами. Кроме того, для разграничения доступа можно присваивать каждому документу метку access_level или access_role, определяющую, кто имеет право на просмотр, либо кластеризировать данные по уровням доступа.

Порядок обработки документа и конечная JSON-подобная структура
Порядок обработки документа и конечная JSON-подобная структура

Как работает поиск: данные переводятся в векторы и индексируются. Запрос пользователя также конвертируется в вектор и база ищет самые близкие к нему по смыслу. Алгоритмы вроде 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 и проверяем данные:

Сохранённый Point и его данные в Qdrant
Сохранённый Point и его данные в Qdrant

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

Минимальное/максимальное остовое дерево из векторов в Qdrant
Минимальное/максимальное остовое дерево из векторов в Qdrant

Поиск похожих по семантике данных

Создали эндпоинт /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. Эти показатели можно использовать для ранжирования результатов.

Ответ API: список релевантных документов
Ответ API: список релевантных документов

Минимальное значение косинусного сходства не всегда значит лучший результат. Поэтому мы увеличиваем выборку — берём 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

Процесс включает четыре шага:

  1. Предобработка: приводим вопрос в удобный для анализа вид — нормализуем текст, исправляем ошибки, убираем лишние символы, уточняем формулировки. Также создаём n альтернативных вариантов вопроса для дальнейшей работы.

  2. Трансформация и поиск: преобразуем запросы в векторы и ищем k наиболее близких документов. В результате имеем n × k кандидатов.

  3. Ранжирование: оцениваем найденные документы по релевантности, удаляем дубликаты, формируем упорядоченный список.

  4. Подкреплённая генерация: передаём отобранный контекст в LLM, которая на основе него составляет ответ для пользователя.

Базовый цикл работы RAG-системы
Базовый цикл работы RAG-системы

Проще всего попросить 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-составляющей:

Без RAG LLM не даёт полезного ответа на вопрос
Без RAG LLM не даёт полезного ответа на вопрос

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

Прямого ответа в базе нет, но модель вернула контакты ответственных — честный, полезный результат
Прямого ответа в базе нет, но модель вернула контакты ответственных — честный, полезный результат

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

Отличный вопрос, отличный ответ — всё, как и должно быть
Отличный вопрос, отличный ответ — всё, как и должно быть

Способы повышения качества поиска

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

Даже в безжизненной пустыне можно найти ответы. Достаточно лишь использовать простой советский...
Даже в безжизненной пустыне можно найти ответы. Достаточно лишь использовать простой советский...

Предобработка и очистка пользовательского вопроса

Первый шаг — нормализовать вопрос: убрать «шум», исправить опечатки, провести лемматизацию. Это даёт более точный запрос и повышает релевантность поиска.

Часто применяют подход RAG Fusion: просим LLM предложить несколько вариантов очищенного запроса и по каждому выполняем семантический поиск. В результате получаем выборку размером [число предобработанных запросов] × [число релевантных документов], которую можно ранжировать и объединять в общий контекст.

В больших системах на этапе предобработки также классифицируют запросы: например, как просьбу, вопрос или жалобу, либо по отделам (бухгалтерия, HR). В нашей Java-реализации такую метаинформацию мы сохраняли в ключе tags.

Ансамбли

Помимо ансамблирования разных формулировок запроса, используют и ансамбли из нескольких LLM или нескольких сервисов-ретриверов. В нашем простом примере была связка «трансформер — вектор — векторное хранилище» (Dense Retriever). Также можно подключать и традиционные методы поиска (Sparse Retriever), где релевантность оценивается по частоте вхождений терминов в документ.

RAG с ансамблем бурлаков: несколько моделей тянут поиск к точности
RAG с ансамблем бурлаков: несколько моделей тянут поиск к точности

Дополнительный шаг поиска — динамическое обучение

Прежде чем выдавать клиенту финальный ответ, можно сгенерировать несколько черновых вариантов на основе ранжированных документов и использовать их как подсказки для LLM. Это позволяет модели опираться на конкретные примеры и давать более релевантный ответ. На этом этапе полезно применять zero-shot, few-shot и похожие методики.

Оценка результата

Оценка RAG — задача непростая: важно считать, сколько из извлечённых документов действительно релевантные, и насколько ответ опирается на них.
Для извлечения precision и recall. Для генерации — faithfulness/groundedness, совпадение с эталоном или оценка результата другой LLM-моделью.

Кроме того, смотрят порог уверенности по logits — распределение вероятностей для токенов в сгенерированном тексте. Это помогает понять, насколько модель уверена в ответе, и при низкой уверенности подставлять честное сообщение: «На данный вопрос не найден ответ».

Заключение

Мы разобрали, как собрать простую RAG-систему на Java и Spring AI: от теории до кода, от нормализации запросов и поиска векторных представлений до ансамблирования и повышения качества ответов. Теперь у вас есть рабочие примеры и понимание принципов, которые можно расширять под свои задачи.

Он больше не бурлак, не рыбак, не Сизиф, а путник, оставивший пустыню позади
Он больше не бурлак, не рыбак, не Сизиф, а путник, оставивший пустыню позади

Надеюсь, статья оказалась полезной и вдохновляющей. Если у вас есть вопросы, идеи или замечания — делитесь ими в комментариях. Буду рад конструктивной критике: вместе мы сделаем наш путь ещё интереснее и полезнее.

Присоединяйтесь к моему Telegram-каналу, чтобы не пропустить следующие материалы.
До встречи в новой статье!

© 2025 ООО «МТ ФИНАНС»

Комментарии (20)


  1. ekzes
    28.08.2025 16:14

    Очень интересно, здорово, что на пальцах объяснена математика векторного поиска по тексту. И здорово, когда у компании файлы базы знаний находятся в txt формате или хотя бы адекватно сделан pdf. Правда куча геморроя начинается, когда начинаешь использовать OCR для документов, особенно когда в ПДФках есть таблицы :) Так что если автор решит проблему адекватной векторизации ПДФ, буду раз почитать)


    1. APKAH9
      28.08.2025 16:14

      А в чем проблема предварительно pdf подготовить в .ps через тот же OCR вне RAG? Вся суть rag, что вся база данных актуальной должна быть., чтобы не глючить, а выдавать четкий конкретный ответ по запросу.


  1. Smollett322
    28.08.2025 16:14

    Статья по делу и наглядно, уважаемо


  1. federini123
    28.08.2025 16:14

    Самое интересное впереди начинается на прикладном уровне оценки и оптимизации работы. Для погружения статья отличная, покрывает всю просветительскую базовую базу


  1. vitminc
    28.08.2025 16:14

    С моей точки зрения, именно такими и должны быть статьи на тему LLM. Техническая основа, ясные принципы и обоснованное применение.

    Статья заинтересовала с практической точки зрения. Есть огромная база уже существующего кода, отчасти легаси, отчасти нового. На поддержку уходит много времени. Для себя написал плагин для Intellij, который умеет работать с различными AI платформами (Ollama, Antropic, OpenAI). Т.к. кодовая база закрыта, то публичными моделями пользоваться нельзя и используются локальные модели (или внутренний Mistral, который порой хуже открытых моделей). Плагин умеет автоматически вытаскивать зависимости, подмешивать релевантные данные и добавлять все это в контекст, что помогает. Однако длины контекста для более точного анализа не хватает.

    Вот здесь похоже и может помочь метод приведенный в статье. Если перевести кодовую базу в эмбеддинг формат и подмешивать релевантные данные в контекст, то предположительно ответ должен качественно улучшится. По сути это микс поисковой системы и LLM анализа. Сюда можно добавить и техническую документацию и даже переписку по интересующим темам.

    Автор задал направление для движения, за что ему спасибо. Интересно было бы взглянуть на реализацию или код, который судя по содержанию статьи, несомненно, существует.


    1. qqqgod
      28.08.2025 16:14

      Товарищ думаю столкнетесь с другой задачей, векторная эмбердинг в точности в разы лучше чем технология ллм. Но есть проблема в объеме по токенам и также есть галюцинации.


  1. APKAH9
    28.08.2025 16:14

    Какая же ты умничка, br0mberg. Очень структурно и красиво оформлено, а самое главное - та тема, которую неохотно все внедряют из-за неведения и слабого представлнния всего потенциала четкой RAG

    Единственное, взята в пример конкретная внедренная структура, парсинг и очистка может быть совсем иная. Векторность так же можно через llm обучить , но с геммором. Все зависит от поставленных задач, величины базы данных и железа) Но суть статьи - RAG must have. Куда донатить за статью?)


    1. br0mberg Автор
      28.08.2025 16:14

      Большое спасибо, согласен, что нюансы от системы к системе бывают разительны.

      Лучшим донатом за статью для меня - рассказать своим коллегам и друзьям о ней:)


      1. APKAH9
        28.08.2025 16:14

        Done


  1. Revertis
    28.08.2025 16:14

    Когда мне надо было начать писать RAG, я спросил об этом у Kimi, и она мне примерно так же, как в статье, всё расписала. Реализовал подобное на Расте. Но кроме ембеддингов подключил ещё индекс на tantivy, тоже неплохой способ добавить контекста.

    Кстати, не сказали ничего о том, что большие документы надо разбивать на чанки (абзацы), так как иначе происходит размывание векторов.


    1. br0mberg Автор
      28.08.2025 16:14

      Косвенно изобразил разбиение на чанки в схеме с порядком обработки документа, а так, согласен с вами полностью


  1. Serge555
    28.08.2025 16:14

    Подскажите какая самая лучшая текстовая модель для чат бота поддержки?

    И где самые дешевые эмбединги для изображений?


    1. br0mberg Автор
      28.08.2025 16:14

      Просто на этот вопрос не ответить, слишком много переменных. Необходимо глянуть разные лидерборды, и оценить свою систему. Подумать над "перспективами" выбранной модели. Для локального размещения хороши последние версии Ollama и Mistral, из облачных выбирать из всего что на слуху и к чему есть доступ)


  1. ogogoggogog
    28.08.2025 16:14

    В вашем примере, как понимаю эмбеддинги генерит "дядя", а не он-прем?

    А спринг умеет сам всё делать или придется либы навроде ml4j подключать? По моему вариант с дядей для большинства неприменим. Дорого, долго и может быть конфиденциалочка.

    Если спринг всё сам умеет, было бы интересно узнать примерные требования к железу на мегабайт теста. GPU/CPU? Параллелится или нет.


    1. br0mberg Автор
      28.08.2025 16:14

      Spring AI умеет работать и on-prem через Ollama или интеграцию с локальными моделями, кроме того в qdrant есть свой трансформер, но он уступает в качестве. Либы вроде ml4j подключать не нужно, достаточно готовых адаптеров. По требованиям к железу не подскажу, зависит от модели. Параллелится без проблем на большинстве компонентов.


  1. izibrizi2
    28.08.2025 16:14

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

    Кстати, пробовали ли вы делать эмбединги использую doctovec?


    1. br0mberg Автор
      28.08.2025 16:14

      Обучить и дообучить сложновато и дорого, для меня пока эта задача покрыта мраком.
      В целом, rag отличная вещь, за свою ресурсную стоимость must have.

      Возможно, попробую когда-нибудь doctovec, а так - альтернатив море


      1. APKAH9
        28.08.2025 16:14

        Почитайте про RAG Flow. . RAG на двух видюхах (моделях) самообучающийся.

        И ждем новую статью)


  1. Zabalkanskiy
    28.08.2025 16:14

    Спасибо за статью.
    Можно ли на java spring дополнить векторный поиск гибридным или поиском по ключевым словам?
    Я новичек в работе с llm rag.
    В своем проекте на питон сделал векторный поиск и он очень плохо работает на моих данных. ai предлагает сделать гибридный поиск. Смотрю в его сторону поможет ли мне гибридный поиск улучшить качество ответов?


    1. br0mberg Автор
      28.08.2025 16:14

      Попробуй воспользоваться методами повышения качества приведёнными в статье, гибридный поиск - тоже хороший вариант, тот же самый elasticsearch можно использовать