Форум программистов, компьютерный форум, киберфорум
JVM_Whisperess
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

AI-чатбот на Java с Langchain4j и MongoDB Atlas

Запись от JVM_Whisperess размещена 05.10.2025 в 20:00
Показов 3274 Комментарии 0

Нажмите на изображение для увеличения
Название: AI-чатбот на Java с Langchain4j и MongoDB Atlas.jpg
Просмотров: 239
Размер:	121.0 Кб
ID:	11261
Помню тот день, когда наш техлид пришёл на планёрку с новой идеей - прикрутить AI-ассистента к внутренней базе знаний компании. Все сразу заговорили про Python, LangChain, модные фреймворки. А я сидел и думал: «У нас же весь стек на Java, зачем городить огород с микросервисами на разных языках?»

Тогда я ещё не знал про Langchain4j. Больше того - я вообще сомневался, что Java способна тягаться с Python в области машинного обучения. Но копнув глубже, я обнаружил целую экосистему инструментов для работы с LLM на родной для нас платформе.

Первый запуск оказался неожиданно простым. Я подключил пару зависимостей, написал штук сорок строк кода и получил работающего бота, который мог общаться через OpenAI API. Никакого танца с бубном вокруг виртуальных окружений Python, никаких проблем с версиями библиотек - просто Maven, Spring Boot и привычный workflow. Но настоящий вызов начался, когда я захотел научить бота работать с нашими документами. Тут встал вопрос векторного поиска. Мы уже использовали MongoDB в проде, и идея добавить туда векторные индексы казалась логичной. Зачем поднимать отдельный Pinecone или Weaviate, если можно обойтись тем, что уже крутится в продакшене?

Дальше будет история о том, как я прошёл путь от простого чатбота до полноценной RAG-системы, которая понимает контекст наших внутренних доков лучше, чем половина джунов в команде. Без восторженных возгласов про "революцию в AI" - просто технический разбор того, что работает, а что нет.

Выбор технологического стека: почему именно эта связка



Когда передо мной встала задача выбора инструментов, я начал с простого вопроса: что у нас уже есть? В нашем случае это был монолитный Java-сервис на Spring Boot, PostgreSQL для транзакционных данных и MongoDB для аналитики и логов. Команда знает эти технологии, инфраструктура отлажена, мониторинг настроен. Первая мысль - взять готовое решение типа Dialogflow или AWS Lex. Но там всё упирается в ограничения по кастомизации и цену за запрос. Плюс зависимость от внешнего сервиса, которая для корпоративной базы знаний выглядела сомнительно. Нужен был контроль над данными и логикой.

Python с LangChain казался очевидным выбором - документация богатая, сообщество активное, примеров море. Но тут начинались головняки с интеграцией. Отдельный микросервис на Python означал новый runtime в продакшене, дополнительные зависимости в CI/CD, другой подход к логированию и трейсингу. А самое неприятное - разделение кодовой базы и необходимость поддерживать два стека технологий.

Langchain4j появился в моём радаре случайно, когда я искал способы работы с embedding'ами в Java. Библиотека оказалась довольно молодой, но с разумной архитектурой. Авторы явно вдохновлялись Python-версией, но переосмыслили некоторые концепции под особенности JVM. Главное - она предоставляла единый интерфейс для работы с разными LLM-провайдерами: OpenAI, Anthropic, локальные модели через Ollama. MongoDB Atlas с векторным поиском был естественным выбором. Мы уже платили за кластер, там лежала куча неструктурированных данных. Добавить векторные индексы оказалось делом пары команд - никаких отдельных инстансов, никакого нового железа. Просто ещё одна коллекция с специальным индексом.

Альтернативы были. Pinecone обещал лучшую производительность для чистого векторного поиска, но это ещё один сервис в инфраструктуре и ещё одна статья расходов. Weaviate выглядел интересно своими GraphQL API, но документация местами хромала. Elasticsearch тоже научился работать с векторами, однако у нас его не было, а разворачивать ради одной фичи казалось избыточным.

Что в итоге получилось? Весь код на одном языке, единая кодовая база, привычные инструменты разработки и отладки. Spring Boot берёт на себя конфигурацию и dependency injection, Langchain4j предоставляет абстракции для работы с LLM, MongoDB хранит и индексирует данные. Никакого зоопарка технологий - просто логичное расширение существующего стека.

Конечно, не обошлось без компромиссов. Экосистема Java для ML всё ещё уступает Python по разнообразию библиотек. Langchain4j моложе своего Python-собрата, документация кое-где неполная, приходится лезть в исходники. Но для энтерпрайз-проекта, где важнее стабильность и поддерживаемость, чем bleeding edge фичи, этот выбор оказался разумным.

В MongoDB что такое «кластер» и «коллекция»? In MongoDB, what is a "Cluster" and a "Collection"?
В MongoDB что такое «кластер» и «коллекция»? What is a "Cluster" and what is a "Collection"? ...

Tomcat HTTP Status 500 (java.lang.NoClassDefFoundError && java.lang.ClassNotFoundException) with MongoDB
Здравствуйте, пишу простенький сервер, который мог бы работать с MongoDB. Создал классы для...

Подключение клиента написанного на java к MongoDB
Всем привет =) Недавно начал изучать Android и есть задачка, а именно: Нужно написать приложение...

Как установить MongoDB на PHP 5.3.10?
По этой статье: http://mongodb.ru/blog/14.html Пытаюсь поставить MongoDB, но phpinfo() не выводит...


Langchain4j - что под капотом фреймворка



Когда я впервые открыл документацию Langchain4j, меня поразило, насколько авторы постарались не копировать слепо Python-версию, а переосмыслить концепции под идиоматичный Java-код. Никаких динамических словарей и магических методов - всё строго типизировано, с чёткими интерфейсами и предсказуемым поведением.

Архитектура и основные компоненты



В основе фреймворка лежит несколько ключевых абстракций. Первая и главная - ChatLanguageModel. Это интерфейс, который унифицирует работу с разными LLM-провайдерами. Хочешь OpenAI GPT-4? Пожалуйста. Нужен Claude от Anthropic? Без проблем. Решил запустить локальную Llama через Ollama? Меняешь одну строчку в конфигурации.
Вот как выглядит базовая инициализация:

Java
1
2
3
4
5
6
7
ChatLanguageModel model = OpenAiChatModel.builder()
    .apiKey(System.getenv("OPENAI_API_KEY"))
    .modelName("gpt-4")
    .temperature(0.7)
    .build();
 
String response = model.generate("Объясни, что такое векторные embedding'и");
Температура тут управляет "креативностью" модели - чем выше значение, тем более непредсказуемые и разнообразные ответы. Для технической документации я обычно ставлю 0.3-0.5, для генерации идей можно поднять до 0.9.

Второй важный компонент - EmbeddingModel. Он преобразует текст в векторные представления. Эти векторы - не просто набор чисел, это многомерное пространство, где семантически близкие фразы оказываются рядом. "Машинное обучение" и "искусственный интеллект" будут иметь похожие векторы, хотя слова разные.

Java
1
2
3
4
5
6
7
8
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
    .apiKey(apiKey)
    .modelName(OpenAiEmbeddingModelName.TEXT_EMBEDDING_3_SMALL)
    .build();
 
Response<Embedding> embeddingResponse = embeddingModel.embed("Java Spring Boot разработка");
float[] vector = embeddingResponse.content().vector();
// Получаем массив из 1536 float'ов для модели text-embedding-3-small
Размерность вектора зависит от модели. У text-embedding-3-small это 1536 измерений, у text-embedding-3-large - 3072. Больше измерений не всегда лучше - это компромисс между точностью поиска и скоростью работы. Для большинства задач 1536 более чем достаточно.

Интеграция с языковыми моделями



Один из неочевидных моментов - управление контекстом. LLM не хранят историю разговора сами по себе. Если отправишь два отдельных запроса, модель не поймёт связи между ними. Это нужно делать явно, передавая всю историю диалога с каждым новым сообщением. Langchain4j решает это через ChatMemory. Простейший вариант - MessageWindowChatMemory, который хранит последние N сообщений:

Java
1
2
3
4
5
6
7
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);
 
chatMemory.add(UserMessage.from("Как установить Spring Boot?"));
chatMemory.add(AiMessage.from("Spring Boot устанавливается через Maven или Gradle..."));
chatMemory.add(UserMessage.from("А какую версию выбрать?"));
 
// Модель видит весь контекст беседы
Проблема в том, что LLM имеют лимит на количество токенов в запросе. GPT-4 Turbo принимает до 128K токенов, но это не значит, что нужно скармливать ему всю историю. Чем больше контекст, тем дороже запрос и медленнее ответ. Я обычно ограничиваюсь 10-15 сообщениями или использую суммаризацию старых частей диалога. Есть и более хитрые варианты. TokenWindowChatMemory учитывает не количество сообщений, а реальное число токенов. Это удобнее, потому что одно сообщение может быть коротким "Ок", а другое - простыней кода на три экрана.

Управление промптами и шаблонами ответов



Промпты - это искусство само по себе. Одна и та же задача может решаться по-разному в зависимости от формулировки. Langchain4j предлагает PromptTemplate для создания переиспользуемых шаблонов:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PromptTemplate promptTemplate = PromptTemplate.from(
    """
    Ты - эксперт по {{topic}}.
    Ответь на вопрос пользователя, используя только факты.
    Если не уверен - так и скажи.
    
    Вопрос: {{question}}
    """
);
 
Map<String, Object> variables = new HashMap<>();
variables.put("topic", "Java concurrency");
variables.put("question", "Чем volatile отличается от synchronized?");
 
Prompt prompt = promptTemplate.apply(variables);
String response = model.generate(prompt.text());
Такой подход позволяет централизованно управлять промптами, версионировать их, тестировать разные варианты. Я храню шаблоны в отдельных файлах и загружаю через ресурсы - так проще экспериментировать, не перекомпилируя код.
Интересная фича - StructuredOutputParser. Он заставляет модель возвращать ответ в заданном формате, например JSON:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
class CodeReview {
    String summary;        // Краткое описание проблемы
    List<String> issues;   // Найденные баги
    int severity;          // Критичность от 1 до 10
}
 
AiService<CodeReviewService> service = AiServices.builder(CodeReviewService.class)
    .chatLanguageModel(model)
    .build();
 
CodeReview review = service.reviewCode(javaCodeString);
// Получаем строго типизированный объект вместо сырого текста
Под капотом это работает через инструкции в системном промпте, где модели объясняют нужную структуру JSON. Не всегда срабатывает идеально - иногда модель выдаёт невалидный JSON или игнорирует часть полей. Поэтому я добавляю валидацию и retry-логику на случай сбоев.

Ещё одна штука, которую я недооценивал поначалу - Tooling или функциональность вызова внешних функций. Представь: модель может не только генерировать текст, но и дёргать твои методы, когда ей это нужно. Хочешь, чтобы чатбот проверял погоду? Определяешь метод, аннотируешь его, и модель сама понимает, когда его вызвать.

Java
1
2
3
4
5
6
7
8
9
10
@Tool("Получает текущую температуру для указанного города")
public String getCurrentTemperature(@P("Название города") String city) {
    // Реальный вызов API погоды
    return weatherService.getTemperature(city);
}
 
@Tool("Вычисляет квадратный корень числа")  
public double sqrt(@P("Число для вычисления") double number) {
    return Math.sqrt(number);
}
Фреймворк автоматически генерирует описания этих функций в формате, понятном модели. Когда пользователь спрашивает "Какая погода в Москве?", GPT-4 анализирует вопрос, понимает, что нужно вызвать getCurrentTemperature, передаёт параметр "Москва" и использует результат в ответе. Магия? Не совсем - это часть OpenAI Function Calling API, обёрнутая в удобные Java-абстракции. Проблема в том, что не все модели поддерживают tool calling. GPT-3.5 и GPT-4 - да, но если решишь переключиться на локальную Llama, придётся либо искать специально файнтюненную версию, либо реализовывать логику вызова функций самостоятельно через chain-of-thought промпты.

Chains в Langchain4j - это последовательное выполнение операций. Допустим, тебе нужно: взять вопрос пользователя, найти релевантные документы в базе, сформировать промпт с контекстом, отправить в модель и вернуть ответ. Каждый шаг можно инкапсулировать:

Java
1
2
3
4
5
6
7
ConversationalRetrievalChain chain = ConversationalRetrievalChain.builder()
    .chatLanguageModel(model)
    .retriever(documentRetriever)  // Компонент поиска по базе
    .chatMemory(chatMemory)
    .build();
 
String answer = chain.execute("Как настроить MongoDB Atlas?");
Под капотом это координация между несколькими компонентами: retriever достаёт документы, chat memory добавляет историю, prompt template формирует финальный запрос. И всё это можно кастомизировать - подменить retriever на свой, добавить пре- или постпроцессинг ответов, встроить логирование на каждом этапе.

Streaming - ещё один момент, который сильно влияет на пользовательский опыт. Вместо того чтобы ждать полный ответ (который для длинных текстов может генериться 10-15 секунд), можно получать токены по мере их появления:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
model.generate("Напиши подробную статью про JVM", new StreamingResponseHandler<String>() {
    @Override
    public void onNext(String token) {
        System.out.print(token);  // Выводим по мере поступления
    }
    
    @Override
    public void onComplete(Response<String> response) {
        System.out.println("\n[Готово]");
    }
    
    @Override
    public void onError(Throwable error) {
        logger.error("Ошибка генерации", error);
    }
});
Это не просто визуальный эффект "печатной машинки". Streaming позволяет начать обрабатывать ответ раньше, чем он сгенерится полностью. В одном проекте я использовал это для параллельной валидации: пока модель генерирует код, валидатор уже проверяет первые строчки на синтаксические ошибки.

Обработка ошибок и ретраи тоже заслуживают внимания. API OpenAI может отвалиться по rate limit, вернуть 500-ку или просто таймаутнуть. Langchain4j даёт инструменты для graceful degradation:

Java
1
2
3
4
5
6
7
ChatLanguageModel resilientModel = OpenAiChatModel.builder()
    .apiKey(apiKey)
    .timeout(Duration.ofSeconds(30))
    .maxRetries(3)  // Автоматические повторы
    .logRequests(true)
    .logResponses(true)
    .build();
Правда, с ретраями нужно быть аккуратным. Если запрос идёт 25 секунд, упал, и ты делаешь три повтора - пользователь будет ждать полторы минуты. Лучше комбинировать с circuit breaker паттерном, чтобы быстро fail fast при массовых сбоях.

MongoDB Atlas как векторное хранилище



Нажмите на изображение для увеличения
Название: AI-чатбот на Java с Langchain4j и MongoDB Atlas 2.jpg
Просмотров: 49
Размер:	151.5 Кб
ID:	11262

Помню, как я объяснял коллеге идею векторного поиска. Он смотрел на меня как на шарлатана: "Блин, у нас уже есть полнотекстовый поиск в Mongo, зачем это всё?" Пришлось показывать на реальном примере. Я взял два запроса: "настройка Spring приложения" и "конфигурация спринг проекта". Классический текстовый поиск нашёл только первый вариант из документации, а векторный подтянул оба - потому что понял смысл, а не просто сматчил слова. Когда MongoDB объявила о встроенной поддержке векторного поиска в Atlas, многие отнеслись скептически. Мол, специализированные решения типа Pinecone всё равно быстрее. Может быть. Но вопрос в другом: нужна ли тебе абсолютная максимальная производительность, если можно получить достаточную производительность без добавления нового сервиса в стек?

У нас уже был кластер M30 в Atlas, который обрабатывал метрики и логи приложений. Добавить туда векторные индексы - это буквально два шага: создать коллекцию и определить индекс. Никаких отдельных инстансов, никаких дополнительных connection pool'ов, никаких новых точек отказа в архитектуре.

Векторный поиск vs традиционный поиск



Разница фундаментальная. Представь, что у тебя в базе лежит статья про "параллельную обработку данных в Java". Пользователь спрашивает про "concurrent programming". Традиционный full-text search вернёт пустоту - ни одного совпадающего слова. А векторный поиск скажет: "Эй, это про то же самое!" и подтянет нужный документ.

Работает это через косинусное сходство векторов. Каждый документ превращается в точку в многомерном пространстве (обычно 1536 измерений для text-embedding-3-small). Запрос тоже конвертится в вектор. Дальше - математика: ищем ближайшие точки.

Формула косинусного сходства выглядит так:

https://www.cyberforum.ru/cgi-bin/latex.cgi?\text{similarity} = \frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| \times |\mathbf{B}|}

Где https://www.cyberforum.ru/cgi-bin/latex.cgi?\mathbf{A} и https://www.cyberforum.ru/cgi-bin/latex.cgi?\mathbf{B} - это векторы запроса и документа. Результат от -1 до 1, где 1 означает полное совпадение. На практике значения выше 0.8 уже считаются релевантными для большинства задач.

Но есть нюанс. Векторный поиск не заменяет полнотекстовый - он его дополняет. Когда пользователь ищет точное название класса или метода типа MongoDbEmbeddingStore, лучше сработает классический индекс. А для "как сохранить эмбеддинги в монго" - векторный.

Настройка коллекций и индексов



Первым делом я создал отдельную коллекцию для хранения документов с их эмбеддингами:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MongoDatabase database = mongoClient.getDatabase("chatbot_db");
MongoCollection<Document> collection = database.getCollection("knowledge_base");
 
// Структура документа
Document doc = new Document()
    .append("_id", new ObjectId())
    .append("content", "Текст статьи про MongoDB")  
    .append("embedding", embeddingVector)  // float[] преобразованный в List<Double>
    .append("metadata", new Document()
        .append("source", "confluence")
        .append("author", "ivanov")
        .append("created_at", new Date())
    );
 
collection.insertOne(doc);
Metadata тут не просто для красоты. Потом можно фильтровать результаты поиска: показывать только документы определённого автора или за последний месяц. MongoDB позволяет комбинировать векторный поиск с обычными фильтрами, и это реально удобно.

Теперь индекс. Тут я споткнулся первый раз - создавал через MongoDB Compass UI и получал странные ошибки. Оказалось, нужно точно указать размерность вектора и метрику сходства:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
db.knowledge_base.createSearchIndex(
  "vector_index",
  "vectorSearch",
  {
    fields: [
      {
        type: "vector",
        path: "embedding",
        numDimensions: 1536,
        similarity: "cosine"
      }
    ]
  }
);
numDimensions должен строго совпадать с размерностью, которую выдаёт твоя embedding модель. У text-embedding-3-small это 1536, у ada-002 было 1536, у text-embedding-3-large уже 3072. Если напутаешь - поиск не заработает, и ошибка будет невнятная. С метрикой тоже есть варианты: cosine, euclidean или dotProduct. Для большинства NLP-задач косинусное сходство оптимально, потому что оно нормализует длину векторов. Евклидово расстояние чувствительно к масштабу, а dot product требует предварительной нормализации векторов.

Индекс создаётся не мгновенно. Для коллекции в 50 тысяч документов ушло минут пятнадцать. MongoDB строит специальные структуры данных (вероятно, HNSW-подобные графы) для быстрого поиска ближайших соседей. После создания размер коллекции вырос примерно на 30% - векторный индекс жрёт память.

Оптимизация производительности



Первые тесты показали latency в районе 200-300 мс для поиска по коллекции в 100K документов. Не космос, но и не быстро. Начал копать - оказалось, я не настроил количество кандидатов для поиска:

Java
1
2
3
4
5
6
7
8
9
10
11
List<Document> pipeline = Arrays.asList(
    new Document("$vectorSearch", 
        new Document("index", "vector_index")
            .append("path", "embedding")  
            .append("queryVector", queryEmbeddingList)
            .append("numCandidates", 150)  // Сколько документов проверять
            .append("limit", 10)           // Сколько вернуть
    )
);
 
AggregateIterable<Document> results = collection.aggregate(pipeline);
numCandidates - это tradeoff между точностью и скоростью. Чем больше кандидатов, тем выше шанс найти действительно релевантные документы, но тем дольше работает запрос. Я экспериментировал с разными значениями и остановился на 150 для limit=10. Это даёт достаточную точность при latency около 100-120 мс.

Ещё один момент - батчинг при загрузке данных. Изначально я вставлял документы по одному через insertOne. Для 10 тысяч статей это заняло вечность. Переписал на батчи:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
List<Document> batch = new ArrayList<>();
for (Article article : articles) {
    Embedding embedding = embeddingModel.embed(article.getContent()).content();
    
    Document doc = new Document()
        .append("content", article.getContent())
        .append("embedding", embedding.vectorAsList())
        .append("metadata", article.getMetadata());
    
    batch.add(doc);
    
    if (batch.size() >= 100) {
        collection.insertMany(batch);
        batch.clear();
    }
}
 
if (!batch.isEmpty()) {
    collection.insertMany(batch);  // Не забываем остаток
}
Время загрузки сократилось в разы. Правда, тут есть подводный камень - rate limit на embedding API. OpenAI пускает ограниченное количество запросов в минуту, и если гнать батчи слишком быстро, получишь 429-ю ошибку. Пришлось добавить простейший rate limiter через Thread.sleep(), хотя в идеале стоит использовать что-то типа Resilience4j с его RateLimiter.

Chunking документов - ещё одна тема. Если запихнуть в embedding целую статью на 5000 слов, информация "размажется" по вектору, и точность поиска упадёт. Лучше разбить на чанки по 500-1000 токенов с небольшим overlap'ом:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Разбиваем текст на фрагменты с перекрытием
int chunkSize = 1000;  // Токенов
int overlap = 100;
 
List<String> chunks = splitIntoChunks(article.getContent(), chunkSize, overlap);
 
for (int i = 0; i < chunks.size(); i++) {
    Embedding embedding = embeddingModel.embed(chunks.get(i)).content();
    
    Document chunkDoc = new Document()
        .append("content", chunks.get(i))
        .append("embedding", embedding.vectorAsList())  
        .append("metadata", new Document()
            .append("article_id", article.getId())
            .append("chunk_index", i)
            .append("total_chunks", chunks.size())
        );
    
    collection.insertOne(chunkDoc);
}
Overlap нужен, чтобы не потерять контекст на границах чанков. Если предложение разрезается пополам, его смысл может исказиться. 100 токенов overlap'а - это примерно 2-3 предложения, обычно достаточно.

Сравнение производительности векторного поиска MongoDB с альтернативами



Когда я презентовал решение на MongoDB Atlas техлиду, первый вопрос был ожидаемым: "А не медленнее ли это, чем специализированные векторные базы?" Честно говоря, я сам хотел это проверить. Развернул тестовый стенд с Pinecone и Weaviate, загрузил одинаковый датасет в 50 тысяч документов и погнал бенчмарки. Результаты оказались неоднозначными. Pinecone на cold queries выдавал latency 40-60 мс - впечатляет. MongoDB Atlas показывал 100-120 мс. Разница в два раза, казалось бы, всё плохо. Но дальше начались нюансы. При concurrent нагрузке (100 одновременных запросов) картина выровнялась: Pinecone деградировал до 150-180 мс, Atlas держался на своих 120-140 мс. Видимо, дело в том, что мой кластер M30 имел достаточно ресурсов, тогда как бесплатный tier Pinecone начинал троттлить.

Weaviate показал себя странно. На простых запросах - быстро, 50-70 мс. Но стоило добавить фильтры по метаданным (типа "найди документы автора Иванова за последний месяц"), и latency улетала за 300 мс. MongoDB справлялся с комбинированными запросами стабильнее - около 150 мс независимо от сложности фильтров. Подозреваю, тут играет роль зрелость оптимизатора запросов у Mongo.

Что касается точности поиска, я использовал метрику recall@10 - насколько из топ-10 результатов действительно релевантны. Эталоном выступала ручная разметка 500 тестовых запросов. Pinecone дал 0.87, MongoDB - 0.84, Weaviate - 0.86. Разница в пределах погрешности, честно говоря. Все три решения используют похожие алгоритмы approximate nearest neighbor search, так что принципиальных отличий быть не должно. Интересный момент обнаружился с обновлением данных. В Pinecone добавление новых векторов в существующий индекс иногда вызывало заметные просадки по latency - до секунды на некоторых запросах. MongoDB обрабатывал инкрементальные апдейты плавнее, без видимых spike'ов. Правда, я не тестировал сценарии с миллионами документов - возможно, там ситуация иная.

Стоимость - отдельная песня. Pinecone берёт деньги за каждый индекс и за количество векторов. Для моего случая с 50K документов это вышло бы около $70 в месяц на минимальном плане. MongoDB Atlas у нас уже был развёрнут, добавление векторного поиска не увеличило счета - просто использовал существующий кластер. Это не совсем честное сравнение, конечно. Если бы разворачивал Mongo с нуля только ради векторов, цена была бы сопоставима или выше.

Ещё одна штука, которую я недооценивал - экосистема и тулинг. С MongoDB у меня уже настроен мониторинг через Grafana, алерты на аномалии, бэкапы крутятся автоматически. Для Pinecone пришлось бы всё это поднимать заново - метрики в другом формате, API для бэкапов работает иначе, документация местами куцая. Это не технический аргумент, но он реально влияет на скорость разработки.

Вердикт такой: если ты строишь ML-платформу с нуля, где векторный поиск - core functionality, и нужна максимальная производительность - бери специализированное решение типа Pinecone или Qdrant. Если у тебя уже есть MongoDB в стеке, и векторный поиск - одна из фич среди прочих - Atlas справится отлично. Не нужно переоценивать разницу в 50 миллисекунд latency, если ты экономишь неделю на интеграцию нового сервиса.

Создание базового AI-ассистента



Когда я собрал первую рабочую версию, она состояла из трёх файлов: конфигурация Spring Boot, сервис с логикой и REST-контроллер. Никакой магии - просто связал компоненты Langchain4j в правильном порядке. Правда, первый запуск выдал исключение, потому что я забыл добавить API ключ в переменные окружения. Классика.

Подключение зависимостей и конфигурация



Maven-конфигурация получилась компактной. Главное - не забыть про версии и не смешивать разные релизы Langchain4j:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
    <version>0.27.1</version>
</dependency>
 
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-open-ai</artifactId>
    <version>0.27.1</version>
</dependency>
 
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
    <version>0.27.1</version>
</dependency>
 
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.2.3</version>
</dependency>
 
<dependency>
    <groupId>org.mongodb</groupId>
    <artifactId>mongodb-driver-sync</artifactId>
    <version>4.11.1</version>
</dependency>
В application.yml я вынес все настройки, которые могут меняться между окружениями:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
openai:
  api-key: ${OPENAI_API_KEY}
  model: gpt-4-turbo-preview
  temperature: 0.7
  max-tokens: 2000
 
mongodb:
  uri: ${MONGODB_URI:mongodb://localhost:27017}
  database: chatbot_db
  collection: knowledge_base
  vector-index: vector_index
 
embedding:
  model: text-embedding-3-small
  dimension: 1536
  batch-size: 100
 
chat:
  memory-window: 15
  system-message: |
    Ты - техническиий ассистент, специализирующийся на Java и Spring Boot.
    Отвечай точно и по делу. Если не знаешь - скажи об этом честно.
    Используй примеры кода там, где это уместно.
Системное сообщение тут критично. Именно оно задаёт тон всему взаимодействию. Я перепробовал десятки вариантов, прежде чем нашёл формулировку, которая даёт стабильные результаты. Если написать "будь полезным и дружелюбным", модель начинает генерить воду и извиняться по любому поводу. Чёткая инструкция "отвечай точно" работает лучше.
Spring конфигурация собирает всё воедино через обычный @Configuration класс:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Configuration
public class ChatbotConfig {
    
    @Value("${openai.api-key}")
    private String apiKey;
    
    @Value("${openai.model}")
    private String modelName;
    
    @Value("${openai.temperature}")
    private Double temperature;
    
    @Value("${mongodb.uri}")
    private String mongoUri;
    
    @Bean
    public MongoClient mongoClient() {
        return MongoClients.create(mongoUri);
    }
    
    @Bean
    public ChatLanguageModel chatModel() {
        return OpenAiChatModel.builder()
            .apiKey(apiKey)
            .modelName(modelName)
            .temperature(temperature)
            .timeout(Duration.ofSeconds(60))
            .maxRetries(3)
            .logRequests(false)  // В проде лучше выключить - треш в логах
            .logResponses(false)
            .build();
    }
    
    @Bean
    public EmbeddingModel embeddingModel() {
        return OpenAiEmbeddingModel.builder()
            .apiKey(apiKey)
            .modelName(OpenAiEmbeddingModelName.TEXT_EMBEDDING_3_SMALL)
            .build();
    }
    
    @Bean
    public EmbeddingStore<TextSegment> embeddingStore(
            MongoClient mongoClient,
            @Value("${mongodb.database}") String dbName,
            @Value("${mongodb.collection}") String collectionName) {
        
        return MongoDbEmbeddingStore.builder()
            .mongoClient(mongoClient)
            .databaseName(dbName)
            .collectionName(collectionName)
            .indexName("vector_index")
            .createIndex(false)  // Индекс создаём вручную через UI
            .build();
    }
    
    @Bean
    public ChatMemory chatMemory(@Value("${chat.memory-window}") int windowSize) {
        return MessageWindowChatMemory.withMaxMessages(windowSize);
    }
}
Флаг createIndex(false) я поставил после того, как приложение упало при старте - оказалось, автоматическое создание индекса требует специальных прав в MongoDB Atlas, которых у сервисного аккаунта не было. Проще создать индекс один раз руками через веб-интерфейс.

Реализация цепочек обработки запросов



Сердце системы - это ContentRetriever, который находит релевантные документы перед тем, как отправить запрос в LLM. Langchain4j предоставляет готовый EmbeddingStoreContentRetriever:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class DocumentRetrievalService {
    
    private final ContentRetriever contentRetriever;
    
    public DocumentRetrievalService(
            EmbeddingStore<TextSegment> embeddingStore,
            EmbeddingModel embeddingModel) {
        
        this.contentRetriever = EmbeddingStoreContentRetriever.builder()
            .embeddingStore(embeddingStore)
            .embeddingModel(embeddingModel)
            .maxResults(5)
            .minScore(0.75)  // Порог релевантности
            .build();
    }
    
    public List<Content> retrieve(String query) {
        Query queryObj = Query.from(query);
        return contentRetriever.retrieve(queryObj);
    }
}
Порог minScore = 0.75 подобран экспериментально. Ниже 0.7 начинают проскакивать нерелевантные документы, выше 0.8 - теряются граничные случаи, где пользователь формулирует вопрос необычно.
Главный сервис склеивает всё в RAG-паттерн (Retrieval-Augmented Generation):

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Service
public class ChatbotService {
    
    private final ChatLanguageModel chatModel;
    private final DocumentRetrievalService retrievalService;
    private final ChatMemory chatMemory;
    
    @Value("${chat.system-message}")
    private String systemMessage;
    
    public ChatbotService(
            ChatLanguageModel chatModel,
            DocumentRetrievalService retrievalService,
            ChatMemory chatMemory) {
        this.chatModel = chatModel;
        this.retrievalService = retrievalService;
        this.chatMemory = chatMemory;
    }
    
    public String chat(String userMessage) {
        // Достаём релевантные документы
        List<Content> relevantDocs = retrievalService.retrieve(userMessage);
        
        // Формируем контекст из найденных документов
        String context = buildContext(relevantDocs);
        
        // Создаём промпт с контекстом
        String augmentedPrompt = buildPrompt(context, userMessage);
        
        // Добавляем в историю
        chatMemory.add(UserMessage.from(augmentedPrompt));
        
        // Генерируем ответ
        Response<AiMessage> response = chatModel.generate(
            chatMemory.messages()
        );
        
        // Сохраняем ответ в историю
        chatMemory.add(response.content());
        
        return response.content().text();
    }
    
    private String buildContext(List<Content> docs) {
        if (docs.isEmpty()) {
            return "Релевантных документов не найдено.";
        }
        
        StringBuilder context = new StringBuilder("Контекст из базы знаний:\n\n");
        for (int i = 0; i < docs.size(); i++) {
            context.append(String.format("[Документ %d]\n", i + 1));
            context.append(docs.get(i).textSegment().text());
            context.append("\n\n");
        }
        return context.toString();
    }
    
    private String buildPrompt(String context, String question) {
        return String.format("""
            %s
            
            %s
            
            Вопрос пользователя: %s
            
            Ответь на вопрос, используя информацию из контекста.
            Если контекст не содержит нужной информации - скажи об этом.
            """, systemMessage, context, question);
    }
}
Тут я намеренно не использовал готовые chain'ы типа ConversationalRetrievalChain, потому что хотел контролировать каждый шаг. В production-коде такая детализация полезна для отладки - можно логировать промежуточные результаты, добавлять кастомные фильтры, кешировать embeddings повторяющихся запросов.
Контроллер получился тривиальным:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RestController
@RequestMapping("/api/chat")
public class ChatController {
    
    private final ChatbotService chatbotService;
    
    public ChatController(ChatbotService chatbotService) {
        this.chatbotService = chatbotService;
    }
    
    @PostMapping
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        String answer = chatbotService.chat(request.getMessage());
        return ResponseEntity.ok(new ChatResponse(answer));
    }
    
    @PostMapping("/stream")
    public SseEmitter chatStream(@RequestBody ChatRequest request) {
        SseEmitter emitter = new SseEmitter(60_000L);
        
        // Асинхронная обработка в отдельном потоке
        CompletableFuture.runAsync(() -> {
            try {
                streamResponse(request.getMessage(), emitter);
                emitter.complete();
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        });
        
        return emitter;
    }
    
    private void streamResponse(String message, SseEmitter emitter) {
        // Реализация streaming'а - об этом дальше
    }
}
DTO-классы для запроса и ответа:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatRequest {
    private String message;
    private String sessionId;  // Для multi-user поддержки
}
 
@Data
@AllArgsConstructor
public class ChatResponse {
    private String response;
    private long timestamp = System.currentTimeMillis();
}
Первый же тест показал проблему: каждый запрос генерировал новый embedding для поиска, а это 50-80 мс лишнего времени. Добавил простейший кеш через Caffeine:

Java
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class CacheConfig {
    
    @Bean
    public Cache<String, Embedding> embeddingCache() {
        return Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofHours(1))
            .build();
    }
}
И обернул retrieval сервис:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private final Cache<String, Embedding> embeddingCache;
 
public List<Content> retrieve(String query) {
    Embedding queryEmbedding = embeddingCache.get(query, 
        key -> embeddingModel.embed(key).content()
    );
    
    List<EmbeddingMatch<TextSegment>> matches = embeddingStore.findRelevant(
        queryEmbedding, 5, 0.75
    );
    
    return matches.stream()
        .map(match -> Content.from(match.embedded()))
        .collect(Collectors.toList());
}
Latency упал с 200 до 120 мс для повторяющихся или похожих запросов. В логах я видел, что пользователи часто перефразируют один и тот же вопрос - кеш отработал отлично.

Работа с контекстом и памятью чатбота



MessageWindowChatMemory хранит историю в памяти процесса. Для одного пользователя это работает, но стоило запустить нагрузочный тест с concurrent запросами - всё посыпалось. Память одного пользователя мешалась с памятью другого.
Решение - сделать память per-session. Добавил простой ConcurrentHashMap:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class SessionAwareChatMemoryProvider {
    
    private final Map<String, ChatMemory> sessionMemories = new ConcurrentHashMap<>();
    private final int maxMessages;
    
    public SessionAwareChatMemoryProvider(@Value("${chat.memory-window}") int maxMessages) {
        this.maxMessages = maxMessages;
    }
    
    public ChatMemory getMemory(String sessionId) {
        return sessionMemories.computeIfAbsent(
            sessionId, 
            id -> MessageWindowChatMemory.withMaxMessages(maxMessages)
        );
    }
    
    public void clearMemory(String sessionId) {
        sessionMemories.remove(sessionId);
    }
    
    @Scheduled(fixedRate = 3600000)  // Каждый час
    public void cleanupInactiveSessions() {
        // Удаляем старые сессии без активности
        // В реальности нужно хранить timestamp последнего обращения
    }
}
Модифицировал сервис:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String chat(String userMessage, String sessionId) {
    ChatMemory memory = memoryProvider.getMemory(sessionId);
    
    List<Content> relevantDocs = retrievalService.retrieve(userMessage);
    String context = buildContext(relevantDocs);
    String augmentedPrompt = buildPrompt(context, userMessage);
    
    memory.add(UserMessage.from(augmentedPrompt));
    
    Response<AiMessage> response = chatModel.generate(memory.messages());
    memory.add(response.content());
    
    return response.content().text();
}
Теперь каждый пользователь получает изолированный контекст диалога. Правда, при рестарте приложения вся история терялась - memory in-memory же. В следующей версии я прикрутил персистентность в MongoDB, но это уже другая история. Проблема с управлением токенами обнаружилась на второй день в проде. Пользователь завёл длинный диалог про настройку Kubernetes, память чатбота распухла до 20 сообщений, и следующий запрос вернул 400-ку от OpenAI: "context length exceeded". Оказалось, я превысил лимит в 8192 токена для gpt-4-turbo-preview.

Считать токены в Java не так тривиально, как в Python. Там есть tiktoken, тут пришлось искать альтернативы. Нашёл библиотеку jtokkit:

XML
1
2
3
4
5
<dependency>
  <groupId>com.knuddels</groupId>
  <artifactId>jtokkit</artifactId>
  <version>0.6.1</version>
</dependency>
Добавил подсчёт токенов перед отправкой:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Service
public class TokenAwareChatService {
  
  private final Encoding encoding;
  private final int maxContextTokens = 6000;  // Оставляем запас
  
  public TokenAwareChatService() {
      EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
      this.encoding = registry.getEncoding(EncodingType.CL100K_BASE);
  }
  
  public String chat(String userMessage, String sessionId) {
      ChatMemory memory = memoryProvider.getMemory(sessionId);
      
      // Формируем промпт
      List<Content> docs = retrievalService.retrieve(userMessage);
      String context = buildContext(docs);
      String prompt = buildPrompt(context, userMessage);
      
      // Проверяем текущий размер контекста
      int currentTokens = countTokens(memory.messages());
      int newMessageTokens = countTokens(prompt);
      
      // Если превышаем лимит - обрезаем историю
      if (currentTokens + newMessageTokens > maxContextTokens) {
          trimMemory(memory, maxContextTokens - newMessageTokens);
      }
      
      memory.add(UserMessage.from(prompt));
      
      Response<AiMessage> response = chatModel.generate(memory.messages());
      memory.add(response.content());
      
      return response.content().text();
  }
  
  private int countTokens(List<ChatMessage> messages) {
      int total = 0;
      for (ChatMessage msg : messages) {
          total += encoding.countTokens(msg.text());
      }
      return total;
  }
  
  private void trimMemory(ChatMemory memory, int targetTokens) {
      List<ChatMessage> messages = new ArrayList<>(memory.messages());
      
      // Всегда сохраняем системное сообщение
      ChatMessage systemMsg = messages.get(0);
      messages.remove(0);
      
      // Удаляем старые сообщения, пока не влезем в лимит
      while (countTokens(messages) > targetTokens && messages.size() > 2) {
          messages.remove(0);  // Удаляем самое старое
          messages.remove(0);  // И его ответ
      }
      
      // Пересоздаём память
      memory.clear();
      memory.add(systemMsg);
      messages.forEach(memory::add);
  }
}
Такой подход работал, но терял контекст старых частей диалога. Пользователь мог сослаться на что-то, сказанное 10 сообщений назад, а чатбот уже забыл. Решил попробовать суммаризацию - периодически сворачивать старую часть истории в краткое резюме:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private String summarizeOldMessages(List<ChatMessage> messages) {
    if (messages.size() < 6) {
        return null;  // Недостаточно истории для суммаризации
    }
    
    // Берём первые N сообщений (кроме системного)
    List<ChatMessage> toSummarize = messages.subList(1, Math.min(6, messages.size()));
    
    String dialogText = toSummarize.stream()
        .map(msg -> {
            String role = msg instanceof UserMessage ? "User" : "Assistant";
            return role + ": " + msg.text();
        })
        .collect(Collectors.joining("\n"));
    
    String summaryPrompt = String.format("""
        Создай краткое резюме следующего диалога, сохранив ключевые факты и контекст:
        
        %s
        
        Резюме должно быть компактным, но информативным.
        """, dialogText);
    
    Response<AiMessage> summary = chatModel.generate(summaryPrompt);
    return summary.content().text();
}
Теперь при достижении лимита токенов я сначала суммирую старую часть истории, добавляю резюме как system message, и только потом обрезаю остальное. Получилось что-то вроде скользящего окна с компрессией.

Потоковая передача ответов и управление токенами



Streaming оказался нетривиальной задачей в Spring. Первая попытка через StreamingResponseHandler работала, но блокировала поток на всё время генерации. Переписал на Server-Sent Events:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@PostMapping("/stream")
public SseEmitter chatStream(@RequestBody ChatRequest request) {
    SseEmitter emitter = new SseEmitter(120_000L);
    String sessionId = request.getSessionId();
    
    executorService.execute(() -> {
        try {
            ChatMemory memory = memoryProvider.getMemory(sessionId);
            
            // Получаем контекст
            List<Content> docs = retrievalService.retrieve(request.getMessage());
            String context = buildContext(docs);
            String prompt = buildPrompt(context, request.getMessage());
            
            memory.add(UserMessage.from(prompt));
            
            // Стримим ответ токен за токеном
            StringBuilder fullResponse = new StringBuilder();
            
            chatModel.generate(memory.messages(), new StreamingResponseHandler<AiMessage>() {
                @Override
                public void onNext(String token) {
                    try {
                        fullResponse.append(token);
                        emitter.send(SseEmitter.event()
                            .data(token)
                            .name("token"));
                    } catch (IOException e) {
                        emitter.completeWithError(e);
                    }
                }
                
                @Override
                public void onComplete(Response<AiMessage> response) {
                    memory.add(response.content());
                    emitter.complete();
                }
                
                @Override
                public void onError(Throwable error) {
                    logger.error("Streaming error", error);
                    emitter.completeWithError(error);
                }
            });
            
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    
    return emitter;
}
На фронте это выглядело живее - текст появлялся постепенно, пользователь видел, что система работает. Но появилась другая проблема: если коннект обрывался на середине ответа, память чатбота оказывалась в inconsistent состоянии - user message есть, а AI response нет. Пришлось добавлять обработку отмены:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
emitter.onTimeout(() -> {
    logger.warn("SSE timeout for session: {}", sessionId);
    cleanupIncompleteResponse(sessionId);
});
 
emitter.onCompletion(() -> {
    logger.debug("SSE completed for session: {}", sessionId);
});
 
private void cleanupIncompleteResponse(String sessionId) {
    ChatMemory memory = memoryProvider.getMemory(sessionId);
    List<ChatMessage> messages = new ArrayList<>(memory.messages());
    
    // Если последнее сообщение - от пользователя, и нет ответа - удаляем
    if (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) {
        messages.remove(messages.size() - 1);
        memory.clear();
        messages.forEach(memory::add);
    }
}
Rate limiting для API запросов я реализовал через Resilience4j:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
public class RateLimiterConfig {
    
    @Bean
    public RateLimiter openAiRateLimiter() {
        RateLimiterConfig config = RateLimiterConfig.custom()
            .limitForPeriod(50)
            .limitRefreshPeriod(Duration.ofMinutes(1))
            .timeoutDuration(Duration.ofSeconds(5))
            .build();
        
        return RateLimiter.of("openai", config);
    }
}
 
// В сервисе
@Autowired
private RateLimiter rateLimiter;
 
public String chat(String message, String sessionId) {
    return Try.ofSupplier(
        RateLimiter.decorateSupplier(rateLimiter, () -> 
            chatInternal(message, sessionId)
        )
    ).getOrElseThrow(ex -> 
        new RateLimitExceededException("Too many requests", ex)
    );
}
OpenAI имеет лимиты и по количеству запросов в минуту (RPM), и по токенам в минуту (TPM). Для tier 1 это 500 RPM и 200K TPM. Мой rate limiter покрывал только RPM, для TPM пришлось бы писать более хитрую логику с отслеживанием реального потребления токенов.
Последний штрих - graceful degradation при недоступности OpenAI. Добавил fallback на кешированные ответы для частых вопросов и Circuit Breaker:

Java
1
2
3
4
5
6
7
8
9
10
@Bean
public CircuitBreaker chatCircuitBreaker() {
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        .failureRateThreshold(50)
        .waitDurationInOpenState(Duration.ofSeconds(30))
        .slidingWindowSize(10)
        .build();
    
    return CircuitBreaker.of("chat", config);
}
Когда OpenAI недоступен, circuit breaker размыкается, и последующие запросы фейлятся fast без реальных вызовов API. Это спасло нас однажды, когда у OpenAI случился downtime - вместо 60-секундных таймаутов пользователи получали мгновенные ошибки с предложением попробовать позже.

Продвинутые сценарии использования



Нажмите на изображение для увеличения
Название: AI-чатбот на Java с Langchain4j и MongoDB Atlas 3.jpg
Просмотров: 46
Размер:	107.2 Кб
ID:	11263

Базовый чатбот работал, но хотелось большего. Помню, как на очередном созвоне продакт попросил добавить поиск по авторам документов и датам создания. "Легко", - подумал я. Оказалось, что комбинирование векторного поиска с обычными фильтрами требует нетривиальных трюков.

Мультимодальность: работа с текстом и метаданными



Первая задача - научить систему учитывать не только семантическую близость, но и структурированные атрибуты. Пользователь спрашивает: "Что писал Петров про микросервисы в прошлом месяце?" Тут нужно и векторный поиск по "микросервисам", и фильтр по автору, и временной диапазон.
MongoDB позволяет комбинировать $vectorSearch с обычными $match стадиями в aggregation pipeline. Я модифицировал retrieval сервис:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public List<Content> retrieveWithFilters(
        String query, 
        Map<String, Object> filters) {
    
    Embedding queryEmbedding = embeddingModel.embed(query).content();
    List<Double> vectorList = Arrays.stream(queryEmbedding.vector())
        .boxed()
        .collect(Collectors.toList());
    
    List<Bson> pipeline = new ArrayList<>();
    
    // Векторный поиск
    Document vectorSearch = new Document("$vectorSearch",
        new Document("index", "vector_index")
            .append("path", "embedding")
            .append("queryVector", vectorList)
            .append("numCandidates", 100)
            .append("limit", 20));  // Берём больше кандидатов
    
    pipeline.add(vectorSearch);
    
    // Добавляем фильтры по метаданным
    if (!filters.isEmpty()) {
        Document matchStage = new Document();
        
        if (filters.containsKey("author")) {
            matchStage.append("metadata.author", filters.get("author"));
        }
        
        if (filters.containsKey("dateFrom")) {
            matchStage.append("metadata.created_at", 
                new Document("$gte", filters.get("dateFrom")));
        }
        
        if (filters.containsKey("tags")) {
            @SuppressWarnings("unchecked")
            List<String> tags = (List<String>) filters.get("tags");
            matchStage.append("metadata.tags", 
                new Document("$in", tags));
        }
        
        pipeline.add(new Document("$match", matchStage));
    }
    
    // Ограничиваем финальный результат
    pipeline.add(new Document("$limit", 5));
    
    MongoCollection<Document> collection = mongoClient
        .getDatabase(databaseName)
        .getCollection(collectionName);
    
    List<Content> results = new ArrayList<>();
    collection.aggregate(pipeline).forEach(doc -> {
        String text = doc.getString("content");
        Document metadata = doc.get("metadata", Document.class);
        results.add(Content.from(text, metadata));
    });
    
    return results;
}
Порядок стадий в pipeline критичен. Сначала векторный поиск отбирает семантически близкие документы, потом фильтры сужают выборку. Если поменять местами - фильтры применятся ко всей коллекции, что гораздо медленнее.
Проблема обнаружилась, когда фильтры были слишком жёсткими. Векторный поиск находил 20 релевантных документов, но после фильтрации оставалось 0-1. Пришлось делать адаптивную стратегию:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public List<Content> retrieveWithAdaptiveFilters(
        String query,
        Map<String, Object> strictFilters,
        Map<String, Object> softFilters) {
    
    // Пробуем со строгими фильтрами
    List<Content> results = retrieveWithFilters(query, strictFilters);
    
    if (results.size() >= 3) {
        return results;
    }
    
    // Если мало результатов - смягчаем фильтры
    logger.debug("Недостаточно результатов с строгими фильтрами, применяем мягкие");
    
    Map<String, Object> relaxedFilters = new HashMap<>(softFilters);
    results = retrieveWithFilters(query, relaxedFilters);
    
    if (results.size() >= 3) {
        return results;
    }
    
    // В крайнем случае - только векторный поиск
    logger.debug("Поиск без фильтров метаданных");
    return retrieveWithFilters(query, Collections.emptyMap());
}
Пользователь может не знать точное имя автора или ошибиться в дате. Система сначала пытается найти точные совпадения, но если ничего нет - ослабляет критерии. Это улучшило user experience на порядок.

RAG-подход для работы с документами



RAG (Retrieval-Augmented Generation) - это когда модель не просто генерирует ответ из своих параметров, а сначала достаёт актуальную информацию из внешнего источника. Я уже использовал базовый RAG, но хотелось добавить продвинутые техники.

Первая - переранжирование результатов. Векторный поиск иногда даёт ложноположительные совпадения. Документ может содержать нужные слова, но в неправильном контексте. Например, запрос "как обрабатывать исключения в Spring" может найти документ про "исключения из правил конфигурации Spring". Решение - второй проход с cross-encoder моделью. Она оценивает релевантность пары "запрос-документ" точнее, чем косинусное сходство:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Service
public class RerankingService {
    
    private final ChatLanguageModel model;
    
    public List<Content> rerank(String query, List<Content> candidates) {
        if (candidates.size() <= 3) {
            return candidates;  // Не тратим время на малое количество
        }
        
        List<ScoredContent> scored = new ArrayList<>();
        
        for (Content content : candidates) {
            double score = calculateRelevanceScore(query, content.text());
            scored.add(new ScoredContent(content, score));
        }
        
        // Сортируем по убыванию релевантности
        scored.sort(Comparator.comparingDouble(
            ScoredContent::score).reversed());
        
        return scored.stream()
            .limit(5)
            .map(ScoredContent::content)
            .collect(Collectors.toList());
    }
    
    private double calculateRelevanceScore(String query, String document) {
        String prompt = String.format("""
            Оцени релевантность документа для данного запроса.
            Верни только число от 0 до 1, где 0 - совершенно нерелевантен, 1 - идеально подходит.
            
            Запрос: %s
            
            Документ: %s
            
            Оценка:
            """, query, document.substring(0, Math.min(500, document.length())));
        
        String response = model.generate(prompt);
        
        try {
            return Double.parseDouble(response.trim());
        } catch (NumberFormatException e) {
            logger.warn("Не удалось распарсить оценку: {}", response);
            return 0.5;  // Средняя оценка по умолчанию
        }
    }
    
    private record ScoredContent(Content content, double score) {}
}
Это добавляет задержку - каждый вызов LLM для оценки занимает 200-300 мс. Но точность поиска выросла ощутимо. В одном A/B тесте пользователи оценили релевантность результатов на 23% выше после внедрения reranking.
Вторая техника - гибридный поиск. Комбинирую векторный и классический полнотекстовый поиск, а результаты объединяю через weighted fusion:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public List<Content> hybridSearch(String query) {
    // Векторный поиск
    List<ScoredContent> vectorResults = vectorSearch(query);
    
    // Полнотекстовый поиск MongoDB
    List<ScoredContent> textResults = textSearch(query);
    
    // Объединяем с весами
    Map<String, Double> combinedScores = new HashMap<>();
    
    double vectorWeight = 0.7;
    double textWeight = 0.3;
    
    for (ScoredContent result : vectorResults) {
        String id = result.content().id();
        combinedScores.put(id, 
            combinedScores.getOrDefault(id, 0.0) + result.score() * vectorWeight);
    }
    
    for (ScoredContent result : textResults) {
        String id = result.content().id();
        combinedScores.put(id,
            combinedScores.getOrDefault(id, 0.0) + result.score() * textWeight);
    }
    
    // Сортируем по комбинированному скору
    return combinedScores.entrySet().stream()
        .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
        .limit(5)
        .map(entry -> getContentById(entry.getKey()))
        .collect(Collectors.toList());
}
 
private List<ScoredContent> textSearch(String query) {
    MongoCollection<Document> collection = mongoClient
        .getDatabase(databaseName)
        .getCollection(collectionName);
    
    Bson textSearchStage = Aggregates.match(
        Filters.text(query)
    );
    
    List<ScoredContent> results = new ArrayList<>();
    collection.aggregate(Arrays.asList(
        textSearchStage,
        Aggregates.limit(10)
    )).forEach(doc -> {
        String text = doc.getString("content");
        Double score = doc.getDouble("score");
        results.add(new ScoredContent(Content.from(text), score));
    });
    
    return results;
}
Веса подобраны эмпирически. Для технической документации с точными терминами работает 70/30 в пользу векторного поиска. Для более общих текстов можно делать 50/50 или даже инвертировать.

Третья фишка - контекстное дополнение. Если найденный фрагмент слишком короткий или вырван из контекста, я достаю соседние чанки того же документа:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private List<Content> expandContext(Content content, int windowSize) {
    Document metadata = content.metadata();
    String articleId = metadata.getString("article_id");
    int chunkIndex = metadata.getInteger("chunk_index");
    
    // Достаём соседние чанки
    MongoCollection<Document> collection = mongoClient
        .getDatabase(databaseName)
        .getCollection(collectionName);
    
    List<Content> expanded = new ArrayList<>();
    
    for (int i = chunkIndex - windowSize; i <= chunkIndex + windowSize; i++) {
        if (i < 0) continue;
        
        Document chunk = collection.find(
            Filters.and(
                Filters.eq("metadata.article_id", articleId),
                Filters.eq("metadata.chunk_index", i)
            )
        ).first();
        
        if (chunk != null) {
            expanded.add(Content.from(chunk.getString("content")));
        }
    }
    
    return expanded;
}
Это особенно полезно для кода. Если пользователь ищет конкретный метод, векторный поиск может найти только его сигнатуру. А развернув контекст, модель увидит и реализацию, и комментарии, и использование.

Обработка embeddings и семантический поиск



Embedding модели имеют ограничения на входной текст. text-embedding-3-small принимает до 8191 токена, но это не значит, что стоит скармливать ей целые статьи. Качество embedding'а деградирует с ростом размера текста - информация "размазывается". Я экспериментировал с разными стратегиями chunking. Naive подход - резать по фиксированному количеству символов:

Java
1
2
3
4
5
6
7
public List<String> chunkByLength(String text, int chunkSize) {
    List<String> chunks = new ArrayList<>();
    for (int i = 0; i < text.length(); i += chunkSize) {
        chunks.add(text.substring(i, Math.min(i + chunkSize, text.length())));
    }
    return chunks;
}
Проблема - разрезает на середине предложений, иногда даже слов. Лучше работает semantic chunking - резать по смысловым блокам:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public List<String> semanticChunk(String text) {
    // Разбиваем на абзацы
    String[] paragraphs = text.split("
 
+");
    
    List<String> chunks = new ArrayList<>();
    StringBuilder currentChunk = new StringBuilder();
    int currentTokens = 0;
    
    for (String para : paragraphs) {
        int paraTokens = tokenizer.countTokens(para);
        
        if (currentTokens + paraTokens > 800) {
            // Текущий чанк заполнен
            if (currentChunk.length() > 0) {
                chunks.add(currentChunk.toString());
                currentChunk = new StringBuilder();
                currentTokens = 0;
            }
        }
        
        currentChunk.append(para).append("
 
");
        currentTokens += paraTokens;
    }
    
    if (currentChunk.length() > 0) {
        chunks.add(currentChunk.toString());
    }
    
    return chunks;
}
Это сохраняет целостность абзацев. Для кода я использую другую логику - режу по границам функций или классов, чтобы не разделять связанные блоки.
Overlap между чанками тоже важен. Без него теряется контекст на границах. Я добавил sliding window:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public List<String> chunkWithOverlap(String text, int size, int overlap) {
    List<String> sentences = splitIntoSentences(text);
    List<String> chunks = new ArrayList<>();
    
    int start = 0;
    while (start < sentences.size()) {
        int end = Math.min(start + size, sentences.size());
        
        String chunk = String.join(" ", sentences.subList(start, end));
        chunks.add(chunk);
        
        start += (size - overlap);
    }
    
    return chunks;
}
Overlap в 20-30% работает хорошо для большинства текстов. Меньше - теряется контекст, больше - раздувается объём данных без особой пользы.

Кеширование embeddings критично для производительности. Генерация embedding для одного текста через OpenAI занимает 50-100 мс. Для коллекции в 10K документов это 10-20 минут чистого времени, плюс стоимость API запросов. Я кеширую на трёх уровнях:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Service  
public class CachedEmbeddingService {
    
    private final LoadingCache<String, Embedding> memoryCache;
    private final RedisTemplate<String, byte[]> redisCache;
    private final EmbeddingModel model;
    
    public CachedEmbeddingService(EmbeddingModel model) {
        this.model = model;
        
        this.memoryCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofHours(1))
            .build(this::generateEmbedding);
    }
    
    public Embedding getEmbedding(String text) {
        // Уровень 1: память процесса
        try {
            return memoryCache.get(text);
        } catch (Exception e) {
            logger.error("Cache error", e);
            return generateEmbedding(text);
        }
    }
    
    private Embedding generateEmbedding(String text) {
        // Уровень 2: Redis
        String cacheKey = "emb:" + hashText(text);
        byte[] cached = redisCache.opsForValue().get(cacheKey);
        
        if (cached != null) {
            return deserializeEmbedding(cached);
        }
        
        // Уровень 3: реальная генерация
        Embedding embedding = model.embed(text).content();
        
        // Сохраняем в Redis
        redisCache.opsForValue().set(
            cacheKey, 
            serializeEmbedding(embedding),
            Duration.ofDays(7)
        );
        
        return embedding;
    }
    
    private String hashText(String text) {
        return DigestUtils.sha256Hex(text);
    }
}
Три уровня дают hit rate около 85-90% для типичной нагрузки. Первый уровень (Caffeine) отрабатывает за микросекунды, второй (Redis) - за 1-2 мс, третий (OpenAI API) - за 50-100 мс.
Последняя оптимизация - батчинг embedding запросов. OpenAI позволяет отправлять до 2048 текстов за раз:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Map<String, Embedding> batchEmbed(List<String> texts) {
    Map<String, Embedding> results = new HashMap<>();
    
    // Разбиваем на батчи по 100 (безопасный лимит)
    List<List<String>> batches = Lists.partition(texts, 100);
    
    for (List<String> batch : batches) {
        Response<List<Embedding>> response = model.embedAll(batch);
        
        for (int i = 0; i < batch.size(); i++) {
            results.put(batch.get(i), response.content().get(i));
        }
    }
    
    return results;
}
Это ускоряет массовую загрузку документов в 5-10 раз по сравнению с последовательными запросами. Правда, надо следить за rate limits - легко превысить лимит токенов в минуту при больших батчах.

Оптимизация производительности запросов



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

Первое открытие - размер numCandidates напрямую влияет на latency. Я использовал 150 кандидатов для получения топ-10 результатов, что казалось разумным. Но эксперименты показали нелинейную зависимость: увеличение до 200 добавляло всего 10-15 мс, зато заметно улучшало precision. А вот при 300 кандидатах время росло экспоненциально.

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public List<Content> optimizedVectorSearch(String query, int topK) {
    Embedding queryEmbedding = embeddingCache.get(query,
        key -> embeddingModel.embed(key).content()
    );
    
    // Эмпирическое правило: numCandidates = topK * 15
    int numCandidates = Math.min(topK * 15, 200);
    
    Document vectorSearchStage = new Document("$vectorSearch",
        new Document("index", "vector_index")
            .append("path", "embedding")
            .append("queryVector", queryEmbedding.vectorAsList())
            .append("numCandidates", numCandidates)
            .append("limit", topK)
    );
    
    // Добавляем проекцию - не тянем лишние поля
    Document projectStage = new Document("$project",
        new Document("content", 1)
            .append("metadata", 1)
            .append("score", new Document("$meta", "vectorSearchScore"))
    );
    
    List<Bson> pipeline = Arrays.asList(
        vectorSearchStage,
        projectStage
    );
    
    long startTime = System.nanoTime();
    List<Content> results = executeSearch(pipeline);
    long duration = (System.nanoTime() - startTime) / 1_000_000;
    
    metrics.recordSearchLatency(duration);
    
    return results;
}
Метрики показали среднюю latency около 95 мс после оптимизации, против 180 мс до. Но это был ещё не предел.
Вторая проблема - cold start эффект. Первый запрос после простоя мог занимать до 2 секунд, пока MongoDB прогревал индексы в памяти. Решение - warmup job при старте приложения:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class VectorIndexWarmer {
    
    private final DocumentRetrievalService retrievalService;
    
    @EventListener(ApplicationReadyEvent.class)
    public void warmupIndex() {
        logger.info("Прогреваем векторный индекс...");
        
        List<String> warmupQueries = Arrays.asList(
            "Java Spring Boot",
            "MongoDB настройка",
            "микросервисы архитектура",
            "REST API разработка",
            "Docker контейнеризация"
        );
        
        warmupQueries.parallelStream().forEach(query -> {
            try {
                retrievalService.retrieve(query);
            } catch (Exception e) {
                logger.warn("Warmup failed for: {}", query, e);
            }
        });
        
        logger.info("Индекс прогрет");
    }
}
После прогрева даже первые запросы отрабатывали за 100-120 мс. Небольшая задержка на старте приложения окупается стабильной производительностью.

Третий момент - параллелизация при RAG. Изначально я последовательно: сначала поиск документов, потом формирование промпта, потом вызов LLM. Но поиск и embedding генерация независимы - можно делать concurrent:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public String chatWithParallelRetrieval(String message, String sessionId) {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    
    try {
        // Параллельно: поиск документов и генерация query embedding
        Future<List<Content>> documentsFuture = executor.submit(() -> 
            retrievalService.retrieve(message)
        );
        
        Future<String> contextFuture = executor.submit(() ->
            buildAdditionalContext(sessionId)
        );
        
        List<Content> docs = documentsFuture.get(5, TimeUnit.SECONDS);
        String additionalContext = contextFuture.get(2, TimeUnit.SECONDS);
        
        String prompt = buildPrompt(docs, additionalContext, message);
        
        ChatMemory memory = memoryProvider.getMemory(sessionId);
        memory.add(UserMessage.from(prompt));
        
        Response<AiMessage> response = chatModel.generate(memory.messages());
        memory.add(response.content());
        
        return response.content().text();
        
    } catch (TimeoutException e) {
        logger.error("Retrieval timeout", e);
        throw new ServiceException("Search took too long");
    } catch (Exception e) {
        logger.error("Parallel retrieval failed", e);
        throw new ServiceException("Internal error");
    } finally {
        executor.shutdown();
    }
}
Экономия составила 30-50 мс на типичном запросе - не космос, но каждая миллисекунда на счету для интерактивности.
Мониторинг производительности я организовал через Micrometer и Prometheus:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Component
public class SearchMetrics {
    
    private final MeterRegistry registry;
    private final Timer searchTimer;
    private final Counter cacheHitCounter;
    private final Counter cacheMissCounter;
    
    public SearchMetrics(MeterRegistry registry) {
        this.registry = registry;
        this.searchTimer = Timer.builder("vector.search.duration")
            .description("Время выполнения векторного поиска")
            .register(registry);
        
        this.cacheHitCounter = Counter.builder("embedding.cache.hits")
            .description("Попадания в кеш эмбеддингов")
            .register(registry);
        
        this.cacheMissCounter = Counter.builder("embedding.cache.misses")
            .description("Промахи кеша эмбеддингов")
            .register(registry);
    }
    
    public void recordSearchLatency(long milliseconds) {
        searchTimer.record(milliseconds, TimeUnit.MILLISECONDS);
    }
    
    public void recordCacheHit() {
        cacheHitCounter.increment();
    }
    
    public void recordCacheMiss() {
        cacheMissCounter.increment();
    }
}
Grafana дашборд с этими метриками спас меня не раз - сразу видно, если latency растёт или cache hit rate падает. Однажды заметил, что hit rate упал с 85% до 40% за ночь. Оказалось, кто-то из разработчиков поменял хеш-функцию для ключей кеша, и все старые записи стали недоступны.

Последняя оптимизация - использование projection в MongoDB запросах. Не нужно тянуть весь документ с 1536-мерным вектором, если для ответа нужен только текст:

Java
1
2
3
4
5
6
7
Document projectStage = new Document("$project",
    new Document("content", 1)
        .append("metadata.author", 1)
        .append("metadata.title", 1)
        .append("_id", 0)
        .append("embedding", 0)  // Явно исключаем вектор
);
Размер передаваемых данных уменьшился на 60-70%, что ощутимо сказалось на network latency в распределённой инфраструктуре.

Пришло время собрать всё в единое приложение. Я потратил выходные на рефакторинг разрозненных компонентов в законченную систему, которую можно развернуть и использовать. Получился проект со структурой, которую не стыдно показать на code review.

Структура проекта



Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
chatbot-mongodb/
├── src/main/java/com/example/chatbot/
│   ├── config/
│   │   ├── MongoConfig.java
│   │   ├── OpenAiConfig.java
│   │   └── CacheConfig.java
│   ├── controller/
│   │   └── ChatController.java
│   ├── service/
│   │   ├── ChatService.java
│   │   ├── DocumentIngestionService.java
│   │   ├── EmbeddingService.java
│   │   └── RetrievalService.java
│   ├── repository/
│   │   └── VectorRepository.java
│   ├── model/
│   │   ├── ChatMessage.java
│   │   ├── Document.java
│   │   └── SearchResult.java
│   └── ChatbotApplication.java
├── src/main/resources/
│   ├── application.yml
│   └── prompts/
│       └── system-message.txt
└── pom.xml
Конфигурация MongoDB вынесена в отдельный класс с детальной настройкой connection pool и timeout'ов:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
public class MongoConfig {
    
    @Value("${mongodb.uri}")
    private String uri;
    
    @Value("${mongodb.database}")
    private String database;
    
    @Bean
    public MongoClient mongoClient() {
        MongoClientSettings settings = MongoClientSettings.builder()
            .applyConnectionString(new ConnectionString(uri))
            .applyToConnectionPoolSettings(builder -> 
                builder.maxSize(20)
                    .minSize(5)
                    .maxWaitTime(2, TimeUnit.SECONDS)
                    .maxConnectionIdleTime(30, TimeUnit.SECONDS)
            )
            .applyToSocketSettings(builder ->
                builder.connectTimeout(5, TimeUnit.SECONDS)
                    .readTimeout(10, TimeUnit.SECONDS)
            )
            .retryWrites(true)
            .build();
        
        return MongoClients.create(settings);
    }
    
    @Bean
    public MongoDatabase database(MongoClient client) {
        return client.getDatabase(database);
    }
}
Эти настройки спасли от таймаутов под нагрузкой. Изначально я использовал дефолтный connection pool размером 100, что оказалось избыточным и приводило к deadlock'ам при пиковой нагрузке.

Сервис загрузки документов



DocumentIngestionService отвечает за индексацию новых документов. Он разбивает текст на чанки, генерирует embeddings и сохраняет в MongoDB:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@Service
public class DocumentIngestionService {
    
    private final EmbeddingService embeddingService;
    private final VectorRepository repository;
    private final Tokenizer tokenizer;
    
    private static final int CHUNK_SIZE = 800;
    private static final int OVERLAP = 100;
    
    public void ingestDocument(String title, String content, Map<String, Object> metadata) {
        List<String> chunks = chunkDocument(content);
        
        for (int i = 0; i < chunks.size(); i++) {
            String chunk = chunks.get(i);
            
            Embedding embedding = embeddingService.generateEmbedding(chunk);
            
            Map<String, Object> chunkMetadata = new HashMap<>(metadata);
            chunkMetadata.put("title", title);
            chunkMetadata.put("chunk_index", i);
            chunkMetadata.put("total_chunks", chunks.size());
            chunkMetadata.put("indexed_at", LocalDateTime.now());
            
            repository.save(chunk, embedding, chunkMetadata);
        }
        
        logger.info("Проиндексирован документ '{}': {} чанков", title, chunks.size());
    }
    
    private List<String> chunkDocument(String text) {
        String[] paragraphs = text.split("
 
+");
        List<String> chunks = new ArrayList<>();
        
        StringBuilder currentChunk = new StringBuilder();
        int currentTokens = 0;
        
        for (String paragraph : paragraphs) {
            int paraTokens = tokenizer.count(paragraph);
            
            if (currentTokens + paraTokens > CHUNK_SIZE && currentChunk.length() > 0) {
                chunks.add(currentChunk.toString().trim());
                
                // Добавляем overlap из последних предложений
                String overlap = extractOverlap(currentChunk.toString(), OVERLAP);
                currentChunk = new StringBuilder(overlap);
                currentTokens = tokenizer.count(overlap);
            }
            
            currentChunk.append(paragraph).append("
 
");
            currentTokens += paraTokens;
        }
        
        if (currentChunk.length() > 0) {
            chunks.add(currentChunk.toString().trim());
        }
        
        return chunks;
    }
    
    private String extractOverlap(String text, int tokens) {
        String[] sentences = text.split("\\. ");
        StringBuilder overlap = new StringBuilder();
        int count = 0;
        
        for (int i = sentences.length - 1; i >= 0 && count < tokens; i--) {
            String sentence = sentences[i];
            int sentTokens = tokenizer.count(sentence);
            
            if (count + sentTokens <= tokens) {
                overlap.insert(0, sentence + ". ");
                count += sentTokens;
            } else {
                break;
            }
        }
        
        return overlap.toString();
    }
}
Метод extractOverlap гарантирует, что между чанками сохраняется контекст полными предложениями, а не обрывками фраз.

Основной сервис чатбота



ChatService координирует работу всех компонентов - поиск, формирование промпта, вызов LLM:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@Service
public class ChatService {
    
    private final RetrievalService retrievalService;
    private final ChatLanguageModel chatModel;
    private final Map<String, ChatMemory> sessionMemories = new ConcurrentHashMap<>();
    
    @Value("${chat.max-context-tokens}")
    private int maxContextTokens;
    
    public ChatResponse chat(ChatRequest request) {
        String sessionId = request.getSessionId();
        ChatMemory memory = getOrCreateMemory(sessionId);
        
        // Поиск релевантных документов
        List<SearchResult> documents = retrievalService.search(
            request.getMessage(),
            5,
            0.75
        );
        
        // Формирование контекста
        String context = buildContext(documents);
        String fullPrompt = buildPrompt(context, request.getMessage());
        
        // Проверка лимита токенов
        ensureTokenLimit(memory, fullPrompt);
        
        // Генерация ответа
        memory.add(UserMessage.from(fullPrompt));
        
        Response<AiMessage> response = chatModel.generate(memory.messages());
        
        memory.add(response.content());
        
        return new ChatResponse(
            response.content().text(),
            documents.stream()
                .map(doc -> doc.getMetadata().get("title"))
                .collect(Collectors.toList())
        );
    }
    
    private ChatMemory getOrCreateMemory(String sessionId) {
        return sessionMemories.computeIfAbsent(
            sessionId,
            id -> MessageWindowChatMemory.withMaxMessages(10)
        );
    }
    
    private String buildContext(List<SearchResult> results) {
        if (results.isEmpty()) {
            return "Релевантной информации в базе знаний не найдено.";
        }
        
        StringBuilder context = new StringBuilder();
        context.append("Информация из базы знаний:
 
");
        
        for (int i = 0; i < results.size(); i++) {
            SearchResult result = results.get(i);
            context.append(String.format("Источник %d (%s):
", 
                i + 1, 
                result.getMetadata().get("title")));
            context.append(result.getContent());
            context.append("
 
---
 
");
        }
        
        return context.toString();
    }
    
    private String buildPrompt(String context, String question) {
        return String.format("""
            %s
            
            Вопрос: %s
            
            Ответь на вопрос, используя предоставленный контекст.
            Если контекста недостаточно - честно скажи об этом.
            При цитировании указывай номер источника.
            """, context, question);
    }
    
    private void ensureTokenLimit(ChatMemory memory, String newPrompt) {
        Tokenizer tokenizer = new OpenAiTokenizer();
        
        int currentTokens = memory.messages().stream()
            .mapToInt(msg -> tokenizer.count(msg.text()))
            .sum();
        
        int newTokens = tokenizer.count(newPrompt);
        
        while (currentTokens + newTokens > maxContextTokens && memory.messages().size() > 2) {
            memory.messages().remove(1); // Удаляем после system message
            memory.messages().remove(1); // Удаляем соответствующий ответ
            
            currentTokens = memory.messages().stream()
                .mapToInt(msg -> tokenizer.count(msg.text()))
                .sum();
        }
    }
    
    public void clearSession(String sessionId) {
        sessionMemories.remove(sessionId);
        logger.info("Очищена сессия: {}", sessionId);
    }
}
Метод ensureTokenLimit следит, чтобы мы не превысили лимит модели. Я использую 6000 токенов как безопасный максимум, оставляя запас для ответа модели.

REST API контроллер



Контроллер предоставляет три endpoint'а: обычный чат, streaming и очистка сессии:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RestController
@RequestMapping("/api/chat")
public class ChatController {
    
    private final ChatService chatService;
    private final ExecutorService executor = Executors.newCachedThreadPool();
    
    @PostMapping
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        try {
            ChatResponse response = chatService.chat(request);
            return ResponseEntity.ok(response);
        } catch (Exception e) {
            logger.error("Ошибка обработки запроса", e);
            return ResponseEntity.status(500)
                .body(new ChatResponse("Извините, произошла ошибка", List.of()));
        }
    }
    
    @PostMapping("/stream")
    public SseEmitter stream(@RequestBody ChatRequest request) {
        SseEmitter emitter = new SseEmitter(60_000L);
        
        executor.submit(() -> {
            try {
                chatService.chatStream(request, token -> {
                    try {
                        emitter.send(SseEmitter.event()
                            .name("token")
                            .data(token));
                    } catch (IOException e) {
                        emitter.completeWithError(e);
                    }
                });
                
                emitter.complete();
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        });
        
        return emitter;
    }
    
    @DeleteMapping("/session/{sessionId}")
    public ResponseEntity<Void> clearSession(@PathVariable String sessionId) {
        chatService.clearSession(sessionId);
        return ResponseEntity.noContent().build();
    }
}
Это минимальный, но функциональный API. В продакшене я бы добавил rate limiting, авторизацию и валидацию входных данных через Bean Validation.

Запуск и тестирование



Приложение запускается стандартно через Spring Boot:

Java
1
2
3
4
5
6
7
@SpringBootApplication
public class ChatbotApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(ChatbotApplication.class, args);
    }
}
Для тестирования я написал простой скрипт, который загружает тестовые документы и проверяет ответы:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@SpringBootTest
class ChatbotIntegrationTest {
    
    @Autowired
    private DocumentIngestionService ingestionService;
    
    @Autowired
    private ChatService chatService;
    
    @Test
    void testFullWorkflow() {
        // Загрузка тестового документа
        String content = """
            MongoDB Atlas - облачная платформа для работы с MongoDB.
            Она предоставляет автоматическое масштабирование, бэкапы и мониторинг.
            Векторный поиск появился в версии 6.0.11.
            """;
        
        Map<String, Object> metadata = Map.of(
            "author", "test",
            "category", "documentation"
        );
        
        ingestionService.ingestDocument("MongoDB Atlas Overview", content, metadata);
        
        // Даём время на индексацию
        Thread.sleep(2000);
        
        // Тестовый запрос
        ChatRequest request = new ChatRequest(
            "Что такое MongoDB Atlas?",
            "test-session"
        );
        
        ChatResponse response = chatService.chat(request);
        
        assertThat(response.getMessage()).contains("облачная платформа");
        assertThat(response.getSources()).isNotEmpty();
    }
}
Первый прогон показал проблему - индекс не успевал обновиться между загрузкой документа и поиском. Добавил принудительный flush в VectorRepository, проблема решилась.
Реальное развертывание показало несколько неожиданных моментов. Первый запуск на тестовом сервере упал с OutOfMemoryError - оказалось, что Caffeine cache без ограничений жадно сожрал всю heap memory. Пришлось добавить явные лимиты:

Java
1
2
3
4
5
6
7
8
9
10
11
@Bean
public Cache<String, Embedding> embeddingCache() {
    return Caffeine.newBuilder()
        .maximumSize(500)
        .maximumWeight(100_000_000)  // ~100MB
        .weigher((String key, Embedding value) -> 
            value.vector().length * 4)  // float = 4 байта
        .expireAfterAccess(Duration.ofHours(2))
        .recordStats()
        .build();
}
Weigher считает реальный размер embedding'а в памяти. Для 1536-мерного вектора это примерно 6KB на запись. При 500 записях получается ~3MB, что разумно.
Второй косяк вылез при concurrent нагрузке. Пользователи одновременно стартовали новые сессии, и ConcurrentHashMap для session memories начал расти бесконтрольно. Никто не чистил старые сессии. Добавил scheduled job:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Scheduled(fixedRate = 600000)  // Каждые 10 минут
public void cleanupStaleSessions() {
    long cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2);
    
    sessionMemories.entrySet().removeIf(entry -> {
        String sessionId = entry.getKey();
        long lastAccess = sessionLastAccess.getOrDefault(sessionId, 0L);
        
        if (lastAccess < cutoff) {
            logger.debug("Удалена неактивная сессия: {}", sessionId);
            return true;
        }
        return false;
    });
}
Теперь сессии без активности больше двух часов автоматически выпиливаются. В production стоило бы сохранять их в Redis для персистентности, но для демо версии и так сойдёт.
Третья проблема - rate limiting OpenAI API. При 50+ одновременных пользователях я регулярно получал 429-ю ошибку. Resilience4j спасал ситуацию retry'ями, но пользовательский опыт страдал. Решил добавить очередь запросов:

Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Component
public class RequestQueue {
    
    private final LinkedBlockingQueue<Callable<String>> queue = 
        new LinkedBlockingQueue<>(100);
    
    private final ExecutorService executor = Executors.newFixedThreadPool(5);
    private final Semaphore semaphore = new Semaphore(5);
    
    @PostConstruct
    public void start() {
        for (int i = 0; i < 5; i++) {
            executor.submit(this::processQueue);
        }
    }
    
    private void processQueue() {
        while (!Thread.interrupted()) {
            try {
                Callable<String> task = queue.take();
                semaphore.acquire();
                
                try {
                    task.call();
                } finally {
                    semaphore.release();
                }
                
                Thread.sleep(200);  // Дросселируем запросы
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                logger.error("Queue processing error", e);
            }
        }
    }
    
    public CompletableFuture<String> submit(Callable<String> task) {
        CompletableFuture<String> future = new CompletableFuture<>();
        
        queue.offer(() -> {
            try {
                String result = task.call();
                future.complete(result);
                return result;
            } catch (Exception e) {
                future.completeExceptionally(e);
                throw e;
            }
        });
        
        return future;
    }
}
Семафор ограничивает количество одновременных запросов к OpenAI до пяти, остальные ждут в очереди. Задержка в 200ms между запросами дополнительно снижает нагрузку. Это увеличило latency для некоторых пользователей, зато исчезли rate limit ошибки.
Мониторинг я прикрутил через actuator endpoints:

YAML
1
2
3
4
5
6
7
8
9
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true
Grafana dashboard с метриками выглядел примерно так:

Chat requests per second
Average response time
Cache hit rate
Active sessions count
OpenAI API errors
MongoDB query latency

Эти метрики помогли выявить узкие места. Например, я заметил, что cache hit rate падал по ночам до 20%. Оказалось, что scheduled cleanup чистил кеш слишком агрессивно. Увеличил TTL с одного часа до двух, и hit rate стабилизировался на 75-80%.
Финальный штрих - Docker контейнеризация. Написал простой Dockerfile:

Java
1
2
3
4
5
6
7
8
9
10
11
FROM openjdk:17-slim
 
WORKDIR /app
 
COPY target/chatbot-*.jar app.jar
 
EXPOSE 8080
 
ENV JAVA_OPTS="-Xmx512m -Xms256m"
 
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
И docker-compose для локального запуска со всеми зависимостями:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
version: '3.8'
 
services:
  mongodb:
    image: mongo:7.0
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password
    volumes:
      - mongo-data:/data/db
 
  chatbot:
    build: .
    ports:
      - "8080:8080"
    environment:
      MONGODB_URI: mongodb://admin:password@mongodb:27017
      OPENAI_API_KEY: ${OPENAI_API_KEY}
    depends_on:
      - mongodb
 
volumes:
  mongo-data:
Одна команда docker-compose up, и вся инфраструктура поднимается локально. Для продакшена я бы использовал MongoDB Atlas вместо контейнера, но для разработки это удобно.

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

JQuery + MongoDB
Хочу написать сайт с использованием монги. Для админки нужна легкость и быстрота в обращении....

MongoDB + YCSB под Win7
Всем доброго времени суток. Необходимо запустить тест YCSB...

Redis и MongoDb: есть ли существенная разница по производительности?
кто нибудь использовал Redis или MongoDb. Есть ли существенная разница по производительности? ...

Gwt+mongoDB
Добрый день,я думаю такой вопрос уже задавался,но увы ответа на него я не нашёл.По этой причине я и...

Связь "один ко многим" в MongoDB
Как реализовать связь один-ко-многим в mongodb, если можно на примере.

Закрытие соединения с mongodb
добрый день, как закрывать и закрывать ли соединение с базой? У метода close, в классе...

Объединение данных в MongoDB
Доброго времени суток уважаемые! Сегодня, познакомился с MongoDB 2.2-2.4, скачал единственную...

Частичная репликация MongoDB
Не разобрался в офдоках монги. Есть ли в ней частичная реплика, то есть чтобы не все таблицы...

New Enteros UpBeat MongoDB Performance management
Доброго времени суток! Представляю вашему вниманию еще один интересный союз, ниже представлена...

MongoDB + Spring
Всем доброго дня! не могу разобраться с вставкой значений в коллекции. Суть у меня две коллекции,...

Сохранить в mongodb результат curl
Всем привет. Ребята, проблема такая. Используя curl получаю страницу, и результат пишу в бд. На...

MongoDB. Выборка уникальных значений
Доброго времени суток, уважаемые! Второй день ломаю голову над вопросом. У нас есть база данных...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Музыка, написанная Искусственным Интеллектом
volvo 04.12.2025
Всем привет. Некоторое время назад меня заинтересовало, что уже умеет ИИ в плане написания музыки для песен, и, собственно, исполнения этих самых песен. Стихов у нас много, уже вышли 4 книги, еще 3. . .
От async/await к виртуальным потокам в Python
IndentationError 23.11.2025
Армин Ронахер поставил под сомнение async/ await. Создатель Flask заявляет: цветные функции - провал, виртуальные потоки - решение. Не threading-динозавры, а новое поколение лёгких потоков. Откат?. . .
Поиск "дружественных имён" СОМ портов
Argus19 22.11.2025
Поиск "дружественных имён" СОМ портов На странице: https:/ / norseev. ru/ 2018/ 01/ 04/ comportlist_windows/ нашёл схожую тему. Там приведён код на С++, который показывает только имена СОМ портов, типа,. . .
Сколько Государство потратило денег на меня, обеспечивая инсулином.
Programma_Boinc 20.11.2025
Сколько Государство потратило денег на меня, обеспечивая инсулином. Вот решила сделать интересный приблизительный подсчет, сколько государство потратило на меня денег на покупку инсулинов. . . .
Ломающие изменения в C#.NStar Alpha
Etyuhibosecyu 20.11.2025
Уже можно не только тестировать, но и пользоваться C#. NStar - писать оконные приложения, содержащие надписи, кнопки, текстовые поля и даже изображения, например, моя игра "Три в ряд" написана на этом. . .
Мысли в слух
kumehtar 18.11.2025
Кстати, совсем недавно имел разговор на тему медитаций с людьми. И обнаружил, что они вообще не понимают что такое медитация и зачем она нужна. Самые базовые вещи. Для них это - когда просто люди. . .
Создание Single Page Application на фреймах
krapotkin 16.11.2025
Статья исключительно для начинающих. Подходы оригинальностью не блещут. В век Веб все очень привыкли к дизайну Single-Page-Application . Быстренько разберем подход "на фреймах". Мы делаем одну. . .
Фото: Daniel Greenwood
kumehtar 13.11.2025
Расскажи мне о Мире, бродяга
kumehtar 12.11.2025
— Расскажи мне о Мире, бродяга, Ты же видел моря и метели. Как сменялись короны и стяги, Как эпохи стрелою летели. - Этот мир — это крылья и горы, Снег и пламя, любовь и тревоги, И бескрайние. . .
PowerShell Snippets
iNNOKENTIY21 11.11.2025
Модуль PowerShell 5. 1+ : Snippets. psm1 У меня модуль расположен в пользовательской папке модулей, по умолчанию: \Documents\WindowsPowerShell\Modules\Snippets\ А в самом низу файла-профиля. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru