Elasticsearchで部分一致検索と完全一致検索の両方を実現する

数か月前に、Elasticsearchを利用した、部分一致検索(普通の検索)のアプリを作成しました。
その後、完全一致検索(Googleにおける検索語を「""」で囲んだ検索）を追加しました。
途中で少し詰まったところがあるのでまとめます。
Elasticsearchへのアクセスは、pythonのライブラリを用いています。

# 環境
- [Elasticsearch 7.8.0](https://www.elastic.co/jp/elasticsearch/)
- [elasticsearch 7.9.1](https://pypi.org/project/elasticsearch/) (pythonのライブラリ)
 - [Python Elasticsearch 基本的な使い方まとめ](https://qiita.com/satto_sann/items/8a63761bbfd6542bb9a2)

# 部分一致検索と完全一致検索
部分一致検索と完全一致検索という言葉があいまいなので、この記事でのイメージを記載します。
(一般的な定義じゃないです)

- 検索対象文字列が「**関西国際空港**」の場合

| 検索語 | 関西 | 国際 | 空港 | 関西空港 | 大阪空港 | 新大阪駅 |
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| 部分一致 | 〇 | 〇 | 〇 | △ | △ | × |
| 完全一致 | 〇 | 〇 | 〇 | × | × | × |

- 部分一致検索は、検索語を単語分けした後の各単語が、検索対象文字列に含まれている場合にヒットします。
検索語を単語分けした後の各単語の一部が、検索対象文字列に含まれている場合は、ヒットする場合もヒットしない場合もあります。
 - Google検索で、普通の検索と同じと思います。

- 完全一致検索は、検索語の全部が、検索対象文字列に含まれている場合にヒットします。
 - Google検索で、検索語を「"」(ダブルクォーテーション)で囲んだ場合と同じイメージです。

## Elasticsearchでの実装方法
Elasticsearchは文字列のフィールドタイプとして、[text](https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html)と[keyword](https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html)の二つがあります。
textは、文字列を分析して単語ごとに分けて保存したものです。
keywordは、文字列を単語分けせずそのままの状態で保存しています。

部分一致検索は、textに対して、[full text query](https://www.elastic.co/guide/en/elasticsearch/reference/current/full-text-queries.html)の一つである[matchクエリ](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html)を用いることによって実現できます。
完全一致検索は、keywordに対して、[term-level query](https://www.elastic.co/guide/en/elasticsearch/reference/current/term-level-queries.html)の一つの[wildcardクエリ](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wildcard-query.html)を用いることによって実現できます。

| | フィールドタイプ | クエリ |
|:-:|:-:|:-:|
| 部分一致 | [text](https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html) | [match](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html) |
| 完全一致 | [keyword](https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html) | [wildcard ](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wildcard-query.html) |

# textとkeywordの二つのデータを持つ方法

Elasticsearchでは、マルチフィールドでmappingを定義することにより、一つの文字列に対して、複数のフィールドタイプのデータを持たせることができます[(公式リファレンス)](https://www.elastic.co/guide/en/elasticsearch/reference/current/multi-fields.html)。

```mapping.py
# -*- coding: utf-8 -*-
from elasticsearch import Elasticsearch

es = Elasticsearch("localhost:9200")

mapping = {
    "mappings" : {
        "properties" : {
            "content": {
                "type":"text",
                "analyzer": "kuromoji",  # アナライザーはkuromoji
                "fields": {
                    "keyword": {
                        "type": "keyword",
                        "ignore_above": 8191 # デフォルトでは256
                    }
                }
            }
        }
    }
}

es.indices.create(index='myes', body=mapping)
```

これで、"myes"のデータとして、メインにtextタイプのデータ、サブにkeywordタイプのデータを持つことができます。
textタイプのデータには"content"、keywordタイプのデータには"content.keyword"でアクセスできます。
ここで、**サブとして持つ[keyword](https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html)タイプのデータは、デフォルトでは文字列が256文字以上の場合、作成されません。**
256文字以上のデータを取り扱う場合は、明示的に"[ignore_above](https://www.elastic.co/guide/en/elasticsearch/reference/current/ignore-above.html)"を設定する必要があります。
上では、`"ignore_above": 8191`としています。
Elasticsearchの内部で使用されているLuceneというプログラムのkeywordタイプのデータの最大容量が32766バイトであり、UTF-8が１文字で最大４バイトのため、32766/4=8191としています。
また、日本語analyzerとして[kuromoji](https://www.elastic.co/guide/en/elasticsearch/plugins/7.9/analysis-kuromoji.html)を入れています。

# インデックスの登録
サンプルの文字列(300字強)を登録します。
(某ドラマ原作 ^^/）

```index.py
# -*- coding: utf-8 -*-
from elasticsearch import Elasticsearch

es = Elasticsearch("localhost:9200")

sample = u"東京中央銀行大阪西支店の融資課長・半沢直樹のもとにとある案件が持ち込まれる。\
大手ＩＴ企業ジャッカルが、業績低迷中の美術系出版社・仙波工藝社を買収したいというのだ。 \
大阪営業本部による強引な買収工作に抵抗する半沢だったが、やがて背後にひそむ秘密の存在に気づく。\
有名な絵に隠された「謎」を解いたとき、半沢がたどりついた驚愕の真実とは――。\
探偵半沢が絵画に込められた謎を解く、江戸川乱歩賞出身の池井戸潤、真骨頂ミステリー！\
『半沢直樹1 オレたちバブル入行組』の前日譚となるシリーズ原点にして、「やられたら、倍返し！」\
あの突き上げる爽快感とともに、明かされる真実に胸が熱くなる、\
7月19日放送開始ドラマ「半沢直樹」シリーズ待望の最新原作小説が、ついに登場。"

book = {"content":sample}
es.index(index="myes", doc_type="_doc", id=1, body=book)
```

# 部分一致検索
contentに対して、matchクエリで検索します。

```search_p.py
# -*- coding: utf-8 -*-
from elasticsearch import Elasticsearch

es = Elasticsearch("localhost:9200")

rq = {
    "query" : {
        "match": {
            "content": "東京中央銀行"
        }
    }
}

result = es.search(index="myes", body=rq)
print(result)
```

検索語と検索結果は以下の通り。

| 検索語 | 検索結果 |
|:-:|:-:|
| 東京中央銀行 | 〇 |
| 東京の中央銀行 | 〇 |
| 三井住友銀行 | × |

matchクエリでは、検索語を単語ごとに分解してから検索します。
検索語「東京の中央銀行」は、「東京」、「中央」、「銀行」に単語分けしてから、同じく単語分けされたtextタイプの検索対象データを検索するため、検索がヒットします。
検索語「三井住友銀行」も、「銀行」が検索対象データに含まれるためヒットする可能性がありましたが、今回はヒットしませんでした。

# 完全一致検索
content.keywordに対して、wildcardクエリで検索します。
検索語の前後に「\*」(wildcard)を付けます。
「\*」は0文字以上の任意の文字と一致します。

```search_f.py
# -*- coding: utf-8 -*-
from elasticsearch import Elasticsearch

es = Elasticsearch("localhost:9200")

rq = {
    "query" : {
        "wildcard": {
            "content.keyword": "*東京中央銀行*"
        }
    }
}

result = es.search(index="myes", body=rq)
print(result)
```

検索語と検索結果は以下の通り。

| 検索語 | 検索結果 |
|:-:|:-:|
| \*東京中央銀行\* | 〇 |
| \*東京の中央銀行\* | × |
| \*三井住友銀行\* | × |

wildcardクエリ(term-level query)では、検索語をそのまま検索対象の文字列のデータと比較します。
検索対象の文字列には、「東京の中央銀行」、「三井住友銀行」は含まれていないため、検索がヒットしません。

# まとめ
Elasticsearchで部分一致検索と完全一致検索の両方を実現しようとしたところ、簡単にtextとkeywordの二つのデータを持つことができるとわかり、「なんて便利なデータ構造なんだ！」と感心しました。
しかしながら、サブとして持つkeywordタイプのデータではデフォルトで`"ignore_above": 256`になることに気づかず少々ハマりました。
後で知ったのですが、wildcardクエリに最適化された、[wildcardフィールドタイプ](https://www.elastic.co/guide/en/elasticsearch/reference/master/keyword.html#wildcard-field-type)というものもあるようなので、keywordの代わりにこちらを使用した方が良いかもしれません。

# 参考
[初心者のためのElasticsearchその1](https://dev.classmethod.jp/articles/es-01/)
[初心者のためのElasticsearchその2 -いろいろな検索-](https://dev.classmethod.jp/articles/es-02/)