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

Инструмент для такой перестановки называется reranker. Но «поставить reranker» и «получить пользу» — разные вещи. На небольшом CPU-сервере у второго этапа есть цена: каждая пара «вопрос — фрагмент» проходит через нейросеть, а процессорных ядер у вас два, а не двести. Поэтому вместо веры в то, что «с reranker лучше», разумнее провести небольшой эксперимент и получить два числа: насколько вырос шанс увидеть правильный фрагмент наверху и сколько миллисекунд за это заплачено. Ниже — как это устроить, чтобы цифры не обманывали. Общее объяснение, зачем такие модели вообще нужны в RAG, есть в материале Reranker-модели: зачем они нужны в RAG; здесь речь только об измерении на ограниченном железе.

Два разных промаха: фрагмента нет и фрагмент не на месте

Прежде чем что-то ставить, нужно понять, какую болезнь лечим. Плохой ответ RAG может возникнуть по двум несовместимым причинам, и reranker помогает только при одной из них.

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

Вторая причина — порядок плохой. Фрагмент среди кандидатов есть, но стоит ниже границы, которую вы отдаёте в промпт. Вот здесь reranker и может помочь.

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

Дальше для каждого вопроса вы смотрите, на каком месте в выдаче первого этапа стоит правильный фрагмент, и раскладываете вопросы по трём корзинам:

  • Уже наверху. Правильный фрагмент в пределах границы, которую вы отдаёте модели (допустим, топ-5). Reranker здесь ничего не добавит, а при неудаче способен даже испортить.
  • В середине списка. Фрагмент в пределах глубины, которую вы готовы передать второму этапу (допустим, топ-30), но ниже границы промпта. Это территория, где перестановка работает.
  • За пределами списка. Фрагмента нет среди кандидатов. Лечится не перестановкой, а первым этапом: размером кусков, поиском по ключевым словам вместе с векторным, расширением выдачи.

Размеры корзин сразу отвечают на главный вопрос. Если из 100 вопросов 88 в первой корзине, 4 во второй и 8 в третьей, потолок выигрыша от reranker — те самые четыре вопроса, и платить задержкой за них на каждом запросе странно. Если же вторая корзина содержит 25 вопросов, разговор совсем другой. Учебные цифры тут условные, но логика одна: сначала измеряем размер возможного выигрыша, потом покупаем его цену.

Что на самом деле делает CrossEncoder

Схема из практики библиотеки Sentence Transformers выглядит так: первый этап отбирает кандидатов, а CrossEncoder оценивает пары из запроса и кандидата и переставляет результаты (Sentence Transformers: Retrieve and Re-Rank). Из этого следуют два практических свойства, ради которых стоит остановиться.

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

Второе: стоимость растёт с числом пар. Один вопрос и 30 кандидатов — это 30 прогонов модели. Один вопрос и 100 кандидатов — 100. Именно число кандидатов вы контролируете сами, и именно им мы будем играть в эксперименте. Длина фрагментов тоже влияет, потому что чем длиннее входной текст пары, тем больше работы в каждом прогоне, но это отдельная ось, к ней вернёмся.

На выходе у каждой пары — число, оценка релевантности. Список сортируется по ней, и в промпт идут первые k. Всё. Никакой магии, просто второе мнение более внимательного читателя, которому разрешили не торопиться, но на ограниченное число документов.

Две маленькие выдачи до и после перестановки

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

Вопрос 1: «Как продлить сертификат после смены домена?»

Размеченный правильный ответ — фрагмент «Перевыпуск сертификата для нового имени».

Учебный пример: восемь кандидатов первого этапа
МестоДо reranker (первый этап)После reranker
1Общие сведения о сертификатахПеревыпуск сертификата для нового имени
2Список поддерживаемых доменных зонПродление сертификата по расписанию
3Продление сертификата по расписаниюОбщие сведения о сертификатах
4Как сменить домен сайта в панелиКак сменить домен сайта в панели
5Настройка редиректа со старого доменаНастройка редиректа со старого домена
6Перевыпуск сертификата для нового имениСписок поддерживаемых доменных зон
7Журнал изменений панелиЖурнал изменений панели
8Тарифы на хранениеТарифы на хранение

Правильный фрагмент поднялся с шестого места на первое. Если в промпт идут три фрагмента, до перестановки модель его не увидела бы, после — увидит. Почему так вышло? Первый этап клюнул на слова «сертификат» и «домен» и вытащил наверх общие обзорные тексты. Совместное чтение пары заметило, что в вопросе есть смена и продление одновременно, и ближайший по смыслу ответ — именно про перевыпуск под новое имя.

Предположим, этот этап на 8 парах добавил около 63 мс (в учебной модели ниже: 15 мс постоянных накладных расходов и по 6 мс на пару). Для чата, где ответ языковой модели всё равно генерируется секундами, это незаметно.

Вопрос 2: «Лимит размера файла при загрузке»

Правильный фрагмент — «Ограничения на размер загружаемого файла».

Учебный пример: тот же приём на коротком запросе
МестоДо rerankerПосле reranker
1Ограничения на размер загружаемого файлаОграничения на размер загружаемого файла
2Поддерживаемые форматы файловПоддерживаемые форматы файлов
3Как загрузить файл через APIКак загрузить файл через API
4Квоты на хранилищеКвоты на хранилище
5Сжатие изображенийСжатие изображений

Здесь порядок не изменился совсем. Первый этап уже справился, и 5 пар по 6 мс плюс накладные расходы (45 мс в той же учебной модели) потрачены впустую. Если таких вопросов большинство, reranker в вашем сервисе будет честно греть процессор ради нуля. Это та самая первая корзина из прошлого раздела.

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

Метрики, которые не врут

Для сравнения «до» и «после» достаточно трёх простых величин, считаемых по одной и той же разметке.

  • Попадание в топ-k. Доля вопросов, у которых правильный фрагмент оказался среди первых k результатов. Берите k равным тому числу фрагментов, которое реально уходит в промпт. Эта метрика прямо отвечает на вопрос «увидит ли модель нужный текст».
  • Средняя обратная позиция (MRR). Для каждого вопроса берётся 1, делённая на место первого правильного фрагмента, и всё усредняется. Подъём с шестого места на первое даёт заметный прирост (1/6 → 1), а с двадцатого на девятнадцатое почти ничего (0,05 → 0,053). Метрика хорошо показывает, насколько вверх ушёл ответ.
  • Потолок первого этапа. Доля вопросов, у которых правильный фрагмент вообще присутствует среди N кандидатов. После reranker итог не может быть выше этого числа, поэтому оно служит верхней границей.

Сравнивая варианты, держите набор вопросов неизменным. Это кажется очевидным, но соблазн «добавить ещё несколько трудных вопросов, пока тестируем» убивает сравнение: цифры до и после становятся несопоставимыми. Если набор нужно расширить, пересчитайте всё заново на расширенном.

Меняем число кандидатов, остальное замораживаем

Главная ручка эксперимента — N, сколько кандидатов первый этап отдаёт второму. Схема простая: берёте фиксированный набор вопросов (допустим, 100 размеченных), и для каждого прогоняете reranker на N = 10, 20, 30, 50 и 100 кандидатов. Модель, обрезка длины, число потоков, граница k не меняются. Меняется только N.

Для каждого N записываете:

  1. попадание в топ-3 или топ-5 (по вашему k);
  2. MRR;
  3. время этапа перестановки на вопрос: медиану и хвост (95-й перцентиль);
  4. пиковое потребление памяти процессом.

Результат обычно выглядит как кривая с коленом: сначала глубина даёт много, потом всё меньше. Причина понятна. Правильный фрагмент, стоящий на 12-м месте, вполне поднять; на 70-м месте его, скорее всего, нет вовсе, или первый этап считает его настолько далёким, что и совместное чтение вряд ли поможет. Поэтому дальние кандидаты добавляют пар и времени, а число поднятых ответов почти не растёт.

Ниже условная, учебная картина для 100 размеченных вопросов; цифры выдуманы для объяснения хода рассуждения и не отражают какую-либо реальную модель или сервер.

Учебные цифры: попадание в топ-3 в зависимости от числа кандидатов
N кандидатовПотолок первого этапаТоп-3 без rerankerТоп-3 с reranker
1082%61%69%
2089%61%76%
3092%61%80%
5093%61%81%
10094%61%81%

Столбец «без reranker» одинаков во всех строках, потому что исходный топ-3 первого этапа от N не зависит. Он нужен как точка отсчёта. Между N = 30 и N = 100 учебная кривая прибавляет один пункт, а число пар растёт более чем втрое. Ровно такое место и надо искать на реальных данных.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Цена в миллисекундах: как задержка зависит от числа пар

Теперь вторая ось. Задержка этапа на одном запросе приблизительно линейна по числу пар: есть постоянная часть (подготовка входа, запуск) и часть на каждую пару. Для иллюстрации возьмём учебную модель: 15 мс постоянных расходов и 6 мс на пару при коротких фрагментах; для длинных фрагментов — 24 мс на пару, то есть вчетверо больше. Эти цифры придуманы, чтобы показать форму зависимости, а у вашей модели и вашего процессора будут другие, и их можно получить только замером.

Учебная модель задержки одного запроса, мс
Пар на запросКороткие фрагменты (15 + 6·N)Длинные фрагменты (15 + 24·N)
1075255
20135495
30195735
503151215
1006152415

Смотрите, как одна и та же глубина превращается в разные ощущения. 30 пар на коротких фрагментах — 195 мс, пользователь чат-бота их не заметит рядом с генерацией ответа. 100 пар на длинных — две с лишним секунды ещё до того, как языковая модель начала писать первое слово. Это уже изменение характера сервиса.

Теперь сопоставим две таблицы. По учебным цифрам, переход от 30 к 100 кандидатов даёт +1 пункт попадания и стоит +420 мс на коротких фрагментах или +1680 мс на длинных. Переход от 10 к 30 даёт +11 пунктов за +120 мс на коротких. Вот что значит «выбрать глубину»: ты смотришь не на кривую пользы и не на кривую времени отдельно, а на их отношение в каждой точке.

Проверка критериями вместо спора на глаз

Когда прогонов станет много, удобно перестать сравнивать таблицы руками и записать условия приёмки в конфигурацию. В инструменте оценки Promptfoo результаты проверяются assertions, причём их можно собирать в группы условий, а среди проверок предусмотрены стоимость и задержка; критерии задаются конфигурацией теста (Promptfoo: Assertions and Metrics). Для нашей задачи это значит, что одним прогоном можно проверить сразу две вещи: что правильный фрагмент присутствует в выдаче и что задержка укладывается в бюджет, который вы назначили сами.

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

Хороший признак зрелого эксперимента — когда вы можете сказать, при каком результате отказались бы от reranker. Если такой точки нет, значит, вы уже решили заранее, и измерение было театром.

Короткие запросы: где перестановка теряет смысл

Теперь про условия, в которых reranker ведёт себя по-разному. Начнём с самых коротких запросов: одно-два слова вроде «лимит файла» или «SSL». У такого запроса мало информации, и совместное чтение пары не может вытащить из него больше, чем там есть. Если вопрос «SSL», любой абзац про сертификаты одинаково релевантен, и reranker расставит их в порядке, который на глаз почти случаен.

Это не значит, что на коротких запросах он бесполезен; бывает, что короткий запрос однозначен («лимит размера файла»), и первый этап уже справляется. Но поэтому в разметке нужны запросы разной длины, а в отчёте — разрез по длине. Усреднённое «+8 пунктов» может прятать картину «+20 на длинных формулировках и ноль на коротких». Если ваши пользователи в основном пишут короткими обрывками, средняя польза окажется скромнее той, что обещает общая цифра.

Есть и обратная сторона: на коротких запросах время пары определяется в основном фрагментом, а не вопросом, и задержка почти не зависит от длины запроса. Значит, экономить на длине вопроса бессмысленно, экономить нужно на длине фрагмента и числе пар.

Длинные фрагменты: цена контекста

Второй вариант — длинные куски. Если документы нарезаны на абзацы по 100–150 слов, пара коротка и проходит быстро. Если куски по тысяче слов, входной текст пары вырастает на порядок. Вспомните учебную таблицу: учетверённая стоимость пары превратила 195 мс в 735 мс на тех же 30 кандидатах.

У длинных фрагментов есть и качественная проблема. Модели перестановки обычно принимают вход ограниченной длины, и всё, что не поместилось, либо отрезается, либо ломает прогон, в зависимости от настроек. Если ответ спрятан в хвосте длинного куска, а модель видит только начало, ранжирование будет судить по несущественной части. Поэтому в эксперименте длину входа нужно фиксировать и проверять отдельно: сравните качество на тех же вопросах при разной обрезке (допустим, короткой и длинной) и посмотрите, что дороже — потеря точности или время.

Часто выгоднее поменять не reranker, а нарезку: куски покороче дают и более аккуратный первый этап, и более дешёвый второй. Это рычаг на стороне подготовки документов, и его стоит пробовать до покупки более мощного сервера.

Что происходит на CPU: ядра, потоки и память

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

Возьмём упрощённый расчёт на тех же учебных цифрах. Допустим, один запрос из 20 коротких пар требует 135 мс времени при загрузке одного ядра. Сервер имеет 2 vCPU. Если одновременно приходят четыре запроса, суммарная работа — 4 × 135 = 540 мс процессорного времени. На двух ядрах это минимум 270 мс до завершения последнего, а на практике больше из-за конкуренции потоков и других процессов (векторный поиск, веб-сервер, сам вызов языковой модели). То есть пользователь, который в одиночку получал 135 мс, при параллельной нагрузке видит 200–300 мс и больше, и хвост распределения растёт быстрее среднего.

Отсюда три практических следствия.

  • Измеряйте под нагрузкой, а не только последовательно. Запускайте набор вопросов в несколько одновременных потоков, подобранных под ожидаемый трафик, и смотрите на 95-й перцентиль. Среднее при очереди вводит в заблуждение.
  • Не отдавайте процессору больше потоков, чем у вас ядер. Если библиотека по умолчанию запускает много потоков вычислений, а ядер два, потоки начнут мешать друг другу, особенно при нескольких параллельных запросах. Число потоков расчёта и число параллельных запросов — два разных параметра, и их стоит подобрать вместе.
  • Следите за памятью. Модель загружается в оперативную память процесса и остаётся там. К ней добавляются сам индекс, веб-сервис и всё остальное, что живёт на машине. Если на 2 ГБ памяти вы пытаетесь держать и индекс, и модель, и приложение, запас может исчезнуть при росте нагрузки, и тогда вместо медленной выдачи вы получите нехватку памяти и перезапуск процесса. Пиковое потребление нужно записывать в каждом прогоне, а не прикидывать на глаз.

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

Где такой эксперимент запускать

Для замера не нужен большой стенд, нужна среда, близкая к боевой по числу ядер и объёму памяти. На VPS с root-доступом можно ставить Python-окружение, закреплять число потоков, смотреть загрузку ядер и память в процессе прогона и переносить найденные параметры в рабочий сервис без перестройки. У VPS MAATRIX — NVMe-диски, процессоры AMD EPYC и root-доступ; набор тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идёт через Uab (2 vCPU, 4 ГБ), Heka (4 vCPU, 8 ГБ) и Maat (8 vCPU, 16 ГБ) к Djed (16 vCPU, 32 ГБ).

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

Когда reranker скорее навредит, чем поможет

Иногда измерение отвечает «не нужно», и это нормальный результат. Вот признаки, что второй этап вам не нужен:

  • почти все размеченные вопросы лежат в корзине «уже наверху», а остальные — в корзине «за пределами списка»; переставлять нечего;
  • прирост попадания в топ-k укладывается в разброс, который вы получаете, слегка меняя набор вопросов;
  • бюджет задержки уже занят векторным поиском, обращением к языковой модели и сетью, и свободных миллисекунд нет;
  • корпус маленький и однородный, и первый этап вместе с поиском по ключевым словам отдаёт правильный фрагмент почти всегда.

Бывает и обратное: ответ в топ-5 попадает чаще, но модель стала чаще «залипать» на очень похожих, но неправильных фрагментах, потому что reranker поднял их выше. Поэтому смотрите не только на среднее число, но и на конкретные вопросы, где порядок ухудшился. Таких случаев в разметке обычно немного, и разбор десятка из них даёт больше понимания, чем таблица с процентами.

Порядок работы: от разметки до выбранной глубины

Соберём весь ход в последовательность, которую можно повторить.

  1. Разметьте несколько десятков вопросов разной длины и укажите правильные фрагменты.
  2. Для каждого вопроса запишите место правильного фрагмента в выдаче первого этапа при большом N (например, 100) и разложите вопросы по трём корзинам.
  3. Если средняя корзина пуста или мала, остановитесь и улучшайте первый этап.
  4. Прогоните reranker при N = 10, 20, 30, 50, 100 на неизменном наборе вопросов; запишите попадание в топ-k, MRR, медиану и 95-й перцентиль задержки, пик памяти.
  5. Повторите прогон под параллельной нагрузкой, которую ожидаете в реальности.
  6. Разберите по отдельности длинные и короткие вопросы и длинные и короткие фрагменты.
  7. Запишите критерии допустимого результата в конфигурацию проверок, чтобы любое следующее изменение модели или нарезки проходило те же условия.

Где остановить глубину

Вернёмся к учебным таблицам. При N = 10 reranker поднимает попадание в топ-3 с 61% до 69% за 75 мс, это +8 пунктов. При N = 20 уже 76% за 135 мс. При N = 30 — 80% за 195 мс. Дальше начинается плато: N = 50 даёт 81% за 315 мс, N = 100 — те же 81% за 615 мс. Если смотреть на прирост за каждую добавленную миллисекунду, то участок от 10 до 30 кандидатов щедр, а участок после 30 уже почти не окупает ожидание.

Отсюда выбор: в этом условном сценарии глубина 30 — разумная граница. Она забирает почти весь доступный выигрыш, оставляет запас по задержке и по параллельной нагрузке на двух ядрах. Если бы на длинных фрагментах такие 30 пар стоили 735 мс, граница сместилась бы вниз, к 20 или к 15 вместе с укорочением кусков, потому что цена каждой пары там выше, а кривая пользы осталась прежней.

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