Калина Алексей блог программиста

Фасеты с помощью Elasticsearch

Сегодня мы возвращаемся к изучению возможностей, которые предоставляет нам поисковый движок Elasticsearch. На очереди интересная фича из области интернет-магазинов под названием фасеты. Этим странным словом именуются фильтры по разным категориям, которые упрощают поиск необходимого продукта. Мы разберемся, как можно динамически формировать фасеты на основе отображаемых данных и как применять фильтрацию для сужения области поиска.

Задача

Если вы плохо знаете, что из себя представляет Elasticsearch или вам интересно, что мы уже научились делать с помощью этого движка, то читайте предыдущие посты на тему:

Сегодня мы продолжим работу с датасетом Jeopardy, который содержит вопросы и ответы из популярной американской телевикторины. Наша задача состоит в том, чтобы научиться извлекать из базы данных не только документы, соответствующие поисковому запросу, но и все уникальные значения определенных полей для этих документов.

Агрегации

Для возврата уникальных значений для конкретного поля в Elasticsearch используется конструкция aggs. Эта часть запроса выполняется совместно с выражением query и напрямую зависит от его результатов. Значения только тех документов, которые удовлетворяют подзапросу query, будут учтены в ответе aggs. Важным условием работы этой конструкции является то, что она выполняется в Filter контексте, то есть, анализируемые поля ею не поддерживаются. Мы ограничимся значениями раунда и стоимости вопроса для наших фасетов.

Для поиска по уникальным значениям используется агрегация terms. На самом деле агрегаций в Elasticsearch достаточно много, и разбиваются они все на три группы. Первая из них позволяет считать различные метрики для конкретного поля в индексе, такие как максимум, среднее арифметическое и другие. Мы будем использовать второй тип агрегаций, который используется, как правило, для подсчета документов удовлетворяющих некоторому условию. Например, range aggregation позволяет задать несколько числовых промежутков и получить в ответ число документов для каждого интервала. Количество документов для агрегаций приятная фича, и мы обязательно ей воспользуемся. Последняя же группа агрегаций это просто суперпозиция двух других.

Рассмотрим запрос без конструкции query, что означает поиск по всему индексу. Из важных параметров агрегации terms нужно упомянуть атрибут size. По умолчанию он установлен в значение 10 и зачастую возвращает далеко не все уникальные значения. Кроме того, результаты агрегаций возвращаются в порядке убывания количества соответствующих им документов, и это поведение можно изменить с помощью параметра order. Текст запроса:

{
  "aggs": {
    "round_aggs": {
      "terms": {
        "field": "round"
      }
    },
    "value_aggs": {
      "terms": {
        "field": "value",
        "size": 30
      }
    }
  }, 
  "size": 0
}

В ответ на этот запрос приходят пары терм - количество документов, на основе этой информации мы и будем формировать фасеты.

{
  "aggregations": {
    "round_aggs": {
      "buckets": [
        {
          "key": "Jeopardy!",
          "doc_count": 105228
        },
        {
          "key": "Double Jeopardy!",
          "doc_count": 99675
        },
        {
          "key": "Final Jeopardy!",
          "doc_count": 3631
        },
        {
          "key": "Tiebreaker",
          "doc_count": 3
        }
      ]
    },
    "value_aggs": {
      "buckets": [
        {
          "key": 400,
          "doc_count": 42244
        },
        ...
      ]
    }
  }
}

Теперь мы изменим запрос, добавив в него редкое слово для поиска. С помощью внешнего size мы просим Elasticsearch не возвращать в ответе документы, только агрегации.

{
  "query": {
    "match": {
      "question": "ivan"
    }
  },
  "aggs": {
    "round_aggs": {
      "terms": {
        "field": "round"
      }
    },
    "value_aggs": {
      "terms": {
        "field": "value",
        "size": 30
      }
    }
  },
  "size": 0
}

На результатах запроса, представленных ниже, видно, что и число агрегированных значений, и количество документов для них значительно уменьшилось. Агрегации работают именно так, как мы того и хотели.

{
  "aggregations": {
    "round_aggs": {
      "buckets": [
        {
          "key": "Double Jeopardy!",
          "doc_count": 43
        },
        {
          "key": "Jeopardy!",
          "doc_count": 16
        },
        {
          "key": "Final Jeopardy!",
          "doc_count": 1
        }
      ]
    },
    "value_aggs": {
      "buckets": [
        {
          "key": 400,
          "doc_count": 19
        },
        ...
      ]
    }
  }
}

Фильтрация

Фасеты предполагают не только получение текущих уникальных значений, но и возможность фильтрации по ним. Тут нет ничего сложного, мы уже умеем это делать благодаря предыдущему посту на тему. Для этого нам нужно добавить к выражению match, содержащему строку для полнотекстового поиска, выражение filter, в которое заключить необходимые нам значения для фильтрации. Так выглядит запрос, выполняющий поиск по строке ivan только среди раундов Jeopardy! и Final Jeopardy!.

{
  "query": {
    "bool": {
      "must": {
        "match": {
          "question": "ivan"
        }
      },
      "filter": {
        "terms": {
          "round": [
            "Jeopardy!",
            "Double Jeopardy!"
          ]
        }
      }
    }
  },
  "aggs": {
    "round_aggs": {
      "terms": {
        "field": "round"
      }
    },
    "value_aggs": {
      "terms": {
        "field": "value",
        "size": 30
      }
    }
  },
  "size": 20
}

Демонстрационное приложение

Для демонстрации работы фасетов я решил, что консоли будет уже недостаточно и написал простое веб-приложение на ASP.NET Core MVC. Теперь вы наконец можете перейти по ссылке и попробовать все руками, а я тем временем поясню, как именно это работает. Я не очень опытен в верстке и это тестовый вариант для демонстрации возможностей Elasticsearch, поэтому не судите строго за интерфейс.

Jeopardy

Текст из поисковой строки отправляется в выражение match, а из выбранных значений в фасетах формируется filter подзапрос. Вместе с этим в запросе отправляются агрегации по интересующим нас полям. По результатам запроса выводятся топ-20 вопросов с ответами и пересчитываются фасеты (изменяется число документов для возвращенных значений в скобках, а отсутствующие значения в агрегации становятся бледнее).

Заключение

Мы уже значительно продвинулись в изучении возможностей Elasticsearch и без особых проблем разобрались с тем, как с его помощью реализовать функциональность фасетов в своем приложении.

P.S. Спасибо моему хорошему другу Никите Маршалкину за любезно предоставленный хостинг Elasticsearch.

Update: По просьбам опубликовал исходный код демонстрационного веб-приложения: elasticsearch-jeopardy-facets.