Обновить

LLM‑судья вместо косинусной близости: точность подбора кандидатов с 44 до 66% и четыре провалившихся приёма

Время на прочтение11 мин
Охват и читатели5.6K
Всего голосов 1: ↑1 и ↓0+3
Комментарии3

Комментарии 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%.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации