О чем речь
Как сделать фасетный поиск в интернет-магазине? Как формируются значения в фильтрах фасетного поиска? Как выбор значения в фильтре влияет на значения в соседних фильтрах? В поиске ответов дошел до пятой страницы поисковой выдачи Google. Исчерпывающей информации не нашел, пришлось разобраться самому. Статья описывает:
- как реагирует UI, когда пользователь использует фильтры;
- алгоритм формирования значений фильтров;
- шаблоны запросов и структуры индекса ElasticSearch с пояснениями.
Здесь нет готовых решений. Скопировать и вставить не получится. Для решения собственной задачи придется вникнуть.
Понятия, чтобы было понятно
Полнотекстовый поиск — поиск товаров по слову или фразе. Для пользователя — это поле для ввода текста с кнопкой «Найти», которое доступно на любой странице сайта.
Фасетный поиск — поиск товара по нескольким характеристикам: цвету, размеру, объему памяти, цене и т.п. Для пользователя — это набор фильтров. Каждый фильтр связан только с одной характеристикой и наоборот. Значения фильтра — все возможные значения характеристики. Пользователь видит фильтры на странице раздела, категории, на странице с результатами полнотекстового поиска. Когда пользователь выбрал значение, фильтр считается активным.
Поведение фасетных фильтров
Коротко звучит так: фильтр фильтрует товары и фильтрует варианты выбора в других фильтрах.
Фильтрует товары
С этим просто. Пользователь выбрал:
- одно значение, видит товары совпадающие со значением;
- несколько значений в одном фильтре, видит товары совпадающие хотя бы с одним;
- значения в нескольких фильтрах, видит товары совпадающие со значением из каждого фильтра.
В терминах булевой алгебры: между фильтрами по действует логическое «И», между значениями в фильтре логическое «ИЛИ». Простая логика.
Фильтрует варианты выбора в других фильтрах
«Ну… какие варианты есть — отображается, чего нет — скрывается» — примерно так бизнес описывает поведение фильтров. Звучит логично. На практике это работает так:
- Заходим в раздел Телефоны, видим фильтры по характеристикам: Бренд, Диагональ, Память. Каждый фильтр содержит значения.
- Выбираем бренд. Из фильтров Диагональ и Память пропадает часть значений. В фильтре Бренд все значения остаются как на шаге 1.
- Выбираем диагональ. Еще часть значений пропадает из фильтра Память, и пропадает часть значений из фильтра Бренд. Значения в фильтре Диагональ остаются, как на шаге 2.
- Выбираем память. Из фильтров Бренд и Диагональ пропадает еще часть значений. Значения для фильтра Память остаются, как на шаге 3.
- «Сбрасываем» выбранные значения в фильтре Память. Фильтры восстанавливают состояние шага 3 и т.д.
Количество значений фильтра зависит от количества товаров: чем больше товаров с разным значением характеристики, тем больше значений в фильтре. Пользователь сократил количество товаров в выборке для остальных фильтров, когда выбрал бренд. Это привело к обновлению списков значений.
Отсюда вытекает универсальное правило: значения фильтра извлекаются из выборки товаров, которая сформирована остальными активными фильтрами.
Каждый активный фильтр имеет свою выборку товаров.
Если у нас N фильтров и:
- нет активных, то выборка общая. Она одинакова для всех фильтров, и совпадает с поисковой выдачей;
- активно M, и M < N, то количество выборок M + 1, где 1 — выборка на которую наложены все активные фильтры. Она одинакова для всех неактивных фильтров и совпадает с поисковой выдачей;
- активно M, и N = M, то количество выборок N. Каждый фильтр имеет свою выборку.
В итоге, когда пользователь выбирает значение фасетного фильтра, происходит следующее:
- формируется поисковая выборка товаров;
- извлекаются значения для не активных фильтров из поисковой выборки;
- для каждого активного фильтра формируется новая выборка и из нее извлекаются новые значения активных фильтров.
Возникает вопрос — как это реализовать на практике?
Реализация на Elasticsearch (ES)
Характеристики товара не универсальны, поэтому вы не найдете здесь готовую структуру индекса для хранения товаров или готовых запросов. Вместо этого будут ссылки на документацию с объяснениями, как самостоятельно построить «правильные» индексы и запросы. «Правильные» — на основе моего опыта и знаний.
«Правильные» типы текстовых полей
В ES нас интересует 2 типа данных:
- text для полнотекстового поиска. Поля этого типа невозможно использовать для точного сравнения, сортировки, агрегации;
- keyword для строк, которые участвуют в операциях точного сравнения, сортировке, агрегации.
ES анализирует значения в поле с типом text и формирует словарь для полнотекстового поиска. Значения в поле с типом keyword индексируются в том виде, в котором получены. Агрегация и сортировка доступна только для полей с типом keyword.
Пользователь использует характеристики в обоих случаях: в полнотекстовом поиске и через фильтры. ES не позволяет назначить 2 типа одному полю, но предлагает другие решения:
fields
PUT my_index
{
«mappings»: {
«properties»: {
«some_property»: {
«type»: «text», // 1
«fields»: { // 2
«raw»: {
«type»: «keyword»
}
}
}
}
}
}
- характеристику товара объявляем как поле типа text.
- через параметр fields создаем дочернее виртуальное поле типа keyword. Виртуальное, потому что присутствует в индексе и нет в описании товара. ES автоматически сохраняет данные в дочернее поле в том виде, как получил.
Так для каждой характеристики.
В запросах для операций точного сравнения, сортировки и агрегации нужно использовать дочернее виртуальное поле типа keyword. В примере это some_property.raw. Для поиска по тексту — родительское.
copy_to.
PUT my_index
{
«mappings»: {
«properties»: {
«all_properties»: { // 1
«type»: «text»
}, «some_property_1»: {
«type»: «keyword»,
«copy_to»: «all_properties» // 2
},
«some_property_2»: {
«type»: «keyword»,
«copy_to»: «all_properties»
}
}
}
- Создать в индексе виртуальное поле с типом text.
- Каждую характеристику объявить как keyword с параметром copy_to. Значением параметра указать виртуальное поле. ES копирует значение всех характеристик в виртуальное поле при сохранении документа.
Для операций точного сравнения, сортировки и агрегации нужно использовать поле характеристики, для поиска по тексту — поле со значениями всех характеристик.
Оба подхода создают в индексе дополнительные поля, которые отсутствуют в исходной структуре документа. Поэтому для создания запроса нужно знать структуру индекса.
Я предпочитаю вариант с copy_to. Тогда для построения запроса полнотекстового поиска достаточно знать одно поле с копией значений всех характеристик.
Запросы
Для поиска товаров
Будем считать, что структура индекса как в варианте с copy_to. Для полнотекстового поиска в ES используется конструкция match, для сравнения со значениями фасетных фильтров — terms query. boolean query объединяет конструкции в один запрос. Он будет примерно таким:
{
«query» : {
«bool»: {
«must»: {
«match»: {
«virtual_field_for_fulltext_searching»: {
«query»: «some text»
}
}
},
«filter»: {
«must»: [
{«property_1»: [ «value_1_1», …, «value_1_n»]},
…
{«property_n»: [ «value_n_1», …, «value_n_m»]}
]
}
}
}
}
query.bool.must.match основной запрос на полнотекстовый поиск
query.bool.filter фильтры для уточнения основного запроса. must внутри означает логическое «и» между фильтрами. Массив значений в каждом фильтре — логическое «или».
Для значений фильтров
Конструкция terms aggregation группирует товары по значениям характеристики и вычисляет количество в каждой группе. Такая операция называется агрегация. Сложность в том, что для каждого активного фильтра terms aggregation должна выполнится на выборке товаров, сформированной другими активными фильтрами. Для не активных фильтров — на выборке совпадающей с поисковой выдачей. Конструкция filter aggregation позволяет сформировать для каждой агрегации отдельную выборку и «упаковать» операции в один запрос.
Структура запроса будет такой:
{
«size»: 0,
«query» : {
«bool»: {
«must»: {
«match»: {
«field_for_fulltext_searching»: {
«fuzziness»: 2,
«query»: «some text»
}
}
},
«filter»: {
}
}
},
«aggs» : {
«inavtive_filter_agg» : {
«filter» : { …
},
«aggs»: {
«some_inavtive_filter_subagg»: {
«terms» : {
«field» : «some_property»
}
},
...
«some_other_inavtive_filter_subagg»: {
«terms» : {
«field» : «some_other_property»
}
}
}
},
«active_filter_1_agg» : {
«filter»: {
… },
«aggs»: {
«active_filter_1_subagg»: {
«terms» : {
«field»: «property_1»
}
}
}
},
…,
«active_filter_N_agg» : {
«filter»: {
…
},
«aggs»: {
«active_filter_N_subagg»: {
«terms» : {
«field»: «property_N»
}
}
}
}
}
}
query.bool — основной запрос, операции фильтрации выполняются в его контексте. Он состоит из:
- match — запрос на полнотекстовый поиск;
- filters — фильтры по характеристикам, которые не связаны с фасетными фильтрами и должны присутствовать в любом подмножестве. Это может быть фильтр по in_stock, is_visible, если всегда нужно показывать только товары в наличии или только видимые.
aggs.inavtive_filter_agg — агрегация для неактивных фасетных фильтров состоит из:
- filter - условия по характеристикам, которые сформированы активными фасетными фильтрами. Вместе с основным запросом формируют выборку товаров, на котором выполняются дочерние агрегации этого раздела;
- aggs — это объект из именованных агрегаций для каждого не активного фильтра.
aggs.active_filter_1_agg — агрегация получения значений первого из активных фасетных фильтров. Каждая конструкция связана с одним фасетным фильтром. Состоит из:
- filter — условия по характеристикам, которые сформированы активными фасетными фильтрами, кроме текущего. Вместе с основным запросом формирует выборку товаров, на котором выполняется дочерняя агрегация этого раздела;
- aggs — объект из одной агрегации по характеристике текущего активного фасетного фильтра.
Важно указать «size»: 0, иначе получите список товаров соответствующих основному запросу без агрегаций.
В итоге
Получили два запроса:
- для поисковой выдачи, возвращает товары для отображения пользователю;
- для значений фильтров, выполняет агрегацию, возвращает значения фильтров и количество товаров с таким значением.
Каждый запрос самодостаточен, поэтому лучше выполнять их асинхронно.
P.S. Допускаю, существуют более «правильные» подходы и инструменты для решения задачи фасетного поиска. Буду благодарен за дополнительную информацию и примеры в комментариях.
swelf
Недавно делал такое же, таким же способом. Прочитал по диагонали.
terms aggregation показывают кол-во товаров с текущим выбором фильтров. Но не показывают как изменится кол-во, если ты выберешь еще 1 значение value_N_M из набора property_N. Для опеределения что изменится нам надо выключить все value_N_M для property_N, сделать фильтрацию и посмотреть агрегации по property_N. И так для всех property_N, для которых мы в данный момент фильтруем.
Но думаю уйти от эластика и всеже переписать на SQL, толкьо ради фасеточного поиска держать elastic накладно. Посмотрю по экспериментам с производительностью.