# Reasoning-ретривал: мы научили поиск по коду думать, а не просто совпадать

> В Octocode появился опциональный шаг рассуждения: LLM читает найденный код и переранжирует его по реальной релевантности, а результат сливается с гибридным поиском через RRF. На бенчмарке из 127 запросов это даёт +36% к MRR и Hit@5 = 0,953 — все метрики вверх. Вот цифры, тюнинг и то, что не сработало.

Векторный поиск ранжирует по похожести. Гибридный — по похожести плюс совпадению ключевых слов. Но от поиска по коду вам на самом деле нужна **релевантность**, а похожесть — это не релевантность. Фрагмент, у которого больше всего общих токенов с запросом, часто оказывается не тем кодом, который отвечает на вопрос.

Спросите «где мы решаем, что запрос можно повторить», и ранжирование по похожести с радостью выдаст вам структуру конфига ретраев, константу с числом попыток и тест, где это слово встречается четыре раза, — а единственная функция, которая принимает решение, окажется на седьмом месте. Все токены совпали. Ответа нет.

PageIndex сформулировал это для документов: заменить top-k по похожести на LLM, которая _рассуждает_, какие разделы релевантны. Мы захотели того же для кода, где структура богаче, чем оглавление PDF: есть символы, граф вызовов, настоящие тела функций.

Так мы это и сделали: опциональный шаг рассуждения стоит после гибридного поиска, читает код кандидатов и переранжирует их по тому, отвечают ли они на запрос. По умолчанию он выключен, включается одним флагом в конфиге, а `structural_search` остаётся обычным детерминированным грепом, каким и должен быть. На нашем бенчмарке это крупный выигрыш по всем метрикам сразу. Этот пост — честный разбор: цифры, как мы это настраивали и две вещи, которые _не_ сработали.

---

## Основная идея и один трюк, благодаря которому всё заработало

Пайплайн короткий. Запрос проходит через гибридный поиск ровно как раньше — векторная похожесть плюс совпадение по ключевым словам по локальному индексу. Лучшие кандидаты возвращаются вместе с полными телами кода. LLM читает их и ранжирует по тому, отвечают ли они на вопрос. Затем два ранжирования сливаются, и наверх выходят финальные результаты. Никакого нового индекса, никакого второго прохода эмбеддингов, никакой переиндексации.

Наивная версия этого среднего шага очевидна: взять кандидатов, спросить «какие из них отвечают на запрос, по порядку» и использовать этот порядок. Сначала мы так и сделали. Для полноты это была катастрофа.

Вот чистый reasoning-реранкер на 127 запросах:

| метрика   | только гибрид | чистый reasoning | Δ          |
| --------- | ------------- | ---------------- | ---------- |
| MRR       | 0,595         | 0,752            | **+0,157** |
| NDCG@10   | 0,658         | 0,758            | **+0,100** |
| Hit@10    | 0,913         | 0,843            | **−0,071** |
| Recall@10 | 0,886         | 0,811            | **−0,075** |

Метрики ранжирования подскочили — правильный ответ вытянуло наверх. Но Hit@10 и Recall@10 _просели_, потому что модель отсекает: она возвращает горстку того, что сочла релевантным, и молча выбрасывает остальное, включая настоящие попадания, которые стояли на позициях 6–10. Реранкер, который повышает точность ценой полноты, — это не тот реранкер, который вы выкатите в прод. Он выглядит блестяще на запросах, где угадал, и делает сложные запросы безответными.

Исправление маленькое, и оно — вся причина, почему это работает: **не давайте LLM заменять ранжирование, слейте её результат с гибридным через Reciprocal Rank Fusion.**

```
score(candidate) = 1 / (k + hybrid_rank)
                 + reasoning_weight * 1 / (k + reasoning_rank)
```

`k` — обычная демпфирующая константа RRF, которая не даёт одной позиции доминировать в сумме. Важно, что делает каждое слагаемое. Гибридный ранг участвует всегда, поэтому он работает как «пол» по полноте: настоящее попадание, которое LLM понизила или вообще пропустила, опускается, но не исчезает. Reasoning-ранг с весом формирует голову списка. Вы получаете выигрыш в ранжировании и перестаёте терять полноту.

Именно это изменение превратило компромисс чистого reasoning в чистую победу по всем метрикам.

---

## Цифры

Бенчмарк: 127 размеченных вручную запросов по кодовой базе Octocode на зафиксированном коммите (100 обычных + 27 намеренно сложных, сформулированных естественным языком без совпадения ключевых слов). Локальные эмбеддинги `fastembed`, гибридный поиск включён, одни и те же запросы с обеих сторон, в роли reasoning-модели — `deepseek:deepseek-v4-flash`. Эталон — пересечение диапазонов строк с проверенными местами в исходниках.

| метрика   | только гибрид | + reasoning | Δ             |
| --------- | ------------- | ----------- | ------------- |
| MRR       | 0,595         | **0,809**   | +0,214 (+36%) |
| NDCG@10   | 0,658         | **0,833**   | +0,175 (+27%) |
| Hit@5     | 0,827         | **0,953**   | +0,126        |
| Hit@10    | 0,913         | **0,969**   | +0,055        |
| Recall@5  | 0,777         | **0,924**   | +0,147        |
| Recall@10 | 0,886         | **0,944**   | +0,058        |

Все метрики вверх. Две самые важные для сценария «найди мне код, который делает X» — MRR (насколько высоко оказывается первый правильный результат) и NDCG@10 (попадают ли _самые_ релевантные результаты в начало) — выросли на 36% и 27%. Hit@5 поднялся с 0,83 до 0,95: девятнадцать раз из двадцати ответ теперь в первой пятёрке.

Именно это число меняет поведение агента. Когда ответ стабильно в топ-5, агент читает пять результатов и действует. Когда нет — агент расширяет поиск, читает больше файлов, тратит контекст на кандидатов, которые никогда не были релевантными, а иногда сдаётся и идёт грепать. Качество ранжирования выше по потоку — это сэкономленный контекст ниже.

---

## Как мы это настраивали (и где «больше» оказалось хуже)

Мы не угадывали конфиг. Мы прогнали свипы на общем индексе, по одному измерению за раз.

**Вес рассуждения** — насколько сильно слияние опирается на LLM против гибридного «пола». Свип по 1, 2, 3, 5. Ранжирование растёт с весом и выходит на плато около 2–3; 5 не даёт ничего и немного стоит полноты, потому что на этом весе гибридный «пол» перестаёт что-либо значить и вы снова полагаетесь на одну модель. На полном бенчмарке лучшим универсальным вариантом оказалось **2,0**.

**Сколько кандидатов подавать на рассуждение.** Свип по 15, 25, 35, 40. Больше — _не_ лучше: 40 измеримо хуже, чем 25. Больше кандидатов размывают решение модели, и вы платите больше токенов за худший результат. **25** — золотая середина.

**Сколько модель видит от каждого кандидата.** Вот это оказалось решающим. Только сигнатуры, сниппет или полное тело:

| контекст        | Hit@5     | MRR       | Recall@5  |
| --------------- | --------- | --------- | --------- |
| сигнатуры       | 0,933     | 0,806     | 0,900     |
| сниппеты        | 0,933     | 0,849     | 0,883     |
| **полное тело** | **1,000** | **0,857** | **0,967** |

_(Подмножество из 30 запросов; абсолютные числа на подмножестве выше, но важен порядок вариантов.)_

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

**Температура LLM.** Мы предполагали, что ниже (детерминированнее) — значит лучше ранжирование. Ошиблись: температура 1,0 обошла 0,3 и 0,0. Модель рассуждает лучше при обычном сэмплировании.

---

## Что не сработало: контекстные описания на этапе индексации

Очевидный способ поднять полноту — обогатить индекс: попросить LLM написать однострочное «что делает этот код» для каждого чанка и заэмбеддить его рядом с кодом (в contextual retrieval у Anthropic именно так получили большое снижение доли неудачных извлечений). Механика у нас уже была, так что мы это проверили: полная переиндексация с описаниями и тот же A/B.

Стало чуть хуже:

|                                 | Hit@5  | MRR    | NDCG@10 | Recall@10 |
| ------------------------------- | ------ | ------ | ------- | --------- |
| обычный индекс (+reasoning)     | 0,953  | 0,809  | 0,833   | 0,944     |
| контекстный индекс (+reasoning) | 0,945  | 0,836  | 0,859   | 0,925     |
| Δ                               | −0,008 | +0,027 | +0,026  | −0,019    |

Это обмен полноты на ранжирование: MRR и NDCG подрастают, а Hit и Recall проседают. Приписанное впереди LLM-описание разбавляет собственные токены кода в эмбеддинге, и на чистом, хорошо структурированном коде с приличным кодовым эмбеддером это уводит полноту вниз. Прирост полноты, который измерила Anthropic, был на прозе и смешанных корпусах — не на этом. А та польза для ранжирования, которую дал бы контекстный индекс? Её уже забирает шаг рассуждения, из-за чего контекстные описания поверх него становятся избыточными, а то и вредными.

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

---

## Как включить

Reasoning выключен по умолчанию (он стоит одного LLM-вызова на поиск). Включается в `config.toml`:

```toml
[search.reasoning]
enabled          = true
model            = "deepseek:deepseek-v4-flash"  # любой provider:model
max_candidates   = 25       # 25 обошли 40 — больше размывает
context_level    = "full"   # полное тело выигрывает; сниппеты/сигнатуры хуже
reasoning_weight = 2.0      # вес RRF против гибридного «пола» полноты
final_top_k      = 10
```

Каждый параметр объявлен в шаблоне — строгий конфиг, никаких скрытых значений по умолчанию в коде. `deepseek-v4-flash` дешёвая и быстрая; работает любой провайдер, и цифры выше — это то, что даёт дешёвая модель, а не флагманская. Шаг работает только в семантическом поиске: `structural_search` остаётся чистым грепом, без ИИ, как и должно быть. Если вам нужен детерминированный поиск с нулевой стоимостью — просто не включайте флаг, и в вашей текущей схеме ничего не изменится.

---

## Честный потолок

Мы на Hit@5 = 0,953. Дойти до 1,0 на этом бенчмарке нереалистично: последние 27 запросов намеренно враждебные, а часть эталона по-настоящему неоднозначна. Оставшийся разрыв — это не полнота пула кандидатов (тест с контекстным индексом подтвердил, что пул уже покрывает нужное), а действительно сложные запросы. Более сильный базовый эмбеддер в проде (специализированная модель эмбеддингов для кода вместо локального `fastembed` по умолчанию) поднял бы планку ещё, но сам подход с рассуждением здесь уже близок к своему практическому максимуму.

Что нам нравится в этом результате: это настоящий синтез. Гибридный поиск даёт полноту — дёшево и детерминированно. Рассуждение даёт релевантность там, где вы и так платите за LLM. RRF сливает их, чтобы не пришлось выбирать. Ни нового индекса, ни отказа от векторов, ни переиндексации — один флаг, один LLM-вызов, и нужный код оказывается наверху.

## FAQ

**Что такое reasoning-ретривал в одном предложении?**

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

**Он заменяет векторный или гибридный поиск?**

Нет, и в этом суть. Когда ранжирование целиком отдали LLM, мы потеряли 7 пунктов Recall@10. Гибридный ранг остаётся в формуле как «пол» полноты, поэтому настоящее попадание, которое модель проглядела, понижается, а не удаляется.

**Сколько это стоит?**

Один дополнительный LLM-вызов на семантический поиск, максимум по 25 кандидатам. По умолчанию выключено, а цифры бенчмарка получены на `deepseek:deepseek-v4-flash` — дешёвой и быстрой модели. Работает любой `provider:model`, если хотите потратить больше.

**Замедляет ли это `structural_search` или как-то его меняет?**

Нет. Рассуждение работает только в семантическом поиске. `structural_search` остаётся детерминированным грепом без LLM в цепочке.

**Стоит ли заодно включить контекстные описания при индексации?**

На коде наши измерения говорят «нет»: MRR и NDCG слегка выросли, но Hit@5 и Recall@10 просели, а прирост ранжирования уже покрыт шагом рассуждения. Плюс это LLM-вызов на каждый чанк при каждой переиндексации.

**Нужно ли переиндексировать, чтобы этим пользоваться?**

Нет. Рассуждение — шаг на этапе запроса поверх индекса, который у вас уже есть. Включите флаг, перезапустите, готово.

— Don

---

_[Octocode](https://github.com/Muvon/octocode) — open source под Apache-2.0: бенчмарк, эталонная разметка и сырые результаты включены, так что эти цифры можно перепроверить на своей кодовой базе. Это движок поиска по коду за [Octomind](https://octomind.run); общаются они через MCP-сервер._
