Меня зовут Денис, я продолжаю рассказывать о своём проекте. Эта статья — не очередной обзор фич. Это инженерный пост‑мортем: как я спроектировал умный поиск вакансий, где упёрся в 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). Это путь на скользкую дорожку. В проекте принципиально пусть и дорогие, но отечественные модели. Даже есть надежда, что догонят по качеству.
Хотя для текущих нужд справляются отлично.
Правило: внешний этап получает только минимально достаточное представление данных.
Что я делал практически:
-
PII‑redaction перед внешним инференсом: удаляются email, телефон, URL, персональные идентификаторы и часть именованных сущностей.
-
Валидация post‑redaction: если детектор находит потенциальную утечку — запрос не уходит дальше, а мы получаем алерт в мониторинг.
-
Логирование технических метрик, а не контента: в логи попадают только хэш job‑id, latency, количество токенов и классы ошибок.
Главный компромисс:
Безопасность и легальность
минус
дополнительная задержка и CPU‑стоимость.
Но это осознанный выбор: «быстро и незаконно» — не наш путь.
Интересно, что даже в мировых практиках (например, GDPR) подобные подходы становятся стандартом. Исследование «Privacy-Preserving Job Recommendation» от группы ученых из Университета Карнеги‑Меллон (2023) подтверждает, что агрессивная анонимизация снижает точность рекомендаций всего на 2–4%, зато снимает юридические риски.
5. Экономика: почему первый прогон дорогой и как это решал
Первый запуск Smart Search обрабатывает большой срез — у нас это 150 вакансий. Именно здесь горит основной бюджет на токены и инференс.
Что сработало продуктово и технически:
Я сделал «первый запуск с возвратом».
-
Проводится анализ на удешевленной модели, она отсеивает самые мимо пролетающие вакансии, при этом цена обработки достаточно низкая.
-
Кандидаты получившие топ 50 позиций, отправляются повторно на анализ уже более дорогой моделью, которая дает четкие результаты.
-
Биллинг проводит обычное списание (это необходимо для целостности учёта и защиты от ботов).
-
После успешного завершения анализа автоматически вызывается
refund(). -
В истории транзакций пользователь видит две записи: списание и возврат.
-
Мы получаем не дорогой поиск, но затем улучшаем результаты.
Зачем не делать «полностью бесплатно сразу»?
Потому что без 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. Практические рекомендации тем, кто строит похожее
Если вы задумались о внедрении семантического поиска в своём продукте, вот несколько советов, которые сэкономят вам месяцы:
-
Сразу разделяйте pipeline на compliance и inference.
Потом это сделать почти невозможно без полного рефакторинга. -
Версионируйте scoring‑функцию.
Иначе вы не сможете ответить на вопрос, почему score «вчера был 78, сегодня 62». -
Считайте стоимость фичи до запуска.
Без дельта‑кэша AI‑функция может оказаться экономически мёртвой при росте DAU. -
Стабилизируйте контракты между backend и Zod/SDK.
nullvsundefinedв проде ломает UI чаще, чем кажется. -
Логируйте технические метрики, а не пользовательский контент.
Это сильно упрощает legal‑review и compliance‑проверки. -
Считайте экономику, это критически важно, все запросы должны преобразовываться в стоимость расхода на операцию в рублях и стоимость этого анализа для пользователя, чтобы понимать – где у вас дыры, иначе проект и яйца выделенного не стоит.

9. Что бы я сделал иначе с первого дня
Оглядываясь назад, я бы:
-
Раньше ввёл typed contract‑tests между OpenAPI и фронтовыми схемами.
-
Раньше добавил fallback SEO guards для критических роутов.
-
Сразу заложил единообразную политику на nullable поля в API.
-
Выстраивал бы ценовую политику на основе данных а не по ощущениям
10. Куда двигаемся дальше
Планы на ближайшее будущее:
-
Усиление explainability («почему этот score») без роста latency.
-
Дальнейшее снижение cost‑per‑run через более агрессивный кэш и дельта‑стратегии.
-
Уплотнение локального pre‑processing слоя, чтобы ещё меньше данных уходило во внешний контур.
-
По мере развития, отказ от внешних поставщиков LLM в пользу локальных моделей.
Буду рад обсудить в комментариях ваши подходы к семантическому поиску и комплаенсу. Если у вас есть опыт внедрения локальных LLM‑моделей в проде — особенно интересно!
Автор: DVZakusilo


