Привет, Хабр! Меня зовут Антон, я занимаюсь внедрением ИИ в компании Doubletapp. Представьте парадоксальную ситуацию: корпоративный университет крупной компании создает все больше обучающих материалов — от классических PDF до интерактивных онлайн-курсов, но сотрудникам становится только сложнее находить конкретные ответы на свои вопросы. Вместо быстрого решения проблемы в программном обеспечении приходится часами просматривать обучающие материалы. Требовалось решение, которое могло бы мгновенно находить нужную информацию, не заставляя пользователей изучать курсы целиком, и давать ответ на вопрос пользователя, сформулированный естественным языком.

В этой статье мы детально разберем процесс создания корпоративной RAG-системы для поиска по обучающим материалам.
Вы узнаете:
• Какие эмбеддинг-модели лучше работают с русским языком и как оценивать их качество.
• Как повысить точность поиска, комбинируя векторные и классические подходы (BM25+).
• Практические приемы промпт-инжиниринга для улучшения качества ответов LLM.
• Технические детали реализации расширения контекста и маршрутизации запросов между разными источниками.
• Методы оценки качества работы RAG-системы в корпоративной среде.
Статья будет особенно полезна разработчикам и техлидам, которые планируют внедрять подобные решения в своих компаниях.
Подготовка данных: как превратить хаос в порядок
Первым этапом проекта стала обработка учебных материалов. Корпоративный университет располагал внушительной базой курсов в самых разных форматах: от классических PDF-документов до интерактивных SCORM-пакетов. Наша задача заключалась в том, чтобы превратить этот разнородный контент в единый формат, пригодный для создания вопросно-ответной системы.
Борьба с форматами
Каждый тип документов требовал особого подхода:
PDF-документы. Простые текстовые PDF легко поддавались конвертации. Дизайнерские версии с изображениями и сложной версткой создавали серьезные проблемы. Для парсинга неструктурированных PDF существуют проприетарные решения, но они не подходили из-за лицензионных ограничений. Мы эксперементировали с open source библиотеками, такими как Unstructured и PyMuPDF, и остановились на PyMuPDF, который показал лучшие результаты.
Интерактивные курсы. HTML и SCORM-пакеты требовали особого внимания из-за обилия элементов интерфейса. Для JSON-структур мы разработали специальные парсеры, извлекающие только содержательный контент. Пришлось научиться отличать тексты интерфейса от учебного материала, анализируя структуру данных.
В результате мы создали четкую иерархию хранения данных:
курс/
├── формат_1/
│ ├── урок_1.txt
│ ├── урок_2.txt
│ └── ...
├── формат_2/
│ ├── lms/
│ │ ├── урок_1.txt
│ │ └── ...
│ └── ...
└── ...
Дополнительным бонусом стали тестовые вопросы, которые мы извлекли из курсов. Хотя их количество было недостаточным для полноценного тестирования (около 10 вопросов на курс), они пригодились для быстрой валидации работы системы. Позже заказчик предоставил расширенный набор тестовых данных — около 30 вопросов с ответами для каждого из 18 курсов.
Разработка системы поиска (ретривала): от простого к сложному
Создание эффективной системы поиска релевантных фрагментов текста стало следующим важным этапом проекта. Мы начали с простых подходов и постепенно улучшали качество, экспериментируя с различными методами и их комбинациями.
Создание тестового датасета
Для объективной оценки качества поиска (ретривала) нам требовался набор тестовых данных. Мы решили создать его автоматически, используя метод generate_question_context_pairs из LlamaIndex и языковую модель. Процесс выглядел следующим образом:
Разбили тексты на небольшие фрагменты с помощью Sentence Splitter.
Для каждого фрагмента автоматически сгенерировали вопрос, используя следующий промпт:
QUESTION_GENERATOR_PROMPT = """
Вы методист, разрабатывающий материалы для оценки знаний.
У вас есть следующий фрагмент обучающего материала:
---------------------
{{context_str}}
---------------------
На основе этого фрагмента создайте {{num_questions_per_chunk}} проверочный вопрос.
Каждый вопрос должен:
- Основываться исключительно на информации из фрагмента
- Помогать проверить понимание материала по теме {course}
- Иметь четкий и однозначный ответ в тексте
Пожалуйста, сформулируйте вопрос так, чтобы он помог оценить усвоение материала студентом.
"""
qa_dataset = generate_question_context_pairs(
nodes,
llm=llm,
num_questions_per_chunk=1,
qa_generate_prompt_tmpl=QUESTION_GENERATOR_PROMPT
)
Для тестирования поисковой системы мы использовали следующий подход:
Задавали системе сгенерированный вопрос.
Проверяли, находится ли исходный фрагмент текста (из которого был сгенерирован вопрос) среди результатов поиска.
Вычисляли метрики Hit Rate@K и MRR по всему набору тестовых пар. О метриках описано ниже.
Эксперименты с семантическим поиском
В основе RAG-систем лежит семантический поиск: способность находить релевантные фрагменты текста не по точному совпадению слов, а по смыслу. Для этого тексты преобразуются в числовые векторы (эмбеддинги) таким образом, что семантически близкие фрагменты оказываются ближе друг к другу в векторном пространстве. Качество этих эмбеддингов во многом определяет точность поиска, поэтому мы уделили особое внимание выбору подходящей модели.
Для оценки качества поиска мы использовали две ключевые метрики:
Mean Reciprocal Rank (MRR) — метрика, которая оценивает, насколько высоко в результатах поиска находится правильный ответ. Для каждого запроса вычисляется величина, обратная позиции правильного ответа (например, если правильный ответ на первом месте — 1, на втором — 1/2, на третьем — 1/3), затем эти значения усредняются по всем запросам. Чем ближе MRR к 1, тем лучше система находит правильные ответы и размещает их в начале выдачи. В нашем случае мы учитывали только первое появление релевантного фрагмента, даже если система находила другие подходящие контексты.
Hit Rate@K — метрика, показывающая, в каком проценте случаев правильный ответ попадает в первые K результатов поиска. Например, Hit Rate@3 = 0.64 означает, что в 64% случаев правильный ответ находится среди первых трёх результатов. Мы использовали несколько значений K (3, 5, 7), чтобы оценить, как расширение выдачи влияет на полноту поиска. Это особенно важно для RAG-систем, где мы можем передать в языковую модель только ограниченное количество контекста, и нам критически важно, чтобы правильный ответ попал в эти первые K фрагментов.
Мы протестировали несколько современных эмбеддинг-моделей:
E5 (различные размеры).
BGE (несколько версий).
RuBERT (включая файнтюн-версии от Сбера).
Результаты оказались следующими:
E5-large показал лучший результат с Hit Rate@3 = 0.64.
BGE, несмотря на длительное время работы, показал чуть худшие результаты.
RuBERT и его модификации значительно отстали от лидеров.
Перефразирование запросов
Одна из ключевых проблем при поиске — это несовпадение формулировок между запросом пользователя и текстом в документации. Пользователь может спросить «Как поменять пароль?», в то время как в документации это описано как «изменение учетных данных». Даже семантический поиск не всегда справляется с такими различиями. Кроме того, иногда более общая формулировка вопроса помогает найти более полезный контекст.
Для решения этих проблем мы использовали два подхода к перефразированию:
Multi Query Generation
Этот подход позволяет расширить поисковый запрос, генерируя несколько его перефразировок. Мы использовали LangChain для создания цепочки обработки, которая генерирует три альтернативные версии исходного вопроса:
QUERY_EXPANSION_PROMPT = """{inst_token_start}Вы специалист по информационному поиску.
Ваша цель - улучшить поиск информации путем создания альтернативных формулировок запроса.
Исходный запрос: {question}
Пожалуйста, предложите три разных способа задать этот же вопрос. Варианты должны:
- Сохранять исходный смысл запроса
- Использовать разные формулировки
- Быть естественными для пользователя
Запишите каждый вариант с новой строки.{inst_token_stop}"""
MULTIQUERY_GEN = prompts.QUERY_EXPANSION_PROMPT | LLM | StrOutputParser() | (lambda x: x.split("\n"))
В промпте вы можете заметить специальные токены inst_token_start и inst_token_stop — они используются моделью Mistral, чтобы понять границы инструкции.
Вот как это работает на практике:
Пример 1:
• Исходный вопрос: «Как открыть новый файл в Excel?»
• Сгенерированные варианты:
1. «Каким образом создать новый документ Excel?»
2. «Что нужно сделать для создания нового файла в Excel?»
3. «Какие шаги требуются для открытия нового файла Excel?»
Пример 2:
• Исходный вопрос: «Где находится кнопка сохранения?»
• Сгенерированные варианты:
1. «В каком месте интерфейса расположена функция сохранения?»
2. «Как найти опцию для сохранения файла?»
3. «В какой части программы искать кнопку save?»
Использование в коде выглядит просто:
variants = MULTIQUERY_GEN.invoke("Как открыть новый файл в Excel?")
# variants будет содержать список из трех перефразированных вопросов
Stepback Prompting
Этот метод генерирует более общую версию вопроса, что помогает найти более широкий контекст. Реализация также использует LangChain:
GENERALIZATION_PROMPT = """{inst_token_start}Вы аналитик поисковых запросов.
Ваша задача - расширить контекст поиска, преобразовав конкретный вопрос в более общий.
Это поможет найти более полезную информацию в базе знаний.
Примеры преобразования:
Исходный: Как включить темную тему в VS Code?
Общий: Как настроить интерфейс VS Code?
Исходный: Какой размер оперативной памяти нужен для Photoshop?
Общий: Какие системные требования у Photoshop?
Исходный: Как добавить новый слайд в PowerPoint?
Общий: Как работать со слайдами в PowerPoint?
Исходный: Где находится кнопка сохранения в Word?
Общий: Как сохранять документы в Word?
Пожалуйста, преобразуйте следующий вопрос:
{question}
Общий вопрос:{inst_token_stop}"""
В контексте нашей системы этот подход работает так:
• Исходный вопрос: «Как увеличить шрифт в Excel?»
• Более общий вопрос: «Как настроить параметры отображения в Excel?»
• Или: «Какие настройки шрифта доступны в Excel?»
В итоге Multi Query сильно улучшил результаты, а Stepback Prompting помог разнообразить найденные контексты.
Улучшение с помощью BM25+
BM25+ — это классический алгоритм ранжирования текстов, который оценивает релевантность документа на основе частоты встречаемости слов из запроса, с учетом длины документа. В отличие от векторных моделей, он не требует предварительного обучения.
Для повышения качества поиска мы также экспериментировали с этим алгоритмом: базовая версия BM25+ показала Hit Rate@3 = 0.28, а после интеграции с токенизатором от GPT-4 результат улучшился до 0.54.
Вот как выглядят результаты сравнения разных подходов:
Search K | BM25+ | BM25+ (custom preprocess) | d0rj/e5-large-en-ru | deepvk/USER-bge-m3 |
3 | 0.28 | 0.54 | 0.64 | 0.63 |
5 | 0.35 | 0.61 | 0.70 | 0.68 |
7 | 0.38 | 0.65 | 0.75 | 0.73 |
Как видно из таблицы, предварительная обработка текста значительно улучшает результаты BM25+, хотя векторные модели все равно показывают лучшие результаты.
Гибридный поиск: объединяем лучшее
После экспериментов с отдельными подходами мы решили объединить их сильные стороны в гибридном поиске. Наше решение включало:
• Два эмбеддера: E5-large и BGE.
• Алгоритм BM25+ с токенизатором от GPT-4.
• Перефразирование запросов для расширения поиска.
Как работает гибридный поиск
Каждый метод поиска (E5, BGE, BM25+) возвращает топ-5 результатов.
Для каждого запроса генерируются перефразировки, что даёт нам до 75 кандидатов (3 метода × 5 результатов × 5 перефразировок).
Результаты объединяются с помощью алгоритма Reciprocal Rank Fusion.
Из объединенного списка выбираются топ-5 наиболее релевантных фрагментов (или топ-7 при использовании маршрутизации по курсам, о чем ниже).
Итоговый результат превзошел все отдельные подходы:
• Hit Rate@3 достиг 0.75.
• Система стала более устойчивой к различным формулировкам запросов.
• Улучшилось разнообразие найденных контекстов.
Как работает Reciprocal Rank Fusion
Алгоритм Reciprocal Rank Fusion (RRF) работает следующим образом:
1. Для каждого фрагмента текста вычисляется RRF-score по формуле:
RRF_score = Σ 1 / (k + r)
где:
• r — позиция фрагмента в результатах конкретного метода поиска (начиная с 1);
• k — константа (обычно = 60), которая уменьшает влияние высоких позиций;
• Σ - сумма по всем методам поиска, которые вернули этот фрагмент.
2. Например, если фрагмент находится на 1-м месте в BM25+, на 3-м в E5 и отсутствует в BGE:
RRF_score = 1/(60+1) + 1/(60+3) + 0 = 0.0328
3. После подсчета RRF-score для всех фрагментов они сортируются по убыванию этого значения. Чем чаще фрагмент появляется в топе разных методов поиска и чем выше его позиции, тем больше итоговый RRF-score.
Для хранения и сравнения эмбеддингов мы использовали Chroma DB, а BM25+ реализовали как отдельный класс через интерфейс LangChain. Разбиение на чанки осуществлялось с помощью Recursive Splitter из LangChain.
Результаты гибридного поиска, комбинирующего разные подходы:
Search K | BM25+ + E5 base | BM25+ + BGE-m3 | BGE-m3 + E5 base | BM25+ + E5 base + BGE-m3 |
3 | 0.65 | 0.69 | 0.67 | 0.71 |
5 | 0.73 | 0.80 | 0.77 | 0.79 |
7 | 0.77 | 0.82 | 0.79 | 0.85 |
Как мы видим, гибридный подход значительно превосходит результаты отдельных методов. Особенно эффективным оказалось сочетание всех трех подходов, которое позволило достичь Hit Rate@7 = 0.85.
Расширение чанков: контекст решает всё
На финальном этапе оптимизации мы реализовали механизм расширения контекста, который значительно улучшил качество ответов системы. Идея проста: вместо того чтобы работать с изолированными фрагментами текста, мы стали учитывать их окружение.
Как это работает
Процесс расширения контекста включает несколько шагов:
1. Когда система находит релевантный фрагмент текста, она дополнительно захватывает соседние фрагменты до и после найденного.
2. Если в результатах поиска встречаются соседние фрагменты, они объединяются в один больший контекст.
3. Это помогает сохранить связность повествования и избежать потери важной информации на границах фрагментов.
Техническая реализация
Для реализации этого подхода мы использовали Chroma DB с хранением метаданных, таких как:
• курс, к которому относится фрагмент;
• файл-источник;
• позиционный идентификатор (ID) фрагмента.
Ключевым элементом реализации стала система идентификации фрагментов. Каждому фрагменту присваивается последовательный ID в зависимости от его позиции в исходном тексте. Например, если текст разбивается на 10 фрагментов, они получают ID от 1 до 10 в порядке их следования в документе. Эти ID, вместе с информацией о курсе и файле-источнике, сохраняются в Chroma DB как метаданные.
Такой подход позволяет легко находить соседние фрагменты: если у нас есть фрагмент с ID=5, то его соседями будут фрагменты с ID=4 и ID=6 из того же файла. При этом поиск соседей происходит без необходимости выполнять SQL-запросы к базе данных.
Для работы с файлами мы используем компоненты из библиотеки LangChain: Directory Loader для загрузки файлов из директории и Recursive Text Splitter для разбиения текста на фрагменты с учетом структуры документа.
Маршрутизация запросов: как работать с множеством курсов
Одной из ключевых задач стала корректная обработка запросов разной степени конкретности. Пользователи могли задавать как специфические вопросы (например, «Как увеличить шрифт в SAP?»), так и общие («Как увеличить шрифт?» без указания конкретной программы).
Проблема противоречивых контекстов
При работе с множеством курсов мы столкнулись с проблемой: система могла находить противоречащие друг другу фрагменты из разных курсов. Например, инструкции по увеличению шрифта могли отличаться для разных программ. Нам требовалось решение, которое могло бы:
• определить релевантные курсы для конкретного вопроса;
• избежать смешивания контекстов из разных курсов;
• предоставить пользователю структурированный ответ.
Двухэтапный поиск
Мы разработали двухэтапный процесс обработки запросов:
Определение релевантных курсов
• Система выполняет начальный поиск по всей базе знаний.
• Из топ-7 найденных фрагментов извлекается информация о курсах.
• Выбираются максимум 5 наиболее релевантных курсов.Поиск по выбранным курсам
• Для каждого выбранного курса выполняется отдельный поиск.
• Система отбирает топ-5 наиболее релевантных фрагментов.
• Для каждого курса генерируется отдельный ответ.
Представление результатов
В интерфейсе пользователь видит структурированный ответ, где информация из разных курсов представлена отдельно.
Это позволяет:
• четко разграничить контекст разных программ/курсов;
• предоставить пользователю полную картину;
• избежать путаницы в инструкциях для разных систем.
Генерация ответов: выбор и настройка языковой модели
Эксперименты с разными моделями
В поисках оптимальной языковой модели мы протестировали несколько вариантов, которые были наиболее сильными на момент разработки:
• GigaChat и GigaChat Pro;
• Mistral 7B (версии v2 и v3);
• Mistral Nemo 12B.
Хотя GigaChat показывал хорошие результаты, требование развернуть систему внутри корпоративного контура заставило нас сосредоточиться на open-source решениях. В итоге лучшие результаты продемонстрировала модель Mistral Nemo 12B. Кстати, если бы мы разрабатывали систему сейчас, то обязательно бы протестировали модели от Qwen: они хорошо работают с русским языком и также обладают удобной лицензией.
Промпт-инжиниринг
Для получения качественных ответов мы разработали специальный промпт. В нем мы задаем модели четкую роль помощника по информационным продуктам и устанавливаем строгие рамки для генерации ответов. Ключевое требование — опираться исключительно на предоставленный контекст, избегая любых предположений. При этом, когда вопрос требует пошаговых инструкций, промпт направляет модель на структурирование ответа в виде четкого списка действий.
Вот финальная версия промпта:
ANSWER_GENERATION_PROMPT = """{inst_token_start}Вы технический специалист службы поддержки корпоративных систем.
Ваша задача - предоставить точный и понятный ответ на основе предоставленной документации.
Контекст:
-----
{context}
-----
Вопрос пользователя:
{query}
Требования к ответу:
1. Используйте ТОЛЬКО информацию из предоставленного контекста
2. Если вопрос требует пошаговых инструкций, структурируйте ответ в виде нумерованного списка
3. Если информации недостаточно или её нет в контексте, сообщите об этом: "В предоставленной документации информация по данному вопросу отсутствует"
4. Ответ должен быть конкретным и относиться только к заданному вопросу
5. Избегайте предположений и догадок
Ответ:{inst_token_stop}"""
Итоговая схема работы

Результаты и внедрение
Оценка качества ответов
Для оценки качества работы системы заказчик предоставил тестовый набор данных:
• 17 курсов;
• 15–30 вопросов на каждый курс;
• всего 452 вопроса с эталонными ответами.
Результаты тестирования:
• 🟢 77% ответов по смыслу полностью совпали с эталонными;
• 🟡 18% ответов были признаны верными, но отличались от эталонных формулировками;
• 🔴 5% ответов требовали доработки.
Техническая реализация
Система была развернута с использованием:
• FastAPI для создания бэкенда;
• LangChain для построения RAG-системы;
• Chroma DB для хранения и поиска эмбеддингов;
• Streamlit для интерфейса;
• внутренней инфраструктуры заказчика для развертывания.
Несмотря на «шумные» данные (наличие элементов интерфейса в текстах курсов), система продемонстрировала высокую эффективность в реальных условиях эксплуатации.
Заключение: от эксперимента к рабочему решению
Проект по созданию умного помощника для корпоративного обучения наглядно показал, как современные технологии искусственного интеллекта могут решать реальные бизнес-задачи.
Нам удалось:
• превратить разрозненные учебные материалы в единую базу знаний;
• создать эффективную систему поиска;
• обеспечить генерацию понятных и точных ответов;
• успешно внедрить решение в корпоративную инфраструктуру.
Особенно важно отметить, что система не просто работает с «идеальными» данными, а способна эффективно справляться с реальным корпоративным контентом, включая все его несовершенства и особенности.
Работа над проектом также показала, насколько важен комплексный подход к решению подобных задач. Успех определяется не только выбором правильных технологий, но и глубоким пониманием потребностей заказчика, особенностей данных и специфики конкретной области применения.
Если вы тоже столкнулись с задачей автоматизации работы с корпоративными знаниями или хотите обсудить возможности применения современных AI-технологий в вашем бизнесе, команда Doubletapp готова поделиться своим опытом и помочь в реализации ваших идей. Мы специализируемся на разработке заказных решений с использованием искусственного интеллекта и имеем богатый опыт внедрения подобных систем в корпоративный контур.