Привет, Хабр! Меня зовут Денис, я продолжаю рассказывать о проекте hhbro. Эта статья — не очередной обзор фич. Это инженерный пост‑мортем: как я спроектировал умный поиск вакансий, где упёрся в 152-ФЗ, как считал экономику каждого прогона и какие ошибки успел наделать в продакшене.

Если вы делаете LLM/ML‑фичи для B2C/B2B‑продукта в РФ, многие решения покажутся знакомыми, а некоторые — спорными. Буду рад обсуждению в комментариях.

1. Проблема: почему LIKE '%python%' больше не работает

Классический поиск вакансий отвечает на вопрос: «В тексте есть эти слова?» Так работал стандартный поиск на hh для подбора вакансий по резюме раньше.
Пользователь ожидает ответа на другой: «Насколько мой профиль подходит этой вакансии?» - так работает просмотр резюме АТС системами, установленными у HR.

Когда я только начал изучать рынок, оказалось, что большинство сервисов решают задачу либо через полнотекстовый поиск с булевыми правилами, либо через парсинг и простые фильтры. Мировые аналоги вроде LinkedIn Recruiter или Indeed Resume Search используют собственные ML‑модели для ранжирования кандидатов, но их архитектура закрыта и завязана на огромные объёмы исторических данных, которых у меня просто нет. Кроме того, западные сервисы давно не работают с российскими работодателями напрямую, а их API в текущих условиях недоступны. В России же такие возможности есть, пожалуй только у hh, но они целиком и полностью вкладываются в сторону работодателя, потому что: "что возьмешь с безработного?"

Что именно нужно было получить на выходе:

  • Вход:

    • резюме пользователя (структурированное + сырой текст),

    • фильтр поиска (регион, зарплата, ключевые слова),

    • ограничение по бюджету (кредиты).

  • Выход:

    • список вакансий с match_score от 0 до 100,

    • объяснимые сигналы: почему оценка именно такая.

Какие baseline‑подходы я проверил

Подход

Результат

TF‑IDF + cosine

Слишком чувствителен к точным формулировкам. Синонимы не учитываются, «управление командой» и «team lead» считаются разными сущностями.

BM25 + бизнес‑правила

Лучше справляется с ключевыми словами, но всё равно не понимает семантику. Попытка добавить эвристики (например, «если есть слово senior, то повысить вес») быстро превращается в костыльный код.

Embeddings + фичи + re‑rank

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

Именно на третьем варианте я и остановился, добавив поверх него кастомную скоринговую функцию и слой PII‑защиты.

2. Архитектура пайплайна (как оно устроено на самом деле)

Конвейер обработки одного запуска выглядит так:

Resume + SearchFilter
  → Normalize / Validate
  → PII Guard (152-ФЗ слой)
  → Candidate Vacancies Fetch
  → Embedding + Feature Extraction
  → Scoring + Re‑ranking
  → Delta Cache Write
  → Billing / Refund flow

Почему PII‑слой вынесен отдельно?
Потому что это единственный способ формально отделить «сырой» профиль пользователя от внешних сервисов. Благодаря этому мы можем:

  • менять модели эмбеддингов или вендоров без переписывания compliance‑логики;

  • включать аудит и алерты именно на границе нашего периметра;

  • гарантировать, что за пределы контура не утекает ничего лишнего.

Под капотом PII Guard — это комбинация библиотеки от яндекса natasha для русского NER и кастомных регулярных выражений. После обработки резюме превращается в обезличенный текст, где ФИО заменены на [NAME], телефоны — на [PHONE] и т.д.

3. Scoring: как я собирал честный match_score

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

Пример композиции:

score = (
    0.45 * semantic_similarity +   # эмбеддинги резюме и вакансии
    0.20 * experience_fit +        # required_years vs actual_years
    0.15 * salary_fit +            # попадание зарплаты в ожидаемый диапазон
    0.15 * skills_overlap_weighted + # пересечение hard skills с учётом важности
    0.05 * location_fit            # удалёнка / переезд / офис
)
score = clamp(round(score * 100), 0, 100)

Инженерный урок, который я вынес:
Если не нормализовать отдельные сигналы к единой шкале (например, семантическое сходство лежит в [0,1], а опыт — в абсолютных годах), веса становятся бессмысленными. Любая «подкрутка» ломает ранжирование в непредсказуемых местах.

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

4. 152-ФЗ и data minimization: как не улететь в риски

Самая частая ошибка AI‑команд в России — отправить «как есть» резюме во внешний API (OpenAI, Anthropic, Google). Это путь на скользкую дорожку. В проекте принципиально пусть и дорогие, но отечественные модели. Даже есть надежда, что догонят по качеству.
Хотя для текущих нужд справляются отлично.

Правило: внешний этап получает только минимально достаточное представление данных.

Что я делал практически:

  1. PII‑redaction перед внешним инференсом: удаляются email, телефон, URL, персональные идентификаторы и часть именованных сущностей.

  2. Валидация post‑redaction: если детектор находит потенциальную утечку — запрос не уходит дальше, а мы получаем алерт в мониторинг.

  3. Логирование технических метрик, а не контента: в логи попадают только хэш job‑id, latency, количество токенов и классы ошибок.

Главный компромисс:
Безопасность и легальность
  минус
дополнительная задержка и CPU‑стоимость.
Но это осознанный выбор: «быстро и незаконно» — не наш путь.

Интересно, что даже в мировых практиках (например, GDPR) подобные подходы становятся стандартом. Исследование «Privacy-Preserving Job Recommendation» от группы ученых из Университета Карнеги‑Меллон (2023) подтверждает, что агрессивная анонимизация снижает точность рекомендаций всего на 2–4%, зато снимает юридические риски.

5. Экономика: почему первый прогон дорогой и как это решал

Первый запуск Smart Search обрабатывает большой срез — у нас это 150 вакансий. Именно здесь горит основной бюджет на токены и инференс.

Что сработало продуктово и технически:
Я сделал «первый запуск с возвратом».

  1. Проводится анализ на удешевленной модели, она отсеивает самые мимо пролетающие вакансии, при этом цена обработки достаточно низкая.

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

  3. Биллинг проводит обычное списание (это необходимо для целостности учёта и защиты от ботов).

  4. После успешного завершения анализа автоматически вызывается refund().

  5. В истории транзакций пользователь видит две записи: списание и возврат.

  6. Мы получаем не дорогой поиск, но затем улучшаем результаты.

Зачем не делать «полностью бесплатно сразу»?
Потому что без write‑path в биллинг вы получаете дешёвый вектор атаки ботами на ваш ML‑кластер. Я проверял: как только убрали даже намёк на бесплатность, количество мусорных запросов упало в 10 раз.

С точки зрения unit‑экономики, стоимость обработки одной вакансии составляет ≈ 0.07–0.12 руб. (в зависимости от нагрузки и курсов токенов у провайдеров эмбеддингов). Для сравнения, сервисы вроде Textio или Grammarly тратят в разы больше на один документ, но их бизнес‑модель позволяет это окупать.

6. Delta processing: платим только за новое

Самый эффективный оптимизационный слой, который я внедрил.

Принцип работы:

  • Храним обработанные vacancy_id + версию признаков.

  • На следующем прогоне считаем new_ids = incoming_ids - cached_ids.

  • Обрабатываем только дельту.

  • Пересчитываем агрегаты и обновляем ранжирование.

Эффект:

  • Резкое снижение расхода токенов и стоимости инференса (в среднем на 90–95%).

  • Ниже latency повторных запусков.

  • Честная модель списания кредитов: пользователь платит только за реально новые вакансии.

В сутки в выборке появляется 2–5 новых вакансий, так что повторные запуски стоят копейки.

7. Практические рекомендации тем, кто строит похожее

Если вы задумались о внедрении семантического поиска в своём продукте, вот несколько советов, которые сэкономят вам месяцы:

  1. Сразу разделяйте pipeline на compliance и inference.
    Потом это сделать почти невозможно без полного рефакторинга.

  2. Версионируйте scoring‑функцию.
    Иначе вы не сможете ответить на вопрос, почему score «вчера был 78, сегодня 62».

  3. Считайте стоимость фичи до запуска.
    Без дельта‑кэша AI‑функция может оказаться экономически мёртвой при росте DAU.

  4. Стабилизируйте контракты между backend и Zod/SDK.
    null vs undefined в проде ломает UI чаще, чем кажется.

  5. Логируйте технические метрики, а не пользовательский контент.
    Это сильно упрощает legal‑review и compliance‑проверки.

  6. Считайте экономику, это критически важно, все запросы должны преобразовываться в стоимость расхода на операцию в рублях и стоимость этого анализа для пользователя, чтобы понимать - где у вас дыры, иначе проект и яйца выделенного не стоит.

9. Что бы я сделал иначе с первого дня

Оглядываясь назад, я бы:

  • Раньше ввёл typed contract‑tests между OpenAPI и фронтовыми схемами.

  • Раньше добавил fallback SEO guards для критических роутов.

  • Сразу заложил единообразную политику на nullable поля в API.

  • Выстраивал бы ценовую политику на основе данных а не по ощущениям

10. Куда двигаемся дальше

Планы на ближайшее будущее:

  • Усиление explainability («почему этот score») без роста latency.

  • Дальнейшее снижение cost‑per‑run через более агрессивный кэш и дельта‑стратегии.

  • Уплотнение локального pre‑processing слоя, чтобы ещё меньше данных уходило во внешний контур.

  • По мере развития, отказ от внешних поставщиков LLM в пользу локальных моделей.

Буду рад обсудить в комментариях ваши подходы к семантическому поиску и комплаенсу. Если у вас есть опыт внедрения локальных LLM‑моделей в проде — особенно интересно!