Вводная часть

В компании Циан (где я, Клюшев Александр, и работаю в роли ML-инженера) проводятся внутренние хакатоны, и один из таких проходил в начале лета 2023. Достаточно давно в компании обсуждали идею по реализации поиска объявлений через текстовую строку, и было принято решение эту идею воплотить в жизнь. По итогу мы провели пару бессонных ночей, кто-то потусил в загородном отеле (часто внутренние хакатоны организуются на выезде) и заняли 1 место. В статье я расскажу, как выглядит флоу поиска, какую мы использовали модель и какие результаты получили.

Что такое текстовый поиск, и зачем он нам

Под текстовым поиском мы подразумеваем текстовую строку, куда пользователь может вбить запрос в свободной форме «Купить однушку в Москве до 10 млн» и получить объявления, соответствующие своему запросу:

Например:

 Поле для ввода текстового запроса для поиска
Поле для ввода текстового запроса для поиска

превращается в предзаполненные фильтры.

Предзаполненные фильтры
Предзаполненные фильтры

Зачем вообще нужен тестовый поиск, если пользователь может накликать фильтры (а их у нас много)?

Дополнительные фильтры
Дополнительные фильтры

На самом деле на то есть несколько причин:

  1. Не все пользователи дружат с фильтрами. Например, в клиентскую службу часто приходят запросы по типу «подберите мне квартиру в Москве», и одна из причин — сложность с фильтрами.

Запрос в службу поддержки
Запрос в службу поддержки
  1. Хотелось дать возможность через полнотекстовый поиск искать очень узкие кейсы (например: «однокомнатная квартира со светлой кухней на 7 этаже»). Спойлер: не все фильтры из этого запроса будут применены, но база для них заложена.

  2. Ещё мы ожидали сокращение времени, которое пользователь затрачивает на поиск необходимого объявления, и увеличение конверсии в целевое действие (в данном случае это нажатие на кнопку «показать телефон»).

Архитектура решения

Общая схема текстового поиска на момент брейншторма была такая:

Архитектура решения
Архитектура решения
  1. Пользователь вводит запрос: «однокомнатная квартира со светлой кухней на 7 этаже».

  2. Модель (NER) подхватывает запрос и возвращает размеченные сущности.

  3. Сущности приводятся в соответствие со значениями в наших фильтрах.

  4. Фильтры уже отправляются в микросервис, который выдаёт список подходящих объявлений.

  5. Сортируем полученные объявления по мере сходства текстового описания объявления и непротегированной части самого запроса.

  6. Отсортированный список объявлений отдаём пользователю.

Так как в основе флоу лежит модель по разметке сущностей в запросе, начнём с неё, а именно с данных, которые нам для неё нужны.

Что за данные

Первый вопрос, с которым мы столкнулись: какие данные нам использовать для обучения/теста Немного подумав и пообщавшись с коллегами, узнали, что гугл и яндекс при переводе со страницы выдачи пробрасывают нам поисковый запрос, который и привёл на наш сайт. Например, если пользователь ввёл «купить квартиру в Москве»:

Запрос в google
Запрос в google

То при переходе по ссылке поисковая система передаст нам и сам запрос, а мы уже залогируем его.

Так у нас появился достаточно большой (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” для фильтра по числу комнат. 

Теперь та часть, которая, к сожалению, не удалась, а именно сортировка объявлений по мере сходства текстового описания объявления и непротегированной части самого запроса.

Например:

  1. Пользователь ввёл «купить квартиру с белой кухней».

  2. Мы выделили сущности «купить», как тип действия, и «квартира», как вид недвижимости. Остался текст «с белой кухней».

  3. Этот остаток прогоняется через какой-нибудь эмбеддер.

  4. Оцениваем расстояние от него до эмбедингов описаний ранее полученных объявлений.

Идея звучала хорошо, и, как казалось, позволяла учесть те части запроса, на которые у нас нет фильтров. Но возникло две сложности:

  1. Объявления отдаются нам уже в отсортированном порядке, и проранжированы они не только по вероятности клика клиента, но и с учётом платности объявления. Встроиться в такую логику за 2 дня хакатона было слишком сложно.

  2. По основной логике успевали слишком впритык и решили сконцентрироваться на основном флоу.

Итого

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

Сейчас текстовый поиск раскатан в хорошо доработанном виде на мобильный веб (можно и с десктопа потестить, но нужно через DevTools в браузере сменить тип страницы на мобильный) и приложение для ios (раздел с поиском на карте). 

По итогу выкатки на ios получили статзначимый прирост к количеству начатых поисков и прирост к количеству открытых карточек.

Дальше в планах развитие в двух направлениях:

  1. Добавление текстового поиска на десктоп.

  2. Увеличение гибкости текстового поиска за счёт нереализованной части с полнотекстовым поиском.

Спасибо, что дочитали статью до конца, приглашаю вас в комментарии, отвечу на все вопросы.

Комментарии (0)