Вводная часть
В компании Циан (где я, Клюшев Александр, и работаю в роли ML-инженера) проводятся внутренние хакатоны, и один из таких проходил в начале лета 2023. Достаточно давно в компании обсуждали идею по реализации поиска объявлений через текстовую строку, и было принято решение эту идею воплотить в жизнь. По итогу мы провели пару бессонных ночей, кто-то потусил в загородном отеле (часто внутренние хакатоны организуются на выезде) и заняли 1 место. В статье я расскажу, как выглядит флоу поиска, какую мы использовали модель и какие результаты получили.
Что такое текстовый поиск, и зачем он нам
Под текстовым поиском мы подразумеваем текстовую строку, куда пользователь может вбить запрос в свободной форме «Купить однушку в Москве до 10 млн» и получить объявления, соответствующие своему запросу:
Например:
превращается в предзаполненные фильтры.
Зачем вообще нужен тестовый поиск, если пользователь может накликать фильтры (а их у нас много)?
На самом деле на то есть несколько причин:
Не все пользователи дружат с фильтрами. Например, в клиентскую службу часто приходят запросы по типу «подберите мне квартиру в Москве», и одна из причин — сложность с фильтрами.
Хотелось дать возможность через полнотекстовый поиск искать очень узкие кейсы (например: «однокомнатная квартира со светлой кухней на 7 этаже»). Спойлер: не все фильтры из этого запроса будут применены, но база для них заложена.
Ещё мы ожидали сокращение времени, которое пользователь затрачивает на поиск необходимого объявления, и увеличение конверсии в целевое действие (в данном случае это нажатие на кнопку «показать телефон»).
Архитектура решения
Общая схема текстового поиска на момент брейншторма была такая:
Пользователь вводит запрос: «однокомнатная квартира со светлой кухней на 7 этаже».
Модель (NER) подхватывает запрос и возвращает размеченные сущности.
Сущности приводятся в соответствие со значениями в наших фильтрах.
Фильтры уже отправляются в микросервис, который выдаёт список подходящих объявлений.
Сортируем полученные объявления по мере сходства текстового описания объявления и непротегированной части самого запроса.
Отсортированный список объявлений отдаём пользователю.
Так как в основе флоу лежит модель по разметке сущностей в запросе, начнём с неё, а именно с данных, которые нам для неё нужны.
Что за данные
Первый вопрос, с которым мы столкнулись: какие данные нам использовать для обучения/теста Немного подумав и пообщавшись с коллегами, узнали, что гугл и яндекс при переводе со страницы выдачи пробрасывают нам поисковый запрос, который и привёл на наш сайт. Например, если пользователь ввёл «купить квартиру в Москве»:
То при переходе по ссылке поисковая система передаст нам и сам запрос, а мы уже залогируем его.
Так у нас появился достаточно большой (3723 записи, если быть точным) датасет, но не было никакой разметки.
Изначально думали использовать те страницы, на которые попадал пользователь при переходе из поисковой системы. Т.е. в примере выше пользователь попадёт на выдачу, где уже будут указаны основные активные фильтры (тип действия: «покупка», тип недвижимости: «квартира», где: «Москва»). В целом неплохо, но работает только для основных фильтров. Если добавлять в запрос в гугле имя жилого комплекса (ЖК), комнатность и цену, то при переходе на сайт потеряется фильтр только по цене. Поэтому решили использовать ручную разметку, сделанную своими руками. Развернули у себя инструмент doccano (удобный инструмент для разметки, решающий разные задачи по текстам/картинкам/аудио + позволяет управлять права доступа/считать стату по выполненным заданиям), закинули в него уже имеющуюся выборку и разметили своими силами по категориям.
Основные категории:
realty_type — тип недвижимости (квартира/комната/дом и тд);
action_type — аренда /продажа/гостевой дом;
region — регион/субъект РФ;
town — населённый пункт (город, деревня, пгп и т. д.);
street — название улицы/проспекта и т. д.;
price — всё, что про цену — там разберёмся;
is_by_homeowner — от собственика ли объект;
rooms_count — кол-во комнат и всё, что про это;
address_full — адрес полностью;
house_number — номер дома;
jk_name — имя ЖК;
poi_name — название достопримечательности или любого другого важно объекта.
И уже имея размеченный датасет (пример ниже), приступили к обучению модели.
{
'text': 'Нижегородская область, Володарский район, Золинский сельсовет, Новосмолинский поселок, ул. Танковая, 28 продажа',
'label': [
[0, 21, 'region'],
[0, 103, 'address_full'],
[23, 85, 'town'],
[63, 85, 'town'],
[87, 99, 'street'],
[101, 103, 'house_number'],
[104, 111, 'action_type'],
],
'Comments': [],
}
Обучение модели
Перед обучением свели лейблы к немного другому формату:
[
('Нижегородская', 'B_region'),
('область,', 'I_region'),
('Володарский', 'B_town'),
('район,', 'I_town'),
('Золинский', 'I_town'),
('сельсовет,', 'I_town'),
('Новосмолинский', 'I_town'),
('поселок,', 'I_town'),
('ул.', 'B_street'),
('Танковая,', 'I_street'),
('28', 'B_house_number'),
('продажа', 'B_action_type'),
]
где слово, помеченное лейблом с префиксом B_, означает начало какого-либо лейбла, а I_ — данное слово не первое для данного лейбла и ранее где-то есть слово с префиксом B_ этого же лейбла. Плюс лейбл O — для всех слов, которые не отнеслись к каким-либо категориям.
После такого преобразования датасет стал выглядеть примерно так:
В качестве самой модели использовали класс BertForTokenClassification из transformers и модель rubert-base-cased-conversational от DeepPavlov (за что им отдельное спасибо).
В качестве финальной метрики смотрели на долю верно предсказанных лейблов для каждого текста, а потом усреднили по всему датасету.
Прогнали первый цикл (модель обучали на nvidia A100, заняло порядка 10 минут.), посчитали точность (0.75) и решили, что можно лучше. Особенно бросались ошибки на тех названиях улиц/городов, которых не было в обучающей выборке.
Решили попробовать аугментировать датасет: так как в Циан большая база объявлений, то мы из одного текста, например, «купить квартиру в Краснодаре, можем получить ещё штук 5, заменив «квартиру» на «комнату», «гараж» и т. д. Так и поступили для большинства тегов.
Датасет увеличился в несколько десятков раз, качество тоже подросло (выросли до 0,81). Эту модель и решили финально заюзать для хакатона.
Обработка ответа модели и формирование выдачи
Следующий шаг — маппинг значений из сущностей в наши фильтры.
Здесь были идеи по реализации через zero-shot модели, но выиграла комбинация регулярок и поиска наиболее похожего значения через расстояние Левенштейна. Взяли все уникальные значения из размеченного датасета для каждого типа сущности и привели их к значениям в наших фильтрах, например: текстовые значения «однушка», «однокомнатная», «1-комнатная» превращается в int “1” для фильтра по числу комнат.
Теперь та часть, которая, к сожалению, не удалась, а именно сортировка объявлений по мере сходства текстового описания объявления и непротегированной части самого запроса.
Например:
Пользователь ввёл «купить квартиру с белой кухней».
Мы выделили сущности «купить», как тип действия, и «квартира», как вид недвижимости. Остался текст «с белой кухней».
Этот остаток прогоняется через какой-нибудь эмбеддер.
Оцениваем расстояние от него до эмбедингов описаний ранее полученных объявлений.
Идея звучала хорошо, и, как казалось, позволяла учесть те части запроса, на которые у нас нет фильтров. Но возникло две сложности:
Объявления отдаются нам уже в отсортированном порядке, и проранжированы они не только по вероятности клика клиента, но и с учётом платности объявления. Встроиться в такую логику за 2 дня хакатона было слишком сложно.
По основной логике успевали слишком впритык и решили сконцентрироваться на основном флоу.
Итого
По итогу мы выиграли хакатон в категории лучший продукт и затащили первую версию текстового поиска на прод.
Сейчас текстовый поиск раскатан в хорошо доработанном виде на мобильный веб (можно и с десктопа потестить, но нужно через DevTools в браузере сменить тип страницы на мобильный) и приложение для ios (раздел с поиском на карте).
По итогу выкатки на ios получили статзначимый прирост к количеству начатых поисков и прирост к количеству открытых карточек.
Дальше в планах развитие в двух направлениях:
Добавление текстового поиска на десктоп.
Увеличение гибкости текстового поиска за счёт нереализованной части с полнотекстовым поиском.
Спасибо, что дочитали статью до конца, приглашаю вас в комментарии, отвечу на все вопросы.