数か月前に、Elasticsearchを利用した、部分一致検索(普通の検索)のアプリを作成しました。
その後、完全一致検索(Googleにおける検索語を「""」で囲んだ検索)を追加しました。
途中で少し詰まったところがあるのでまとめます。
Elasticsearchへのアクセスは、pythonのライブラリを用いています。
環境
部分一致検索と完全一致検索
部分一致検索と完全一致検索という言葉があいまいなので、この記事でのイメージを記載します。
(一般的な定義じゃないです)
- 検索対象文字列が「関西国際空港」の場合
検索語 | 関西 | 国際 | 空港 | 関西空港 | 大阪空港 | 新大阪駅 |
---|---|---|---|---|---|---|
部分一致 | 〇 | 〇 | 〇 | △ | △ | × |
完全一致 | 〇 | 〇 | 〇 | × | × | × |
-
部分一致検索は、検索語を単語分けした後の各単語が、検索対象文字列に含まれている場合にヒットします。
検索語を単語分けした後の各単語の一部が、検索対象文字列に含まれている場合は、ヒットする場合もヒットしない場合もあります。 -
Google検索で、普通の検索と同じと思います。
-
完全一致検索は、検索語の全部が、検索対象文字列に含まれている場合にヒットします。
-
Google検索で、検索語を「"」(ダブルクォーテーション)で囲んだ場合と同じイメージです。
Elasticsearchでの実装方法
Elasticsearchは文字列のフィールドタイプとして、textとkeywordの二つがあります。
textは、文字列を分析して単語ごとに分けて保存したものです。
keywordは、文字列を単語分けせずそのままの状態で保存しています。
部分一致検索は、textに対して、full text queryの一つであるmatchクエリを用いることによって実現できます。
完全一致検索は、keywordに対して、term-level queryの一つのwildcardクエリを用いることによって実現できます。
フィールドタイプ | クエリ | |
---|---|---|
部分一致 | text | match |
完全一致 | keyword | wildcard |
textとkeywordの二つのデータを持つ方法
Elasticsearchでは、マルチフィールドでmappingを定義することにより、一つの文字列に対して、複数のフィールドタイプのデータを持たせることができます(公式リファレンス)。
# -*- 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字強)を登録します。
(某ドラマ原作 ^^/)
# -*- 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クエリで検索します。
# -*- 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文字以上の任意の文字と一致します。
# -*- 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の代わりにこちらを使用した方が良いかもしれません。