Любой туториал по RAG заканчивается одинаково: векторизуем корпус, векторизуем вопрос, возвращаем топ-10 чанков, кладём их в промпт. На демо выглядит прекрасно. Потом это встречается с продом, и приходит счёт.
Проблема не в модели и обычно не в эмбеддингах. Проблема в контракте. top_k = 10 отвечает на вопрос «какие десять чанков наиболее похожи?» — а этого никто на самом деле знать не хочет. Вызывающей стороне нужно другое: дай ровно столько материала, чтобы на это ответить, и не потрать на это больше N токенов. Это разные вопросы, и бюджет привязан именно ко второму.
Этот пост — про построение под второй контракт. Он вырос из слоя выборки, который я сделал для Hilum Tools, где корпус — мультирепозиторная кодовая база, а вызывающая сторона — автономный агент, который не может посмотреть на выдачу и сказать «хм, это не то».
Почему top-k переплачивает
Три отказа накладываются друг на друга, и все три невидимы на демо.
Похожесть — не достаточность. Десять чанков со score 0.82 могут оказаться десятью почти-копиями одного абзаца. Вы заплатили за десять и узнали одну вещь. При этом единственный чанк, который реально достраивает ответ, стоит на четырнадцатом месте, потому что формулирует ту же мысль другими словами.
Вес чанка ничем не ограничен. «Чанк» — единица выборки, а не единица стоимости. Десять чанков могут весить 800 токенов, а могут 14 000 — в зависимости от того, как в тот день был настроен сплиттер. Заказывали десять штук чего-то, получили непредсказуемый счёт.
Десятое место — это ставка на будущее. k выбирается один раз, на этапе сборки, для всех вопросов сразу. «Где определён AuthService?» требует одного точного попадания. «Как биллинг сводит проваленный вебхук?» требует шести файлов и записи решения. Обслуживать оба одним k — значит переплачивать за первый и морить голодом второй.
Контракт, который это чинит
Верни минимальный набор материала, достаточный, чтобы ответить на X, и уложись в N токенов. Если не получается — скажи об этом.
Три части, и третью обычно пропускают.
Бюджет превращает выборку в задачу отбора с ограничением — а значит, её можно оптимизировать, а не подкручивать на ощупь. Цель достаточности заменяет ранжирование по похожести покрытием: разные грани вопроса вместо десяти вариаций самой сильной. Признание неудачи важнее, чем кажется: агент, работающий без присмотра, не отличит «ничего не совпало» от «ничего не искали», если ответ ему об этом не скажет. Молчание читается как отсутствие, а отсутствие — как разрешение придумать.
Как это строится
1. Резать там, где меняется смысл, а не по смещению в байтах
Окна фиксированного размера — крупнейший единичный источник шума в выборке. Окно на 512 токенов разрезает функцию пополам, отделяет пример кода от объясняющего его предложения и оставляет заголовок таблицы без строк. Каждый такой обрезок становится чанком, который по отдельности находится и по отдельности бесполезен.
Режьте по структуре: границы функций и классов для кода, границы заголовков для документации, целые строки для таблиц. Дальше — нижний порог размера: чанк в три строки почти не несёт сигнала и засоряет индекс шумом, который хорошо ранжируется на коротких запросах. Если естественная единица не влезает в потолок, разбейте её и протащите шапку родительского контекста в каждый кусок — тогда фрагмент хотя бы знает, чему он принадлежит.
2. Считать не одной головой
У чистого семантического поиска есть конкретная предсказуемая слепая зона: он хорош в понятиях и плох в строках. Идентификаторы, коды ошибок, номера версий, ключи конфигов и флаги CLI — ровно те токены, на которых держится инженерный вопрос, и ровно то, что эмбеддинг размазывает. Спросите у векторного индекса E_CONFLICT_1042 — и он с удовольствием вернёт абзац про разрешение конфликтов.
Поэтому — минимум две головы со слиянием:
- Плотная (dense) — индекс приближённых ближайших соседей (HNSW как здравый дефолт) поверх эмбеддингов: понятийные совпадения и парафраз.
- Разреженная / лексическая (sparse) — BM25 или обучаемая sparse-голова: точные идентификаторы и редкие термины.
Сливайте через reciprocal-rank fusion, прежде чем тянуться к чему-то умнее. Ему не нужна калибровка score между головами, он пишется в три строки, и обогнать его руками подобранными весами очень трудно. Мультивекторную голову добавляйте потом, если у материала есть несколько естественных аспектов: сигнатура символа, его документация и места вызова — это три разные вещи, на которые можно быть похожим.
3. Отбирать под бюджет, а не просто ранжировать
Ранжирование даёт упорядоченный список. Отбор — это то, что превращает список в промпт, и это задача о рюкзаке: максимизировать ожидаемое покрытие вопроса при потолке по токенам.
Жадный проход даёт большую часть выигрыша. Идите по слитому ранжированию и допускайте кандидата, только если он добавляет грань, которую текущий набор ещё не покрывает; списывайте его реальную стоимость в токенах с остатка бюджета; останавливайтесь, когда следующее допущение не отобьёт свой вес. Дешёвые и полезные надстройки:
- Дедуплицировать до списания. Два чанка с высокой взаимной похожестью не должны попадать оба; оставьте тот, что ранжируется лучше, а сэкономленные токены потратьте в другом месте.
- Резервировать долю под «дорогое, но необходимое». Запись решения или определение интерфейса может быть большим и при этом быть именно тем, что делает ответ правильным. Чисто жадный отбор по «ценности на токен» его уморит. Резервируйте ~20% бюджета максимум под одно такое допущение.
- На границе предпочитать целые единицы. Половина функции стоит токенов и доставляет непонимание. Не влезает целиком — выбросьте и скажите об этом вызывающей стороне.
4. Ответ должен описывать сам себя
Это то, что отличает retriever, которому агент может доверять, от того, которому не может. Каждый ответ должен нести рядом с материалом:
- Что это такое — отбор под бюджет, а не полный набор. Сказать явно.
- Сколько это стоило — потрачено токенов против разрешённых.
- Что осталось за бортом — сколько кандидатов прошли порог релевантности, но проиграли бюджету. «Ещё три файла подошли, но не поместились» — это то, с чем можно что-то сделать; молчание — нет.
- Насколько свеж индекс — возраст индекса и покрытие дерева. Retriever, уверенно отвечающий из индекса, который перестал обновляться два дня назад, хуже того, который в этом признаётся.
Человек скользит взглядом по выдаче и чувствует, когда что-то не то. У агента такого чувства нет. Если ответ не заявляет собственной надёжности, агент обойдётся с тонким, устаревшим, обрезанным результатом ровно так же, как с полным.
Как померить, что получилось
Релевантность остаётся ощущением, пока не станет числом. Соберите eval-набор из реальных вопросов — двадцати-тридцати достаточно, чтобы приносить пользу, и они должны прийти от ваших пользователей или из транскриптов вашего агента, а не из воображения. Для каждого вопроса зафиксируйте, какой материал компетентный человек считает необходимым для ответа.
Дальше следите за четырьмя величинами:
| Метрика | О чём говорит |
|---|---|
| Recall в бюджете | Какая доля материала, признанного человеком необходимым, вернулась в пределах N токенов. Это главное число. |
| Токены на отвеченный вопрос | Сторона стоимости. Падающие токены при ровном recall — ради этого всё и затевалось. |
| Доля потраченного впустую контекста | Токены, доставленные и не использованные итоговым ответом. Высокие значения — селектор набивает объём. |
| Доля безосновательных ответов | Как часто модель уверенно отвечает из материала, в котором ответа не было. Ровно это должны сбивать путь «не знаю» и самоописывающийся ответ. |
Прогоните бэйзлайн — обычный top_k с вашим текущим чанкером — на том же наборе и оставьте его в стенде навсегда. Каждое изменение чанкинга, скоринга или отбора меряется против него. Без бэйзлайна в петле работа над выборкой вырождается в вайб, и каждый рефакторинг ощущается улучшением.
Как это выглядит на практике
На кодовом корпусе у меня держится такой набор: структурно-осознанный чанкинг с нижним порогом, плотная голова плюс лексическая со слиянием по рангам, жадный отбор под явный потолок токенов с дедупом и одним зарезервированным слотом, и конверт ответа, который всегда заявляет бюджет, трату, пропуски и возраст индекса.
Выигрыш редко выглядит как драматичный скачок recall. Он выглядит так: recall стоит на месте, а трата токенов на вопрос падает кратно — и отказы становятся видимыми. Retriever, который говорит «нашёл два из примерно четырёх нужных кусков, индекс отстаёт на девять минут», — это тот, вокруг которого агент может построить план. Retriever, который молча вернул два чанка и уверенный тон, — это тот, который в масштабе производит правдоподобную неправильную работу.
Выводы
top_kотвечает не на тот вопрос. Выборке нужно давать бюджет и спрашивать достаточность.- Режьте там, где меняется смысл; ставьте нижний порог размера; тащите родительский контекст во фрагменты.
- Никогда не одна скоринговая голова — идентификаторы и коды ошибок это ровно то, что теряют эмбеддинги.
- Отбор — это рюкзак, а не срез ранжирования. Дедуп, резерв под большое-и-необходимое, предпочтение целым единицам.
- Каждый ответ заявляет, что он такое, сколько стоил, что пропустил и насколько свеж индекс.
- Eval-набор собирается до оптимизации, а бэйзлайн живёт в стенде всегда.
Если вы строите выборку поверх собственной документации, вики или кодовой базы и хотите, чтобы её измеряли, а не ощущали, — это направление AI-разработка. Смежное чтение — долговременная память агента: про материал, который вообще не должен был попадать в выборку.
