ESPN2027 recruiting class rankings: Duke enters top 45 in latest updateThe Jerusalem PostFlights must be open to all or to no one, Iranian adviser says following Iraqi airport suspensionDaily MaverickFreedom from the ANC could be our real emancipationPunchLady apologises for AI-generated Itel power tank explosion imageUN NewsThe Takeaway: UN General Assembly debate Day 3RTP DesportoGP de Portugal antecipado para outubro em 2027, Argentina regressa e Hungria saiInquirerMarcos signs BSKE postponement into lawBollywood HungamaA true MASTERSTROKE: Avengers Endgame: Encore has MORE surprises beyond the leaked scenes; Marvel saves its BIGGEST twist for theatres (SPOILERS ahead)SCMP ChinaXi Jinping marks second Mid-Autumn Festival in US during state visitRadio Times10 Questions with Amol Rajan and Hannah FryBillboardMusic Venue Trust Teams Up With Drowned In Sound to Launch Live Music TitleSouth China Morning PostItaly to ban burkas in schools, with 30% cap on pupils with poor Italian
The Daily Newsstand · Free, Always
Friday, September 25, 2026

Гибридный RAG на Налоговом кодексе: восемь находок, семь потерь

Translate

В японских боевых искусствах путь мастера описывают тремя иероглифами — 守破離 (Сю-Ха-Ри): сначала следуй форме, затем меняй её, а после перестань от неё зависеть. Нельзя ломать то, что ещё не успел освоить.

守 (Сю) — это про то, как, сохранив себя, достичь своей лучшей версии. При чём здесь RAG? Да при том, что, прежде чем рваться к агентным RAG и GraphRAG, надо разобраться с базой — довести до ума уже имеющиеся формы, чтобы получить качественную систему.

Поэтому эта статья — про «Сю» продвинутого RAG: как фундаментально поднять качество, не перепрыгивая ступени «навороченности» с неоправданным риском.

У цели нет пути, только самурай

У цели нет пути, только самурай

Привет, меня зовут Бромбин Андрей. Сегодня на примере Налогового кодекса я покажу путь от базового RAG к гибридному поиску и реранкингу: где каждый следующий шаг действительно повышает качество, а где лишь усложняет систему. Все выводы я проверил на воспроизводимом стенде: 240 вопросов из FAQ ФНС, три способа поиска и отдельный прогон реранкера.

RAG это нечто большее, чем то, что было несколько лет назад. На одной чаше весов — «наполнить базу векторами, достать top-k, отдать модели», на другой — агентные системы, которые сами решают, где искать и как проверять результат. Между ними огромная пропасть. Существует множество техник, каждая из которых возникла не просто так. Но обычно мы сначала что-нибудь внедряем, а уже потом пытаемся понять, что оно должно было исправить.

Базовый RAG — это два шага

  1. Retrieval (поиск): находит релевантные данные в базе знаний. В базовом RAG для этого обычно используют векторный поиск — по смыслу, а не по точному совпадению слов. Это ловит перефразирование и синонимы.

  2. Augmented Generation (подкреплённая генерация): передаёт найденные данные LLM, чтобы она сгенерировала точный и полезный ответ.

В базу кладём не документы целиком, а чанки с их эмбеддингами. Приходит вопрос — достаём k ближайших и добавляем их в промпт. Такой поиск сравнительно быстрый и дешёвый, поэтому его недооценивают в обе стороны: одни считают достаточным для любой задачи, другие сразу прыгают к «продвинутым» техникам.

Пять шагов RAG-пайплайна

Пять шагов RAG-пайплайна

У такого dense RAG две больные точки. Первая — точные идентификаторы. Даже когда номер статьи буквально стоит и в вопросе, и в заголовке документа, dense-поиск иногда поднимает выше соседние фрагменты с похожим юридическим смыслом. Смысл он ловит хорошо, а редкий буквальный маркер может посчитать второстепенным. Вторая проблема — порядок выдачи. Нужный документ может найтись и всё равно утонуть среди похожих чанков. Если этот шум попадёт в промпт, он способен сбить LLM с толку. Это нормальная отправная точка. Без базового замера не понять, где система ошибается, и легко прикрутить реранжирование там, где проблема была в чанкинге.

Кстати, если базовый RAG для вас пока тёмный лес — у меня есть отдельная статья про него. Искренне рекомендую прочитать, вам точно понравится :)

В комментариях к ней меня спросили, что делать, когда обычный векторный поиск начинает промахиваться. Совет «добавьте BM25 и реранкер» без условий мало чем полезнее очередного чек-листа. Поэтому я собрал стенд и провёл эксперименты.

Hybrid: возвращаем поиск по буквам

Dense-поиск ищет по смыслу. Точные уникальные токены при этом размываются по базе. Наполняя RAG-базу номерами статей, например статьёй 107 НК РФ, и другими точными юридическими объектами, мы рискуем потерять их среди похожих по смыслу документов. Как это исправить? Возвращением к классическому поиску по совпадению слов. Он добавляет сигнал, для которого dense часто менее надёжен: точное совпадение слов и идентификаторов. Не будем избавляться от него. На вопросе к ФНС «Что указывать в письменных возражениях?» BM25 поставил нужную статью второй, а dense-модель BERTA не нашла её даже среди 50 чанков. Здесь сработало совпадение юридической формулировки, а не номер статьи.

Dense можно представить как друга, который понимает тебя с полуслова. А BM25 — как того самого препода на экзамене, которому нужна «альфа-бетта-гамма-штрих» без единой запинки. Совмещая два подхода, мы закрываем пробелы и того, и другого.

Выборку BM25 объединяем с dense через Reciprocal Rank Fusion (RRF) — метод объединения списков по местам документов в выдаче. Получаем одну выдачу, в которой есть и смысл, и точные совпадения.

Dense-поиск и BM25 объединяются через RRF в общее ранжирование

Dense-поиск и BM25 объединяются через RRF в общее ранжирование

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

\mathrm{RRF}(d) = \sum_{i,\in,\text{списки}} \frac{1}{k + \mathrm{rank}_i(d)},

Если векторная база умеет объединять выдачи сама, городить отдельный компонент не нужно. Qdrant, например, с версии 1.16 позволяет настроить k и объединить sparse- и dense-ветки через RRF прямо в Query API. Только есть сдвиг: в формуле выше места считаются с единицы, а Qdrant — с нуля. Поэтому нашему k = 60 в его API соответствует k = 61:

POST /collections/my-hybrid-collection/points/query
{
  "prefetch": [
    { "query": { "indices": [1, 42], "values": [0.22, 0.8] },
                 "using": "sparse", "limit": 20 },
    { "query": [0.01, 0.45, 0.67], "using": "dense", "limit": 20 }
  ],
  "query": { "rrf": { "k": 61 } },
  "limit": 10
}

Hybrid — это ансамбль из двух ретриверов. Cам факт объединения ничего не гарантирует: если одна ветка шумит, RRF честно подмешает этот шум в общую выдачу.

Reranking: когда ответ нашёлся, но затерялся

Hybrid повысил шансы, что нужный документ вообще окажется среди кандидатов. Но порядок внутри выдачи всё ещё грубый. Bi-encoder кодирует запрос и документ порознь и не видит пару целиком.

Реранкер работает иначе. Cross-encoder, или перекрёстный энкодер, читает пару «запрос — документ» целиком, оценивает её и пересортировывает выборку. После этого в промпт отправляется финальный top-k. Реранкер помогает отсеять лишние документы до передачи в LLM. Модель и сама может разобраться в шуме, но этот шум занимает место в контексте.

В моём стенде, о котором ниже, эту работу делает bge-reranker-v2-m3. Это мультиязычный Transformer-классификатор на базе XLM-RoBERTa, а не генеративная LLM.

Модель возвращает для каждой пары логит — одно необработанное число релевантности. Чем оно выше, тем лучше модель оценила пару. Функция sigmoid может сжать логит до диапазона от 0 до 1, не меняя порядок документов. Но без отдельной калибровки это всё равно не вероятность правильного ответа.

Большие LLM тоже умеют ранжировать документы, но это другой эксперимент. RankGPT показал, что большие LLM умеют ранжировать документы и иногда обходят специализированные модели. Для этого нужен свой промпт, другая схема прогона и отдельный замер цены и задержки. Здесь BGE выбран потому, что запускается локально и даёт воспроизводимую оценку без внешнего API.

Bi-encoder сравнивает готовые векторы, cross-encoder читает пару целиком

Bi-encoder сравнивает готовые векторы, cross-encoder читает пару целиком

// Реранкер чинит порядок, но не промахи retrieval:
// чего нет в выборке, того он не вернёт
List<RetrievedDoc> candidates = hybrid.retrieve(query, rerankLimit);

// Каждый кандидат образует отдельную пару, но GPU считает их батчами
// bge-reranker-v2-m3
double[] scores = reranker.score(query, texts(candidates));

// Пересортировали выборку по score, оставили top-k
return rankByScore(candidates, scores)
    .subList(0, Math.min(k, candidates.size()));

Дёшево собираем широкую выборку, а дорого причёсываем только её.

Что именно мы измеряем

Ниже один набор вопросов ФНС, но две разные проверки.

Этап

Эталон

Что проверяем

Dense, BM25, hybrid

Статья из поля «Источник» в FAQ ФНС

Попала ли статья в первые 10 и 50 результатов

BGE-reranker

Процитированный пункт, однозначно сопоставленный с чанком

Попал ли пункт в top-10 и насколько высоко поднялся

Первая проверка измеряет охват статьи. Она не доказывает, что найден правильный пункт или полный набор оснований.

Для реранкера знания номера статьи мало: надо понимать, какой именно чанк считать правильным. Поэтому я отдельно отобрал 79 из 240 вопросов, где ФНС ссылалась на конкретный пункт, который однозначно помещался в один чанк. Это диагностический срез. Правило отбора было зафиксировано до подсчёта метрик и видело только поле «Источник» и корпус — без вопроса, ответа ФНС, выдачи и оценок моделей. Hit@10 показывает, попал ли хотя бы один нужный пункт в первую десятку. nDCG@10 учитывает порядок всех процитированных пунктов: первое место весит больше десятого, промах получает ноль. Обе проверки заканчиваются на поиске, LLM я здесь не вызываю. Больше замеров — в github-репозитории далее.

Что получилось на Налоговом кодексе

Корпус состоит из 815 статей НК РФ, разбитых на 2074 чанка.

Для проверки я взял 240 вопросов из FAQ ФНС, отобранных до запуска поиска. Эталоном служило поле «Источник» в ответе: если там была ссылка на НК РФ, я проверял, нашёл ли поиск указанную статью. После технической сверки ссылок с зафиксированной редакцией кодекса в предварительный срез вошли 186 вопросов.

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

Вот один из вопросов и эталон для него:

Вопрос ФНС: «Что указывать в письменных возражениях?»

Ответ ФНС: реквизиты оспариваемого акта и мотивированные доводы против претензий проверяющих; к возражениям можно приложить подтверждающие документы.

Источник, указанный ФНС: статья 100 НК РФ.

Первый этап

Хотя бы одна статья нашлась в первых 10 чанках

В первых 50 чанках

BERTA dense

162 из 186

176 из 186

Lucene BM25

153 из 186

177 из 186

hybrid RRF

163 из 186

182 из 186

BM25 здесь не победил dense: в первой десятке у него 153 попадания против 162. Высокий абсолютный результат появился не из-за номеров статей — они встретились лишь в трёх вопросах из 240. Просто FAQ ФНС и Налоговый кодекс говорят на одном юридическом языке. Но когда в вопросе «Кто является налогоплательщиком ЕСХН?» сокращения ЕСХН в нужном тексте не оказалось, BM25 промахнулся, а dense поставил статью первой.

В первой десятке почти ничья. На глубине 50 hybrid уменьшил число вопросов без единой процитированной статьи с десяти до четырёх: с 176/186 до 182/186. Это бинарная метрика. В пяти вопросах с несколькими ссылками hybrid, наоборот, потерял часть процитированных статей.

На вопросе про учёт займа ценными бумагами dense и BM25 поставили нужный фрагмент на третье место, а RRF поднял его на первое. Но бывает наоборот: сильный сигнал одной ветки может разбавиться шумом другой.

Здесь hybrid пригодился перед следующим этапом: две ветки собрали более полную входную выборку. @50 — диагностическая глубина первого этапа, а не рекомендация.

Встроенный и клиентский RRF Qdrant 1.19 на одних ветках дали одинаковые оценки с точностью 1e-7. Разошёлся только порядок при равных оценках, причём иногда прямо на границе top-k. Поэтому в таблицах я использую детерминированную сортировку ничьих по docId.

Когда hybrid можно не запускать

Если тип запроса понятен заранее, его можно направить в подходящую ветку. Например, запросы с номером статьи — в BM25, остальные — в hybrid. Но во внешнем наборе номер статьи встретился лишь в трёх вопросах из 240, поэтому такое правило почти ничего бы не сэкономило. Ценность маршрутизации определяет доля запросов, которые действительно можно уверенно направить в одну ветку.

Что дал reranking на тех же вопросах ФНС

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

Кандидатов получил BGE

Хотя бы один нужный пункт был в выборке

Hit@10 до и после BGE

nDCG@10 до и после BGE

20

72 из 79

с 67 до 71

с 0,603 до 0,727

50

74 из 79

с 67 до 71

с 0,603 до 0,719

При 50 кандидатах на вопрос BGE вернул в top-10 пять вопросов и потерял один. Ещё в одиннадцати вопросах nDCG снизился, но нужный пункт остался в десятке. Главный эффект здесь именно в порядке, а не в четырёх дополнительных попаданиях.

Например, пункт 1 статьи 119 о санкции за непредставление декларации hybrid поставил на шестнадцатое место. BGE поднял его на первое, выше общих норм о налоговых санкциях. Вот ради таких перестановок реранкер и нужен.

BGE не создаёт новые находки. Если нужного документа нет среди кандидатов, никакая пересортировка его не вернёт.

Сколько кандидатов отдавать BGE

Пятьдесят кандидатов нашли два дополнительных целевых фрагмента в хвосте, но ни один не попал в финальную десятку. Выборки из 20 и 50 дали одинаковые 71 из 79, а nDCG@10 на двадцати оказался даже чуть выше.

Это не делает 20 универсальным числом. На 5060 Ti 16 ГБ задержка BGE росла так:

Кандидатов

p50

p95

10

203 мс

229 мс

20

414 мс

460 мс

30

633 мс

689 мс

50

1064 мс

1152 мс

Переход с 20 на 50 кандидатов не улучшил Hit@10, зато увеличил медианную задержку в 2,6 раза. Здесь измерялся только локальный вызов BGE без конкурентной нагрузки. Расширять выборку стоит только пока растёт полнота кандидатов или качество финальной десятки.

Как приручить это в своём RAG

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

Что болит

Что измерить

Что попробовать

Чего не ждать

Dense теряет буквальные совпадения

Hit@10 dense и BM25 на таком срезе

BM25 или маршрутизацию

Не поможет, если текста нет в корпусе

Dense и BM25 находят разные документы

Поштучные находки веток и RRF

Hybrid

RRF может утопить сильный сигнал одной ветки

Документ найден, но ниже top-10

Candidate Hit и nDCG@10 до и после BGE

Reranking

Не вернёт отсутствующий документ

Выборка растёт, а top-10 не улучшается

Качество и задержку на нескольких глубинах

Уменьшить выборку

Ширина добавляет шум и задержку

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

Код, конфигурация эксперимента, метрики по каждому вопросу и полная таблица результатов лежат в Github-репозитории. Дословные вопросы и ответы FAQ ФНС я в репозиторий не выкладываю.

Заключение

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

На этом со ступенью Сю всё. Дальше начинается более неприятная проблема: нужный контекст может разорваться между чанками, а вопрос — оказаться написан совсем не теми словами, что документ. Этим и займёмся во второй части.

Тизер к части 2 про чанкинг и запросы по Дао, будет интересно

Тизер к части 2 про чанкинг и запросы по Дао, будет интересно

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.