[Из песочницы] Фасетные фильтры: как готовить и с чем подавать


Как сделать фасетный поиск в интернет-магазине? Как формируются значения в фильтрах фасетного поиска? Как выбор значения в фильтре влияет на значения в соседних фильтрах? В поиске ответов дошел до пятой страницы поисковой выдачи Google. Исчерпывающей информации не нашел, пришлось разобраться самому. Статья описывает:

  1. как реагирует UI, когда пользователь использует фильтры;
  2. алгоритм формирования значений фильтров;  
  3. шаблоны запросов и структуры индекса ElasticSearch с пояснениями.


Здесь нет готовых решений. Скопировать и вставить не получится. Для решения собственной задачи придется вникнуть.

HjXjB4me68NP2uMdsZmVC77Hv1bATaAjoc-4uSuj


Полнотекстовый поиск — поиск товаров по слову или фразе. Для пользователя — это поле для ввода текста с кнопкой «Найти», которое доступно на любой странице сайта.

Фасетный поиск — поиск товара по нескольким характеристикам: цвету, размеру, объему памяти, цене и т.п. Для пользователя — это набор фильтров. Каждый фильтр связан только с одной характеристикой и наоборот. Значения фильтра — все возможные значения характеристики. Пользователь видит фильтры на странице раздела, категории, на странице с результатами полнотекстового поиска. Когда пользователь выбрал значение, фильтр считается активным.


Коротко звучит так: фильтр фильтрует товары и фильтрует варианты выбора в других фильтрах. 

Фильтрует товары


С этим просто. Пользователь выбрал:

  1. одно значение, видит товары совпадающие со значением;
  2. несколько значений в одном фильтре, видит товары совпадающие хотя бы с одним;
  3. значения в нескольких фильтрах, видит товары совпадающие со значением из каждого фильтра.


В терминах булевой алгебры: между фильтрами по действует логическое «И»,   между значениями в фильтре логическое «ИЛИ». Простая логика. 

Фильтрует варианты выбора в других фильтрах


«Ну… какие варианты есть — отображается, чего  нет — скрывается» — примерно так бизнес описывает поведение фильтров. Звучит логично. На практике это работает так:

  1. Заходим в раздел Телефоны, видим фильтры по характеристикам: Бренд, Диагональ, Память. Каждый фильтр содержит значения. 
  2. Выбираем бренд. Из фильтров Диагональ и Память пропадает часть значений. В фильтре Бренд все значения остаются как на шаге 1.
  3. Выбираем диагональ. Еще часть значений пропадает из фильтра Память, и пропадает часть значений из фильтра Бренд. Значения в фильтре Диагональ остаются, как на шаге 2. 
  4. Выбираем память. Из фильтров Бред и Диагональ пропадает еще часть значений. Значения для фильтра Память остаются, как на шаге 3.
  5. «Сбрасываем» выбранные значения в фильтре Память. Фильтры восстанавливают состояние шага 3 и т.д.


Количество значений фильтра зависит от количества товаров: чем больше товаров с разным значением характеристики, тем больше значений в фильтре. Пользователь сократил количество товаров в выборке для остальных фильтров, когда выбрал бренд. Это привело к обновлению списков значений.

Отсюда вытекает универсальное правило: значения фильтра извлекаются из выборки товаров, которая сформирована остальными активными фильтрами.

Каждый активный фильтр имеет свою выборку товаров.

Если у нас N фильтров и:

  • нет активных, то выборка общая. Она одинакова для всех фильтров, и совпадает с поисковой выдачей;
  • активно M, и M < N, то количество выборок M + 1, где 1 — выборка на которую наложены все активные фильтры. Она одинакова для всех неактивных фильтров и совпадает с поисковой выдачей;
  • активно M, и N = M, то количество выборок N. Каждый фильтр имеет свою выборку.


В итоге, когда пользователь выбирает значение фасетного фильтра, происходит следующее:  

  1. формируется поисковая выборка товаров;  
  2. извлекаются значения для не активных фильтров из поисковой выборки;
  3. для каждого активного фильтра формируется новая выборка и из нее извлекаются новые значения активных фильтров.


Возникает вопрос — как это реализовать на практике?
Характеристики товара не универсальны, поэтому вы не найдете здесь готовую структуру индекса для хранения товаров или готовых запросов. Вместо этого будут ссылки на документацию с объяснениями, как самостоятельно построить «правильные» индексы и запросы. «Правильные» — на основе моего опыта и знаний. 

«Правильные» типы текстовых полей


В 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»
          }
        }
      }
    }
  }
}


  1. характеристику товара объявляем как поле типа  text. 
  2. через параметр 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»
      }
    }
  }


  1. Создать в индексе виртуальное поле с типом text.    
  2. Каждую характеристику объявить как 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, иначе получите список товаров соответствующих основному запросу без агрегаций. 

В итоге 


Получили два запроса:

  1. для поисковой выдачи, возвращает товары для отображения пользователю;
  2. для значений фильтров, выполняет агрегацию, возвращает значения фильтров и количество товаров с таким значением.


Каждый запрос самодостаточен, поэтому лучше выполнять их асинхронно.   

P.S. Допускаю, существуют более «правильные» подходы и инструменты для решения задачи фасетного поиска. Буду благодарен за дополнительную информацию и примеры в комментариях.

© Habrahabr.ru