Графы и векторный поиск кандидатов: гибридные модели и объяснимые рекомендации для нужд HR

Всем привет! С вами Дмитрий Шеверёв. Сегодня разберём такую интересную тему, как поиск по людям с точки зрения HR. Как эффективно искать кандидатов и сотрудников в неструктурированных массивах резюме, профилей, заметок с интервью, статусов по воронкам и другим данным, которые могут быть у компании.

Зачем вообще это нужно? У крупных и средних компаний база кандидатов может исчисляться тысячами человек. Со многими уже встречались и говорили, но через полгода они "пропадают". Дело в том, что часто с такой базой почти никто не работает и в первую очередь это из-за того, что найти в ней кого-то подходящего бывает больно. Проще, например, разместить новую вакансию на hh.ru, чем просматривать все вручную.

Я - Дмитрий Шеверёв, больше года мы с командой копаем эту тему и проверяем подходы в рамках проекта ИВГПК. И мы не просто занимаемся исследованиями ради исследований, а пытаемся решить конкретную бизнес-задачу. На этом с введением покончим и переходим к небольшой предыстории.

Откуда вообще взялись эти графы и векторы?

Представьте, у крупной компании есть база резюме (или профилей сотрудников) и есть вполне понятная задача выполнить по ней поиск релевантных новой вакансии людей.

1. Ключевые слова

Первые такие работали достаточно просто. Поиск был по документам, где встречались ключевые слова, например "Python", "SQL", "3+ лет", "Kafka". Работало быстро и понятно. Иногда даже честно.

Но были и проблемы:

  • человек что-то умеет, но мог написать это другими словами (или вообще не написать);
  • человек назвал себя нестандартно, например "Customer Success" вместо "аккаунт";
  • часто важна комбинация характеристик, а не просто отдельные совпадения ключевиков. Важно, что человек умеет, где он это применял, на каком уровне применял и в каком типе задач.
  • рекрутеру же нужен не просто список наиболее релевантных, но и то, что чего не хватает этому кандидату.

2. Векторный поиск

Векторный поиск строит эмбеддинг текста и ищет похожие по смыслу резюме и профили. Это сильно помогает когда в описании кандидатов есть синонимы и вариативные формулировки.

Но тут начинается неприятное:

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

HR специалисты, называют такой поиск плохим, так как он требует большой работы по ручному просеиванию кандидатов. Но даже несмотря на это, много подходящих и потенциально интересных кандидатов не попадут в ТОП, и HR их даже не увидит.

3. Как граф может помочь

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

Вектора помогают искать по смыслу, даже если слова разные. Граф добавляет порядок - фиксирует реальные связи, позволяет ставить жесткие условия и дает понятные объяснения, почему кандидат в топе. Вместе дает более точный поиск, и больше доверия со стороны HR и руководителей.

Почему резюме так удобно раскладывать в граф

На первый взгляд резюме выглядит как строгая структура: блоки “Опыт”, “Навыки”, “Образование”, даты, названия должностей. Но по факту это просто свободный текст, который люди оформляют как им удобно. Все смешивается:

  • навыки (часть явные, часть подразумеваются),
  • роли (в разной терминологии),
  • компании (с разными написаниями),
  • проекты (иногда без деталей),
  • временные интервалы (часто с дырками),
  • образование (тоже как придется).

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

Небольшой абстрактный пример.
Вакансия: Product Analyst.

Обязательные: SQL, A/B-тесты, продуктовые метрики.
Желательно: воронки и удержание, коммуникация с продактами.
Жесткое ограничение: коммерческий опыт от 1 года.
Кого рассматриваем:

Кандидат А: Data Analyst в e‑commerce, 3 года. SQL, метрики, эксперименты. Делал A/B, но пишет это как "тестировал гипотезы".
Кандидат Б: Product manager, 2 года. Очень «продуктовый» текст, много про стратегию и синхронизацию команд, но без SQL и экспериментов.
Кандидат В: BI developer, 4 года. SQL на месте, A/B только на курсе, коммерческих запусков нет.
Если ранжировать только по эмбеддингам текста вакансии и резюме, кандидат Б может всплыть слишком высоко, так как текст семантически близкий. А кандидат А может просесть: он называет A/B иначе и пишет проще.

Граф помогает двумя вещами: вытаскивает проверяемые факты: "есть навык", "чем подтвержден", "сколько времени" и дает "мосты" по ролям: Data Analyst -> Product Analyst встречается в истории переходов, значит переход реалистичен.

азовая схема HR-графа: узлы и ребра

Разберем типичную схему.

Узлы: Person (кандидат/сотрудник), Role (роль/должность), Skill, Company, Education, Vacancy.

Рёбра: has_skill, held_role_at_time, worked_for, requires_skill, transition_to, endorsed_by и т.д.

Смысл часто живёт именно в связях. Рёбра несут факты - сколько человек работал в роли, когда начался и закончился период, какой был уровень, откуда пришёл сигнал. В табличной модели это обычно превращается в хаос из JOIN-ов и полей с массивами. В графе это естественно.

И графы тут, это уже не только про поиск, это уже про модель хранения и представления данных о кандидатах. Мы сначала превращаем разрозненные факты (роль, навыки, компании, проекты, даты) в единую сеть связей, а поиск, рекомендации и объяснения строим поверх. И поиск становится просто одним из способов навигации по этой сети. А дальше это превращается в рабочий инструмент: можно реанимировать базу (кандидаты из прошлых воронок, которые стали релевантны), делать внутреннюю мобильность (кто может перейти в роль за 1–2 шага и что добрать), строить карты редких связок навыков (например, “DevOps + Security”), и находить неочевидные источники кандидатов через роли-мосты и типичные траектории.

Объяснимость как «побочный продукт» графа

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

В рекомендательных системах это используют давно: граф может показать понятную цепочку причин - какие связи привели к рекомендации. Например, в соцсетях этот принцип можно проиллюстрировать примером: "Вы можете знать А, потому что у вас 7 общих друзей" или "Рекомендуем группу, потому что вы подписаны на X и Y, а их аудитории сильно пересекаются".

Для поиска вакансий вариаций таких цепочек может быть много и вручную их все на задашь шаблонами, как это было в примере с соцсетями. Благо у нас есть LLM-модели. То есть, к результату можно подключить простую LLM-модель, эти объяснения можно превратить из «технического лога» в нормальный человеческий текст. Тут не нужен суперкомпьютер и не нужно выпускать данные наружу: хватит простой модели, запущенной в контуре компании на том же сервере, где работают графы. LLM получает на вход уже готовые факты (skills-evidence, path-evidence, gap-evidence) и просто оформляет их словами. Важный момент: LLM в такой схеме не решает, кого рекомендовать, а просто пересказывает то, что и так доказано по графу. И это, как вы понимаете, больше приятная опция, чем необходимость.

Итак, а как это может выглядеть? Система нашла кандидата и отдала структуру: «совпали навыки SQL, A/B, метрики; путь роли Data Analyst -> Product Analyst встречается в истории переходов; пробел - product thinking». LLM превращает это в текст для рекрутера: «Кандидат закрывает 3 ключевых требования (SQL, метрики, A/B) и его карьерный переход в продуктовую аналитику типичен. Не хватает подтверждений по product thinking - можно проверить на кейсе».

А где же в этой истории место для векторного поиска?

Векторный поиск закрывает то, где графовый запрос начинает буксовать: вариативность языка и недосказанность резюме. Он ловит синонимы и переформулировки («data pipeline» и «ETL», «customer success» и «аккаунт»), вытаскивает навыки, которые не названы напрямую (вместо «A/B» человек пишет «эксперименты», «гипотезы», «uplift»), помогает, когда в данных появляется новая сущность (новая роль, технология или домен) и она еще не заведена в справочники - но по описанию уже понятно, о чем речь. И он хорошо переваривает "человеческий" запрос: рекрутер может сформулировать задачу как текст (“нужен сильный аналитик продукта, будет много экспериментов и метрик, важна работа с командой”), а не как набор фильтров.

Что за гибридная модель поиска и как она работает?

Это значит, что один запрос обрабатывает несколько независимых механизмов поиска, а затем их результаты сводят в общий рейтинг.

Например, смешиваем три канала. Лексический поиск (например, BM25) нужен для точных попаданий: названия библиотек, версии, аббревиатуры. Векторный поиск нужен для смысла: он находит близкие формулировки, даже если слова не совпадают. Графовый слой отвечает за структуру: связи между ролями и навыками и т.д..

И мы можем объединить разные сигналы достаточно просто. Берём три оценки: из обычного текстового поиска, из векторной близости и из графа (пара простых признаков). Приводим их к одному масштабу, например к 0-1. Потом складываем с весами. Веса можем поставить руками: условно 0.5 на лексику, 0.3 на вектора, 0.2 на граф - и дальше чуть крутить по качеству выдачи.

# - нормируем три сигнала в диапазон 0..1:
#  - `s_bm25` — лексический скор
#  - `s_vec` — векторная близость
#  - `s_graph` — графовый скор из признаков
# Плюс добавляем жёсткие фильтры, чтобы семантика не протащила нерелевантного кандидата.

score = 0.5 * s_bm25 + 0.3 * s_vec + 0.2 * s_graph

# hard constraints
if years_commercial < 1:
    score -= 1.0 
if must_have_skills_covered < 3:
    score -= 0.7

# graph features inside s_graph
s_graph = (
    0.45 * skill_coverage
  + 0.35 * transition_typicality   # насколько нормален такой переход
  + 0.20 * evidence_strength       # насколько подтверждены навыки
)

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

По технологиям тут тоже всё достаточно просто. Можем собрать гибрид на движках, где лексика и вектора работают рядом. Или на графовой платформе, которая умеет искать и по тексту, и по эмбеддингам. А можем держать всё в одном месте - Postgres + pgvector (что на мой взгляд идеально подходит для MVP), если удобнее хранить текст, метаданные и эмбеддинги в одной базе и не плодить сервисы.

Три подхода к поиску по резюме с использованием графов \ векторов

Давайте рассматриваем архитектуру как три уровня зрелости. Эти уровни будут ити ступенями: каждый следующий вариант опирается на предыдущий. Например, вариант 3 не заменяет первые два - он делает их лучще.

Вариант 1. Вектора как основной поиск

Суть: строим эмбеддинги резюме/профилей/вакансий, ищем ближайших.

Плюсы: быстрый старт, хорошо ловит смысл, проще инфраструктура.

Минусы: объяснимость слабее, особенно если не делать отдельный слой evidence, трудно учитывать "карьерные мосты" и реальные переходы, риск ошибок, когда текст похож, но профиль не соответствует.

Вариант 2. Гибрид: вектора + граф как признаки для ранжирования

Мы сначала собираем кандидатов быстрым поиском (векторным и/или по ключевым словам).

Потом подключаем граф и добавляем к каждому кандидату понятные сигналы:

  • сколько обязательных навыков совпало;
  • есть ли «мост» по ролям;
  • насколько такой переход вообще типичен;
  • есть ли подтверждения навыков (проекты, опыт, обучение, оценка).

Дальше мы собираем все эти сигналы в один итоговый скор. И тут есть два пути, о которых уже писал выше:

Первый - "Learning-to-rank": мы берём историю решений (кого брали в шорт-лист, звали на интервью, нанимали, кого отсеивали) и учим модель расставлять кандидатов так же, как это делали рекрутеры и менеджеры, но уже с учётом всех признаков. Модель фактически учится, какие признаки в вашей реальности важнее, где можно закрыть глаза на отсутствие того или иного параметра.

Второй путь - правила. Мы задаём понятные ограничения и веса вручную (например, «без 3 обязательных навыков не показываем», «мост в 1–2 шага добавляет +X», «подтверждение навыка в проекте важнее, чем просто упоминание»), а затем суммируем баллы и сортируем.

Правила быстрее запускаются и проще объясняются, Learning-to-rank даст лучшее качество на масштабе, если, конечно, есть достаточно качественных данных для обучения.

Графы + вектора в этом варианте - это уже хорошо. Почему? Все будет работать уже на средних объёмах данных, а если граф местами шумный, он не поломает векторные эмбеддинги. В этом случае он просто даст один из признаков, который ранжирование может и не учитывать.

Вариант 3. Вшиваем граф в эмбеддинги

Мы учим вектора узлов так, чтобы в них была зашита не только семантика текста, но и связи из графа. Тогда поиск становится умнее. Похожими будут считаться не только те, у кого "похожи слова", но и те, у кого похожи траектории и соседство в графе. И тут важно сделать честную оговорку: если связи шумные или данных мало, вариант 3 легко проиграет варианту 2.

Теперь о том, как это сделать. Технически это делают графовые нейросети (GNN). Идея простая: у каждого узла есть вектор, и модель обновляет его, собирая сигналы от соседей по ребрам. Один слой - смотрим на соседей в один шаг, два слоя - уже смотрим соседей соседей, и так далее. Поэтому вектор кандидата со временем начинает учитывать не только текст резюме, но и окружение в графе - какие навыки и роли рядом, какие роли связаны типичными переходами, через какие "мосты" можно прийти к целевой позиции.

Разные архитектуры GNN отличаются тем, как именно они смешивают информацию соседей. Давайте рассмотрим несколько примеров.

Пример 1 - GCN. Делает базовый, "ровный" вариант - нормированную сумму (по смыслу, усреднение) признаков соседей и самого узла. Это часто хорошая отправная точка, но при большом числе слоёв появляется риск сглаживания. Представления разных узлов становятся слишком похожими, и модель хуже различает кандидатов.

Пример 2 - GraphSAGE. Он решает другую практическую проблему - масштаб и новые объекты. Он учится агрегировать признаки, при этом берет не всех соседей, а выборку, и может строить эмбеддинги для новых узлов без полного переобучения.

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

Пример 4 - R-GCN. Важный момент: так как наш граф будет неоднородный (разные типы узлов и ребер: кандидат-роль, роль-навык, роль->роль переход, вакансия-навык), нам важно, чтобы модель различала типы связей. И тут мы подбираемся к R-GCN. Она как раз для этого. Она использует разные преобразования для разных типов ребер, поэтому связь "когда есть навык" и связь "переход между ролями" могут влиять на вектор по-разному. Для нашей задачи это критично, навыки больше отвечают за содержательную близость, а переходы - за траекторию. На практике мы стартуем с текстовых эмбеддингов (резюме/вакансия/описание роли), прогоняем их через графовую модель и получаем graph-aware вектора, по которым уже делаем поиск. А объяснение результата все равно строим на проверяемых фактах - какие навыки совпали, какой путь по графу сработал и чего не хватает и т.д.

Данные: что нужно?

Источники данных

  • ATS: вакансии, резюме, статусы, выборки кандидатов (шорт-листы), интервью-заметки.
  • HRIS: история ролей сотрудников, переводы, даты.
  • LMS: курсы/сертификации как подтверждение навыков.
  • Доп.источники: проектные системы, внутренние справочники компетенций (если есть).

Нормализация - узкое место

Роль «Product Analyst» может быть написана 30 способами. Навык «SQL» тоже можно «разложить» на десятки вариантов.

Если мы все это не приводим к каноническим сущностям, граф распадётся на части.

Тут помогают таксономии/онтологии. Например, в Европе есть ESCO - онтология навыков, компетенций и профессий, которую можно использовать как внешний каркас для нормализации.

Но ESCO не решает все. Есть локальные роли, внутренние грейды, доменные навыки. Так что в реальной системе понадобится гибрид - внешняя онтология + свой словарь.

Какая разметка вообще бывает?

В HR почти никогда нет идеальных меток "релевантен/нерелевантен". Поэтому для оценки мы используем косвенные данные:

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

Сколько данных нужно?

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

Для гетерографа (person/role/skill/vacancy) важнее не количество, а плотность и чистота: сколько подтвержденных связей, сколько нормализовано, сколько с корректными датами.

Подробно: как вшить граф в эмбеддинги?

Напомню варианты, которые мы рассматривали:

Вариант 2 - Есть эмбеддинг текста + есть графовые признаки -> модель решает, как их сложить.

Вариант 3 - Граф и тексты вместе -> новые эмбеддинги, которые уже учитывают соседей и связи.

Какой граф нам нужен? Для HR это почти всегда гетерогенный граф:

  • узлы разных типов (Person, Role, Skill, Vacancy),
  • ребра разных типов (has_skill, held_role, requires_skill, transition_to),
  • атрибуты (время, уровень, домен, источник).

Для таких графов подходят архитектуры, которые умеют работать с разными типами ребер. R-GCN как раз про это: реляционные ребра и разные матрицы для разных отношений. Если граф огромный и нужно индуктивное обучение (добавлять новые узлы), можно использовать GraphSAGE.

Что кладем в признаки узлов?

  • Текстовые эмбеддинги: резюме, описание роли, вакансия, заметки интервью.
  • Категории: грейд, домен, подразделение, локация (если разрешено).
  • Время: свежесть опыта, длительность ролей (лучше как атрибут ребра).

Как обучаем?

- Link prediction.

Мы берем граф и учим модель угадывать: должна ли тут быть связь.

Пример: «насколько реалистичен переход из RoleA в RoleB».

Если в данных часто встречается переход Data Analyst -> Product Analyst, модель учится считать его "нормальным". А BI Developer -> Product Manager (если почти не бывает) - "сомнительным".

- Contrastive / similarity.

Мы учим модель сближать векторы того, что реально похоже по смыслу и профилю, и раздвигать то, что не похоже.

Пример: роли Product Analyst и Data Analyst должны оказаться рядом (общие навыки, похожие задачи).

А Product Analyst и Accountant - далеко, даже если в резюме где-то встречается слово «analysis».

- Supervised ranking.

Если у нас есть история решений (кого добавили в шорт-лист, кого наняли, кого перевели), мы учим модель ранжировать кандидатов так же.

Пример: для вакансии X модель должна ставить выше тех, кто в прошлых похожих кейсах реально доходил до интервью/оффера, и ниже тех, кто красиво выглядит по тексту, но стабильно не проходит по содержанию.

Почему вариант 3 тяжелее, чем кажется

Потому что ошибки в графе влияют на всё пространство эмбеддингов. В варианте 2 грязный графовый признак можно "подавить". В варианте 3 грязная связь меняет соседство узла и тянет за собой представления других узлов.

Это не причина отказаться от варианта 3. Просто тут нужна дисциплина:

  • Версионировать справочники ролей и навыков, что именно считается одной и той же ролью или навыком в этой версии.
  • Регулярно проверять качество связей в графе, где появились дубли, пропуски, странные переходы, ложные навыки.
  • Замечать, когда смысл ролей и навыков смещается со временем и модель начинает хуже попадать в реальность.

Как тестируем?

Мы проверяем две вещи: качество выдачи и скорость ответа. Давайте разберем несколько примеров.

Recall@K - мы вообще нашли нужных?

Берём топ-K кандидатов и смотрим, попал ли туда хотя бы один кандидат, которого в исторических данных считали подходящим.

Пример: Recall@10 = 0.8

значит в 80% вакансий в топ-10 был хотя бы один "правильный" кандидат.

Recall@K не будет говорить нам, на каком месте кандидат стоит. Он только укажет, нашли или нет необходимого кандидата.

NDCG@K - насколько правильно расставили порядок?

Подходит, когда рекрутер реально смотрит первые 5–10 профилей.

Плохо, когда хороший кандидат на 10 месте и хорошо, когда он на 2 месте. Система получает больше очков, если релевантный кандидат выше в списке, и меньше - если он ниже.

MRR - на каком месте первый хороший?

Если нужен один лучший кандидат, а не длинный список. MRR растёт, если первый подходящий кандидат появляется рано. Когда MRR высокий, это значит, что чаще всего подходящий кандидат находится на позициях 1–3.

Latency / P95 - как быстро отвечает система?

Качество не спасает, если система тормозит.

  • Latency - время ответа.
  • P95 - время, быстрее которого укладываются 95% запросов.

P95 = 8–10 секунд - это уже боль. Все привыкли что подобные системы должны работать быстро.

Базовые baselines: с чем сравниваем

Если проверяем, стало ли лучше, то нужно сперва определиться, лучше относительно чего именно? Мы фиксируем несколько опорных точек. Они простые и на них видно, где именно появился выигрыш.

1) Лексика как есть.

Берём текст вакансии, ищем резюме по словам. Без эмбеддингов и без графа. Если есть фильтры (локация, грейд, тип занятости), применяем их одинаково во всех режимах.

2) Только вектора

Строим эмбеддинги вакансии и профилей, ищем ближайших. Не даём графу вмешаться в ранжирование.

3) Только графы

Делаем подбор через граф: навыки, роли, компании, переходы, требования вакансии.

4) Гибридный (наш вариант 2).

Сначала собираем кандидатов (лексика + вектора), потом доранжируем моделью или правилами, куда подаём графовые признаки.

5) GNN (наш вариант 3).

Обучаем эмбеддинги так, чтобы они уже учитывали структуру графа. Потом делаем поиск в этом новом пространстве и смотрим, стало ли лучше.

Куда это все идет: от поиска к диалоговым HR-интерфейсам

Следующий эволюционный шаг после того, как у нас появится качественный поиск по запросам - научиться отвечать на вопросы HR. Вопросы совершенно разные:

  • Кого из нашей базы мы уже видели на похожие роли - и почему тогда не взяли?
  • Какие требования сейчас сильнее всего режут пул - и что будет, если ослабить одно из них?
  • Кого показать менеджеру как 3–5 вариантов с разными компромиссами: скорость выхода, глубина опыта, доменная экспертиза?

Списком тут не отделаешься. Даже если список хороший. HR и нанимающий менеджер должны иметь возможность общаться с системой как с коллегой: уточнять, спорить, менять вводные и спрашивать "а что если ...?".

То есть, все это идет дальше к интерфейсу, где вместо поиска - диалог, а граф - это структура и представление данных. Вы можете спросить про роль, а система не просто найдет релевантных, а объяснит траектории, покажет варианты кого можно дорастить, предупредит о рисках и предложит компромиссы.