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


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

環境

部分一致検索と完全一致検索

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

  • 検索対象文字列が「関西国際空港」の場合
検索語 関西 国際 空港 関西空港 大阪空港 新大阪駅
部分一致 ×
完全一致 × × ×
  • 部分一致検索は、検索語を単語分けした後の各単語が、検索対象文字列に含まれている場合にヒットします。
    検索語を単語分けした後の各単語の一部が、検索対象文字列に含まれている場合は、ヒットする場合もヒットしない場合もあります。

    • Google検索で、普通の検索と同じと思います。
  • 完全一致検索は、検索語の全部が、検索対象文字列に含まれている場合にヒットします。

    • Google検索で、検索語を「"」(ダブルクォーテーション)で囲んだ場合と同じイメージです。

Elasticsearchでの実装方法

Elasticsearchは文字列のフィールドタイプとして、textkeywordの二つがあります。
textは、文字列を分析して単語ごとに分けて保存したものです。
keywordは、文字列を単語分けせずそのままの状態で保存しています。

部分一致検索は、textに対して、full text queryの一つであるmatchクエリを用いることによって実現できます。
完全一致検索は、keywordに対して、term-level queryの一つのwildcardクエリを用いることによって実現できます。

フィールドタイプ クエリ
部分一致 text match
完全一致 keyword wildcard

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

Elasticsearchでは、マルチフィールドでmappingを定義することにより、一つの文字列に対して、複数のフィールドタイプのデータを持たせることができます(公式リファレンス)

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タイプのデータは、デフォルトでは文字列が256文字以上の場合、作成されません。
256文字以上のデータを取り扱う場合は、明示的に"ignore_above"を設定する必要があります。
上では、"ignore_above": 8191としています。
Elasticsearchの内部で使用されているLuceneというプログラムのkeywordタイプのデータの最大容量が32766バイトであり、UTF-8が1文字で最大4バイトのため、32766/4=8191としています。
また、日本語analyzerとしてkuromojiを入れています。

インデックスの登録

サンプルの文字列(300字強)を登録します。
(某ドラマ原作 ^^/)

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

es = Elasticsearch("localhost:9200")

sample = u"東京中央銀行大阪西支店の融資課長・半沢直樹のもとにとある案件が持ち込まれる。\
大手IT企業ジャッカルが、業績低迷中の美術系出版社・仙波工藝社を買収したいというのだ。 \
大阪営業本部による強引な買収工作に抵抗する半沢だったが、やがて背後にひそむ秘密の存在に気づく。\
有名な絵に隠された「謎」を解いたとき、半沢がたどりついた驚愕の真実とは――。\
探偵半沢が絵画に込められた謎を解く、江戸川乱歩賞出身の池井戸潤、真骨頂ミステリー!\
『半沢直樹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フィールドタイプというものもあるようなので、keywordの代わりにこちらを使用した方が良いかもしれません。

参考

初心者のためのElasticsearchその1
初心者のためのElasticsearchその2 -いろいろな検索-