Привет, Хабр! Меня зовут Никита Мишнев, я руководитель команды разработки умного поиска на основе генеративного AI в Just AI. В этой статье я расскажу о нашем опыте в умный поиск — как от mvp RAG-сервиса для Q&A бота нашей службы поддержки мы пришли к облачной платформе Jay Knowledge Hub (сокращенно KHUB), которая помогает нашим клиентам автоматизировать поиск по различным источникам знаний.
Как все начиналось: прототип на базе RAG
Наш основной продукт — диалоговая система для создания чат-ботов JAICP. Она мощная, но требует технической подготовки, базовых навыков программирования, поэтому у пользователей часто возникают вопросы по ее использованию.

Эти вопросы призвана решать документация — https://help.cloud.just-ai.com/jaicp/ , но, как правило, всем лень рыться в тонне букв. Поэтому много клиентских вопросов ложится на плечи поддержки. Мы решили чуть облегчить им жизнь и ,как следствие, сэкономить трудовые ресурсы взять часть вопросов на бота, который бы мог выполнить умный поиск по клиентскому вопросу.
Для этого мы собрали RAG-пайплайн классической наивной архитектуры на базе LLamaIndex и библиотек для чанкинга из Langchain. В качестве LLM модели на тот момент мы использовали GPT-3.5 Turbo, в качестве эмбеддера — text-3-embeddings-large от OpenAI. В качестве датасета использовали вручную размеченную документацию — около 580 mdx-документов, каждый документ укладывался в диапазон 800-1000 символов, что представляло из себя один чанк. Картинки и прочая графика в обработке не участвовали — только текст, только буквенная информация, только хардкор. Векторное хранилище было построено на ElasticSearch с плагинами для knn-поиска векторов. Самого бота, который под капотом обращается к RAG-сервису, мы построили на том же JAICP и вывели его во встроенный виджет:

Оценка качества пайплайна с помощью LLM-as-a-judge (на основе сета эталонных вопросно- ответных пар) показала 74% — уже неплохо для MVP.
Итог — решение помогло снизить нагрузку на поддержку: бот забирал 10% сложных вопросов и 35% стандартной или легкой сложности. Мы убедились, что у решения есть потенциал для масштабирования на запросы умного поиска среди наших клиентов.
Какие проблемы были в MVP
Прототип был далек от идеала. Не учитывался контекст диалога (zero-shot prompting не давал опоры на историю запросов). Решение было не Copilot-ready — не подходило для сложных сценариев поиска по коду и генерации кода.
Чтобы уйти от zero-shot запросов и сделать опору на контекст диалога, мы прикрутили llm-трансформер запроса. Общая архитектура классического rag-пайплайна в этом случае:

Чтобы решить запросы по типу «как я могу написать код…» и добавить копилотную поддержку, мы решили использовать агентский RAG — моно-агент с набором тулл-колов:

В итоге mvp-решения у нас на руках было две реализации RAG-пайплайна, каждая из которых хороша по-своему:
Классический пайплайн – высокая скорость и паритетное качество с агентским в семантическом поиске;
Агентский пайплайн – более высокая точность и полнота ответа благодаря многоступенчатому рекурсивному ретривингу.
Точность которую мы получили на тест-сете методом оценки llm-as-a-judge:
Тест-сет | Объем (кол-во вопросных пар) | Классический RAG | Агентский |
Ручной | 300 | 80% | 85% |
Синтетический* | 580 | 88% | 95% |
* - синтетические вопросно-ответные пары – сгенерированные на каждый чанк через LLM.
Переход от внутреннего решения к услуге для клиентов
Когда наш внутренний прототип начал стабильно работать и приносить пользу как команде поддержки, так и нам, разработчикам, стал очевиден потенциал. На тот момент уже был восходящий тренд на систематизацию корпоративных знаний и умный поиск. Так родилась идея превратить наш пайплайн в тиражируемый продукт. Мы начали строить базы знаний под ключ.

Процесс, казалось бы, стандартный: собираем датасет, размечаем, парсим, режем на чанки и превращаем их в эмбеддинги для векторной БД. Затем настраиваем RAG-пайплайн: количество схожих векторов для семантического knn-поиска, радиус охвата соседних чанков, промпт для генерации и настройки LLM. Проводим оценку качества ответов RAG. И, наконец, если база знаний соответствует требованиям, она готова. В противном случае совершается еще одна итерация цикла для достижения приемлемого результата. Ничего сложного, кажется. Но, как это обычно бывает, дьявол кроется в деталях.

Сложности создания базы данных под ключ
Большую часть времени при разработке таких решений отнимает процесс подготовки данных, их структуризация. К тому же, если в нашем внутреннем mvp задача заключалась в подготовке 580 уже структурированных документов, общий объем которых помещался в пару мегабайт, то в клиентских проектах начиналось самое интересное: десятки разных источников, гигабайты PDF-документов, docx, сканов, таблиц, вложенных архивов. Документация, написанная в Confluence, Jira, Word, Excel и даже в PPTX. А иногда — на смеси этих форматов да еще и с графикой внутри, по которой тоже требовалось давать поиск. Умножайте это все на количество клиентских кейсов и можно уже выделять отдельное подразделение для разметки и парсинга на ручном приводе. У нас такой роскоши не было.
В части инференса базы данных немаловажной задачей было обеспечить быструю отдачу ответов. Кроме того, решение должно было быть готово к использованию в инфраструктуре клиентов on-premise и легко интегрироваться в изолированные системы.

Как мы парсили «сложные» документы
Мы разработали собственный ETL для обработки сырых данных клиентов:

Наш пайплайн содержит два основных процессинговых узла. Сырые неструктурированные файлы подаются в парсер, который мы построили на основе отечественного open-source решения Dedoc. Мы немножко переделали исходный код, убрали блокирующий API и подвязали очередь парсинг-джобов на общую шину данных на основе Redis. Сами инстансы Dedoc у нас объединены в кластер для большей производительности и отказоустойчивости. Также мы расширили возможность OCR-слоя, внедрив туда адаптер к Vision-модели, которая позволяла нам сделать OCR по графике внутри документов. На выходе мы имеем уже структурированные данные в формате Markdown (исходно структурированные документы: json, html, yaml — мы отдельно не парсим) и аттачменты к ним в виде картинок и таблиц. Каждый аттачмент внутри md-документа помечается ссылкой-якорем. Все это складывается в объектное хранилище S3-like с системой контроля версий LakeFS.


Далее размеченные и структурированные данные попадали на ingest-этап (процесс индексации БЗ) и дробились на чанки. По каждому чанку мы делали отдельные расширения смыслового контента посредством обращения к LLM:
Проводилась самаризация;
Генерировались кейворды;
Генерировались вопросы;
В случае наличия ссылок на аттачменты-картинки они доставались из репозитория проекта в LakeFS, и запрашивалось описание через мультимодальную LLM.
По каждому из этих расширений генерировались эмбеддинги, добавлялись к структуре чанка и сохранялись в поисковый индекс проекта в Elasticsearch.

Изначально ETL мы построили на dag`ах Airflow. Но позже, когда столкнулись с большим потреблением ресурсов кластера Airflow и регулярными беспричинными отказами задач Celery (по тому как мы боролись с Airflow, можно посвятить отдельную статью), было принято решение перевести ETL на Spring Batch и написать все на типизированном и предсказуемом на этапе компиляции Котлине.
Все это позволило нам существенно снизить время обработки датасета, сократить расходы на дата-разметчиков оптимизировать процессы и обрабатывать помимо текста еще и графический контент в пределах одной модальности эмбеддингов поискового индекса.
Advanced retrieving: не KNN едины

Клиентские кейсы дали нам понять, что просто искать семантическую близость порой недостаточно. Среди груды файлового контента есть похожие чанки, которые относятся к совершенно разным тематикам, к которым также требуется настроить мультитенантность в границах индекса одного проекта. Отчасти точность семантического поиска улучшают проделанные работы на этапе индексации в ETL — кейворды и вопросы. Но мы на этом не остановились — внедрили полнотекстовый поиск (FTS) и расширили стратегии нахождения релевантной информации:
Гибридная стратегия — когда мы мержим найденные чанки после knn-поиска и fts-поиска;
Взвешенная стратегия — смешиваем knn результаты с порцией fts;
Пороговая стратегия — у каждого эмбеддинга в векторном пространстве есть оценка близости к вектору исходного запроса. Берем найденные чанки от knn-поиска, которые выше установленного score-порога и добавляем требуемое количество чанков от результата fts поиска, если общего числа чанков не хватает.
Также, у нас появились графические чанки — чанки с описанием по картинкам, которые мы добавляем в том случае, если в найденном чанке есть ссылка на картинку = id графического чанка. Чтобы сузить область поиска внутри индекса и разграничить доступ к релевантной информации, мы добавили сегментирование чанков. Ну и также после этапа поиска внедрили реранкинг на основе модели.
Кажется все. Погодите-ка, а что насчет таблиц — xlsx и csv файлов? А вот тут все немного отличается: в векторном хранилище мы держим лишь репрезентативное представление о данных в таблице — хедеры и несколько строк таблицы, чтобы можно было определить смысловую нагрузку данных и их структуру. Соответственно, по этим данным храним эмбеддинг и помечаем его табличным типом. Данные по табличным файлам хранятся в сыром виде — они уже структурированы в lakefs репозитории. И когда мы хотим сделать поиска по таблицам, мы делаем стандартный knn-поиск по эмбеддингам репрезентативных представлений таблиц, находим нужную таблицу и загружаем ее в in-memory sql-базу данных через генерацию DDL запроса. Все эти движения делает агентский сервис через LLM тул-коллинг. Он же выполняет последующий select-запрос, результаты которого и есть финальные результаты ретривинга.
Если в двух словах, то поиск у нас выглядит следующим образом:

Есть пользовательский запрос, он проходит этап трансформации с учетом контекста истории диалога. Далее он попадает в классификатор. Классификатор определяет, данный запрос относится к таблицам или к обычным документам, и уже в зависимости от этого у нас происходит либо семантический KNN + FTS-поиск с реранкингом, либо табличный ретривинг.
Таким образом улучшенный ретривинг обеспечил не только более высокую точность и работу с различными форматами данных, но и возможность обрабатывать табличную информацию, по сути, производя обычные SQL-запросы к базам данных.
Ускоряем RAG
В решении задачи ускорения Баз Знаний мы столкнулись с классической ситуацией с двумя стульями:

На одном стуле — масштабирование толстых LLM (мы считаем таковыми >= 32b) или fine-tuning маленьких (7b – 8b), действия долгие и дорогие, и порой совершенно не оправданные по результатам. На другом — кэширование и стриминг. Стриминг очевидным образом может ускорить получение первого токена генерации, как бы растянуть ответ во времени, а кэширование, помимо ускорения, также позволяет существенно сократить расходы на inference. Я не буду раскрывать здесь тему стримингового completionа – он поддерживается любой современной LLM. А вот как мы реализовали кэширование в RAGе – расскажу.
В двух словах – кэширование работает следующим образом: используется семантический кэш на векторном хранилище и применяется после ретривинга, поскольку крайне чувствителен к его результатам.

В зависимости от того, есть кэш-хит или нет, происходит либо генерация ответа, либо возврат уже готового.


Ах да, ответ у нас генерируется моделькой в виде structured output, что позволяет получить уже серилизуемый объект с необходимыми атрибутами – текстовым результатом, релевантными документами, классификацией результата и т.п.
Зачем кэшу ретривинг и что еще за дисперсия? Результаты ретривинга – это как соль для хэширования. Представим ситуацию, когда мы задаем два семантических близких запроса один за другим. Первый запрос выполнится и сохраняется в кэш. Во время второго будет проверка кэша. И в данном случае наборы ретривинг-множеств будут достаточно близки, чтобы считать, что у нас произошел кэш-хит. В этом случае имеет низкую дисперсию ретривинга между запросом и найденным в кэше результатом. Дисперсию ретривинга — расхождение ретривинг множеств — мы считаем через Жаккардовое расстояние.

Противоположная ситуация, когда мы задаем два совершенно разных смысловых запроса, но близких семантически. В этом случае у нас будут достаточно высокие по скору результаты knn-поиска, но наборы ретривинг-множеств разные.

Вот реальный пример прогона кэша в реальном времени. Мы провели эксперимент: взяли тысячу запросов к базе знаний, использовали классический (не агентский) RAG-пайплайн и гоняли тест около часа. Все вопросы были семантически близки, но при этом структурно различались — это реальные запросы пользователей с продакшена.

Что это нам дало? Мы сэкономили примерно миллион токенов на реквестах и около 26 тысяч токенов на генерации ответов:
Раунд | Промт (токены) | Completion (токены) |
No Cache | 4316104 | 87925 |
With Cache | 3152009 | 61859 |
Таким образом, мы ускорили время отклика — от 1 до 5 секунд в зависимости от применения трансформера запросов, снизили количество токенов и увидели, что на хорошо прогретом кэше база знаний схлопывается до уровня CDQA-подобной системы и практически не обращается к LLM.
Прыгаем в коробку
С точки зрения on-premise-ready решения, когда мы разворачивали базы знаний под ключ, все отлично сложилось в «компактный сендвич»: у нас есть балансировщик нагрузки, масштабируемые RAG-сервисы, сопряженные с векторным хранилищем и кэшем. Все это взаимодействует с изолированным LLM-хостингом, а также хостингом ML-моделей Caila — нашего продукта для on-prem-развертывания.

Технический опыт, который мы получили на кейсах при создании решений под ключ, показал: если подобный «кухонный стол» нужен нам, чтобы готовить базы знаний, то это точно нужно и другим клиентам, которые сами строят базы знаний на своих датасетах. Так родилась идея перенести это решение как платформу в облако — появился Knowledge Hub.
Jay Knowledge Hub — PaaS для создания баз знаний полного цикла
Jay Knowledge Hub — это наша облачная платформа для создания баз знаний полного цикла: от момента загрузки пользовательского датасета до ретривинга, генерации и оценки качества полученной базы знаний. Платформа предоставляет публичные API, доступна для интеграции с другими продуктами экосистемы Conversation Cloud и обладает гибкими настройками, чтобы достичь нужного качества ответов.
Какой функционал доступен пользователю:
Создание множества проектов Баз Знаний
Загрузка пользовательских источников в репозиторий проекта
Настройка интеграции со своими системами хранения знаний, такими как Confluence
Процессинг датасета – парсинг и последующая индексация знаний
Настройка ретривинга и генерации
Тестирование диалога общения с Базой Знаний
Аналитика качества Базы Знаний
Статистика использования
Интеграция с платформой JAICP за один клик – нажали и готовый бот для вашей Базы Знаний создан.
Публичный token-based api к каждому проекту.
Ну и конечно же – пользовательская документация.



Прыгаем в большую коробку

Платформа Knowledge Hub также упаковывается в коробочное решение для контурной поставки в инфраструктуру клиента. Набор комплектующих для этого маневра можно увидеть на графике выше.
Вместо заключения
Мы прошли путь от простого прототипа до облачной платформы, которая помогает клиентам эффективно пользоваться информацией по своим знаниям. Если вы заинтересованы в решении для умного поиска по документации, не спешите строить свои RAG, попробуйте Knowledge Hub, мы подумали о вас. Мы будем рады вашей оценке и обратной связи.
Зарегистрироваться можно тут: https://jayknowledgehub.com/