Векторный поиск ранжирует по похожести. Гибридный — по похожести плюс совпадению ключевых слов. Но от поиска по коду вам на самом деле нужна релевантность, а похожесть — это не релевантность. Фрагмент, у которого больше всего общих токенов с запросом, часто оказывается не тем кодом, который отвечает на вопрос.
Спросите «где мы решаем, что запрос можно повторить», и ранжирование по похожести с радостью выдаст вам структуру конфига ретраев, константу с числом попыток и тест, где это слово встречается четыре раза, — а единственная функция, которая принимает решение, окажется на седьмом месте. Все токены совпали. Ответа нет.
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:
[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 — open source под Apache-2.0: бенчмарк, эталонная разметка и сырые результаты включены, так что эти цифры можно перепроверить на своей кодовой базе. Это движок поиска по коду за Octomind; общаются они через MCP-сервер.


