
Комментарии 3
Результат в 66% показывает насколько беспомощен чистый векторный поиск, но сам по себе показатель для современного RAG довольно скромный.
Вы уперлись в потолок из-за того, что ваш LLM-судья пытается переранжировать изначально плохую первичную выборку. Если векторный поиск отсеял правильных кандидатов на первом шаге, судье просто не из кого выбирать.
Я создаю подобные системы и они пробивают планку в 85%.
Вашу систему можно улучшить с 66% до ~80% всего двумя изменениями:
Во-первых, используйте контекстуальный чанкинг на этапе индексации. Когда перед кодированием каждого фрагмента резюме модель добавляет в него глобальный контекст (роль, общий стаж, грейд), векторная база перестает терять релевантных людей и кардинально поднимает полноту поиска.
Во-вторых, поставьте на роль судьи Gemma 4 31B в thinking режиме (я перебрал очень много моделей и для HR система эта одна из лучших судей). Благодаря встроенной цепочке рассуждений модель пошагово валидирует каждую строчку в черновике, что полностью убирает галлюцинации с выдуманными цитатами. Там есть несколько неочевидных моментов, но я уверен, что вы разберетесь.
Вдобавок она может крутиться на собственном железе и не сливает персональные данные кандидатов в сторонние облачные API.
Контекстуальный поиск на входе плюс Gemma в режиме рассуждений на выходе как раз и дадут тот самый скачок точности. Все остальные базовые вещи в виде BM25 и RRF у вас уже есть.
Спасибо за разбор, по главному тезису вы правы. Потолок задаёт первичная выборка, и судья не спасёт того, кого поиск не показал. Мы это как раз мерили, вот числа с нашего эталона. Подходящих кандидатов за пределами первой сотни векторного поиска оказалось 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 и остаётся далеко внизу. Мы выбросили формулу слияния и оставили простое объединение пулов, а разбираться с порядком отдаём судье.
а если строго, то расхождение реальное и интересное.
строго
Какой у вас размер размеченного набора и сколько вердиктов в шкале?
Изначально был 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%.
LLM‑судья вместо косинусной близости: точность подбора кандидатов с 44 до 66% и четыре провалившихся приёма