Помню тот день, когда наш техлид пришёл на планёрку с новой идеей - прикрутить 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 как векторное хранилище

Помню, как я объяснял коллеге идею векторного поиска. Он смотрел на меня как на шарлатана: "Блин, у нас уже есть полнотекстовый поиск в Mongo, зачем это всё?" Пришлось показывать на реальном примере. Я взял два запроса: "настройка Spring приложения" и "конфигурация спринг проекта". Классический текстовый поиск нашёл только первый вариант из документации, а векторный подтянул оба - потому что понял смысл, а не просто сматчил слова. Когда MongoDB объявила о встроенной поддержке векторного поиска в Atlas, многие отнеслись скептически. Мол, специализированные решения типа Pinecone всё равно быстрее. Может быть. Но вопрос в другом: нужна ли тебе абсолютная максимальная производительность, если можно получить достаточную производительность без добавления нового сервиса в стек?
У нас уже был кластер M30 в Atlas, который обрабатывал метрики и логи приложений. Добавить туда векторные индексы - это буквально два шага: создать коллекцию и определить индекс. Никаких отдельных инстансов, никаких дополнительных connection pool'ов, никаких новых точек отказа в архитектуре.
Векторный поиск vs традиционный поиск
Разница фундаментальная. Представь, что у тебя в базе лежит статья про "параллельную обработку данных в Java". Пользователь спрашивает про "concurrent programming". Традиционный full-text search вернёт пустоту - ни одного совпадающего слова. А векторный поиск скажет: "Эй, это про то же самое!" и подтянет нужный документ.
Работает это через косинусное сходство векторов. Каждый документ превращается в точку в многомерном пространстве (обычно 1536 измерений для text-embedding-3-small). Запрос тоже конвертится в вектор. Дальше - математика: ищем ближайшие точки.
Формула косинусного сходства выглядит так:

Где и - это векторы запроса и документа. Результат от -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-секундных таймаутов пользователи получали мгновенные ошибки с предложением попробовать позже.
Продвинутые сценарии использования

Базовый чатбот работал, но хотелось большего. Помню, как на очередном созвоне продакт попросил добавить поиск по авторам документов и датам создания. "Легко", - подумал я. Оказалось, что комбинирование векторного поиска с обычными фильтрами требует нетривиальных трюков.
Мультимодальность: работа с текстом и метаданными
Первая задача - научить систему учитывать не только семантическую близость, но и структурированные атрибуты. Пользователь спрашивает: "Что писал Петров про микросервисы в прошлом месяце?" Тут нужно и векторный поиск по "микросервисам", и фильтр по автору, и временной диапазон.
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. Выборка уникальных значений Доброго времени суток, уважаемые!
Второй день ломаю голову над вопросом. У нас есть база данных...
|