🦁

Elasticsearchの絞り込みと並び替えでイケイケな検索結果を得る

2024/11/26に公開

はじめに

Elasticsearchを使っているが要件を満たす検索結果を実現するにはどうすればいいかわからない、、またはElasticsearchを使ってみたいが勝手がわからない、、などのお悩みあると思います。
具体例を交えて絞り込みと並び替えの基本を紹介していきたいと思います。

Elasticsearchはどうやって絞り込み、並び替えをしている?

絞り込みですが、queryとfilterで絞り込みができます。
queryには色々な種類がありますが基本的なものとしてBoolean queryのmust句、should句、must_not句があります。今回はこれらのクエリについて紹介したいと思います。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html

並び順に関してですが関連度スコアというものを計算して並び順を決定しています。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html#relevance-scores
デフォルトではこの関連度スコアの高い順に並べられます。
他にもスクリプトで計算したスコアで並べ替えたり、特定のfieldの値の単純ソートも可能です。
応用的な使い方として、ベクトルの類似度で並べ替えたり、複数のスコアを組み合わせて並びを決めることもできます。

とは言っても技術的な話しだけではイメージしづらいと思うので実際に試してみましょう。

実際に試してみる

イメージしやすいようにファッションサイトの検索機能を作って実験してみましょう。

検索時の情報としてはこんなものがあればよいでしょうか?
服の名前、服の説明、カテゴリ、サイズ、色、価格 などなど。
下記の内容でindexを作成してみましょう。

PUT clothes
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "kuromoji"
      },      
      "description": {
        "type": "text",
        "analyzer": "kuromoji"
      },
      "category": {
        "type": "keyword"
      },
      "size": {
        "type": "keyword"
      },
      "color": {
        "type": "keyword"
      },
      "price": {
        "type": "integer"
      }
    }
  }
}

使うデータとしては下記の内容にしてみましょう。

服名 説明 カテゴリ サイズ 価格
パーカー カジュアルなパーカー トップス S,M,L 白,黒,赤,紫 5000
パーカー カジュアルなパーカー トップス M,L,XL 白,黒,赤,緑,青 6000
パーカー キレイめなパーカー トップス M,L,XL 白,黒,赤,緑,青 7000
パーカー キレイめなパーカー トップス S,M,L 白,黒,赤,緑,青 5000
パーカー カジュアルなパーカー トップス M,L,XL 白,黒,赤,緑 12000
デニムパンツ カジュアルなデニムパンツ ボトムス S,M,L 黒,青 10000
スラックス カジュアルなスラックス ボトムス S,M,L 白,黒,赤,緑 8000

上記のデータをbulk insertします。

POST _bulk
{"index": {"_index": "clothes"}}
{"name": "パーカー", "description": "カジュアルなパーカー", "category":"トップス", "size": ["S","M","L"], "color": ["白","黒","赤","紫"], "price": "5000"}
{"index": {"_index": "clothes"}}
{"name": "パーカー", "description": "カジュアルなパーカー", "category":"トップス", "size": ["M","L","XL"], "color": ["白","黒","赤","緑", "青"], "price": "6000"}
{"index": {"_index": "clothes"}}
{"name": "パーカー", "description": "キレイめなパーカー", "category":"トップス", "size": ["M","L","XL"], "color": ["白","黒","赤","緑", "青"], "price": "7000"}
{"index": {"_index": "clothes"}}
{"name": "パーカー", "description": "キレイめなパーカー", "category":"トップス", "size": ["S","M","L"], "color": ["白","黒","赤","緑", "青"], "price": "5000"}
{"index": {"_index": "clothes"}}
{"name": "パーカー", "description": "カジュアルなパーカー", "category":"トップス", "size": ["M","L","XL"], "color": ["白","黒","赤","緑"], "price": "12000"}
{"index": {"_index": "clothes"}}
{"name": "デニムパンツ", "description": "カジュアルなデニムパンツ", "category":"ボトムス", "size": ["S","M","L"], "color": ["黒","青"], "price": "10000"}
{"index": {"_index": "clothes"}}
{"name": "スラックス", "description": "カジュアルなスラックス", "category":"ボトムス", "size": ["S","M","L"], "color": ["白","黒","赤","緑"], "price": "8000"}

それでは実際に検索クエリを書いてみましょう!
例えば、青でXLサイズのパーカー(トップス)を探してみたいとします。
filter句を使ってAND条件で絞り込みにしてみます。

GET clothes/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "category": {
              "value": "トップス"
            }
          }
        },
        {
          "term": {
            "size": {
              "value": "XL"
            }
          }
        },
        {
          "term": {
            "color": {
              "value": "青"
            }
          }
        }
      ]
    }
  }
}

下記のような検索結果が返ってきました。2件ヒットしておりいずれも青でXLサイズのパーカーがヒットしています。

{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 0,
    "hits": [
      {
        "_index": "clothes",
        "_id": "kGmDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "6000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kWmDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "7000"
        }
      }
    ]
  }
}

しかし2件だけに絞るより他の色やサイズのパーカーも出すのはどうでしょうか?
ちょっと条件から外れるものも検索結果に出したいという要件はあると思います。
AND条件だったところをOR条件にして検索してみます。

GET clothes/_search
{
  "query": {
    "bool": {
      "filter": [
+        {
+          "bool": {
+            "should": [
              {
                "term": {
                  "category": {
                    "value": "トップス"
                  }
                }
              },
              {
                "term": {
                  "size": {
                    "value": "XL"
                  }
                }
              },
              {
                "term": {
                  "color": {
                    "value": "青"
                  }
                }
              }
+            ]
+          }
+        }
      ]
    }
  }
}

下記のような結果が得られました。今度は条件に部分的に一致している服も取得対象となっており6件ヒットしています。しかし並び順を見てみると先程AND条件で一致していた服が1件目に来ていません。より条件に一致している服を上位に表示するのがよいでしょう。

{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 0,
    "hits": [
      {
        "_index": "clothes",
        "_id": "j2mDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "紫"
          ],
          "price": "5000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kGmDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "6000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kWmDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "7000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kmmDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "5000"
        }
      },
      {
        "_index": "clothes",
        "_id": "k2mDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑"
          ],
          "price": "12000"
        }
      },
      {
        "_index": "clothes",
        "_id": "lGmDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "デニムパンツ",
          "description": "カジュアルなデニムパンツ",
          "category": "ボトムス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "黒",
            "青"
          ],
          "price": "10000"
        }
      }
    ]
  }
}

一致しているほど上位に表示するために、関連度スコアを使って並べ替えしましょう。
filter句を外すと関連度スコアを計算してくれデフォルトで関連度スコア順に並び替えてくれるようになります。

GET clothes/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "term": {
            "category": {
              "value": "トップス"
            }
          }
        },
        {
          "term": {
            "size": {
              "value": "XL"
            }
          }
        },
        {
          "term": {
            "color": {
              "value": "青"
            }
          }
        }
      ]
    }
  }
}

下記のような検索結果になりました。今度は上位2件がトップス、青、XLの条件に一致しています。

{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 2.3456545,
    "hits": [
      {
        "_index": "clothes",
        "_id": "kGmDUpMBQoI2sJ2k4Uer",
        "_score": 2.3456545,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "6000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kWmDUpMBQoI2sJ2k4Uer",
        "_score": 2.3456545,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "7000"
        }
      },
      {
        "_index": "clothes",
        "_id": "k2mDUpMBQoI2sJ2k4Uer",
        "_score": 1.5113764,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑"
          ],
          "price": "12000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kmmDUpMBQoI2sJ2k4Uer",
        "_score": 1.2089715,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "5000"
        }
      },
      {
        "_index": "clothes",
        "_id": "lGmDUpMBQoI2sJ2k4Uer",
        "_score": 0.83427805,
        "_source": {
          "name": "デニムパンツ",
          "description": "カジュアルなデニムパンツ",
          "category": "ボトムス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "黒",
            "青"
          ],
          "price": "10000"
        }
      },
      {
        "_index": "clothes",
        "_id": "j2mDUpMBQoI2sJ2k4Uer",
        "_score": 0.37469345,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "紫"
          ],
          "price": "5000"
        }
      }
    ]
  }
}

ですが、下から2番目の検索結果を見てみると「色」の条件のみ一致しており、パーカーよりも上位にきてしまっています。青でXLサイズのパーカーを探しているときに青だからと言ってデニムパンツでいいや!とはならないでしょう。つまりこの場合「色」よりも「カテゴリ」の方が重要な検索条件ということです。
検索かける際に「カテゴリ」に一致している場合関連度スコアに重みをつけるようにしてみましょう。

GET clothes/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "term": {
            "category": {
              "value": "トップス",
+              "boost": 10
            }
          }
        },
        {
          "term": {
            "size": {
              "value": "XL"
            }
          }
        },
        {
          "term": {
            "color": {
              "value": "青"
            }
          }
        }
      ]
    }
  }
}

これで「デニムパンツ」が一番下にきました。

{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 5.7178955,
    "hits": [
      {
        "_index": "clothes",
        "_id": "kGmDUpMBQoI2sJ2k4Uer",
        "_score": 5.7178955,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "6000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kWmDUpMBQoI2sJ2k4Uer",
        "_score": 5.7178955,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "7000"
        }
      },
      {
        "_index": "clothes",
        "_id": "k2mDUpMBQoI2sJ2k4Uer",
        "_score": 4.8836174,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑"
          ],
          "price": "12000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kmmDUpMBQoI2sJ2k4Uer",
        "_score": 4.5812125,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "5000"
        }
      },
      {
        "_index": "clothes",
        "_id": "j2mDUpMBQoI2sJ2k4Uer",
        "_score": 3.7469344,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "紫"
          ],
          "price": "5000"
        }
      },
      {
        "_index": "clothes",
        "_id": "lGmDUpMBQoI2sJ2k4Uer",
        "_score": 0.83427805,
        "_source": {
          "name": "デニムパンツ",
          "description": "カジュアルなデニムパンツ",
          "category": "ボトムス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "黒",
            "青"
          ],
          "price": "10000"
        }
      }
    ]
  }
}

それでもパーカー探してるのにパンツはそもそもいらないでしょ?という話しもあるので、完全にフィルタリングでもいいかもしれません。ついでに価格の絞り込みも入れてみましょう。
先ほども登場したfilter句を使ってみましょう。こちらは関連度スコアに影響しません。

GET clothes/_search
{
  "query": {
    "bool": {
+      "filter": [
+        {
+          "range": {
+            "price": {
+              "lte": 10000
+            }
+          }
+        },
        {
          "term": {
            "category": {
              "value": "トップス"
            }
          }
        }
      ],
      "should": [
        {
          "term": {
            "size": {
              "value": "XL"
            }
          }
        },
        {
          "term": {
            "color": {
              "value": "青"
            }
          }
        }
      ],
      "minimum_should_match": 0
    }
  }
}

下記のような結果になりました。カテゴリで絞り込んでいるので検索結果がトップスのみになっており、10,000円以下の絞り込みも入れているので12,000円のパーカーは検索結果から消えています。
minimum_should_matchはshould句の絞り込み条件で最低いくつマッチするべきかを指定しています。

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4,
      "relation": "eq"
    },
    "max_score": 1.9709611,
    "hits": [
      {
        "_index": "clothes",
        "_id": "kGmDUpMBQoI2sJ2k4Uer",
        "_score": 1.9709611,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "6000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kWmDUpMBQoI2sJ2k4Uer",
        "_score": 1.9709611,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "7000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kmmDUpMBQoI2sJ2k4Uer",
        "_score": 0.83427805,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "5000"
        }
      },
      {
        "_index": "clothes",
        "_id": "j2mDUpMBQoI2sJ2k4Uer",
        "_score": 0,
        "_source": {
          "name": "パーカー",
          "description": "カジュアルなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "紫"
          ],
          "price": "5000"
        }
      }
    ]
  }
}

最後に、同じパーカーでもキレイめなパーカーがほしいな、、、と思った時はどうすればいいでしょうか?商品説明の情報もElasticsearchに登録されているのでこの説明情報から検索するのはどうでしょうか?このような場合に使えるのが全文検索クエリです。
"キレイめ"というキーワードを含むもので絞り込んでみます。

GET clothes/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "range": {
            "price": {
              "lte": 10000
            }
          }
        },
        {
          "term": {
            "category": {
              "value": "トップス"
            }
          }
        }
      ],
+      "must": [
+        {
+          "match": {
+            "description": "キレイめ"
+          }
+        }
+      ],
      "should": [
        {
          "term": {
            "size": {
              "value": "XL"
            }
          }
        },
        {
          "term": {
            "color": {
              "value": "青"
            }
          }
        }
      ],
      "minimum_should_match": 0
    }
  }
}

このように商品説明に"キレイめ"というキーワードを含むものに絞り込むことができました。

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 4.0930023,
    "hits": [
      {
        "_index": "clothes",
        "_id": "kWmDUpMBQoI2sJ2k4Uer",
        "_score": 4.0930023,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "M",
            "L",
            "XL"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "7000"
        }
      },
      {
        "_index": "clothes",
        "_id": "kmmDUpMBQoI2sJ2k4Uer",
        "_score": 2.956319,
        "_source": {
          "name": "パーカー",
          "description": "キレイめなパーカー",
          "category": "トップス",
          "size": [
            "S",
            "M",
            "L"
          ],
          "color": [
            "白",
            "黒",
            "赤",
            "緑",
            "青"
          ],
          "price": "5000"
        }
      }
    ]
  }
}

いかがでしたでしょうか?これでElasticsearchでの絞り込みと並び替えなんとなくわかったかと思います!

最後に

スペースマーケットでは、一緒にサービスを成長させていく仲間を探しています。

ビジネスサイド、エンジニアメンバー共に話しやすいメンバーが多く非常に働きやすい環境だと思います!
ご興味ある方ぜひ見てみて下さい!

https://spacemarket.co.jp/recruit/engineer/
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1061116

Discussion