slavb18

    Методы извлечения навыков из резюме и вакансий

    AINLPHRHRTechLLM

    Методы извлечения навыков из резюме и вакансий

    Автоматическое извлечение навыков (skills extraction) - ключевая задача в системах подбора персонала, рекомендации вакансий и анализа рынка труда. На вход поступает неструктурированный текст: раздел "Требования" вакансии или блок "Опыт/Навыки" резюме. На выходе ожидается нормализованный список компетенций, пригодный для поиска, сравнения и аналитики.

    В этой статье рассматривается конвейер, реализованный в iskillmatching, который объединяет три взаимодополняющих подхода:

    1. NER на основе LLM - нейросетевое распознавание именованных сущностей.
    2. Сопоставление шаблонов через spaCy - поиск по заранее составленному словарю навыков.
    3. Нормализация через векторные представления - приведение извлечённых вариантов к каноническим формам с помощью семантического сходства.

    1. NER на основе LLM (нейросетевое распознавание сущностей)

    Что такое NER

    Named Entity Recognition (NER) - задача последовательной классификации, при которой каждому токену текста присваивается метка: является ли он частью именованной сущности (например, "технология", "навык", "организация") или нет. Традиционно NER решался с помощью CRF и правил, однако современные трансформерные LLM (Large Language Models) достигают значительно более высокого качества за счёт контекстного понимания текста.

    Используемая модель

    В ner_utils.py применяется пайплайн HuggingFace Transformers:

    from transformers import pipeline
    
    def get_ner_extractor(model_name="dondosss/rubert-finetuned-ner"):
    return pipeline(
    "token-classification",
    model=model_name,
    aggregation_strategy="simple"
    )
    

    Модель dondosss/rubert-finetuned-ner - это дообученная (fine-tuned) версия RuBERT на задаче NER для русскоязычных текстов. RuBERT - российский аналог BERT, предобученный на большом корпусе русского языка. Дообучение на размеченных текстах резюме и вакансий позволяет модели распознавать технологии, фреймворки и инструменты как именованные сущности.

    Параметр aggregation_strategy="simple" обеспечивает автоматическое объединение субтокенов (BPE-фрагментов) в целые слова - без него результат содержал бы артефакты вроде ##Script вместо JavaScript.

    Процесс извлечения

    def extract_ner_skills(texts, ner_extractor):
    results = ner_extractor(texts)
    piped_skills = []
    for ents in results:
    skills = list(set([e["word"].lower() for e in ents]))
    piped_skills.append("|".join(skills))
    return piped_skills
    

    Модель обрабатывает батч текстов и возвращает для каждого список сущностей. Результат сериализуется в строку с разделителем | для удобного хранения в CSV. Дубликаты устраняются через set.

    Достоинства и ограничения

    ДостоинстваОграничения
    Находит навыки без словаря, "из контекста"Может галлюцинировать (ложные сущности)
    Справляется с опечатками и морфологиейЗависит от качества дообучающей выборки
    Работает с аббревиатурами и составными терминамиВысокие требования к памяти GPU/CPU

    2. Сопоставление шаблонов через spaCy (PhraseMatcher)

    Идея подхода

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

    Реализация в spaCy

    from spacy.matcher import PhraseMatcher
    
    def get_spacy_matcher(nlp, skills_list):
    matcher = PhraseMatcher(nlp.vocab, attr="LOWER")
    patterns = [nlp.make_doc(skill) for skill in skills_list]
    matcher.add("SKILL", patterns)
    return matcher
    

    Ключевой момент: attr="LOWER" означает регистронезависимое сравнение. nlp.make_doc создаёт spaCy-документ из строки - это быстрее, чем полный парсинг, так как не требует запуска всего NLP-конвейера.

    Сам поиск работает за линейное время от длины текста (O(n)), поскольку под капотом spaCy использует оптимизированный хэш-матч по токенам.

    Батчевая обработка через nlp.pipe

    def extract_spacy_skills(texts, nlp, matcher):
    piped_skills = []
    for doc in nlp.pipe(texts):
    matches = matcher(doc)
    seen_skills = set()
    extracted_skills = []
    for match_id, start, end in matches:
    span = doc[start:end]
    skill_name = span.text
    if skill_name.lower() not in seen_skills:
    extracted_skills.append(skill_name)
    seen_skills.add(skill_name.lower())
    piped_skills.append("|".join(extracted_skills))
    return piped_skills
    

    nlp.pipe обрабатывает тексты потоком, что существенно эффективнее цикла с nlp(text), - тексты передаются батчами, токенизация векторизована.

    Источник словаря навыков

    Словарь загружается из CSV-файла или извлекается как fallback из столбцов skills/stack входных данных:

    def extract_fallback_skills(df):
    fallback_skills = set()
    for col in ["skills", "stack"]:
    if col in df.columns:
    series = df[col].fillna("").astype(str)
    for val in series:
    parts = [p.strip().lower() for p in val.split("|") if p.strip()]
    fallback_skills.update(parts)
    return sorted(list(fallback_skills))
    

    Достоинства и ограничения

    ДостоинстваОграничения
    Высокая точность (precision) при хорошем словареНе найдёт навык, которого нет в словаре
    Детерминированность и воспроизводимостьТребует поддержки словаря
    Быстрая работа даже на CPUПлохо справляется с вариантами написания

    3. Объединение результатов и очистка

    Оба метода дают пересекающиеся, но дополняющие друг друга результаты. В clean_utils.py они объединяются и очищаются:

    def combine_and_clean(ner_piped, spacy_piped):
    ner_set = set(ner_piped.split("|")) if ner_piped else set()
    spacy_set = set(spacy_piped.split("|")) if spacy_piped else set()
    combined_set = ner_set.union(spacy_set)
    cleaned_skills = []
    seen = set()
    for s in combined_set:
    cleaned = clean_skill(s)
    if cleaned and cleaned not in seen:
    cleaned_skills.append(cleaned)
    seen.add(cleaned)
    return "|".join(sorted(cleaned_skills))
    

    Функция clean_skill решает несколько задач:

    • Удаление BERT-артефактов: субтокены вида ##ScriptScript.
    • Удаление шума на границах: лишние пробелы, дефисы, скобки, кавычки.
    • Фильтрация стоп-слов: слова "знание", "опыт", "уровень", "разработка" и т.д. не являются навыками и исключаются.

    Список стоп-слов (SKILL_STOP_WORDS) специфичен для русскоязычных текстов резюме и вакансий и насчитывает более 50 типичных "пустышек".


    4. Нормализация через векторные представления

    Проблема, которую решает нормализация

    После объединения NER и spaCy-результатов мы получаем "сырые" навыки: "python 3", "python3", "Python", "питон" - это одна и та же технология, но разные строки. Без нормализации они будут учитываться раздельно, что делает невозможным корректный поиск и агрегацию.

    Семантические векторы (Sentence Embeddings)

    В normalize_utils.py используется библиотека SentenceTransformers:

    from sentence_transformers import SentenceTransformer
    from sklearn.metrics.pairwise import cosine_similarity
    
    class SkillNormalizer:
    def __init__(self, skills_data, model_name, threshold=0.6):
    self.model = SentenceTransformer(model_name)
    self.threshold = threshold
    self.skill_embeddings = self.model.encode(
    self.skills,
    normalize_embeddings=True,
    batch_size=128
    )
    

    Модель anass1209/resume-job-matcher-all-MiniLM-L6-v2 - это дообученная версия all-MiniLM-L6-v2, адаптированная специально для задачи сопоставления резюме и вакансий. Она преобразует строку навыка в вектор размерностью 384, кодирующий семантический смысл.

    Флаг normalize_embeddings=True гарантирует, что все векторы имеют единичную длину. В этом случае косинусное сходство совпадает со скалярным произведением, что ускоряет вычисления.

    Алгоритм нормализации

    def normalize_batch(self, piped_skills_batch):
    for piped_str in piped_skills_batch:
    current_skills = [s.strip() for s in piped_str.split("|") if s.strip()]
    embeddings = self.model.encode(current_skills, normalize_embeddings=True)
    sims = cosine_similarity(embeddings, self.skill_embeddings)
    normalized_set = set()
    for row in sims:
    best_idx = np.argmax(row)
    if row[best_idx] >= self.threshold:
    normalized_set.add(self.skills[best_idx])
    results.append("|".join(sorted(normalized_set)))
    

    Для каждого извлечённого навыка алгоритм:

    1. Строит его эмбеддинг.
    2. Вычисляет косинусное сходство с эмбеддингами всех эталонных навыков.
    3. Выбирает наиболее похожий эталон (argmax).
    4. Принимает его, если сходство превышает порог (threshold=0.8 в extract.py).

    Если порог не достигнут, навык отбрасывается - это защита от ложных обобщений (например, чтобы слово "работа" не нормализовалось к "Java"). Класс SkillNormalizer принимает порог как параметр конструктора (по умолчанию 0.6), а в точке вызова в extract.py он переопределяется до 0.8 для получения более строгих совпадений в продакшн-сценарии.

    Визуализация работы нормализации

    Сырой навык → Эмбеддинг → Ближайший эталон → Сходство
    ─────────────────────────────────────────────────────────────────────────
    "python3" → [0.12, ...] → "Python" → 0.97 ✓
    "react.js" → [0.34, ...] → "React" → 0.91 ✓
    "разработка на С++" → [0.56, ...] → "C++" → 0.82 ✓
    "опыт работы" → [0.78, ...] → "Работа" → 0.51 ✗ (ниже порога)
    

    Достоинства и ограничения

    ДостоинстваОграничения
    Не зависит от точного совпадения строкТребует предобученной модели (RAM/диск)
    Обрабатывает опечатки и синонимыСкорость зависит от размера словаря эталонов
    Работает для многоязычных текстовПорог подбирается эмпирически

    5. Общая архитектура конвейера

    ┌─────────────┐
    Текст вакансии/резюме │ extract.py │
    ─────────────────────► │ │
    └──────┬──────┘
    │
    ┌─────────────────┼─────────────────┐
    ▼ ▼
    ┌──────────────┐ ┌──────────────────┐
    │ NER (LLM) │ │ spaCy Matcher │
    │ RuBERT NER │ │ (PhraseMatcher) │
    └──────┬───────┘ └────────┬─────────┘
    │ │
    └──────────────┬─────────────────────┘
    ▼
    ┌─────────────────┐
    │ combine_and_clean│
    │ (stop words, │
    │ dedup, clean) │
    └────────┬────────┘
    ▼
    ┌─────────────────┐
    │ SkillNormalizer │
    │ (SentenceTransf.│
    │ cosine sim.) │
    └────────┬────────┘
    ▼
    Нормализованный список навыков
    

    Каждый шаг сохраняется в отдельный столбец CSV (ner, spacy, combined, normalized), что позволяет отлаживать конвейер и анализировать вклад каждого метода.


    6. Сравнение подходов

    КритерийNER (LLM)spaCy MatcherВекторная нормализация
    Полнота (recall)ВысокаяСредняя (ограничена словарём)-
    Точность (precision)СредняяВысокаяВысокая
    СкоростьМедленно (GPU желателен)Быстро (CPU)Средне
    ПоддержкаНужно дообучениеНужен актуальный словарьНужен словарь эталонов
    МультиязычностьЗависит от моделиЗависит от словаряЗависит от модели

    7. Запуск

    # Установка зависимостей (spaCy модель для токенизации)
    uv run python -m spacy download en_core_web_lg
    
    # Запуск с внешним словарём навыков
    uv run -m iskillmatching.extract -i input.csv -o output.csv -s skills.csv -l 500
    
    # Запуск без словаря (навыки извлекаются из столбцов skills/stack)
    uv run -m iskillmatching.extract -i input.csv -o output.csv
    

    Входной CSV должен содержать столбец requirement_ru с текстами на русском языке. Результаты записываются в столбцы ner, spacy, combined, normalized.


    Материалы

    Можно попробовать обучение собственной модели NER на основе NER-Web-App-TensorFlowJS или настройку prompt к LLM через DSPy.

    1. Сравнение различных вариантов NER: arxiv.org/pdf/2407.19816, модель из статьи: dondosss/rubert-finetuned-ner
    2. Модель NER на tensorflow: NER-Web-App-TensorFlowJS
    3. SentenceTransformer based on all-mpnet-base-v2: TechWolf/JobBERT-v2
    4. SentenceTransformer based on all-MiniLM-L6-v2: anass1209/resume-job-matcher-all-MiniLM-L6-v2
    5. Ещё один SentenceTransformer based on all-MiniLM-L6-v2: hetbhagatji09/Job-resume-ner-match-model
    6. Сервис для запуска моделей: michaelfeil/infinity
    7. NER на LLM с DSPy: MarcusElwin/ner-dspy

    Исходный код конвейера: iconicompany/iskillmatching


    📚 Читайте также