[Из песочницы] Фасетные фильтры: как готовить и с чем подавать
Как сделать фасетный поиск в интернет-магазине? Как формируются значения в фильтрах фасетного поиска? Как выбор значения в фильтре влияет на значения в соседних фильтрах? В поиске ответов дошел до пятой страницы поисковой выдачи Google. Исчерпывающей информации не нашел, пришлось разобраться самому. Статья описывает:
- как реагирует UI, когда пользователь использует фильтры;
- алгоритм формирования значений фильтров;
- шаблоны запросов и структуры индекса ElasticSearch с пояснениями.
Здесь нет готовых решений. Скопировать и вставить не получится. Для решения собственной задачи придется вникнуть.
Полнотекстовый поиск — поиск товаров по слову или фразе. Для пользователя — это поле для ввода текста с кнопкой «Найти», которое доступно на любой странице сайта.
Фасетный поиск — поиск товара по нескольким характеристикам: цвету, размеру, объему памяти, цене и т.п. Для пользователя — это набор фильтров. Каждый фильтр связан только с одной характеристикой и наоборот. Значения фильтра — все возможные значения характеристики. Пользователь видит фильтры на странице раздела, категории, на странице с результатами полнотекстового поиска. Когда пользователь выбрал значение, фильтр считается активным.
Коротко звучит так: фильтр фильтрует товары и фильтрует варианты выбора в других фильтрах.
Фильтрует товары
С этим просто. Пользователь выбрал:
- одно значение, видит товары совпадающие со значением;
- несколько значений в одном фильтре, видит товары совпадающие хотя бы с одним;
- значения в нескольких фильтрах, видит товары совпадающие со значением из каждого фильтра.
В терминах булевой алгебры: между фильтрами по действует логическое «И», между значениями в фильтре логическое «ИЛИ». Простая логика.
Фильтрует варианты выбора в других фильтрах
«Ну… какие варианты есть — отображается, чего нет — скрывается» — примерно так бизнес описывает поведение фильтров. Звучит логично. На практике это работает так:
- Заходим в раздел Телефоны, видим фильтры по характеристикам: Бренд, Диагональ, Память. Каждый фильтр содержит значения.
- Выбираем бренд. Из фильтров Диагональ и Память пропадает часть значений. В фильтре Бренд все значения остаются как на шаге 1.
- Выбираем диагональ. Еще часть значений пропадает из фильтра Память, и пропадает часть значений из фильтра Бренд. Значения в фильтре Диагональ остаются, как на шаге 2.
- Выбираем память. Из фильтров Бред и Диагональ пропадает еще часть значений. Значения для фильтра Память остаются, как на шаге 3.
- «Сбрасываем» выбранные значения в фильтре Память. Фильтры восстанавливают состояние шага 3 и т.д.
Количество значений фильтра зависит от количества товаров: чем больше товаров с разным значением характеристики, тем больше значений в фильтре. Пользователь сократил количество товаров в выборке для остальных фильтров, когда выбрал бренд. Это привело к обновлению списков значений.
Отсюда вытекает универсальное правило: значения фильтра извлекаются из выборки товаров, которая сформирована остальными активными фильтрами.
Каждый активный фильтр имеет свою выборку товаров.
Если у нас N фильтров и:
- нет активных, то выборка общая. Она одинакова для всех фильтров, и совпадает с поисковой выдачей;
- активно M, и M < N, то количество выборок M + 1, где 1 — выборка на которую наложены все активные фильтры. Она одинакова для всех неактивных фильтров и совпадает с поисковой выдачей;
- активно M, и N = M, то количество выборок N. Каждый фильтр имеет свою выборку.
В итоге, когда пользователь выбирает значение фасетного фильтра, происходит следующее:
- формируется поисковая выборка товаров;
- извлекаются значения для не активных фильтров из поисковой выборки;
- для каждого активного фильтра формируется новая выборка и из нее извлекаются новые значения активных фильтров.
Возникает вопрос — как это реализовать на практике?
Характеристики товара не универсальны, поэтому вы не найдете здесь готовую структуру индекса для хранения товаров или готовых запросов. Вместо этого будут ссылки на документацию с объяснениями, как самостоятельно построить «правильные» индексы и запросы. «Правильные» — на основе моего опыта и знаний.
«Правильные» типы текстовых полей
В 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. Допускаю, существуют более «правильные» подходы и инструменты для решения задачи фасетного поиска. Буду благодарен за дополнительную информацию и примеры в комментариях.