Перейти к содержимому
28 февр. 2026 г.·8 мин чтения

Dense, sparse и hybrid retrieval: как честно сравнить

Dense, sparse и hybrid retrieval можно сравнить честно, если заранее выровнять корпус, запросы, метрики и правила чанкинга для разных типов документов.

Dense, sparse и hybrid retrieval: как честно сравнить

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

Проблема обычно не в модели и не в индексе. Все сбивается раньше, когда в один тест складывают тексты разного типа и ждут одного победителя. На смешанном корпусе это почти не работает. Sparse лучше цепляется за точные слова и редкие термины, dense чаще находит смысловое совпадение, а hybrid нередко выигрывает просто потому, что ему дали больше свободы в настройке.

Короткие и длинные документы тоже дают разный сигнал. Заметка на три строки часто держится на одном-двух точных словах. Регламент на 40 страниц, наоборот, размазывает ответ по нескольким разделам, и там уже решает не только сам метод поиска, но и то, как вы режете текст на чанки. Если это не выровнять, спор быстро уходит от retrieval к preprocessing.

Есть и более приземленная причина: команды называют одно и то же по-разному. В одном отделе пишут "номер договора", в другом "ID контракта", в третьем - внутренний код заявки. Sparse может просесть из-за словаря, dense - спутать близкие сущности, а hybrid - выглядеть лучше только потому, что закрывает оба типа промаха. Это не делает его лучшим для любого корпуса.

Чаще всего тест уводят в сторону четыре вещи: разный чанкинг для методов, запросы одного типа, разная очистка текста перед индексацией и расплывчатая оценка релевантности. На практике это видно на любом смешанном наборе: база знаний, служебные письма, регламенты, короткие комментарии операторов. Один метод отлично ищет по заметкам и заметно хуже работает на длинных документах. Другой показывает обратную картину.

Поэтому dense, sparse и hybrid retrieval нельзя сравнивать "в среднем по больнице". Сначала задайте общие правила: один корпус, одна подготовка текста, сопоставимые вычислительные условия и единый способ решать, что считать релевантным ответом.

Что выровнять до первого прогона

Сравнение ломается еще до метрик, если у методов разный вход. Для честного теста сначала зафиксируйте корпус: одну дату выгрузки, один список документов, одну схему полей. Если сегодня в базе 120 тысяч документов, а завтра 127 тысяч, разница в результате может идти не от метода, а от новых текстов.

Дальше уберите явные дубли и почти дубли. Это частая ловушка в смешанном корпусе, где одна и та же инструкция лежит в PDF, в wiki и в базе знаний команды поддержки. Тогда один метод будто бы "нашел больше", хотя на деле он просто поймал несколько копий одного ответа. Лучше оставить один канонический документ или хотя бы заранее пометить семейства дублей.

Одинаковая очистка текста нужна всем. Если sparse индексирует сырой HTML с меню, подвалом и служебными блоками, а dense получает уже очищенный текст, вы сравниваете не методы поиска, а две версии корпуса. Сразу зафиксируйте один пайплайн: как вы убираете шум, что делаете с таблицами, как чистите OCR, сохраняете ли заголовки и подписи.

С чанкингом тоже не стоит смешивать эксперименты. Короткие и длинные документы ведут себя по-разному, и размер чанка сильно меняет итог. Если один прогон идет по документам целиком, а второй по кускам на 300 токенов с overlap, причина разницы уже не в retriever. Сначала сравните методы на одной схеме разбиения. Потом отдельно сравнивайте сами схемы.

Перед первым прогоном полезно заморозить несколько вещей:

  • дату выгрузки корпуса
  • список поисковых полей
  • фильтры по языку, типу документа и правам доступа
  • правила очистки текста
  • схему чанкинга для этого запуска

Особенно внимательно проверьте поля и фильтры. Если sparse ищет по title и body, а dense кодирует только body, тест уже смещен. То же самое с фильтрами по языку, подразделению или статусу документа. Они должны работать одинаково во всех прогонах.

На смешанном корпусе это проявляется сразу. Допустим, юристы пишут короткие заметки, саппорт хранит длинные статьи, а product-команда любит таблицы и списки. Без общей подготовки dense может выиграть просто потому, что лучше пережил шумный формат, а не потому, что сам поиск сильнее. Сначала выровняйте вход, потом смотрите на цифры.

Как собрать запросы без перекоса

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

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

Потом разложите запросы по простым срезам. Достаточно взять короткие формулировки на 2-4 слова, длинные вопросы с несколькими условиями, разговорные запросы вроде "подскажите, где...", термины и аббревиатуры, а также шумные формы с опечатками и смешением русского и английского.

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

Даже для добора редких случаев не стоит писать все запросы одной рукой. Подключите несколько ролей: саппорт, аналитика, редактора базы знаний, инженера. Обычно уже на десятом примере видно, как у них расходится язык. Это хорошо. Так вы ближе к реальному трафику.

Полезно пометить и намерение запроса. Для практики хватает пяти меток: факт, инструкция, сравнение, точная фраза и поиск по идентификатору или термину. Такая разметка сильно упрощает разбор результатов. Если dense хуже ищет точные фразы, а hybrid выигрывает на длинных разговорных вопросах, это станет видно сразу, а не потеряется в средней метрике.

Как подготовить короткие и длинные документы

Одинаковый чанкинг для всего корпуса почти всегда портит сравнение. Если вы режете все документы на куски по 500 токенов, короткие карточки теряют форму, а длинные тексты распадаются так, что ответ остается между двумя chunk. После этого вы сравниваете уже не методы, а разные версии смысла.

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

С длинными документами подход другой. Регламент, договор, инструкция или отчет лучше делить по заголовкам, подзаголовкам и законченным смысловым блокам. Фиксированный размер можно использовать как верхнюю границу, но не как единственное правило. Иначе в один chunk попадут две темы, а в другой останется ссылка на таблицу без самой таблицы.

В смешанном корпусе полезно хранить метаданные отдельно от текста. Минимум - тип документа и команда-источник. Это нужно не только для фильтров. Так вы увидите, что один метод лучше ищет по FAQ, а другой по регламентам, и не спутаете разницу в алгоритме с разницей в стиле письма.

Базовая схема выглядит просто:

  • короткие карточки хранить целиком
  • длинные тексты делить по структуре документа
  • добавлять небольшой overlap только там, где мысль часто переходит через границу блока
  • сохранять тип документа, автора и раздел в отдельных полях
  • проверять chunk на полноту ответа, а не только на длину

Последний пункт часто пропускают. Возьмите 20-30 запросов с явным ответом в корпусе и вручную проверьте, содержит ли найденный chunk ответ полностью. Не намек и не половину абзаца, а фрагмент, который можно отдать в RAG без догадок.

Простой пример: в документе есть раздел "Лимиты по операциям", а ниже таблица с исключениями. Если chunk заканчивается перед таблицей, sparse найдет нужный заголовок, dense - похожий абзац, hybrid вернет оба фрагмента. Но ни один вариант не даст полный ответ. Проблема не в retriever, а в том, как вы нарезали документ.

Как провести тест

Попробуйте модели с открытыми весами
Если важны задержка и хранение данных в стране, попробуйте модели с открытыми весами на AI Router.

Начните с двух простых базовых линий. Для sparse обычно хватает BM25 без ручной подстройки. Для dense возьмите один эмбеддинг-чекпоинт и один способ чанкинга, без reranker на старте. Если сразу собрать сложный пайплайн, вы не поймете, что именно дало прирост.

Потом добавьте hybrid с самым простым fusion. Чаще всего хватает reciprocal rank fusion или понятной линейной смеси скорингов. Не пытайтесь в первый же день подбирать десяток весов. Для честного теста лучше грубая, но прозрачная схема, чем хитрая настройка, которую нельзя повторить через неделю.

Один и тот же набор запросов должен пройти через все три варианта. Нельзя менять формулировки, фильтры, правила чанкинга или состав корпуса между прогонами. Даже мелкая разница ломает сравнение, особенно если в корпусе рядом лежат короткие FAQ, длинные регламенты и документы от разных команд.

Практический порядок такой:

  • зафиксировать корпус, чанки, стоп-слова и фильтры
  • построить sparse-индекс и dense-индекс на одних и тех же данных
  • прогнать один пул запросов без ручной чистки выдачи после поиска
  • посчитать Recall@k, MRR и nDCG на одном наборе k, например 5 и 10
  • отдельно снять latency, размер индекса и цену обновления

Метрики лучше читать вместе. Recall@k показывает, нашла ли система нужный фрагмент вообще. MRR полезен, когда важно, насколько высоко он поднялся в списке. nDCG лучше показывает разницу, если релевантность не бинарная, а есть градации вроде "точно подходит", "частично подходит" и "шум".

Не смешивайте офлайн-качество и стоимость в одну цифру. Проще сделать обычную таблицу: качество, средняя latency p50 и p95, объем индекса, время и цена переиндексации. Тогда видно, например, что dense дал прирост на длинных документах, но обновляется в три раза дольше, а hybrid держит смешанный корпус ровнее и почти не проигрывает по скорости.

Если хотите получить результат, которому можно верить, оставьте тюнинг на второй круг. Первый прогон нужен не для рекорда, а для чистой отправной точки. Только после него имеет смысл подбирать веса fusion, менять размер чанка или добавлять reranker.

Пример на смешанном корпусе

Хороший тест видно на простом, но неровном наборе документов. Допустим, в базе лежат короткие FAQ на 2-3 строки, подробные инструкции на две страницы и шаблоны писем для поддержки. Все это описывает один процесс возврата, но тексты писали разные команды, поэтому формулировки гуляют.

Запрос берем разговорный: "как вернуть товар без чека". Он неудобный для чистого сравнения, и именно поэтому полезен. Пользователь пишет как умеет, а не так, как назвали раздел в базе знаний.

Как выглядит выдача

Sparse обычно цепляется за точные слова. Если в FAQ есть строка "возврат без чека возможен при подтверждении покупки", такой документ часто поднимется высоко. Сигнал сильный, потому что совпадение почти буквальное.

Dense ведет себя иначе. Он может найти длинную инструкцию, где нет слов "без чека", но есть абзац вроде "если клиент не сохранил чек, сотрудник проверяет оплату по карте или номеру заказа". По смыслу это правильный ответ, хотя формулировка другая.

На том же корпусе hybrid часто дает лучший верх выдачи. Он может держать рядом FAQ с точной фразой и длинную инструкцию, где описан порядок действий. Для RAG это обычно лучше, чем один короткий ответ без деталей или один длинный документ без явного совпадения в заголовке.

Упрощенный топ может выглядеть так:

  • sparse: FAQ "возврат без чека", шаблон письма с фразой "чек утерян", потом инструкция
  • dense: инструкция по возврату, FAQ про подтверждение покупки, потом заметка для операторов
  • hybrid: FAQ "возврат без чека", инструкция с шагами проверки покупки, потом шаблон письма

Где ломается fusion

Проблема часто не в самой идее hybrid, а в склейке рангов. Если fusion слишком давит семантический сигнал, наверх поднимутся просто похожие тексты про обмен, гарантию или отмену заказа. Если он, наоборот, слишком любит точные совпадения, длинная инструкция с нужным сценарием уйдет ниже короткого FAQ, и модель в RAG ответит слишком общо.

На таком корпусе полезно смотреть не только на первый документ, но и на top-3. Если в первой тройке есть и точный FAQ, и подробная инструкция, система ведет себя разумно. Если там три почти одинаковых шаблона писем, поиск формально что-то нашел, но пользы от этого мало.

Именно на таких запросах честный тест показывает реальную разницу. Sparse ловит формулировку "возврат без чека", dense понимает разговорный вопрос, а hybrid выигрывает только тогда, когда fusion не опускает точные совпадения ниже слишком общих смысловых попаданий.

Где методы расходятся

Начните с вашего корпуса
Прогоните свои RAG-сценарии через единый API и посмотрите, где ответы реально лучше.

Одно среднее по всему корпусу часто скрывает самое важное. Система может показать хороший общий балл и при этом провалиться именно на тех запросах, которые чаще всего приходят от людей.

Sparse обычно лучше ловит точные совпадения. Если в запросе есть код ошибки, артикул, номер формы, имя поля, аббревиатура или редкий термин, такой поиск часто попадает в цель быстрее dense. Для корпуса, который собирали разные команды, это обычная картина: название поля вроде client_id, "Форма 12" или внутренний код процесса sparse находит уверенно, а dense иногда уводит в похожий по смыслу, но неверный документ.

Dense лучше держится там, где человек пишет длинно и своими словами. Перефраз, разговорный вопрос, описание проблемы без точного термина - его сильная сторона. Если сотрудник спрашивает "как передать согласие клиента в новую анкету", dense чаще найдет нужный документ, даже если в тексте используется другая формулировка, например "перенос подтверждения в профиль".

Hybrid полезен, когда один и тот же процесс разные авторы описали разными словами. В таких корпусах гибридный поиск в RAG часто дает самый ровный результат: sparse цепляется за точные слова, dense подтягивает смысл. Это особенно заметно в базах знаний, которые собирались годами и не редактировались под единый словарь.

Но hybrid тоже ошибается. Если один сигнал становится слишком громким, он тянет выдачу не туда. Типичный случай: BM25 слишком высоко поднимает документ с нужным словом, но без ответа на вопрос. Бывает и обратная ошибка: dense ставит наверх текст "про то же самое", хотя нужный документ содержит точный реквизит и должен быть выше.

Поэтому смотрите не только на общий nDCG или Recall. Разбейте запросы хотя бы на несколько срезов: точные идентификаторы и коды, короткие запросы из 2-4 слов, длинные вопросы в свободной форме, перефразы одного намерения и случаи, где у одного процесса несколько названий.

После такого разреза картина становится честнее. Иногда оказывается, что победителя "в среднем" вообще нет: sparse нужен для точности, dense - для смысла, а hybrid имеет смысл только если вы умеете настраивать вес каждого сигнала под тип запроса.

Частые ошибки в оценке

Больше всего ошибок появляется не в формулах, а в подготовке теста. Если в одном корпусе лежат FAQ на три строки, длинные регламенты и документы от разных команд, метрики легко дают красивое, но нечестное сравнение.

Первая ошибка - сравнивать методы на разном чанкинге. Если sparse работает по целой странице, а dense видит куски по 300-500 токенов, вы сравниваете не поиск, а разные представления корпуса. Иногда после этого делают вывод, что один подход лучше, хотя он просто получил более удобный формат текста.

Вторая ошибка - подбирать fusion под тестовый набор. Это случается постоянно: берут hybrid, крутят веса dense и BM25, пока метрика не вырастет, а потом называют это финальным результатом. Для настройки нужен отдельный dev-набор. Тестовый набор должен оставаться закрытым до конца.

Третья ошибка тише, но тоже ломает выводы: корпус чистят только под один индекс. Например, убирают повторы заголовков, шаблонные фразы и стоп-слова для sparse, а dense оставляют на другом тексте. Или наоборот, переписывают поля и склеивают секции только ради эмбеддингов. Честный тест начинается с одной замороженной версии корпуса, из которой строятся все индексы.

Еще один источник шума - дубли, шаблоны и старые версии документов. Они часто поднимают score почти случайно. Запрос попадает не в нужный документ, а в его копию, черновик или устаревшую редакцию. На бумаге метрика выглядит нормально, а в реальной RAG-системе пользователь получает не тот ответ.

Средний score сам по себе тоже мало что говорит. Две системы могут иметь похожий Recall@10, но ошибаться по-разному. Одна стабильно теряет длинные документы. Другая путается в коротких служебных заметках, где мало терминов.

После замера полезнее вручную разобрать хотя бы 20-30 промахов и отметить причину. Обычно повторяются несколько сценариев: релевантный фрагмент не попал в индекс, чанк оказался слишком большим или слишком мелким, запрос и документ используют разную лексику, в выдачу попала старая версия или дубль, либо hybrid смешал ранги хуже, чем каждый метод по отдельности. Такой разбор обычно дает больше пользы, чем еще один знак после запятой в таблице.

Проверка перед финальным замером

Быстрее повторяйте замеры
После правок чанкинга или словаря переключайте модели через один и тот же интерфейс.

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

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

Перед запуском стоит пройтись по короткому списку:

  • взять один и тот же корпус из одной выгрузки
  • прогонять один набор запросов без смены формулировок между методами
  • держать одну схему разметки релевантности
  • зафиксировать одинаковый top-k
  • смотреть отчет по срезам, а не только одно общее число

Последний пункт часто и решает спор. Средняя метрика может выглядеть ровно, но внутри dense лучше держит длинные объясняющие тексты, sparse вытаскивает точные совпадения в коротких документах, а hybrid сглаживает разницу только на части запросов.

Рядом с таблицей метрик оставьте 20-30 ручных разборов промахов. Этого уже хватает, чтобы увидеть повторяющийся сбой: плохой chunk, шумные заголовки, путаницу языков, дубли или слишком общий запрос. Без таких примеров легко поверить красивому числу и пропустить реальную проблему.

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

Что делать после замера

После замера не спешите объявлять победителя по одной общей цифре. Средний nDCG или Recall@k полезен, но продукт живет не на среднем. Если 70% трафика у вас составляют короткие запросы с кодами, названиями тарифов или артикулами, сначала смотрите на этот срез.

Бывает так: dense выигрывает по общей метрике, но чаще ошибается на запросах, которые приносят пользователям больше всего боли. Тогда это не победитель для продукта. Логичнее брать метод, который надежнее закрывает приоритетные сценарии, даже если среднее значение у него чуть ниже.

Часто разумнее выбрать не один метод, а основную схему и запасной вариант. Например, dense лучше понимает свободные формулировки, а sparse точнее ловит аббревиатуры, номера форм и редкие термины. В таком случае не обязательно запускать hybrid везде. Иногда проще держать fallback и отправлять запросы с кодами и exact term во второй retriever.

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

Такой шаблон быстро убирает споры по ощущениям. Команда начинает смотреть на одни и те же цифры и одни и те же примеры.

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

Есть и другая ловушка: команда одновременно меняет retrieval и LLM-слой. Тогда уже непонятно, что дало прирост - поиск, reranking или новая модель ответа. Если вы параллельно тестируете разные модели, удобно держать инфраструктуру доступа к ним отдельной от эксперимента с retrieval. Например, AI Router на airouter.kz дает один OpenAI-совместимый endpoint для разных моделей, так что retrieval можно сравнивать отдельно, не переписывая SDK, код и промпты.

Нормальный итог такого теста звучит скучно, но именно это и нужно: на каких типах запросов каждый метод силен, где он ошибается, сколько стоит в поддержке и что вы выбрали для продакшена. Если ответ на эти вопросы у команды есть, сравнение прошло не зря.