Elasticsearchで日本語の全文検索の機能を実装する
全文検索は一般的に知られていますが、検索エクスペリエンスで非常に重要な役割を果たしています。ただし、日本語など、一部の言語では、全文検索を実装するのが難しい場合があります。このブログでは、日本語で全文検索を実装する際の課題を探り、Elasticsearchでこれらの課題を解決する方法をいくつか示します。
全文検索とは?
Wikipediaより、下記が定義となります。
全文検索とは、コンピュータにおいて、複数の文書(ファイル)から特定の文字列を検索すること。「ファイル名検索」や「単一ファイル内の文字列検索」と異なり、「複数文書にまたがって、文書に含まれる全文を対象とした検索」という意味で使用される。
全文検索は、現在多くのデジタル体験を強化するものです。全文検索は、データセット内に隠れている可能性のある単語やフレーズを見つけようとしてくれます。例えば、ネットショッピングして「phone」を検索すると、全文検索では、phoneとして分類されている、説明にphoneがある、メーカー名にphoneがあるなどの商品が返されます。検索結果には、「phone」、「saxophone」などが含まれます。
このような検索は、必要なものをできるだけ早く見つけるのに非常に便利ではあるものの、検索エンジンが適切に構成および管理されていない場合は、非常に不便であることもあります。 「Phone」を検索するとき、おそらく「Saxophone」を検索しているのではないことを、適切に構成されている検索エンジンなら知っているべきです。 それでは、検索エンジンの興味深い部分を見ていきましょう!
適合率と再現率
一般的に使われる全文検索システムの評価指標として「再現率(英: recall)」と「適合率(精度、英: precision)」があります。再現率は「どれだけ検索漏れが少ないか」をあらわし、適合率は「どれだけ検索ノイズが少ないか」をあらわします。一般的に両者はトレードオフの関係にあるといわれています。例えば、適合率の高い検索では、アイテムが「phone」として分類されている場合のみ、「phone」の結果が返されます。一方で、再現率の高い検索では、「saxophone」も含め、「phone」という単語が含まれる全てのアイテムが返されます。
適合率と再現率の間には常にトレードオフがあるため、ユースケースに応じて、最適なものを自分で決めて、それを段階的にテストして調整する必要があります。
転置インデックスとアナライズ
本の後ろにある索引を考えてみてください。本の重要な用語は、ページ番号とともにソートおよびリストされているため、その用語の場所がすぐにわかります。Elasticsearchの全文検索でも、同様な転置インデックスを使用しています。
インデックス作成時に、文字列が分析されて転置インデックスとして追加されるため、対象とする文字列を非常に簡単に見つけることができます。 また、検索時にクエリの文字列も同様にアナライズされます。アナライズのフローでは、さまざまなフィルター(lowercase、stop words、stemmingなど)を適用できます。 英語圏の言語では、インデックス作成時と検索時の両方に同じアナライザーを使用することは一般的です。しかし、このブログで紹介する日本語の全文検索のような特殊なユースケースでは、異なるアナライズ設定が必要となる場合もあります。
この文字列のアナライズ処理により、単語やフレーズの詳細な転置インデックスが作成され、全文検索可能になります。
日本語での全文検索は英語と何が違いますか?
非常に良い質問です!答える前に、同じ質問の英語版と日本語版を比べてみましょう。
- What’s different about full-text search in Japanese?
- 日本語での全文検索は英語と何が違いますか?
同じ質問ですが、(異なる言語のため、)見た目はだいぶ違います。これは全文検索の実装が異なる理由の一つでもあります。
単語の区切りがわかりにくい
テキスト文字列をアナライズするには、アナライザーが必要です。欧米の言語では、単語がホワイトスペースによって分割されるので、文章を単語に分解することが容易にできます。しかしながら、日本語の場合、単語の切れ目をホワイトスペースでは判断できません。
では、どうすれば良いでしょうか?
2 つの方法で単語をアナライズする
日本語は単語の切れ目がわかりにくいので、転置インデックスのキーは主に次の2つの手法で作成します。
- n-gram: N文字ずつ文章を区切る
- 形態素解析: 辞書などを用いて意味のある単語で区切る
しかしながら、片側の対策のみでは不十分です。具体的には、
- n-gram では、インデックス肥大化になりがち。品詞情報に基づく処理が不可能なので、無意味の分割が多い。(検索漏れが少ないが、検索ノイズが多い)
- 形態素解析では、新語(未知語)に弱い。また、辞書ベースの場合、辞書にない単語は検出不能。(検索ノイズが少ないが、検索漏れが多い)
まさに、前述のようなトレードオフとなります。
日本語の全文検索には両方が必要!
その状況を踏まえ、日本語の全文検索では、両方のアナライズ方法を利用することで、採長補短でき、最適な検索効果の実現が期待できます。
- n-gramのように検索漏れが少ない
- 形態素解析のように検索ノイズが少ない
Elasticsearchで、形態素解析とn-gramを併用した日本語の全文検索の実現
Elasticsearchでは、そのような対応はできるので、適合率と再現率の両方をともに重視した全文検索の実現はできます。
実装概要とサンプル
結論から言いますと、インデックスの構成(mappingsとsettings)でn-gramと形態素解析用の2種類のアナライザーを定義し、それらを各フィールドに割り当てる必要があります。 インデックス作成時に、この2種類のアナライザーを使用して転置インデックスを作成します。 検索時に、2つの転置インデックスの両方に対して、検索を行います。
詳細説明に入る前に、まず日本語の全文検索の実装参考例を見てみましょう。
主な対応要件
- 日本語の全文検索ができる。例:「東京大学」で検索したら、「東京大学」を含めたドキュメントが表示される。また、「米国」で検索したら、「米国」を含めたドキュメントが表示される
- 加えて、同義語の検索ができる。例:「東京大学」で検索したら、「東大」を含めたドキュメントが表示される。また、「米国」で検索したら、「アメリカ」を含めたドキュメントが表示される
実装に向けての準備
- アナライズ処理のため、analysis-icuとanalysis-kuromojiのプラグインをインストールしておきます
- 全文検索用のインデックスを作成。この例では、
my_fulltext_search
という名前のインデックスを作成。その中のmy_field
は全文検索用のフィールドとします
例 : index settingsとmappingsの設定
PUT my_fulltext_search
{
"settings": {
"analysis": {
"char_filter": {
"normalize": {
"type": "icu_normalizer",
"name": "nfkc",
"mode": "compose"
}
},
"tokenizer": {
"ja_kuromoji_tokenizer": {
"mode": "search",
"type": "kuromoji_tokenizer",
"discard_compound_token": true,
"user_dictionary_rules": [
"東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞"
]
},
"ja_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": [
"letter",
"digit"
]
}
},
"filter": {
"ja_index_synonym": {
"type": "synonym",
"lenient": false,
"synonyms": [
]
},
"ja_search_synonym": {
"type": "synonym_graph",
"lenient": false,
"synonyms": [
"米国, アメリカ",
"東京大学, 東大"
]
}
},
"analyzer": {
"ja_kuromoji_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"ja_index_synonym",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
},
"ja_kuromoji_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"ja_search_synonym",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
},
"ja_ngram_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"lowercase"
]
},
"ja_ngram_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"ja_search_synonym",
"lowercase"
]
}
}
}
},
"mappings": {
"properties": {
"my_field": {
"type": "text",
"search_analyzer": "ja_kuromoji_search_analyzer",
"analyzer": "ja_kuromoji_index_analyzer",
"fields": {
"ngram": {
"type": "text",
"search_analyzer": "ja_ngram_search_analyzer",
"analyzer": "ja_ngram_index_analyzer"
}
}
}
}
}
}
例 : データ準備
上述「主な対応要件」で説明したように、アメリカ、米国や東京大学、東大などの単語を含めたドキュメントを準備します。
# データ準備
POST _bulk
{"index": {"_index": "my_fulltext_search", "_id": 1}}
{"my_field": "アメリカ"}
{"index": {"_index": "my_fulltext_search", "_id": 2}}
{"my_field": "米国"}
{"index": {"_index": "my_fulltext_search", "_id": 3}}
{"my_field": "アメリカの大学"}
{"index": {"_index": "my_fulltext_search", "_id": 4}}
{"my_field": "東京大学"}
{"index": {"_index": "my_fulltext_search", "_id": 5}}
{"my_field": "帝京大学"}
{"index": {"_index": "my_fulltext_search", "_id": 6}}
{"my_field": "東京で夢の大学生活"}
{"index": {"_index": "my_fulltext_search", "_id": 7}}
{"my_field": "東京大学で夢の生活"}
{"index": {"_index": "my_fulltext_search", "_id": 8}}
{"my_field": "東大で夢の生活"}
{"index": {"_index": "my_fulltext_search", "_id": 9}}
{"my_field": "首都圏の大学 東京"}
例 : 検索クエリの準備と検索結果
A. キーワード「米国」での検索クエリと結果はこちら
「米国」で検索すると、「米国」を含めたドキュメントが表示されます。また、同義語設定されている「アメリカ」を含めたドキュメントも表示されます。
# 検索 query
GET my_fulltext_search/_search
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "米国",
"fields": [
"my_field.ngram^1"
],
"type": "phrase"
}
}
],
"should": [
{
"multi_match": {
"query": "米国",
"fields": [
"my_field^1"
],
"type": "phrase"
}
}
]
}
}
}
# 結果
{
"took" : 37,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : 10.823313,
"hits" : [
{
"_index" : "my_fulltext_search",
"_type" : "_doc",
"_id" : "2",
"_score" : 10.823313,
"_source" : {
"my_field" : "米国"
}
},
{
"_index" : "my_fulltext_search",
"_type" : "_doc",
"_id" : "1",
"_score" : 9.0388565,
"_source" : {
"my_field" : "アメリカ"
}
},
{
"_index" : "my_fulltext_search",
"_type" : "_doc",
"_id" : "3",
"_score" : 7.0624585,
"_source" : {
"my_field" : "アメリカの大学"
}
}
]
}
}
B. キーワード「東京大学」での検索クエリと結果はこちら
「東京大学」で検索すると、「東京大学」を含めたドキュメントが表示されます。また、同義語設定されている「東大」を含めたドキュメントも表示されます。
# 検索 query
GET my_fulltext_search/_search
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "東京大学",
"fields": [
"my_field.ngram^1"
],
"type": "phrase"
}
}
],
"should": [
{
"multi_match": {
"query": "東京大学",
"fields": [
"my_field^1"
],
"type": "phrase"
}
}
]
}
}
}
# 結果
{
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : 8.391829,
"hits" : [
{
"_index" : "my_fulltext_search",
"_type" : "_doc",
"_id" : "4",
"_score" : 8.391829,
"_source" : {
"my_field" : "東京大学"
}
},
{
"_index" : "my_fulltext_search",
"_type" : "_doc",
"_id" : "8",
"_score" : 6.73973,
"_source" : {
"my_field" : "東大で夢の生活"
}
},
{
"_index" : "my_fulltext_search",
"_type" : "_doc",
"_id" : "7",
"_score" : 5.852869,
"_source" : {
"my_field" : "東京大学で夢の生活"
}
}
]
}
}
マッピング構成
下記は私達がマッピングを決める際に考案した主な要素となります。
- マッピング設計では、まずn-gramと形態素解析の2つのフィールドの用意が必要。上述のように、この記事では、
my_field
という名前で形態素解析用のフィールドを用意。また、その下に、multi-fieldでもう一つのn-gram解析用のngram
フィールド(my_field.ngram
)を用意。 - 形態素解析とn-gram解析のそれぞれのアナライザーの設定を行う。詳細は後述に説明。
- 形態素解析用のアナライザーに関して、インデックス時と検索時の処理内容が異なるため、インデックス側用アナライザー(
ja_kuromoji_index_analyzer
)と検索側用アナライザー(ja_kuromoji_search_analyzer
)をそれぞれ定義。 - また、n-gram解析のアナライザーに関しても、インデックス時と検索時の処理内容が異なるため、インデックス側用アナライザー(
ja_ngram_index_analyzer
)と検索側用アナライザー(ja_ngram_search_analyzer
)をそれぞれ定義。
"mappings": {
"properties": {
"my_field": {
"type": "text",
"search_analyzer": "ja_kuromoji_search_analyzer",
"analyzer": "ja_kuromoji_index_analyzer",
"fields": {
"ngram": {
"type": "text",
"search_analyzer": "ja_ngram_search_analyzer",
"analyzer": "ja_ngram_index_analyzer"
}
}
}
}
}
詳細 : 形態素解析処理について
- 形態素解析処理の設計はkuromoji tokenizerを中心に設計。
- その前後に必要な正規化を行う。
- また、検索側に、synonym graph token filterを利用し、同義語辞書を配置
インデックスアナライザー(ja_kuromoji_index_analyzer)の構成
char_filter
でICU normalizer(nfkc)を使用して、検索対象における英数字とカタカナ文字の全角と半角表記の混在を吸収できるようにした。例:全角の「1」は半角の「1」に変換される。- トークナイザーはカスタム定義の
ja_kuromoji_tokenizer
を利用(後述説明) - トークンフィルターは、主にkuromoji_analyzerに含まれるデフォルトのものを利用
"ja_kuromoji_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
}
インデックスアナライザーで使用するトークナイザー(ja_kuromoji_tokenizer)の構成
- 細かく単語を分割するために、
kuromoji tokenizer
のsearch
モードを使用。 discard_compound_token
オプションはバージョン7.9(>=7.9)から使えるようになったためtrue
に設定。本設定の目的は、同義語展開時に生成された元々の複合語(例:東京大学の分割結果は「東京、大学、東京大学」、そのうち「東京大学」は元々の複合語)の存在により発生する同義語展開処理の失敗を回避するためである。また、バージョン7.9より前(< 7.9)の場合、このオプションは対応されていないため、synonym graph token filterの設定にてlenient
をtrue
にし、同義語展開の失敗を無視するといった対応が必要。詳細に関して、discard_compound_tokenとkuromoji_tokenizerのドキュメントに記載があるので、必要に応じてご参考ください。- ユーザー辞書を定義。このサンプル例では、kuromoji_tokenizerのドキュメントに記載された内容を使用し、インラインで定義する。
"ja_kuromoji_tokenizer": {
"mode": "search",
"type": "kuromoji_tokenizer",
"discard_compound_token": true,
"user_dictionary_rules": [
"東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞"
]
}
検索アナライザー(ja_kuromoji_search_analyzer)の構成
- 基本的な考え方は、インデックス側用のアナライザーと同様である。
- 違いは、検索アナライザー側で、synonym graph token filterを利用し、同義語辞書を配置するのみ。
- 日本語の全文検索では、同義語辞書を利用するユースケースはよくあるため、このブログでも紹介する。また、検索側のみ同義語辞書を配置する理由は、別のブログBoosting the power of Elasticsearch with synonymsにて記されているように、(インデックス側に配置しないことにより)インデックスのサイズ大きくならないことや、同義語のメンテナンス時にドキュメントのreindexが必要ないなど、総合的なメリットが大きいといった点があげられる。
"ja_kuromoji_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"ja_search_synonym",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
}
下記はsynonym filterの定義となります。要点としてはこちら:
- マルチワードの処理でも正しく対応できるよう、
synonym_graph
token filterを利用。 - また、同義語の展開でエラーが発生した場合でもインデックスの作成が継続できるよう、(特に上述のように7.9より前のバージョンをご利用の場合、)
lenient
をtrue
に設定。
"ja_search_synonym": {
"type": "synonym_graph",
"lenient": true,
"synonyms": [
"米国, アメリカ",
"東京大学, 東大"
]
}
}
検索アナライザーで使用するトークナイザー(ja_kuromoji_tokenizer)の構成
検索側用アナライザーで使用するトークナイザーは、インデックス側と同じものを利用して通常問題ありません。また、異なるユーザー辞書を利用したいなど、特別な要件がある場合、独自で定義することもできます。
詳細 : n-gramの解析処理について
n-gramの解析処理は、ngram
トークナイザーを中心に設計し、その前後に必要な正規化処理を行います。また、形態素解析処理と同じように、検索側にsynonym graph token filterで同義語辞書を配置します。
インデックスアナライザー(ja_ngram_index_analyzer)の構成
char_filter
でICU normalizer(nfkc)を使用して、検索対象における英数字とカタカナ文字の全角と半角表記の混在を吸収できるようにした。例:全角の「1」は半角の「1」に変換される。- lowercase token filterを使用して、アルファベットを小文字に正規化するようにする。例:「TOKYO」は「tokyo」に変換される。
"ja_ngram_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"lowercase"
]
}
インデックスアナライザーで使用するトークナイザー(ja_ngram_tokenizer)の構成
トークナイザーの設計では、bi-gram(バイグラム)を使用します。すなわち、最小も最大もバイグラムに文字が分割されるように設定します。日本語では、一般的にはバイグラムが使われます。ただし、バイグラムの場合、細かく分割されすぎることがあり、パフォーマンスとして良くない場合もあります。パフォーマンス重視の場合、特定のフィールドに対し、tri-gram(トリグラム)を利用するといった使い分けも考えられます。更に、分割されるトークンからより意味のあるものを取得できるよう、letter
とdigit
のみを対象とします。
"ja_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": [
"letter",
"digit"
]
}
bi-gramのトークナイズ処理例
# request
GET my_fulltext_search/_analyze
{
"tokenizer": "ja_ngram_tokenizer",
"text": ["日本語"]
}
# response
{
"tokens" : [
{
"token" : "日本",
"start_offset" : 0,
"end_offset" : 2,
"type" : "word",
"position" : 0
},
{
"token" : "本語",
"start_offset" : 1,
"end_offset" : 3,
"type" : "word",
"position" : 1
}
]
}
検索アナライザー(ja_ngram_search_analyzer)の構成
基本的な考え方はインデックス側のアナライザーと同様です。違いは、上述の形態素解析と同様、検索アナライザー 側に、graph synonym token filterを利用し、辞書を配置するのみです。
"ja_ngram_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize"
],
"tokenizer": "ja_ngram_tokenizer",
"filter": [
"ja_search_synonym",
"lowercase"
]
}
検索アナライザーで使用するトークナイザー(ja_ngram_tokenizer)の構成
インデックスアナライザーと同じトークナイザーを利用して問題ありません。
日本語の全文検索のクエリ設計
主な考え方は、同時に形態素解析とn-gram解析の両方のフィールドに対して検索を行うことです。n-gram解析のフィールドに対し、bool
クエリでmust
を利用し、結果がヒットすることを保証します。また、形態素解析のフィールドに対し、bool
クエリでshould
を利用し、スコアをブーストし、関連性の高い結果をより早い順で返答されるにします。
このブログのサンプルで、boolクエリのmust
を使用する理由は、n-gramクエリで関連性の低い結果を除外することにより適合率を保たせたいためです。要件次第ですが、must
ではなくshould
を利用し、検索範囲を広げることで再現率を上げるといった考え方もあります。
ブーストのスコア設定ですが、ユースケースや学習などに基づいて調整すべきです。このブログでは、特に調整せず、my_field.ngram^1
, my_field^1
を使用します。
また、1フィールド(形態素解析とn-gram解析の両方)のみを対象に検索する他に、複数のフィールドをまたぐ検索(例えば、タイトルと本文を一度に検索)のユースケースもよくあります。その場合、multi_match
クエリのfields
パラメータに、対象フィールドを追加することができます。更に、異なるフィールドに対し、要件に応じてそれぞれ個別にブーストチューニングも可能です。
なお、語順を考慮し、match phrase
クエリか、もしくはmulti_match
クエリのtype: phrase
での検索を行うほうが良いと考えられます。
下記は検索キーワードが「東京大学」の例を示します。
GET my_fulltext_search/_search
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "東京大学",
"fields": [
"my_field.ngram^1"
],
"type": "phrase"
}
}
],
"should": [
{
"multi_match": {
"query": "東京大学",
"fields": [
"my_field^1"
],
"type": "phrase"
}
}
]
}
}
}
関連度のチューニング
関連度を改善する方法は、フレッシュネスブーストなどの関連性チューニング要素をクエリに組み込んで、検索結果の可視性を高めることがあげられます。関連度は大きくて複雑な(そして楽しい!)トピックであり、このブログでは取り上げませんが、読者の皆様はご自身のユースケースに適したものを判断するために、チューニング方法の調査を進めたい場合は、function scoreのドキュメントを読むか、Elasticsearch: The Definitive Guideの関連性の詳細理論を調べるか、下記の関連性についてのブログを参考することをお勧めします。
- Practical BM25 - Part 1, Part 2, and Part 3
- Easier Relevance Tuning in Elasticsearch 7.0
- Searching for a needle in a haystack
結論
このブログでは、日本語の全文検索の実装が英語よりもはるかに難しい理由を述べました。 次に、日本語の全文検索を実装するためのいくつかの考慮事項について説明しました。 更に、実装の詳細例を紹介し、クエリの設計についての考えをまとめました。
このようなトピックにご興味がある場合は、「Elasticsearchで日本語のサジェストの機能を実装する」ブログを読むこともお勧めします。全文検索でも説明したように、日本語でサジェスト機能を実装するには、英語にはない多くの考慮が必要となります。
ご自分で実装を試したいものの、お手元にElasticsearchクラスターがない場合は、Elastic Cloudの無料トライアルにご登録いただき、analysis-icuとanalysis-kuromojiのプラグインを追加すれば、このブログでご紹介した設定例とデータをご利用いただけます。よろしければ、Elasticのフォーラムにフィードバックをお寄せください。
ぜひ日本語の全文検索の旅をお楽しみください!