[Elasticsearch]なぜ text 型と keyword 型のマルチフィールド型へ multi_match クエリするのか[Rails]

# 概要

最近、Ruby on Rails アプリケーションの開発で Elasticsearch に関するタスクを進めていたところ、以下のようなマルチフィールド型として定義されたフィールドに対して multi_match クエリを実行するコードがあり、色々疑問点があったので調べてみました。

```ruby:マッピング定義の一部
mapping _source: { enabled: true } do
  indexes :analysis_keywords, type: :text, fields: {
    keyword: {
      type: :keyword,
    },
  }
end
```

```ruby:クエリの一部
{ multi_match: { query: keyword, fields: ["analysis_keywords", "analysis_keywords.keyword"], operator: "and" } }
```

:::note info
この記事では基本的な内容も説明していますが、ある程度の基礎知識があるということを前提としています。「そもそもElasticsearchって何？」「フィールドって何のことだかわからない！」という読者の方はまず公式ドキュメントなどである程度基礎的な用語を確認してみてください。
:::

## マルチフィールド定義とは

まず、マルチフィールド定義とは、一つのフィールドに対して、異なるデータ型を定義することです。

```ruby:マルチフィールド定義の例
indexes :analysis_keywords, type: :text, fields: {
  keyword: {
    type: :keyword,
  },
}
```

この例では、`analysis_keywords`フィールドに対して、`text`型と`keyword`型の 2 つのデータ型を定義しています。
これにより、`analysis_keywords`フィールドは、トークン化されたデータ（`text`型）とトークン化されていない生データ（`keyword`型）の両方で格納されます。適切なクエリタイプ（`match`や`term`など）と組み合わせることで、部分一致検索と完全一致検索の両方を効果的に行うことができます。

## multi_match クエリとは

multi_match クエリは、一つの検索クエリで、複数のフィールドに対して同じ検索文字列で全文検索するためのクエリです。検索クエリ文字列は、検索を実行する前に分析されて単語分割(トークン化)されます。(ちなみにこのように検索文字列に対して単語分割を行いたくない場合はフィールドごとに term クエリを使用することになります)
また、 `operator: "and"` は、検索文字列が分割された全ての単語がドキュメント内に存在する場合に限り検索にヒットするという意味です。（AND 検索）

```ruby:multi_matchクエリの例
{ multi_match: { query: query_keyword, fields: ["analysis_keywords", "analysis_keywords.keyword"], operator: "and" } }
```

### 具体例

```
格納データ: "春の桜は美しい"
検索クエリ: "春の桜は美しい" -> matchクエリの場合、[春, 桜, 美しい]と分割された後に検索される
```

- **text 型**: アナライザにより[春, 桜, 美しい]とトークン化されて格納されており、単語分割された検索クエリ[春, 桜, 美しい]にそれぞれが全て一致するためマッチする
- **keyword 型**: "春の桜は美しい"が完全な文字列として格納されており、単語分割された検索クエリ[春, 桜, 美しい]のそれぞれの要素とは完全一致しないためにマッチしない

## 疑問点

ようやく本題に入ります。
上記２点の基本を理解した上で冒頭のコードを改めて読んでみた時に一点疑問が湧いてきました。
それは、**クエリ文字列をトークン化して検索する match クエリの場合、`text`型に対する検索のみで`keyword`型に対する検索結果も全てヒットするのではないか**ということです。
このような場合に`keyword`型のみに該当してヒットするパターンがあるのかどうかを調査しましたが、なかなかクリティカルな情報が得られなかったので、`claude-3.7-sonnet-thinking`と議論してみたところ、以下のような回答を得ました。

```
例えば、インデックス格納時と検索時のアナライザーが一致していない場合に起こる問題です。
Elasticsearchでは:
インデックス時アナライザー: ドキュメント格納時に適用
検索時アナライザー: クエリ解析時に適用
これらが異なると不一致が発生します：
例：インデックス時に「C++」→「c」と変換されたが、検索時は「C++」→「c++」と変換される
これに対して、一般的なベストプラクティスでは:
- インデックス時と検索時で同一のアナライザーを使用
- 同じフィールドに対しては一貫した分析プロセスを適用
このように一貫性を保った設計では、text型フィールドの検索結果はkeyword型の結果を包含するのが通常です。
```

なるほど、確かに異なるアナライザが適用されると、`text`型ではヒットせず`keyword`型ではヒットするということがわかります。
しかし、実際は上記のようなケースは特殊な例で、Elasticsearch のデフォルトの設定ではインデックス時と検索時で同一のアナライザが適用されるため、基本的には`text`型の検索結果は`keyword`型の検索結果を包含すると考えて良いと思います。

では、`text`型の単一フィールドへのクエリで全ての件数を獲得できるのに、マルチフィールド型への multi_match クエリする必要があるのでしょうか？
何なら上記の具体例を見ると、格納した文字列と全く同じ文字列で検索してもヒットしないのに、`keyword`型を対象に match クエリする意味ってあるの？という疑問も湧いてきます。
これらの疑問に対する回答は、Elasticsearch のスコアリングという概念について理解することで明らかになります。

## スコアリングとは

Elasticsearch の検索結果には「relevance score（関連性スコア）」が付与されます。このスコアは、クエリに対してドキュメントがどれだけ関連性が高いかを数値化したものです。

スコアの計算には主に以下の要素が考慮されます：

- 単語の出現頻度（TF：Term Frequency）- ドキュメント内でクエリの単語が多く出現するほどスコアが高くなる
- 逆文書頻度（IDF：Inverse Document Frequency）- 全ドキュメント中で珍しい単語ほど重要とみなされスコアが高くなる
- フィールドの長さ - 短いフィールドでの一致は長いフィールドでの一致より重要とみなされる

multi_match クエリで複数フィールドを指定する重要な理由の一つがこのスコアリングです。複数のフィールドにマッチした場合、そのスコアは合算され、より上位に表示されます。例えば「春の桜」という検索で、text 型の「analysis_keywords」フィールドに部分一致し、かつ keyword 型の「analysis_keywords.keyword」フィールドに完全一致した場合、両方のスコアが合算されるため、keyword 型にもマッチしていないドキュメントより上位に表示されます。

これにより、完全一致したコンテンツをより上位に表示するという自然な検索体験を提供できます。単一フィールドへのクエリでも全件ヒットするかもしれませんが、マルチフィールドへのクエリでは適切な関連性順にソートされるという大きなメリットがあります。

### 具体的なスコア計算例

以下のような文字列配列のドキュメントが`text`型と`keyword`型のマルチフィールドで格納されているとします：

```
ドキュメントA: { "analysis_keywords": ["Smart", "Watch", "Wearable"] }
ドキュメントB: { "analysis_keywords": ["SmartWatch", "Fitness Tracker"] }
ドキュメントC: { "analysis_keywords": ["Apple Watch", "Smart Technology"] }
ドキュメントD: { "analysis_keywords": ["Smart", "Assistant", "AI"] }
```

そして、「Smart Watch」で検索する場合を考えてみましょう：

```ruby
{
  multi_match: {
    query: "Smart Watch",
    fields: ["analysis_keywords", "analysis_keywords.keyword"],
    operator: "and"
  }
}
```

match クエリなので「Smart Watch」は分析されて「smart」「watch」に分割され、and オペレータにより分割されたトークンが全てドキュメント内に存在する場合にのみマッチすることになります。

この場合のスコア計算：

- ドキュメント A:
  - **text** 型: 「smart」「watch」両方のトークンを含む → スコア = 1.5
  - **keyword** 型: 「Smart」と「Watch」タグが個別に存在する → スコア = 1.0
  - 最終スコア = 2.5
- ドキュメント B:
  - **text** 型: 「SmartWatch」が分析されると「smartwatch」となり、「smart」「watch」と別トークンにはならないためマッチしない → スコア = 0
  - **keyword** 型: 「SmartWatch」タグはあるが、「Smart」「Watch」の完全一致はない → スコア = 0
  - 最終スコア = 0 (結果に表示されない)
- ドキュメント C:
  - **text** 型: 「smart」「watch」の両方のトークンを含む → スコア = 1.5
  - **keyword** 型: 配列内に「Apple Watch」「Smart Technology」はあるが、「Smart」や「Watch」単独のタグはないため完全一致しない → スコア = 0
  - 最終スコア = 1.5
- ドキュメント D:
  - **text** 型: 「smart」トークンはあるが「watch」がないため、and オペレータではマッチしない → スコア = 0
  - **keyword** 型: 「Smart」タグはあるが「Watch」タグがないため、and オペレータではマッチしない → スコア = 0
  - 最終スコア = 0 (結果に表示されない)

## 結論: マルチフィールド型への multi_match クエリでは text 型のみの match クエリよりも関連性の高いドキュメントを上位に表示することができる

上記の結果から通常の text 型へ の match クエリであればドキュメント A, C のスコアは同じ 1.5 となることがわかります。この場合、両者の順序は関連度順でなく内部的なソート順(ドキュメント ID 順など)によって決定されるため厳密な関連性順位は保証されません。

しかし、マルチフィールド型への multi_match クエリでは keyword 型のスコアが加算されるためドキュメント A, C のスコアはそれぞれ 2.5, 1.5 となり、ドキュメント A が高いスコアとなり明確に上位に検索がヒットすることになります。

これにより、マルチフィールド型への multi_match クエリでは、text 型のみの match クエリよりも関連性の高いドキュメントを上位に表示することができるということがわかります。

::: note warn

上記の具体例は、設定されているアナライザによってトークン分割の結果が異なるため、実際のスコア計算は異なる結果となる可能性があります。
また、`multi_match`クエリには`type`というオプションがあり、この設定によってもスコア計算が異なる結果となる可能性があります。

:::

## まとめ

今回は、Elasticsearch のマルチフィールド型への multi_match クエリでは text 型のみの match クエリよりも関連性の高いドキュメントを上位に表示することができるということを確認しました。

Elasticsearch はひとつひとつの機能はシンプルですが、それらが組み合わさるとなかなか理解が難しく、記事を書きながら色々と調べてみてようやく理解できた( AI にとっても難しいのか結構解答の精度が低かったです)ので、これを機に Elasticsearch の理解を深めていきたいと思います。

また、調べれば調べるほど奥深く、これだけで一つの部署が成り立っている会社があるという話も納得できるな〜と感じました。

### しかし...

冒頭のコードの意味が理解できて、スッキリしたな〜と思ってさらにコード読み進めてみたら、以下のようなコードが。

```ruby
# searchは__elasticsearch__.searchメソッドをsizeとsourceを指定して実行できるようにラップしたメソッド
search(query, size: size, source: source).records.records
```

上記の引数の`query`は、冒頭のコードで定義した`multi_match`クエリです。
`records`メソッドは、`elasticsearch-rails` gem のメソッドで、一回目の`records`メソッドが`search`メソッドの実行結果を`Elasticsearch::Model::Response::Records`オブジェクトとして返し、二回目の`records`メソッドがそのオブジェクトを`ActiveRecord::Relation`オブジェクトに変換しています。
つまり、平たくいうとこの部分で実際に Elasticsearch にクエリを投げてその結果を`ActiveRecord::Relation`のオブジェクトとして取得しているということです。

問題はこのオブジェクトを実際に使用する際に、Elasticsearch から得られた検索結果の id を元に実際のデータベースへ`where in`クエリを実行してレコードを取得するということです。
`where in`クエリは、クエリの際に指定された id の並び順を検索結果に反映しません。

ん？

**`where in`クエリは、クエリの際に指定された id の並び順を検索結果に反映しません。**(二回目)

え？

じゃあ、Elasticsearch へ multi_match クエリで精度の高い関連度順を取得しても、変換の際に並び順がリセットされるからやっぱり意味ないじゃん！

また新たな疑問が生まれてしまいました。。(これについての調査はまた次回に)

## 追記

最終段の新たな疑問点ですが、`order_as_specified`というgemのおかげでActiveRecordに変換してもソート順が維持される(IN句に渡した配列の順番が維持される)仕組みのようでした！すっきり！

## 参考

- [公式ドキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/current/multi-fields.html)
- [RailsプロジェクトでのElasticsearchとの付き合い方について](https://www.wantedly.com/companies/appbrew/post_articles/872985)