У меня сайт по поиску работы. Там куча джоб-бордов подключена — hh, SuperJob, Зарплата, ТрудВсем и ещё пара штук. Я планирую добавлять еще множество источников, но пока сосредоточен на стабилизации, чтобы hhbro был полезным в первую очередь. Всё круто, вакансии тянутся, ИИ помогает резюме составлять. Но была одна проблема, которая убивала всю конверсию на корню.
Люди не могли пользоваться поиском.
Вот серьёзно. Заходишь в аналитику — и видишь: человек открывает страницу с фильтрами, смотрит на все эти поля, что-то тыкает, жмёт «Сохранить», переходит в поиск — и получает фигню. Либо 500 вакансий дворников, хотя он вообще-то бухгалтер со стажем, либо пустой экран «ничего не найдено». И уходит. Навсегда.
Я думал: ну это же элементарно! Написал в строке «Ведущий бухгалтер», выбрал город Москва, зарплату от 200к — и готово. Как на любом нормальном сайте. Но нет. Люди как будто впадали в ступор. Я начал копать.
Хроника моих провалов (спойлер: я сам виноват)
Сначала я добавил большой дисклеймер сверху: «ВНИМАНИЕ! Для корректного поиска обязательно заполните поле “Поисковый запрос” и выберите город». Красивый, с иконкой, вежливый.
Никто не читал. Вообще. Там же кнопка «Сохранить» рядом, зачем читать?
Ладно, думаю. Сделал поле «Поисковый запрос» обязательным. Без него кнопка серая, не нажать. Стало ещё хуже. Пользователи вбивали туда «работа», «вакансия», «нужна работа» — и получали полную кашу. Они искренне не понимали, почему система не угадывает, что они специалисты, а не уборщики.
Сделал автокомплит, который с hh тянет названия вакансий, эффект примерно никакой.
Тогда я психанул и добавил фичу для гиков: вставляешь ссылку на поиск с hh.ru, а мы сами распарсим параметры и заполним фильтры. Умно? Очень. Пользовались? Три человека в месяц. Обычный человек не знает, что такое URL с параметрами.
Дальше — больше. Я выделил незаполненные важные поля красной рамкой. Добавил красный блок с криком: «ЗАПОЛНИТЕ ПОЛЯ!». Знаете, что случилось? Люди стали пугаться и уходить ещё быстрее. Как будто я им не работу предлагаю, а вызов в налоговую.
В этот момент я сидел, смотрел на экран и понимал: если я прямо сейчас не придумаю, как сделать всё за пользователя, проект можно хоронить.
Нейросеть, спасай! (И тут начался ад)
Ну очевидно же: пусть ИИ сам заполняет фильтры. Пользователь загружает резюме, пишет желаемую должность — и магия! Все поля на всех сервисах расставлены правильно. Звучит как план.
Я начал делать. И упёрся в реальность, от которой у меня чуть волосы дыбом не встали.
У одного только hh 1600 городов. У каждого города свой ID. У SuperJob — другой ID. У Зарплаты.ру — третий. У «Работы России» — четвёртый, какой-то 19-значный монстр. А ещё специализации (сотни штук), отрасли, типы занятости, графики работы — и для всего этого нужны правильные идентификаторы, свои для каждого сервиса.
Я прикинул: если тупо взять и скормить LLM все возможные значения всех фильтров для всех сервисов, получится больше 140 000 токенов. Это как отправить нейросети «Войну и мир» и спросить: «Какой город выбрать?».
Цена одного такого запроса — рублей 25-40. А пользователей у меня тысячи. Да я разорюсь на второй день. А если НЕ передавать словари, то нейросеть начинает галлюцинировать. Придумывает города, которых нет. Возвращает ID, которых не существует. В итоге поиск ломается ещё сильнее.
Тупик. Либо точность и разорение, либо дёшево и бесполезно.
Инсайт, который всё спас
Сижу ночью, курю подходы к оптимизации. И вдруг до меня доходит.
У меня же в проекте уже есть универсальная система фильтров. Я же сам её проектировал полгода назад, когда делал мультисервисный поиск!
Смотрю в базу данных. Там лежат таблицы:
job_filters— все возможные фильтры (коды типаarea,experience,professional_role).job_filter_options— значения этих фильтров для каждого сервиса отдельно. Вот где хранятся ID и человеческие названия.job_service_filters— какие фильтры конкретный сервис поддерживает и как их трансформировать.city_masterиcity_source_map— маппинг городов на ID в разных сервисах.
И главное — у меня уже есть код, который:
Понимает, что HH ждёт
area=1для Москвы, а SuperJob —area=4.Умеет конвертировать каноническое значение
experience="between1And3"в то, что надо конкретному API.Находит город по названию и возвращает правильный ID для каждого сайта.
То есть вся информация для заполнения фильтров уже структурирована в базе. Мне не нужно пихать её в нейросеть. Мне нужно только научить GigaChat понимать, ЧТО ИМЕННО хочет пользователь, а всю чёрную работу с ID пусть делает мой собственный код.
Решение: data-driven пайплайн, который не боится ничего
Я спроектировал систему из двух фаз. И она работает на данных из БД, а не на хардкоде.
Фаза 0: Классификация фильтров на лету
Я написал сервис, который загружает все активные фильтры из базы (is_ai_eligible = true) и делит их на 4 категории автоматически:
Малые словари (≤ 30 значений). Это опыт работы (4 варианта), тип занятости (4), график (5) и так далее. Всего набирается штук 10 таких полей, суммарно ~400 токенов. Их я передаю в промпт — пусть нейросеть выбирает точное каноническое значение из списка.
Большие словари (> 30 значений или поле с автокомплитом). Города, специализации, отрасли. Их я НЕ передаю. Вместо этого прошу нейросеть вернуть текстовое название («Москва», «Программист»). А мой код потом сам найдёт правильный ID через поиск по базе.
Свободный текст — поле «Поисковый запрос». Нейросеть генерирует короткую фразу вроде «Python backend developer».
Числа и даты — зарплата, дата публикации. Часть берём прямо из резюме, часть может подсказать AI.
Порог в 30 значений не хардкодится. Если завтра появится новый фильтр с 25 вариантами — он автоматически попадёт в первую категорию и окажется в промпте. Ни строчки кода менять не надо.
Фаза 1: Собираем умный промпт и зовём LLM
Дальше BuildSearchFiltersPromptTask собирает инструкцию для нейросети динамически, опираясь на результаты классификации.
Пример того, что видит нейронка (сгенерировано автоматически из БД!):
== ФИЛЬТРЫ С ВЫБОРОМ (выбери ТОЛЬКО из указанных значений) == experience — Опыт работы: noExperience = "Нет опыта" between1And3 = "От 1 года до 3 лет" between3And6 = "От 3 до 6 лет" moreThan6 = "Более 6 лет" employment_form — Тип занятости: FULL = "Полная занятость" PART = "Частичная занятость" ... (всё из базы) == СЛОВАРНЫЕ ПОЛЯ (верни НАЗВАНИЕ текстом, НЕ код) == city_name — Город (например: "Москва") professional_role_name — Специализация (например: "Программист") == ТЕКСТОВЫЕ ПОЛЯ == text — Поисковый запрос (2-8 слов)
На вход нейросети также подаётся:
Сжатая выжимка из резюме (навыки, опыт, город, желаемая зарплата).
Желаемая должность, которую ввёл пользователь.
Список активных сервисов.
LLM возвращает строгий канонический JSON:
{ "text": "Python backend developer", "excluded_text": "junior, стажёр", "area_name": "Москва", "professional_role_name": "Программист, разработчик", "experience": "between1And3", "employment_form": ["FULL"], "salary": { "from": 250000, "to": null } }
Обратите внимание: никаких ID. Только текстовые названия и канонические значения из тех, что были в промпте. Вся магия конвертации происходит потом.
Фаза 2: Превращаем ответ нейросети в реальные фильтры
Тут в дело вступает мой код.
Резолвим словари. Пользователь написал «Москва». Мой
SearchCitiesActionищет этот город вcity_master, находит его, а потом черезcity_source_mapполучает ID для HH (1), для SuperJob (4), для ТрудВсем (7700000000000).Собираем результат для каждого сервиса. Для HH: текст = «Python backend developer», город = 1, опыт = between1And3. Для SuperJob: тот же текст, город = 4, опыт = between1And3.
Отдаём фронтенду готовый объект
unified_filtersи список человеческих названий (filter_labels), чтобы поля красиво отображались.
Всё. Пользователь видит заполненные фильтры на всех сервисах сразу. Ему остаётся только проверить и нажать «Сохранить». Никаких красных полей. Никаких инструкций. Только магия.
Что в итоге? Цифры и факты
По деньгам:
Наивный подход (все словари в промпт): ~140 000 токенов → 25-40 рублей за запрос.
Data-driven пайплайн: ~850 токенов на вход + 150 на выход → 0,1 рубля за запрос.
Экономия в 150 раз. При тысяче пользователей в день разница между 15 000 рублей и 150 рублями. Почувствуйте.
По точности:
Малые словари нейросеть выбирает идеально, потому что видит все варианты.
Большие словари резолвятся через мою базу — никаких галлюцинаций. Если город не найден — возвращаем предупреждение, а не ломаем поиск.
По масштабированию:
Добавляется новый сервис? Просто заносим его фильтры в базу. Промпт перестроится сам, сборка добавит новый ключ в результат. Код AI-части не трогаем.
Добавляется новый фильтр? Он автоматически классифицируется и попадает в промпт.
По бизнесу:
Отказы на этапе настройки поиска снизились (bounce rate упал).
Люди используют платную генерацию (средний чек растёт), а тот кто сам настраивает - экономит и себе и мне.
Пользователи наконец-то перестали бояться кнопки «Найти».
Мораль для тех, кто тоже делает продукты с AI
Ребята, не надо пихать в нейросеть всё, что у вас есть. LLM — это круто для понимания намерений и семантики. Но точное сопоставление с тысячами идентификаторов — это задача для старой доброй инженерии данных.
Прежде чем кормить LLM мегабайтами справочников, посмотрите на свою архитектуру. Скорее всего, у вас уже есть всё, чтобы ответить на вопрос «какой ID у этого города» без участия AI. Нейросеть должна сказать что искать, а как именно искать — пусть решает ваш код.
Я сэкономил 150x на токенах, сделал фичу, которая реально работает, и перестал пугать людей красными рамками. Чего и вам желаю. Что из этого получится и где вылезут проблемы, покажет время, пока надежда на уменьшение отказов для тех пользователей, кому не хочется ручками тыкать.
