У нас есть внутренняя платформа подбора ИТ‑специалистов. Вакансии приходят от заказчиков, и по каждой платформа находит в базе резюме, близкие к её тексту по смыслу, даже если слова в них другие. Внутри обычный векторный поиск на эмбеддингах.
У любого такого поиска есть врождённая болезнь. Он находит похожие тексты, а рекрутёру нужны подходящие люди. Резюме сильного программиста совпадает с вакансией архитектора почти по всем ключевым словам, но это другая роль, и кандидат не подходит. Рекрутёр видит такое за несколько секунд. Поиск не видит вовсе. У такого резюме высокая косинусная близость к тексту вакансии, и поиск выводит его в верх поисковой выдачи.
Этим летом мы переделали ранжирование кандидатов. Теперь место кандидата в поисковой выдаче зависит от ответа на вопрос, который рекрутёр и так задаёт себе про каждого. «Показал бы я этого кандидата клиенту?» На этот вопрос отвечает языковая модель (LLM). Она встроена в платформу как судья, перечитывает найденные поиском резюме, и каждый вердикт обязана доказывать дословными цитатами из них.
Доля действительно подходящих кандидатов в первой десятке поисковой выдачи выросла с 43,7 до 66,3 процента. Замерено на эталонном наборе, который мы заморозили до начала работ. Как устроен замер, расскажу ниже.
Кроме прироста точности эта переделка дала четыре истории, где проверка на эталонном наборе перечеркнула то, что казалось очевидным. Тендер на роль судьи, который мы устроили между четырьмя LLM, выигрывала недорогая и быстрая из них, пока мы не проверили, настоящие ли цитаты из резюме она приводит. Экономия на глубине рассуждений прошла все проверки качества и провалилась на проверке цитат. Перебор 1330 вариантов взвешивания оценок судьи доказал, что веса не нужны. А RRF, стандартный приём слияния семантического и полнотекстового поиска, первую же проверку на эталонном наборе провалил, он ухудшил и полноту, и точность. Обо всём по порядку.
Откуда взялись числа
Любое «стало лучше» начинается с ответа на вопрос «лучше чего». До судьи мы зафиксировали эталонный набор. 476 пар «вакансия и резюме» по 19 живым вакансиям, каждую пару человек разметил одним из трёх вердиктов. «Подходит» получили 174 пары, «пограничный» 120, «мимо» 182. Разметчик был один. Слабость такой разметки мы понимаем, вернусь к ней в конце.
Набор заморожен 3 июля 2026 года. К файлу привинчен контрольный тест, он падает, если базовые числа перестают воспроизводиться. Это скучное решение оказалось самым полезным во всём проекте, потому что дальше каждый спор «а стало ли лучше» решался прогоном, а прогоны разных недель было с чем сравнивать.
Главная метрика простая. Строгая точность топ-10, доля пар с вердиктом «подходит» среди десяти верхних строк поисковой выдачи. У чистого векторного поиска вышло 83 из 190, где 190 это 19 вакансий по 10 верхних строк, те самые 43,7%. Мягкая точность, где засчитываются и пограничные, 70,0%. Разброс по вакансиям огромный, от 10 до 90 процентов.
Почему так мало? Это та самая врождённая болезнь из начала статьи, теперь в числах. Косинусная близость меряет похожесть текстов, а вердикт «подходит» требует судить роль, уровень и опыт человека.
Судья и его чек‑лист
Судья устроен как промпт с жёсткой рубрикой. LLM играет опытного рекрутёра, ведущего конкретную вакансию. Порядок анализа прописан явно. Сначала фактическая роль по последним двум‑трём позициям опыта, титул в шапке резюме ролью не считается. Потом уровень, по датам и масштабу проектов. Навыки и стек только после того, как сошлась роль. В промпте это сформулировано как правило калибровки, роль важнее совпадения технологического стека.
На выходе строгий JSON. Пять оценок от 0 до 100 (роль, уровень, навыки, опыт, общая), по строке объяснения на каждую и чек‑лист требований вакансии. Каждое требование получает статус «есть», «частично» или «не найдено», и главное, к статусу LLM обязана приложить доказательство, дословную цитату из резюме длиной до 200 символов. Нет дословного подтверждения, значит source равен null, а статус не выше «частично». Прямо в промпте стоит «НЕ выдумывай цитаты».
Зачем такая строгость. Вердикт без доказательства для рекрутёра бесполезен, перепроверять оценку по всему резюме дольше, чем оценить самому. А цитата превращает вердикт в проверяемое утверждение. Забегая вперёд, ровно это требование и вскрыло самое интересное.
Тендер на роль судьи
Претендентов было четыре. Каждый прогнан по всем 476 парам эталона с одним и тем же промптом. Столбец про цитаты объясню чуть ниже, он окажется главным.
LLM |
Строгая точность топ-10 |
Цитаты мимо резюме |
Стоимость 476 пар |
Медианная задержка |
|---|---|---|---|---|
gpt-4.1-mini |
64,2% |
27,2% |
$1,21 |
10,3 с |
gpt-4.1-nano |
57,4% |
31,6% |
$0,22 |
3,6 с |
gpt-5-mini |
65,8% |
6,5% |
$4,77 |
47,4 с |
gpt-4o (референс) |
64,2% |
14,0% |
$6,07 |
4,4 с |
Три наблюдения из таблицы. Первое. Любой судья бьёт косинус с большим запасом, прирост строгой точности от 14 до 22 процентных пунктов. Гипотеза «LLM‑переранжирование даёт основной выигрыш», ради которой тендер и затевался, подтвердилась на любой из 4 LLM.
Второе. Полноразмерный референс gpt-4o показал ровно столько же, сколько gpt-4.1-mini, при цене в 5 раз выше. Старший класс LLM на этой задаче ничего не добавляет. Отдельная история с gpt-5, которая планировалась референсом. Эта рассуждающая LLM выжигала бюджет токенов на скрытые размышления и на большинстве пар возвращала пустой ответ с finish_reason=length, даже когда потолок подняли до 24 тысяч токенов. Прогон стоил около $8,60 и почти ничего не вернул. Референсом стала gpt-4o.
Третье, и это поворот сюжета. По соотношению цены, скорости и точности побеждала gpt-4.1-mini, и первая рекомендация тендера так и звучала. С одной оговоркой. Столбец «цитаты мимо резюме» показывает долю цитат LLM, которые не нашлись в тексте резюме дословно. У победителя таких 27,2%.
Цитаты, которых в резюме нет
Сначала о том, как мы это считали. Проверка строгая и тупая, вхождение подстроки после нормализации пробелов и регистра. Разбор руками показал, что выдуманных фактов среди расхождений почти нет. Есть цитирование по памяти. LLM заменяет глагол на синоним, вносит опечатку в слово, склеивает два соседних пункта списка в одну строку, теряет маркеры перечисления. Смысл сохранён, буква нарушена.
Для ранжирования это безвредно. Мы бы и не заметили, если бы чек‑лист оставался внутренней кухней. Но чек‑лист с цитатами решили показывать рекрутёру в карточке кандидата, и правила игры поменялись мгновенно. Человек, который один раз кликнул по «цитате» и не нашёл её в резюме, перестаёт верить всем остальным цитатам навсегда. Доверие к объяснимости не делится на проценты.
Дальше два решения, оба понадобились. Судьёй стала gpt-5-mini, самая точная в цитатах LLM из всех протестированных, с её 6,5% расхождений. Она дороже и медленнее победителя тендера, и этот размен мы сделали осознанно, потому что цитаты пошли живым людям.
А поверх любой LLM встал детерминированный валидатор цитат, без единого обращения к нейросети. У каждой цитаты три исхода. Дословное вхождение остаётся как есть. Небольшое расхождение, от 0,85 по доле совпадения строк, лечится подменой, в интерфейс уходит настоящий кусок резюме, ближайший к тому, что «процитировала» LLM. Всё, что дальше от текста, выбрасывается, пункт чек‑листа остаётся без доказательства и не может держать статус «есть». И у валидатора есть порог годности всей конфигурации. Если у обязательных требований выживает меньше 90% цитат, конфигурация в прод не идёт.
На боевой конфигурации выживаемость цитат 93,1%, порог пройден с запасом. Этот порог 90 скоро понадобится снова.
Дешёвое мышление рождает бюрократа
У gpt-5-mini регулируется глубина рассуждений. На пониженной глубине она отвечает вдвое быстрее (медиана 20,7 секунды против 47,4) и вдвое дешевле ($2,19 против $4,77 за прогон эталона). Точность практически та же, 65,3% против 65,8. По всем метрикам качества конфигурация проходит. Экономия в 2 раза на ровном месте, бери и включай.
Выживаемость цитат у обязательных требований, 86,8%. Порог 90 не пройден. LLM на низкой глубине чаще «цитирует по памяти», и валидатор выбрасывает почти вдвое больше её цитат, 716 против 365 на полной глубине.
И второй эффект, которого мы не ждали. Формальные пункты, которые промпт прямо велит игнорировать, на пониженной глубине полезли в чек‑листы гуще, всего их стало больше на 64%, а формальных «обязательных и не найденных» в 3,4 раза. Перлы вроде «Готовность работать по NDA» и «Готовность к интервью» со статусом «обязательное, не найдено» на полной глубине не появлялись ни разу. LLM, которой урезали время подумать, компенсирует его бюрократией.
Конфигурация с экономией в прод не пошла, порог цитат её не пропустил. Фильтр формальных пунктов мы всё равно добавили в код, он полезен при любой глубине рассуждений.
Призрачная полнота
Эту проверку мы устроили себе сами, и она ударила по нашим же красивым числам. Тендерные 65,8% были сняты на замкнутом наборе, судья ранжировал все размеченные пары эталона. В том числе пары, которых живая система никогда бы ему не показала, потому что они лежат за пределами первой сотни векторного поиска, а судья в проде видит только её.
Пересчёт на живом пуле, только пары из реальной поисковой выдачи, дал 63,7%. Прирост к косинусу +20,0 пункта. Настоящий, но на 2 пункта скромнее тендерного.
Урок вскрылся до прода и потому обошёлся даром. Закрытый оффлайн‑набор льстит новой LLM, мерить надо на том пуле, который система видит в бою.
Машина подбора весов доказала, что подбирать нечего
Судья отдаёт 5 шкал, и рука сама тянется собрать из них взвешенную сумму. Вес роли побольше, вес стека поменьше, немного веса косинусу за чувство меры. Мы прогнали полный перебор, 1330 комбинаций весов по сетке.
Против подгонки стояла кросс‑валидация по вакансиям, каждый раз одна из 19 откладывается, веса подбираются на остальных и проверяются на отложенной. Потом перестановочный тест. Метки эталона перемешиваются 1000 раз, перебор повторяется на каждом перемешивании, и 95-й перцентиль его лучших результатов на чистом шуме вышел 46,8%. Ниже этой планки любое «улучшение» неотличимо от подгонки. И бутстрэп по вакансиям для доверительных интервалов.
Результат отрезвляет. На каждом разбиении кросс‑валидации веса схлопывались в одну и ту же точку, взятую из головы до всякого перебора. Парная разница между взвешенной суммой и простой сортировкой по общей оценке судьи легла в интервал от минус 2,6 до 0,0 процентного пункта. Взвешивание не даёт ничего, в лучшем случае не вредит.
В прод ушла сортировка по одной шкале, общей оценке судьи. Ноль подогнанных параметров. Это лучший исход перебора, какой я знаю. Машина оптимизации доказала, что оптимизировать нечего, и в проде теперь нечему ломаться при дрейфе данных.
Стандартное слияние поисков провалило проверку на эталоне
У векторного поиска есть слепое пятно, кандидаты с точным редким термином и непохожим в целом текстом. На эталоне за пределами первой сотни векторного поиска, которую видит судья, осталось 34 подходящих. 14 из них векторный поиск не возвращал вовсе, ни на какой глубине. Полнотекстовый BM25 видит большинство таких кандидатов, термин в резюме стоит буквально.
Каждый второй туториал по гибридному поиску советует одно и то же, слить два ранжирования через RRF, сумму обратных рангов 1/(60+ранг). Мы прогнали RRF на эталоне в двух вариантах состава текстовых полей, дальше числа лучшего из них. Полнота по подходящим упала, со 140 до 137 из 174. Из 14 невидимых спасён один. Строгая точность топ-10 просела на 0,5 пункта.
Причина в самой формуле. RRF награждает консенсус двух поисков. Кандидат, которого векторный поиск потерял, получает вклад только от BM25. В нашем прогоне 74-я строка полнотекстового ранжирования превращалась после слияния в 246-ю, далеко за пределами первой сотни объединённого ранжирования. RRF систематически недоплачивает именно тем, ради кого гибрид затевался.
Сработала конструкция без формулы слияния. Пулы просто объединяются, первая сотня векторного поиска плюс верх BM25 до 150-й строки, и весь объединённый пул ранжирует судья. RRF решает спор двух ранжирований арифметикой над рангами, судья решает его чтением резюме. Итог на тех же размеченных парах, но с боевым ограничением пула, 66,3%, плюс 2,6 пункта к живому замеру. 6 из 14 невидимых кандидатов вошли в объединённый пул, цена на размеченных парах около двух лишних судейств на вакансию.
Что это стоит в проде
Экономику держат скучные механизмы. Кэш оценок, таблица только на запись. Ключ собран из версии вакансии, идентификатора резюме, хеша его содержимого, имени LLM и версии промпта. Изменилось резюме, пара пересчитается. Не изменилось ничего, оценка бесплатна. Ночной пересчёт отправляет в LLM только новые пары.
Бюджетные капы. Не больше 3000 холодных оценок за ночь. При медиане 47 секунд на вызов и пяти параллельных потоках это около 8 часов, ночь в буквальном смысле. Одна оценка стоит около цента или дешевле, точная цифра гуляет с длиной резюме и вакансии. Прогон всего эталона боевой LLM обошёлся в $4,77. Включение объединённого пула потребовало разово прогнать через судью весь добавленный BM25-хвост, в среднем около 100 пар на вакансию, этот бэкфилл стоил $22,74.
И журнал. Каждый пересчёт поисковой выдачи пишется в append‑only таблицу, на уровне базы триггер запрещает UPDATE и DELETE. Любую выдачу любого дня можно восстановить и разобрать.
Отдельной строкой ежемесячный зонд полноты, страховка от вопроса «а не пропускаем ли мы сильных кандидатов, которых никогда не видим». Раз в месяц система берёт по 20 кандидатов на вакансию из‑за пределов собственной поисковой выдачи, включая слой базы кандидатов, который в обычный пул не попадает вовсе, и отдаёт судье. Активных вакансий к первому прогону было уже 27 против 19 в июльском эталоне, итого 540 пар, расчётная цена цикла около $3,5. Тревога поднимается только на вытесняющих находках, у которых общая оценка судьи от 80, и притом выше минимальной в текущей десятке. Без этой поправки зонд кричал бы на массовых ролях, где сильных кандидатов больше десяти.
Что из этого переносится
Наши проценты к вам не переедут. Другой домен, другие данные, другая разметка. Переносится конструкция, и вот её каркас в порядке важности.
Замороженный эталон раньше любых улучшений. Размеченный набор, дата заморозки, контрольный тест, который пинит бейзлайн. Без такого эталона сравнивать улучшения будет не с чем.
Вердикты с обязательными доказательствами, и детерминированная проверка доказательств поверх LLM. Просить LLM “не выдумывать” недостаточно, промпт у нас это прямо требовал, и всё равно у четырёх LLM из тендера расходилось с текстом от 6,5 до 31,6% цитат. Проверку доказательств делает обычный код, без нейросетей, с числовым порогом годности.
Замер на живом пуле. Оффлайн‑набор обязан воспроизводить ограничения боевой системы, иначе он льстит LLM. У нас закрытый набор завысил результат на 2 пункта.
Перестановочный потолок. Прежде чем радоваться приросту, посмотрите, что ваш перебор выжимает из перемешанных меток. У нас 95-й перцентиль подгонки на шуме выходил 46,8%, и это число дисциплинирует лучше любого код‑ревью.
Регулярный зонд на пропуски. Система, которая оценивает только собственную поисковую выдачу, никогда не узнает о тех, кого не видит. Зонду хватает нескольких долларов в месяц.
Оговорки
Разметчик один, вакансий 19, домен один, ИТ‑подбор в аутстаффинге. Доверительные интервалы строгой точности на бутстрэпе широкие, от 50 до 77% у судьи на живом пуле и от 33 до 54% у косинуса, края перекрываются. Утешает устойчивость прироста. Ни одна из 19 вакансий не просела, на 15 есть прирост. Все замеры внутренние, на нашей базе и наших вакансиях. И нанимает по‑прежнему человек. Судья делает первичный отсев и приносит рекрутёру доказательства к вердиктам.
Если строите похожее, начните с эталона и с вопроса, на который у вашей LLM должны быть доказательства. Остальное, как выяснилось, проверяется дешевле, чем кажется.
Комментарии (3)

ToxaBes
25.08.2026 14:44а если строго, то расхождение реальное и интересное.
строго
Какой у вас размер размеченного набора и сколько вердиктов в шкале?
Изначально был 500 документов от 1 до 15 страниц. Вердикт считался в целых процента, те 100 вердиктов. Такая разметка довольно сложная и долгая, но дает отличные результаты.
Резюме не режется на фрагменты, оно кодируется целиком одним вектором, и глобальный контекст стоит в начале текста, роль, грейд, навыки, потом опыт.
По-моему опыту это очень плохо работает с резюме кандидатов у которых 10+ лет опыта (объемные резюме) либо резюме очень детально написано. Поэтому пришлось отказаться от единого векторного представления.
Если вы имели в виду что-то другое, поправьте.
Да, именно это я и имел ввиду.
Одно уточнение про RRF. У нас он как раз не работает, об этом половина статьи.
И это странно. Судя по тому, что вы написали, он должен у вас нормально работать, но я не знаю деталей системы потому подсказать куда копать не смогу.
В своих системах я пошел еще дальше, помимо контекстуального чанкинга я создал парсинг резюме сразу в строгий формат (JSON) по схеме, которая покрывает все резюме (pdf/doc) с любой разметкой/чередованием блоков и тд. Примерно вот так:
class PersonData(BaseModel): name: str = Field(description="Полное имя") age: int = Field(description="Возраст") about: str = Field(description="О себе") mobile: str = Field(description="Мобильный телефон") email: str = Field(description="Email") telegram: str = Field(description="Telegram") skype: str = Field(description="Skype") location: str = Field(description="Местоположение") birthdate: int = Field(description="Год рождения") gender: str = Field(description="Пол") education: str = Field(description="Образование") education_grade: str = Field(description="Уровень образования") languages: List[str] = Field(description="Языки") skills: List[str] = Field(description="Навыки") cv_date: str = Field(description="Дата создания резюме в формате YYYY-MM-DD") class WorkData(BaseModel): job_title: str = Field(description="Должность") work_type: str = Field(description="Тип работы") work_regime: str = Field(description="Режим работы") trips: str = Field(description="Готовность к командировкам") salary: str = Field(description="Желаемая зарплата") class WorkExperience(BaseModel): job_title: str = Field(description="Должность") company: str = Field(description="Название компании") project: str = Field(description="Проект") class CVResponsePerson(BaseModel): data: PersonData class CVResponseWork(BaseModel): data: WorkData class CVResponseExpeirence(BaseModel): data: List[WorkExperience] = Field(description="Опыт работы") class CVResponseDates(BaseModel): start_date: str = Field(description="Дата начала в формате YYYY-MM-DD") end_date: str = Field(description="Дата окончания в формате YYYY-MM-DD") description: str = Field(description="Описание работы")Для этого я использую дообученную мультимодальную модель и pydantic.
В итоге системе вообще не важно откуда и в каком формате приходит резюме, а также не важен его размер (я пробовал до 16 страниц).
Система использует как контекстуальный чанкинг с BM25 и RRF, так и tool calling c четким форматом и поиском.
По нормальным резюме точность достигает 95%, но таких резюме в реальной работе в лучшем случае половина. Думаю, вы тоже насмотрелись на всякое в таких документах. Из-за этого результирующий процент падает в среднем до 85%.
ToxaBes
Результат в 66% показывает насколько беспомощен чистый векторный поиск, но сам по себе показатель для современного RAG довольно скромный.
Вы уперлись в потолок из-за того, что ваш LLM-судья пытается переранжировать изначально плохую первичную выборку. Если векторный поиск отсеял правильных кандидатов на первом шаге, судье просто не из кого выбирать.
Я создаю подобные системы и они пробивают планку в 85%.
Вашу систему можно улучшить с 66% до ~80% всего двумя изменениями:
Во-первых, используйте контекстуальный чанкинг на этапе индексации. Когда перед кодированием каждого фрагмента резюме модель добавляет в него глобальный контекст (роль, общий стаж, грейд), векторная база перестает терять релевантных людей и кардинально поднимает полноту поиска.
Во-вторых, поставьте на роль судьи Gemma 4 31B в thinking режиме (я перебрал очень много моделей и для HR система эта одна из лучших судей). Благодаря встроенной цепочке рассуждений модель пошагово валидирует каждую строчку в черновике, что полностью убирает галлюцинации с выдуманными цитатами. Там есть несколько неочевидных моментов, но я уверен, что вы разберетесь.
Вдобавок она может крутиться на собственном железе и не сливает персональные данные кандидатов в сторонние облачные API.
Контекстуальный поиск на входе плюс Gemma в режиме рассуждений на выходе как раз и дадут тот самый скачок точности. Все остальные базовые вещи в виде BM25 и RRF у вас уже есть.
bm_kuznetsov Автор
Спасибо за разбор, по главному тезису вы правы. Потолок задаёт первичная выборка, и судья не спасёт того, кого поиск не показал. Мы это как раз мерили, вот числа с нашего эталона. Подходящих кандидатов за пределами первой сотни векторного поиска оказалось 34, из них 14 плотный поиск не возвращал вовсе. Объединение с BM25 вернуло в пул 6 из этих 14 и добавило 2,6 пункта точности. В проде на живых вакансиях объединение даёт около четырёх с половиной кандидатов на вакансию, которых нашёл только полнотекстовый поиск и которые дошли до верхней десятки.
Про 66% против 85% сравнивать напрямую не стоит, пока не сверены метрики. У нас три вердикта разметки, и 66,3% это доля строго подходящих в первой десятке, где пограничные идут в ноль. На том же замере мягкая точность, то есть подходящие плюс пограничные, 85,8%. Если ваши 85% считаются как релевантность с учётом пограничных, мы примерно там же, а если строго, то расхождение реальное и интересное. Какой у вас размер размеченного набора и сколько вердиктов в шкале?
Про контекстуальный чанкинг. У нас его нет, и проблемы, которую он лечит, тоже нет. Резюме не режется на фрагменты, оно кодируется целиком одним вектором, и глобальный контекст стоит в начале текста, роль, грейд, навыки, потом опыт. Приём полезен там, где документ длинный и рвётся на куски, теряющие владельца. Если вы имели в виду что-то другое, поправьте.
Про рассуждающую модель на роли судьи наш замер вас подтверждает. Мы прогнали четыре модели по 476 парам одним промптом и смотрели долю цитат, которых нет в резюме дословно. У нерассуждающих 27,2% и 31,6%, у рассуждающей 6,5%, и выбрали мы её именно за это, хотя она вчетверо дороже и почти впятеро медленнее. За конкретную модель спасибо, посмотрим на неё. Довод про локальное железо и персональные данные принимаю без спора, это честный минус нашего решения.
Одно уточнение про RRF. У нас он как раз не работает, об этом половина статьи. На эталоне он уронил полноту со 140 до 137 подходящих из 174 и снял полпункта точности, потому что кандидат, которого не нашёл плотный поиск, получает вклад только от BM25 и остаётся далеко внизу. Мы выбросили формулу слияния и оставили простое объединение пулов, а разбираться с порядком отдаём судье.