---
title: "Гибридный поиск для RAG: как «ноги» усиливают друг друга"
date: 2026-10-25T09:00:00+03:00
type: post
lang: ru
domains: ["ai","architecture"]
tags: ["rag","search","reranking","knowledge-graph"]
series: rag-in-production
series_weight: 1
summary: "Векторный и лексический поиск, граф знаний и LLM-реранкер в одной связке: nDCG@10 на эталонном наборе вырос с 0.756 до 0.813, а ответ ускорился с 7.7 до 1.3 с."
llm_summary: "Первая статья цикла «RAG в проде». Поиск RAG-ассистента по корпоративной базе знаний на русском (38 тыс. вики-страниц, 600 тыс. карточек реестра) построен из независимых «ног»: векторной (HNSW), лексической (RUM), графа знаний и двухпроходного реранкера Qwen3-Reranker-4B. Абляции показывают, что ноги дополняют друг друга: вектор держит топ-10, лексика и граф наполняют глубокий пул. Итог: nDCG@10 0.756 → 0.813, key-doc R@10 0.647 → 0.755, задержка p50 7.7 → 1.3 с. Разобраны RRF по спискам источников, переход с кросс-энкодера на декодерный реранкер (KV-кэш, FP8) и порядок «сначала состав, потом порядок» в хвосте ранжирования."
---


Гибридный поиск из нескольких независимых «ног» с переранжированием и графом знаний поднял nDCG@10 на нашем эталонном наборе с 0.756 до 0.813, а долю найденных ключевых документов в топ-10 — с 0.647 до 0.755. Ни один приём по отдельности такого не даёт: выигрыш возникает там, где ноги закрывают слепые зоны друг друга.

> [!BIZ]
>
> - **Ассистент отвечает почти в 6 раз быстрее:** медианное время поиска упало с 7.7 до 1.3 с.
> - **В выдаче чаще то, что нужно:** в топ-10 теперь попадает в среднем 75% ключевых документов запроса против 44% на старте, а сама выдача выросла с 6 до 9.4 документа из 10.
> - **Главный урок:** волшебной технологии нет. Результат дала связка нескольких дешёвых способов поиска и одного дорогого «судьи», и мерить их нужно только вместе.

> [!NOTE] О цикле «RAG в проде»
> Четыре статьи о том, как за две-три недели октября 2026 года мы перестроили поиск корпоративного RAG-ассистента. «Мы» — это автор и AI-агенты, которым он делегировал часть исследований и правок. Компания и системы обезличены, цифры настоящие.
>
> 1. **Гибридный поиск: как «ноги» усиливают друг друга** ← вы здесь
> 2. [Индексы под гибридный поиск в PostgreSQL](/posts/postgres-hybrid-indexes)
> 3. [Структурный чанкинг и контекстные эмбеддинги](/posts/structural-chunking)
> 4. [Граф знаний в RAG](/posts/knowledge-graph-rag)

> [!TERM]- Термины и метрики за минуту
>
> - **RAG** (Retrieval-Augmented Generation) — ассистент сначала ищет фрагменты документов, затем языковая модель отвечает по ним [[1]](#литература).
> - **Чанк** — фрагмент документа, единица поиска и контекста модели.
> - **«Нога» поиска** — независимый способ набрать кандидатов: по смыслу (векторы), по словам (лексика), по связям (граф).
> - **Реранкер** — модель, которая оценивает пару «запрос — документ» точнее поисковых индексов, но дороже. Поэтому ей дают уже отобранный пул.
> - **nDCG@10** — качество порядка в первой десятке с учётом степени релевантности: 1.0 — идеальный порядок.
> - **key-doc R@10 (g3)** — доля ключевых документов запроса (оценка 3 по шкале 0–3), попавших в топ-10.
> - **nR@10** — полнота в топ-10, нормированная на min(10, число релевантных документов).
> - **Success@5 (g3)** — доля запросов, у которых хотя бы один ключевой документ есть в топ-5.
> - **MRR@10** — насколько высоко стоит первый правильный документ (среднее 1/ранг).
> - **п.п.** — процентные пункты: рост с 0.647 до 0.755 — это +10.8 п.п.
> - **95%-й бутстреп-интервал [a; b]** — диапазон, в котором с 95%-й уверенностью лежит эффект. Если он не включает ноль, эффект статистически значим.
> - **LLM-судья** — модель, которая размечает релевантность пар «запрос — документ» по шкале 0–3 (по мотивам UMBRELA [[11]](#литература)).
> - **p50 / p95** — медиана и 95-й перцентиль задержки.

## Постановка {audience="biz arch"}

Задача — поиск для RAG-ассистента по корпоративной базе знаний на русском языке. Корпус неоднородный:

- две вики-платформы, около 38 тысяч страниц и статей: регламенты, стандарты, технические описания, таблицы;
- реестр мастер-данных, около 600 тысяч коротких карточек: договоры, справочники, коды;
- запросы пользователей — от естественного вопроса до «каши» из ключевых слов и кодов.

Один векторный поиск на таком корпусе ведёт себя предсказуемо плохо. Он хорошо понимает перефразировки, но путает похожие регламенты соседних подразделений, не различает коды и артикулы и тонет в 600 тысячах однотипных карточек реестра.

Качество мы меряем на эталонном наборе из 111 запросов пяти классов: точечный поиск документа, короткий запрос, «каша» из ключевых слов, строка таблицы, многошаговый запрос через сущности. Релевантность размечает LLM-судья [[11]](#литература); кандидатов для разметки собирают со всех сравниваемых прогонов (пулинг), а разницу между прогонами мы оцениваем бутстрепом по запросам. Определения метрик — в блоке «Термины и метрики» выше.

## Исходные условия {audience="biz eng"}

На старте программы поиск отвечал за 7.7 с (p50) и возвращал в среднем 6 документов из 10, а в топ-10 попадало в среднем 44% ключевых документов запроса. Архитектура ног уже была, но часть из них не работала:

- **Графовая стадия таймаутила в 100% запросов** и добавляла около 5 с к каждому ответу, возвращая пустой результат. Причина — план запроса к графовой БД с полным сканом 4.7 млн вершин вместо индекса.
- **Векторная нога теряла кандидатов на фильтре по источнику:** из 50 запрошенных строк для одной из вики оставалось 12, для реестра — 0 (подробно — во [второй статье](/posts/postgres-hybrid-indexes)).
- **Реранкер** (bge-reranker-large) получал пул, глубина которого была привязана к длине выдачи: при ответе из 10 документов на переранжирование попадало слишком мало кандидатов.
- **Гейт релевантности** отрезал выдачу в среднем до 6 документов.

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

| Метрика | До начала работ | Сейчас |
| --- | ---: | ---: |
| nDCG@10 | 0.542 | 0.813 |
| key-doc R@10 (g3) | 0.439 | 0.755 |
| nR@10 | 0.497 | 0.824 |
| Success@5 (g3) | 0.865 | 0.973 |
| MRR@10 | 0.911 | 0.979 |
| Документов в выдаче, среднее | 6.0 | 9.4 |
| Сквозная задержка p50, с | 7.7 | 1.3 |
{caption="Поиск до и после программы. 111 запросов, единый набор судейских оценок"}

Между стартом и точкой, от которой считается мост в разделе [«Итоговые цифры»](#итоговые-цифры) (nDCG@10 0.756), лежат три изменения: глубина пула реранкера отвязана от длины выдачи, на вход реранкера добавлен заголовок документа, а сам реранкер заменён на bge-reranker-v2-m3. Задержку сняли в первую очередь исправления графовой стадии и индексов. Переход на Qwen3-реранкер потом добавил к ней около 0.5 с, и итоговые 1.3 с уже включают эту цену.

## Архитектура {audience="arch eng"}

Конвейер строится вокруг одного принципа: дешёвые ноги набирают широкий пул кандидатов, дорогой реранкер расставляет его одной линейкой, а дешёвые множители после него только переставляют похожие.

```mermaid {caption="Конвейер поиска: две ноги и граф сходятся в один общий проход реранкера"}
flowchart TB
  q([Запрос пользователя]) --> a["Анализ запроса<br/>тип запроса, термины, коды"]
  a --> v["Векторная нога<br/>HNSW по halfvec(2000), по источникам"]
  a --> l["Лексическая нога<br/>RUM, AND → OR, по источникам"]
  v --> f["Reciprocal Rank Fusion<br/>по спискам каждого источника"]
  l --> f
  f --> ce["Кросс-энкодер по ногам<br/>до 40 документов × 3 чанка"]
  f --> g["Граф знаний<br/>сиды: топ-k после слияния"]
  ce --> m["Слияние<br/>среднее по ногам + графовые кандидаты"]
  g --> m
  m --> r["Общий проход реранкера<br/>Qwen3-Reranker-4B, fp8"]
  r --> t["Хвост ранжирования<br/>фьюжн → гейт → свежесть → архив ×0.8"]
  t --> o([Топ-10 в контекст модели])
  classDef key stroke-width:3px
  class r key
```

Граф стартует от топа после RRF и работает параллельно с первым проходом реранкера, поэтому почти не добавляет задержки. Векторная и лексическая ноги ищут по каждому источнику отдельно: зачем — в следующем разделе, а устройство индексов — во [второй статье цикла](/posts/postgres-hybrid-indexes).

> [!ARCH]
> Ключевое решение — разделить роли по цене. Ноги дешёвые и отвечают за полноту пула, реранкер дорогой и отвечает за порядок. Всё, что после реранкера, умеет только переставлять. Благодаря этому каждую ступень можно менять и мерить отдельно, не боясь, что она сломает соседнюю.

## Синергия: что ловит каждая нога {audience="ml arch"}

Абляции показывают, что ноги не дублируют, а дополняют друг друга. Уберите векторную — ключевые документы в топ-10 теряются на 13 п.п. Уберите лексическую — проседает полнота глубокого пула.

Замер сделан на ранней версии конвейера (до структурного чанкинга и нового реранкера), выдача 20 документов. В таблице — разница с полным конвейером, в скобках 95%-й бутстреп-интервал.

| Конфигурация | Δ nDCG@10, п.п. | Δ key-doc R@10, п.п. | Δ R@50 пула, п.п. |
| --- | ---: | ---: | ---: |
| Только лексика | −8.6 [−12.2; −5.2] | −13.0 [−18.2; −8.3] | −10.7 [−14.5; −7.3] |
| Только векторы | −1.1 [−4.8; +2.4] | −2.2 [−6.8; +2.2] | −5.2 [−9.1; −1.6] |
| Без графа | +0.1 [−0.9; +1.1] | −0.1 [−1.4; +1.2] | −2.1 [−3.1; −1.2] |
{caption="Абляции ног: разница с полным конвейером"}

Что за этим стоит:

- **Векторная нога** ловит семантику и перефразировки [[2]](#литература): «как оформить отпуск» ↔ «порядок предоставления ежегодного оплачиваемого отпуска». На ней держится топ-10.
- **Лексическая нога** ловит коды, артикулы, номера стандартов, редкие термины и строки таблиц. Эмбеддинг видит «ABC-1234» и «ABC-1235» почти одинаково, а индекс по словам — нет. К тому же лексика устойчивее векторов вне домена, на котором обучали эмбеддинги [[3]](#литература). Она наполняет пул кандидатов, из которого реранкер потом достаёт нужное.
- **Граф знаний** на топ-10 не влияет, но даёт +2 п.п. полноты глубокого пула. Это документы, связанные с выдачей общей сущностью (системой, процессом, стандартом), но не похожие на запрос ни по словам, ни по смыслу. Для RAG важнее другое: граф кладёт в контекст модели связи между сущностями [[9]](#литература), а это метриками ранжирования не измеряется. Подробно — в [четвёртой статье](/posts/knowledge-graph-rag).

> [!ML]
> Обратите внимание на последнюю колонку. У строк «Только векторы» и «Без графа» интервалы для nDCG@10 включают ноль, а для R@50 пула — нет. Если оценивать ногу только по топ-10, её вклад не виден: он проявляется через пул, из которого реранкер выбирает финальный порядок.

### Почему списки — по источникам {audience="arch eng"}

Каждая нога ищет отдельно по каждому источнику и отдаёт свой список в Reciprocal Rank Fusion [[4]](#литература). Без этого реестр из 600 тысяч однотипных карточек статистически вытесняет из общего топа вики-страницы. Со списками по источникам лучший документ каждого источника получает ранг 1 в своём списке, и RRF даёт ему равные шансы попасть к реранкеру.

RRF удобен ещё и тем, что игнорирует скоры и смотрит только на ранги. У нас k = 60 — классическое значение из исходной работы:

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

Косинусная близость векторов и ts_rank лексики живут в несравнимых шкалах, а ранги сравнимы всегда.

### Почему реранк — по ногам, а потом общий {audience="eng ml"}

Кросс-энкодер [[5]](#литература) сначала оценивает кандидатов каждой ноги отдельно (до 40 документов × 3 чанка), затем оценки усредняются при слиянии, а после добавления графовых кандидатов идёт один общий проход. Повторные пары не пересчитываются: в рамках запроса они мемоизируются, между запросами — кэшируются. В итоге графовые чанки не получают незаслуженного преимущества из-за своих исходных скоров, а финальный скор всех кандидатов измерен одной линейкой.

Резать глубину реранка ради скорости нельзя. При 30 документах × 2 чанка мы теряли весь выигрыш нового реранкера, а при 25 × 1 качество упало ниже старого (−4.6 п.п. nDCG@10). Лексическая нога полезна именно через глубокий пул.

## Реранкер: от классификатора к декодеру {audience="ml eng"}

Замена bge-reranker-v2-m3 (568M параметров, классический кросс-энкодер [[7]](#литература)) на Qwen3-Reranker-4B [[8]](#литература) дала +2.4 п.п. nDCG@10 и +3.3 п.п. key-doc R@10, оба прироста статистически значимы. Цена — около 0.5 с на запрос, и почти вся инженерная работа ушла на то, чтобы её снизить.

Qwen3-Reranker — это декодер. Он получает промпт «инструкция + запрос + документ» и должен ответить yes или no. Релевантность — нормированная вероятность первого токена (та же идея, что у ранжирования через токен «true/false» [[6]](#литература)):

```latex
s(q, d) = \frac{p(\text{yes} \mid q, d)}{p(\text{yes} \mid q, d) + p(\text{no} \mid q, d)}
```

Три практических наблюдения.

1. **Серверный rerank-эндпоинт инференс-движка сжимал скоры в полосу 0.85–0.97.** Причина — шаблон чата без пустого блока рассуждения, который модель ожидает перед ответом. Мы отказались от эндпоинта: собираем промпт ровно как в карточке модели, шлём пачку в генерацию с max_new_tokens = 1 и считаем s по logprobs двух токенов. Скоры снова растянулись на весь диапазон [0, 1].
2. **Задержку душил не декодер, а конфигурация памяти.** При доле видеопамяти 0.18 веса занимали почти всё, KV-кэш получал 1 698 токенов, и пары шли по 2–3 за раз. При 0.30 кэш вырос до ~60 тысяч токенов. Реальный объём — около 40 тысяч токенов prefill на запрос (~200 пар) при пропускной способности 24–48 тысяч токенов в секунду. Общий префикс «инструкция + запрос» переиспользуется через radix-кэш, так что вычисления тратятся только на тела документов.
3. **FP8 дал скорость без потери качества.** Квантизация весов при загрузке ускорила реранк в 2.3 раза при том же nDCG@10 (0.813 против 0.813).

| Конфигурация | nDCG@10 | key-doc R@10 | Реранк p50 / p95, с |
| --- | ---: | ---: | ---: |
| bge-reranker-v2-m3 | 0.789 | 0.722 | 0.09 / 0.20 |
| Qwen3-Reranker-4B, bf16, 40 × 3 | 0.813 | 0.759 | 1.42 / 2.92 |
| **Qwen3-Reranker-4B, fp8, 40 × 3** | **0.813** | **0.755** | **0.63 / 1.23** |
| Qwen3-Reranker-4B, fp8, 30 × 2 | 0.787 | 0.707 | 0.51 / 0.91 |
| Qwen3-Reranker-4B, fp8, 25 × 1 | 0.743 | 0.642 | 0.43 / 0.80 |
{caption="Реранкеры и глубина реранка. «40 × 3» — до 40 документов на ногу и до 3 чанков на документ; все строки на одном индексе и одних судейских оценках. Жирным — конфигурация в проде"}

> [!ENG] Проверьте у себя
>
> - Если скоры LLM-реранкера сбились в узкую полосу у единицы, сначала сверьте шаблон промпта с карточкой модели, а потом уже трогайте пороги.
> - Посмотрите, сколько токенов KV-кэша остаётся после загрузки весов. Если там единицы тысяч, пары идут по одной-две, и никакая квантизация задержку не спасёт.
> - Держите общий префикс «инструкция + запрос» в начале промпта: тогда radix-кэш считает его один раз на запрос.

## Хвост ранжирования после реранкера {audience="arch eng"}

После реранкера идут четыре дешёвых шага, и их порядок важен: сначала решаем, кто попадает в выдачу, потом — в каком порядке. Модель читает контекст неравномерно, поэтому состав и порядок топа важнее его длины [[10]](#литература).

1. **Фьюжн скоров.** Итоговый скор — взвешенная сумма нормированных скоров вектора (0.2), лексики (0.2) и реранкера (0.6). Реранкер доминирует, но точное совпадение кода или термина не теряется из-за одной его ошибки.
2. **Гейт релевантности с полом.** Порог отсечки зависит от типа запроса: для «каши» из ключевых слов и коротких запросов он свой. Пол гарантирует минимальную длину выдачи: пустой ответ для RAG хуже слабого. Пороги калибруются под конкретный реранкер: декодер оценивает средне-релевантные документы выше, чем классификатор.
3. **Свежесть.** Скор умножается на множитель, который затухает с возрастом документа a — числом дней с его последней правки в источнике — с периодом полураспада 180 дней. Применяется после гейта, поэтому меняет порядок, но не состав выдачи.
4. **Понижение архивных.** Архивные статьи остаются находимыми, но их скор умножается на 0.8. Тот же принцип: состав решён гейтом, множитель только переставляет. Актуальная страница при равной релевантности стоит выше своей заменённой версии.

```latex
m_{\text{свежесть}}(a) = 1 + 0.15 \cdot 2^{-a/180}
```

> [!ARCH]
> Правило «сначала состав, потом порядок» делает множители безопасными для настройки. Они не могут выбросить из выдачи релевантный документ или втянуть нерелевантный — только переставить похожие. Бизнес может крутить «свежесть» и «архив» без риска сломать полноту.

## Итоговые цифры {audience="biz ml"}

Вместе изменения дали +10.7 п.п. key-doc R@10 [+5.9; +15.7] и +5.7 п.п. nDCG@10 [+3.2; +8.4] при прежнем MRR@10 ≈ 0.97–0.98. Первый правильный документ система находила и раньше, а растёт полнота нужного в топе.

```nivo
{
  "chart": "bar",
  "waterfall": true,
  "indexBy": "step",
  "keys": ["key-doc R@10"],
  "data": [
    {"step": "Исходная точка", "key-doc R@10": 0.647, "kind": "total", "label": "0.647"},
    {"step": "Чанкинг + граф", "key-doc R@10": 0.065, "kind": "delta", "label": "+6.5 п.п."},
    {"step": "HNSW m=32", "key-doc R@10": 0.010, "kind": "noise", "label": "+1.0 п.п. (шум)"},
    {"step": "Qwen3-Reranker fp8", "key-doc R@10": 0.033, "kind": "delta", "label": "+3.3 п.п."},
    {"step": "Итог", "key-doc R@10": 0.755, "kind": "total", "label": "0.755"}
  ],
  "labels": true,
  "valueFormat": ".2f",
  "valueScale": {"min": 0, "max": 0.8},
  "axisValueLegend": "key-doc R@10",
  "height": 340,
  "caption": "Мост key-doc R@10 от 0.647 до 0.755: вклад каждого изменения. Бледный столбец — 95%-й интервал включает ноль. 111 запросов, единые судейские оценки, октябрь 2026",
  "fallback": {"kind": "table"}
}
```

Перестройка индекса с классическим реранкером дала всего +1 п.п., а в паре с Qwen3 — значимые +3.9 п.п. (отдельный прогон). Более сильный реранкер умеет пользоваться кандидатами, которых прежний индекс не находил, а слабый не дотягивает их до топа. Это и есть синергия: ступени конвейера нужно мерить вместе.

## Выводы {audience="biz arch eng ml"}

- **Ноги ценны разным.** Вектор держит топ, лексика и граф наполняют глубокий пул. Оценивать ногу только по nDCG@10 — значит не увидеть её вклад.
- **Списки по источникам в RRF** — дешёвая защита от доминирования самого большого источника.
- **LLM-реранкер окупается,** если следить за KV-кэшем и квантизацией, а глубину реранка не трогать.
- **Множители после реранкера** (свежесть, архив) применяются после гейта и потому безопасны.

Дальше по циклу — [индексы в PostgreSQL](/posts/postgres-hybrid-indexes), на которых стоят обе ноги.

## Литература {audience="ml" .references}

1. Lewis P. et al. [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401). arXiv:2005.11401, 2020.
2. Karpukhin V. et al. [Dense Passage Retrieval for Open-Domain Question Answering](https://arxiv.org/abs/2004.04906). arXiv:2004.04906, 2020.
3. Thakur N. et al. [BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models](https://arxiv.org/abs/2104.08663). arXiv:2104.08663, 2021 — почему BM25 остаётся сильным базовым уровнем вне домена обучения.
4. Cormack G., Clarke C., Buettcher S. [Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods](https://dl.acm.org/doi/10.1145/1571941.1572114). SIGIR 2009 (без версии на arXiv).
5. Nogueira R., Cho K. [Passage Re-ranking with BERT](https://arxiv.org/abs/1901.04085). arXiv:1901.04085, 2019.
6. Nogueira R. et al. [Document Ranking with a Pretrained Sequence-to-Sequence Model](https://arxiv.org/abs/2003.06713). arXiv:2003.06713, 2020 — ранжирование через вероятность токена «true/false», предшественник схемы yes/no.
7. Chen J. et al. [M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation](https://arxiv.org/abs/2402.03216). arXiv:2402.03216, 2024 — основа bge-reranker-v2-m3.
8. Zhang Y. et al. [Qwen3 Embedding: Advancing Text Embedding and Reranking Through Foundation Models](https://arxiv.org/abs/2506.05176). arXiv:2506.05176, 2025.
9. Edge D. et al. [From Local to Global: A Graph RAG Approach to Query-Focused Summarization](https://arxiv.org/abs/2404.16130). arXiv:2404.16130, 2024.
10. Liu N. F. et al. [Lost in the Middle: How Language Models Use Long Contexts](https://arxiv.org/abs/2307.03172). arXiv:2307.03172, 2023 — почему порядок и состав топ-10 важнее длины контекста.
11. Upadhyay S. et al. [UMBRELA: UMbrela is the (Open-Source Reproduction of the) Bing RELevance Assessor](https://arxiv.org/abs/2406.06519). arXiv:2406.06519, 2024 — шкала 0–3 и промпт LLM-судьи.

Идентификаторы arXiv сверены с API arXiv 25 октября 2026.

