Привет! Меня зовут Алексей, я разработчик в Битрикс24.
В чате бизнес-портала Битрикс24 есть умный помощник Марта — ей можно написать вопрос, а она пойдёт в базу знаний, найдёт нужную информацию и ответит, ссылаясь на источники. База знаний — это документация для пользователей helpdesk.bitrix24.ru.
Первая версия этого поиска по helpdesk появилась год назад. Она в целом выполняла свои задачи, но у нас накопился список претензий:
Поиск был только по русскоязычному хелпдеску.
Хотелось одновременно поднять качество, ускорить ответы и расширить набор языков, в которых это всё умеет работать.
Что мы сделали за несколько месяцев:
Новый агент работает целиком на внутреннем стеке.
Расширилась поддержка языков хелпдеска и интерфейса.
На наборе экспертных вопросов F1 на классификации вырос с 0.636 до 0.908, а медианное время ответа сократилось с 22 до 10 секунд.

В этой части расскажу, как мы перестраивали retrieval для helpdesk-агента: от HTML-страниц и скриншотов до гибридного поиска, small-to-big и настройки порога реранкера. Ещё разберу устройство нашего RAG, гиперпараметры, которые влияют сильнее всего, и покажу, как мы выбили из системы максимум.
Ещё будет отдельная вторая часть: про датасеты, eval, автоматическую оптимизацию агента и agentic loop.
То, чем хочу поделиться в статье — это не «вот наш стек, повторяйте», а скорее «вот по каким параметрам мы крутили систему», «какие гипотезы выстрелили, а какие нет», и «как мерили». Параметры универсальные: размеры чанков, веса гибридного поиска, пороги реранкера, способы построения eval, автоматизация тюнинга. На вашем стеке цифры будут другими, но логика поиска оптимума переносится.
Что внутри статьи
С чего всё началось
Что такое RAG в одном абзаце
Подготовка корпуса: HTML на входе, разные форматы для эмбеддингов и для LLM
Нарезка на чанки: семантический Markdown и небольшой неочевидный трюк deep-подчанков
Индексация и небольшой бонус от префиксов
Подход small-to-big
Гибридный поиск: dense + sparse + RRF
-
Что такое реранкер и зачем он нужен
Маленький инсайт про порог реранкера
Где сидит время ответа
Глоссарий
embedding — векторное представление текста, по которому ищут похожие фрагменты.
retrieval — этап поиска релевантных кусков базы знаний под вопрос пользователя.
чанк — небольшой фрагмент документа, по которому работает retrieval.
реранкер — отдельная модель, которая пересортировывает retrieval-кандидатов по релевантности.
RAGAS — библиотека для оценки качества RAG-систем.
LLM as a judge — приём, когда одна LLM оценивает ответы другой.
Recall@K, MRR, Hit Rate@K — стандартные retrieval-метрики. Hit Rate@K = «есть ли хотя бы один правильный ответ в первых K результатах».
F1/Precision/Recall классификации — бинарная оценка: агент корректно берётся за вопрос или уходит в эскалацию.
Precision = доля корректных среди вопросов, за которые агент взялся, Recall = доля корректно обработанных среди вопросов, на которые в принципе можно было ответить, F1 — их гармоническое среднее. Подробнее в разделе про датасеты.
С чего всё началось
Год назад мы подняли первую версию поиска по helpdesk на технологиях стороннего вендора. Это был обычный RAG: embedding для индексации, embedding для поиска, LLM для генерации ответа — всё через один API.
Версия работала, и пользователи ей активно пользовались. Но у неё были несколько системных проблем:
Платим за каждый запрос. Эмбеддинги для индекса и для запроса, генерация ответа — всё уходило к внешнему вендору. На объёмах хелпдеска это уже стоило заметных денег, и сумма росла линейно с трафиком.
Только русский язык. Промпты на русском, эмбеддинги под русский, ответы на русском.
Старый агент часто отвечал «давайте уточним». На экспертном датасете вопросов он реально отвечал только в 58% случаев, в остальных уходил в уточнение. Точность ответов при этом была высокая (precision 0.92), но recall — 0.49.
Цель была не «сделать новый RAG», а перевезти всё на внутренний стек и одновременно прокачать качество. Внешние модели заменили на внутренние аналоги (embedding, реранкер, LLM) и прогнали всю систему через серию контролируемых экспериментов.
Что такое RAG в одном абзаце
Если вы уже знаете, как устроен RAG — пропустите эту часть.
Идея простая: вместо того чтобы LLM придумывала ответ из «общих знаний», она получает на вход релевантные куски документации и отвечает строго по ним. Для этого база знаний заранее режется на небольшие фрагменты (чанки), каждый превращается в вектор (embedding) и кладётся в векторную БД. Когда приходит вопрос, то запрос мы тоже превращаем в вектор, ищем похожие чанки в базе, кладём их в промпт LLM и просим ответить.
Это общая схема, но в каждом конкретном продукте куча нюансов: что класть в чанк, как делить документы, как комбинировать семантический поиск с полнотекстовым, какие префиксы добавлять в запросы, нужен ли реранкер, как мерить качество. В этой статье я как раз пройду по этим нюансам — и для каждого скажу, что у нас сработало и что нет.
Большинство параметров нельзя «вычислить» из общих принципов: нужно строить eval-стенды, прогонять датасеты и сравнивать конфигурации.
Цена ошибки в эксперименте — это время. У нас один полный прогон retrieval-эксперимента на ~600 вопросах — это десятки минут даже с распараллеливанием в 5 потоков, end-to-end eval с агентом на тех же 600 вопросах — 2–3 часа.
Цель, к которой мы стремимся, и цель, которую можно достичь
В идеальном мире хочется простого: найти ровно те чанки, которые отвечают на вопрос пользователя, и отдать LLM только их, без единого лишнего токена. Тогда модель:
не галлюцинирует — ей просто негде взять неверный факт;
не отвлекается на нерелевантный шум;
отвечает быстро, потому что контекст короткий.
В реальном мире такой идеальной выборки не бывает. Сложно даже сформулировать, как выглядит «безупречно релевантный» результат — два эксперта порой расходятся, какая из двух статей лучше отвечает на конкретный вопрос. Тем более этого не умеет ни один алгоритм поиска: какой бы хороший embedding и реранкер вы ни взяли, среди топ-K кандидатов всегда найдётся пара-тройка чанков «около темы», а правильный иногда вообще пропускается.
Из-за этого вся работа в RAG превращается в борьбу за каждый чанк. Каждая оптимизация — это попытка либо вытащить ещё один правильный ответ в топ, либо выкинуть ещё один шумный. Прирост в 2–3 п. п. Recall@10 — это уже хорошая работа, потому что под капотом у каждого такого прироста есть дополнительные 10-20 правильно угаданных и десяток правильно отброшенных документов.
С этим прицелом и пойдём.
Подготовка корпуса: HTML на входе, разные форматы для эмбеддингов и для LLM
Универсального RAG не бывает. Подход, который хорошо работает на юридических документах, может плохо работать на справке по бизнес-продукту. Поэтому все технические решения приходится подбирать под конкретную задачу и конкретный тип документов.
У нас на входе сайт хелпдеска — это HTML со своей вёрсткой, картинками, таблицами и встроенными виджетами. И тут есть важный момент про форматы: эмбеддинг-модели и LLM работают с текстом по-разному.
Для embedding у нас лучше зашёл «чистый» plain text — без HTML- и Markdown-разметки. На синтетическом dev-сете plain text стабильно обходил Markdown по Recall@10.
По нашим наблюдениям, символы вроде ** и # для модели — шум, который слегка дробит embedding и режет качество поиска. Возможно, на другой embedding-модели картина окажется иной — у нас тестировался один основной embedding-кандидат.
LLM при генерации ответа, наоборот, удобнее работает с Markdown — он сохраняет структуру (заголовки, списки, таблицы), и модель использует её как разметку для собственного ответа.
То есть из HTML мы готовим оба представления:
Парсер сайта обходит хелпдеск пауком и выгружает все статьи.
HTML → Markdown. Есть несколько готовых конвертеров, и качество финальной структуры заметно влияет на retrieval — мы протестировали разные и выбрали тот, что меньше всего ломает таблицы и списки.
Из Markdown получаем plain text для embedding-модели, а Markdown сохраняем как «человеческое» представление чанка, которое потом увидит LLM.
Хлебные крошки в заголовок. У каждой статьи хелпдеска есть путь в навигации, например: «AI в Битрикс24 → BitrixGPT → Частые вопросы о BitrixGPT». Этот путь мы приклеиваем к заголовку статьи. На нашем корпусе это давало небольшой, но воспроизводимый прирост качества retrieval на «соседних» темах, которые иначе путались между собой (например, «работа со списками в CRM чатах» vs «работа со списками в визуальном контструкторе»).
Описание скриншотов. В документации Битрикс24 много пошаговых инструкций, где половина смысла находится на скриншоте: «нажмите вот эту кнопку → откроется такой диалог». Без описания картинок модель пропускает целые блоки информации. Мы прогоняем все скриншоты через нашу же модель распознавания изображений, получаем текстовое описание и подмешиваем его в Markdown статьи рядом с самой картинкой. После этого по описанию работает и поиск, и LLM при генерации ответа.
Нарезка на чанки и трюк с deep-подчанками
Подготовленный Markdown нужно нарезать на чанки — фрагменты, которые будут кандидатами для retrieval. Самый простой подход — обычное скользящее окно по символам с перекрытием. Простой и плохой: окно регулярно режет таблицу или список пополам, и оба фрагмента теряют смысл.
Мы пошли по семантическому пути: режем по структурным элементам Markdown (заголовки, абзацы, списки, таблицы), стараясь не ломать смысловые единицы. Дополнительно для каждого чанка сохраняем стек заголовков всех уровней (оглавление) — так даже маленький чанк понимает, в какой статье и в каком разделе он находится.
Дальше есть классический trade-off:
Большой чанк (1500–2000 символов). В одну точку embedding-пространства упаковано много смыслов — вектор размывается, точность retrieval падает.
Маленький чанк (200–500 символов). Точные эмбеддинги, но при генерации ответа модели не хватает контекста — она видит обрывок и ошибается.
Чтобы получить и точный поиск, и богатый контекст для LLM, я сделал так: каждый «большой» семантический чанк дополнительно режется на deep-подчанки скользящим окном с перекрытием. В векторную базу попадают подчанки — у них острые, легко различимые embedding. А при выдаче кандидата мы возвращаем большой родительский чанк со всем окружающим контекстом. По сути это вариация small-to-big, про которую я ещё расскажу ниже.
Я провёл серию экспериментов по разным размерам чанка и подчанка на synthetic-dev (655 вопросов из silver-набора):

Recursive (обычное скользящее окно) и семантический Markdown без deep-подчанков дают практически одинаковый Recall@10 (~0.59), но как только мы добавляем deep-подчанки — Recall@10 прыгает до 0.64, а Recall@1 — с 0.24 до 0.35 (+11 п. п.). Разница за пределами 95% bootstrap CI, paired permutation test даёт p < 0.001.
Дальше — поиск оптимальных значений размера чанка и подчанка на том же silver_dev:

Внутри этой матрицы интересно несколько вещей. Без deep (левая колонка) меньший чанк работает чуть лучше — мелкие embedding острее. Но как только мы добавляем deep, важна именно правильная пара. На маленьких чанках (512–768) deep-подчанки не помогают — они слишком мелкие, чтобы захватить осмысленный кусок. На больших чанках (1536–2048) перебор: подчанки начинают конкурировать друг с другом и расфокусировать выдачу. Победитель — chunk_size=1280 с deep_size=512 и перекрытием в 50%. Конфигурация была дополнительно проверена на silver_test, метрики совпали в пределах CI.
Индексация и небольшой бонус от префиксов
Подготовленные чанки проходят через embedding-модель и попадают в векторную БД. Здесь есть один малозаметный, но почти бесплатный трюк. Современные embedding-модели обычно обучают мультитаск — на попарное сравнение, на retrieval, на классификацию и т. д. — и в карточке модели на Hugging Face её авторы часто пишут что-то вроде:
Если вы используете модель для retrieval, добавляйте к запросу префикс «Instruct: ...». Это улучшит качество для вашей конкретной задачи.
Специально для скептиков проверил, даёт ли это что-то на наших данных (silver_dev, n=655):
без префикса: Recall@10 = 0.650, MRR = 0.448;
с префиксом: Recall@10 = 0.655, MRR = 0.451.
Это +0.5 п. п. Recall@10 и +0.3 п. п. MRR — на нашей выборке прирост находится на грани доверительного интервала, но воспроизводится в нескольких повторных запусках. Не «большой выигрыш», а «забери, пока бесплатно дают»: одно строковое поле в конфиге индекса.
Подход small-to-big
Дальше — про сам поиск. Когда мы ищем чанк под запрос пользователя, нам нужны разные характеристики чанка на двух разных шагах:
На этапе retrieval мы сравниваем вектор запроса с векторами в базе. Здесь чанк должен быть маленьким и «острым» — чтобы его embedding точно отражал один конкретный смысл и не размывался.
На этапе генерации ответа LLM нужен большой связный кусок текста — чтобы у модели был достаточный контекст и она не достраивала ответ из общих знаний.
Маленький чанк хорош для retrieval и плох для генерации; большой — наоборот. Решение: использовать подход Small-to-Big. Индексируем по маленьким embedding (deep-подчанкам), но в метаданных каждого подчанка храним ссылки на N соседних чанков сверху и снизу. Когда retrieval вернул нам подчанк, мы автоматически достраиваем вокруг него контекст из соседей — никаких дополнительных запросов в векторную базу не нужно.
Чему равно N? — выяснять в экспериментах:
Слишком большой контекст = дорого по токенам, долго по времени, размытие внимания LLM, потому что на больших контекстах модели хуже фокусируются на главном.
Слишком маленький контекст = LLM не понимает, о чём речь, и начинает галлюцинировать. У нас по результатам экспериментов оптимум — пять соседей с каждой стороны.

Гибридный поиск: dense + sparse + RRF
Простой первый подход — чистый dense-поиск: каждый чанк превращается в embedding, запрос превращается в embedding, ищем k ближайших соседей по косинусу. На нашей задаче этого мало.
Конкретный симптом: dense отлично понимает общий смысл вопроса, но хуже ловит точные совпадения по словам. Названия модулей, кнопок и разделов интерфейса пользователи спрашивают именно конкретными терминами, а в embedding-пространстве «BitrixGPT», «BitrixGPT-чат» и просто «GPT в Битриксе» лежат рядом, но не в одной точке, и dense может перепутать их между собой.
Поэтому в нашем поиске стоят две ветки:
Dense — семантический поиск по embedding.
Sparse — полнотекстовый BM25-индекс по тем же чанкам.
Результаты двух веток сливаются через RRF (Reciprocal Rank Fusion) с настраиваемыми весами и параметром k. Серия экспериментов на silver_dev (n=655):

Левая панель: чистый dense — Recall@10 = 0.589, MRR = 0.404. Гибрид (dense + sparse с дефолтными весами) — Recall@10 = 0.655, MRR = 0.450. Прирост +6.6 п. п. Recall@10, paired permutation test даёт p < 0.001, 95% bootstrap CI на разницу не содержит ноль. Полнотекстовая ветка окупается.
Правая панель: подбор веса sparse-ветки в RRF, dense_weight=1.0, rrf_k=60. Оптимум — между 0.5 и 1.0; выше 1.0 sparse начинает перетягивать ранжирование на себя, и качество едет назад. Финальная конфигурация: sparse_weight=0.5, dense_weight=1.0, rrf_k=60. На silver_test метрики воспроизвелись.
Что такое реранкер и зачем он нужен
После hybrid retrieval мы получаем 15–30 кандидатов-чанков. Если отправить их все в LLM, она не разберётся: длинный контекст с шумом — это прямой путь к галлюцинациям и медленному ответу. Нужен дополнительный фильтр.
Реранкер — это отдельная небольшая модель (по сути cross-encoder), которая видит пару «запрос-чанк» целиком и выдаёт скор релевантности от 0 до 1. Она дороже dense-сравнения (нужно прогонять каждую пару через модель), но в нашей задаче качество ранжирования получалось сильно выше — реранкер «понимает» формулировку вопроса целиком и поднимает наверх правильные ответы.
Я прогнал три реранкера разного размера (далее условно Small/Medium/Large) поверх одного и того же гибридного retrieval на silver_dev (n=655):

И посмотрел, насколько каждый сужает «воронку кандидатов» — сколько уникальных статей нужно показать LLM, чтобы покрыть заданный процент вопросов (тот же silver_dev):

Тенденции:
Любой реранкер сильно поднимает Recall@1 и MRR — у самых маленьких +9 п. п., у самого большого +12 п. п. То есть он помогает не «найти ответ вообще», а «достать его в первый результат».
Recall@5/@10 почти не меняются — реранкер переранжирует, а не добавляет новых релевантных чанков. Если правильный чанк уже был в первой двадцатке кандидатов, реранкер его поднимет; если его там не было — реранкер не поможет.
Воронка сужается резко. Без реранкера, чтобы покрыть 99% вопросов, нужно показать модели до 15 уникальных статей. С реранкером — 9. Это значит, что генерации можно скармливать заметно меньше токенов, и она будет работать быстрее и точнее.
На синтетическом датасете маленький и большой реранкеры выглядят почти одинаково (см. график выше). Но позже, когда мы делали end-to-end тюнинг с агентом и реальным экспертным датасетом, обнаружилось, что на экспертных вопросах разница между маленьким и большим реранкером заметно больше. Маленькая модель ощутимо проседала на сложных формулировках там, где средняя или большая ещё держались. Это лишний раз показывает: синтетика хороша как «грубое сито» для подбора параметров, но финальный выбор размера модели — за реальными вопросами.
Маленький инсайт про порог реранкера
Реранкер возвращает скоры. Очевидная идея: давайте отбрасывать чанки со score < threshold, чтобы убрать «мусор» и заодно сэкономить токены. Какое значение порога взять?
Сначала я искал ответ ровно так, как полагается — по retrieval-метрикам:

На левой панели — Hit Rate@10 в зависимости от порога. Чем выше порог — тем хуже. На правой панели — распределение скоров для gold-чанков (правильных ответов) и non-gold. Модель в среднем заметно увереннее на правильных чанках, но из-за длинных «толстых» хвостов значимая доля gold-чанков получает низкий скор. Любой ненулевой порог режет правильные ответы. По retrieval-метрикам выходит, что оптимум — threshold = 0.0 (вообще не фильтровать).
Но когда мы померили это уже на end-to-end agent-eval (когда LLM получает контекст и пытается ответить на вопрос), картина поменялась. С threshold = 0.5 агент в среднем отвечал лучше, чем с threshold = 0.0. Почему: на retrieval-метриках мы оптимизируем «есть ли правильный чанк среди возвращённых». И любой gold-чанк, выкинутый порогом, считается потерей. А на agent-eval важно не только что чанк есть, но и что в окружении нет шума. Реранкер с порогом 0.5 выкидывает много «полу-релевантных» чанков, и LLM в итоге чище работает с тем, что осталось.
Урок: retrieval-метрики и end-to-end метрики могут не совпадать. Если оптимизировать только первое — можно прийти к параметрам, которые на самом деле вредят конечному пользователю. У нас выбор финального порога подсказал именно end-to-end eval, и пришли мы к нему уже через autoresearch-петлю (про это будет дальше).
Где сидит время ответа
Раз речь зашла про производительность — нельзя обойти тему latency, задержки между запросом и ответом.
Вот как распределяется время по компонентам — данные с двух источников: гибридный поиск и реранкер замерялись внутри retrieval-эксперимента на silver_dev (n=655), полный end-to-end ответ агента — на golden (n=155, 10 живых прогонов агента):

Гибридный поиск — треть секунды.
Реранкер — около двух секунд медианы (на графике указано время на 1 чанк).
А вот полный end-to-end ответ агента с reasoning LLM — это уже 10 секунд медианы и 19 секунды на 95-м перцентиле.
То есть основная статья расхода времени — это LLM-генерация: reasoning, многократные вызовы поиска, формулировка финального ответа. Retrieval с реранкером — единицы секунд от общего бюджета.
Что отсюда стоит унести: при тюнинге latency имеет смысл сначала смотреть, где у вас на конкретном стеке стоит самый большой компонент. В нашем случае это reasoning-LLM, и ускорение retrieval вдвое (с двух секунд до одной) пользователь по медиане практически не заметит. На стеке без reasoning или с быстрой генерацией перекос будет другим.
Что получилось в итоге на этом этапе и о чём расскажу дальше
Финальная картина на экспертном датасете из специально отобранных сложных запросов — мы их насобирали за год эксплуатации первой версии RAG, как раз из тех мест, где она проседала. Каждый из них для системы потенциально болезненный. То есть метрики ниже — это не «как мы работаем на спокойных пользовательских запросах», а «как мы работаем на худшем, что у нас есть».
Главный вывод: production RAG — это не одна «магическая» оптимизация, а длинная серия небольших улучшений retrieval pipeline. Каждая из них либо помогает поднять правильный чанк выше, либо убирает ещё немного шумного контекста.
Из побочных результатов: мы отвязались от стороннего вендора в этом конкретном продукте — расходов на внешние API за каждый запрос больше нет.
Hume_Core
Полезный материал. Как раз себе RAG на БЗ собираем. Спасибо