Elasticsearchで一覧検索と分類ごとの件数取得を効率的にする方法

# はじめに
私は長年にわたり検索システムの開発に携わっておりますが、私が使用してきた全文検索エンジンは主にSolrでした。しかし最近Elasticsearchを使う機会があり、その際Solrで実現していた機能をElasticsearchでどのように実現するのかを調べる機会がありましたので、その機能について記事にしたいと思います。

# 概要
多くの検索サイトでは検索を行った後、追加の条件で絞り込みが可能となっています。
たとえば、当社の入札情報検索サービス[nSearch](https://nsearch.jp/)では、キーワードで検索した後に検索期間のボタンをクリックすると、以下のように表示されます。

![nSearch_検索期間.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3083168/a69c472d-6f18-65f5-9c8e-c69e565a5f3b.png)
この赤枠内には、さらなる絞り込み条件とそれに該当する件数が表示されています。
このような検索結果を更に絞り込む仕組みは、ファセット（ファセットナビゲーション）と呼ばれています。
この記事ではElasticsearchを使用して、ファセットごとの件数を効率的に取得する方法について説明します。

# どう実現すればよいか

ファセットごとの件数を取得することは、Elasticsearchのアグリゲーション（集約）機能を利用することで効率的に行なえます。
この機能を使うと、検索結果とは別に各ファセットの集計結果を一つの応答としてまとめて返すことができます。

# アグリゲーションの使用例

## データ内容
サンプルに使用するデータの内容は以下の通り。

| brand | size |
|:-:|:-:|
| ナイキ  | S |
| ナイキ  | M |
| ナイキ  | M |
| ナイキ  | L |
| アシックス  | S |

## データのセットアップ
``` 
PUT /shirts
{
  "mappings": {
    "properties": {
      "brand": { "type": "keyword"},
      "size": { "type": "keyword"}
    }
  }
}

POST /shirts/_bulk
{ "index" : { "_id" : 1 } }
{ "brand": "ナイキ", "size": "S" }
{ "index" : { "_id" : 2 } }
{ "brand": "ナイキ", "size": "M" }
{ "index" : { "_id" : 3 } }
{ "brand": "ナイキ", "size": "M" }
{ "index" : { "_id" : 4 } }
{ "brand": "ナイキ", "size": "L" }
{ "index" : { "_id" : 5 } }
{ "brand": "アシックス", "size": "S" }
```

## クエリ
brandに「ナイキ」を指定して、結果とsizeごとの件数を取得するためのクエリ
```
GET /shirts/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "brand": "ナイキ" }}
      ]
    }
  },
  "aggs": {
    "size": {
      "terms": { "field": "size" } 
    }
  }
}
```
## レスポンス
```
{
  // 説明に関係ない部分は省略しています
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "hits" : [
      { 
        "_index" : "shirts",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.0,
        "_source" : {
          "brand" : "ナイキ",
          "size" : "S"
        }
      },
      // 2件目以降の中身は省略します
      { ... }, 
      { ... },
      { ... }
    ]
  },
  "aggregations" : {
    "size" : {
      "buckets" : [
        {
          "key" : "M",
          "doc_count" : 2
        },
        {
          "key" : "L",
          "doc_count" : 1
        },
        {
          "key" : "S",
          "doc_count" : 1
        }
      ]
    }
  }
}
```
検索にヒットした4件と、サイズのごとの件数が「S:1件、M:2件、L:1件」で取得できます。

# ファセットで絞り込まれた後の件数取得方法

先程のサンプルで、ナイキで検索したした後、さらにサイズをMに絞り込むとします。
```
[ ] S(1件)
[X] M(2件)
[ ] L(1件)
```

このときの、クエリを以下のようにすると、他のサイズのファセットの件数が取れなくなってしまします。
```
GET /shirts/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "brand": "ナイキ" }},
        { "term": { "size": "M" }}
      ]
    }
  },
  "aggs": {
    "size": {
      "terms": { "field": "size" } 
    }
  }
}
```
レスポンス
```
{
  // 説明に関係ない部分は省略しています
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "hits" : [
      {
        "_index" : "shirts",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.0,
        "_source" : {
          "brand" : "ナイキ",
          "size" : "M"
        }
      },
      // 2件目以降の中身は省略します
      { ... }
    ]
  },
  "aggregations" : {
    "size" : {
      "buckets" : [
        {
          "key" : "M",
          "doc_count" : 2
        }
      ]
    }
  }
}
```

この結果だと表示は以下のようにSとLの件数を表示することができません。
```
[ ] S(0件)
[X] M(2件)
[ ] L(0件)
```

ここでは本来ヒットする内容をサイズMで絞り込んだ結果にする一方で、ファセットの件数はサイズMで絞り込んでいないものにしたいところです。
そうした検索はPost filterを使用することで実現できます。

## Post filterの使用例
### クエリ
```
GET /shirts/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "brand": "ナイキ" }}
      ]
    }
  },
  "aggs": {
    "size": {
      "terms": { "field": "size" } 
    }
  },
  "post_filter": {
      "term": { "size": "M" } 
  }
}
```
### レスポンス
```
{
  // 説明に関係ない部分は省略しています
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "hits" : [
      {
        "_index" : "shirts",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.0,
        "_source" : {
          "brand" : "ナイキ",
          "size" : "M"
        }
      },
      // 2件目以降の中身は省略します
      { ... }
    ]
  },
  "aggregations" : {
    "size" : {
      "buckets" : [
        {
          "key" : "M",
          "doc_count" : 2
        },
        {
          "key" : "L",
          "doc_count" : 1
        },
        {
          "key" : "S",
          "doc_count" : 1
        }
      ]
    }
  }
}
```
期待した通りヒット件数はサイズがMの2件に絞られ、ファセットはサイズM以外の件数も取れています。

# アグリゲーションではなくMulti search(msearch)ではだめなのか？

アグリゲーションの機能を使用しなくても、Multi search(msearch)機能を使用することで、1度のクエリでヒットした結果とファセットの件数を取得することができます。
ただし、Multi searchでは複数のクエリをまとめて実行しているだけなので、アグリゲーションに比べると効率的ではありません。
Multi Searchでファセットの件数を取得しようとすると以下のようなクエリになります。
```
GET /shirts/_msearch
{"index":"shirts"}
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "term": { "brand": "ナイキ" } }, // メインの条件部分
        { "term": { "size": "S" } }
      ]
    }
  }
}
// sizeにMを指定したクエリ
...
// sizeにLを指定したクエリ
...
```
sizeの3種類の件数を取得するために3つのクエリを実行する必要があります。
このとき"{ "term": { "brand": "ナイキ" } }"のメインの条件部分が、アグリゲーションなら1度で済むのに対して、Multi searchだとクエリの数分重複して行われます。
今回のようなサンプル用の簡単な条件では、応答時間に大きな差はほとんど生じませんが、実際の検索システムでは複雑な条件や大量のデータを扱います。
そのような場合では、応答時間の差が大きくなります。
そのため、処理効率を求める場合は、アグリゲーションを使用することが適しています。

# 番外編：Solrでの実現方法
Solrでは、今回説明したファセット件数の取得や特定の検索条件の除外するこをfacet機能とtag機能を使用することで実現できます。
詳細は「[Apache Solr Reference Guide: Tagging and Excluding Filters](https://solr.apache.org/guide/solr/latest/query-guide/faceting.html#tagging-and-excluding-filters)」をご覧ください。

# 参考文献
* [Elasticsearch Guide: Post filter](https://www.elastic.co/guide/en/elasticsearch/reference/current/filter-search-results.html#post-filter)
* [Apache Solr Reference Guide: Tagging and Excluding Filters](https://solr.apache.org/guide/solr/latest/query-guide/faceting.html#tagging-and-excluding-filters)