Elasticsearchを利用して、検索しているときに、想定していない単語にヒットすることがありませんか?
望んだ形で検索できる仕組みを作るためには、転置インデックスと入力された文章を解析する仕組みを理解するのが一番の近道です。
転置インデックスとは?
全文検索のインデックスとして用いられるデータ形式の1つです。
書籍の後ろにある「索引」(Index)を想像していただくのが一番わかりやすいのですが、
調べたい「単語」を見つけると、その単語が出てくる「ページ番号」がわかります。
これが書籍の索引です。書籍の場合は、著者や編集者が索引に載せるべき単語を抽出します。
転置インデックスの場合は、この索引に当たる構造を全ての「単語」に対して作成します。
また、「ページ番号」の代わりに「ドキュメントID」を用いる形になります
(実際にはそれ以外の情報もインデックスには入っていますが、ここでは省略します)。
転置インデックスの例
単語 | ドキュメントID |
---|---|
寿司 | 1 |
ラーメン | 2,3 |
美味しい | 1,2,3 |
では、検索エンジンにとっての「単語」とはどういうものでしょう?
テキスト解析とは?
そこで実際に使用されるのがText Analysisという処理です。
文章から、単語の列(TokenStream)を出力します。
この処理をするのがAnalyzerです。
"ラーメンが美味しい" -> Analyzer -> ["ラーメン", "が", "美味しい"]
Lucene/Elasticsearchでは、このAnalyzerの出力から、転置インデックスの単語を作り出します。
私たちがイメージするのは次のような転置インデックスだと思います。
単語 | ドキュメントID |
---|---|
ラーメン | 1 |
が | 1 |
美味しい | 1 |
このAnalyzerによる処理の出力を確認することができる便利なAPIがAnalyze APIです。
実際にデフォルトのAnalyzerを動かしてみる
先ほどの"ラーメンが美味しい"という文書をデフォルトのAnalyzerであるstandard
analyzerがどのように単語の列を出力するか、_analyze APIで見てみましょう。
GET _analyze
{
"text": "ラーメンが美味しい",
"analyzer": "standard"
}
するとレスポンスは次のようになります。
{
"tokens": [
{
"token": "ラーメン",
"start_offset": 0,
"end_offset": 4,
"type": "<KATAKANA>",
"position": 0
},
{
"token": "が",
"start_offset": 4,
"end_offset": 5,
"type": "<HIRAGANA>",
"position": 1
},
{
"token": "美",
"start_offset": 5,
"end_offset": 6,
"type": "<IDEOGRAPHIC>",
"position": 2
},
{
"token": "味",
"start_offset": 6,
"end_offset": 7,
"type": "<IDEOGRAPHIC>",
"position": 3
},
{
"token": "し",
"start_offset": 7,
"end_offset": 8,
"type": "<HIRAGANA>",
"position": 4
},
{
"token": "い",
"start_offset": 8,
"end_offset": 9,
"type": "<HIRAGANA>",
"position": 5
}
]
}
想像していたものと違いませんか?
standard
analyzerは「カタカナ」が続いた場合は、1つの単語に、それ以外の「漢字」や「ひらがな」については1文字ずつを単語として出力します。
日本語の文章を登録して、特にAnalyzerを気にしない場合に検索結果が思った通りにいかない原因がここにあります。
日本語の形態素解析ライブラリであるKuromojiというAnalyzerを利用してみるとどうなるでしょう?
(analysis-kuromoji pluginのインストールが終わっていることが前提条件)
GET _analyze
{
"text": "ラーメンが美味しい",
"analyzer": "kiuromojki"
}
結果は次のようになります。
{
"tokens": [
{
"token": "ラーメン",
"start_offset": 0,
"end_offset": 4,
"type": "word",
"position": 0
},
{
"token": "美味しい",
"start_offset": 5,
"end_offset": 9,
"type": "word",
"position": 2
}
]
}
想定に近いものになったのではないでしょうか?
Kuromojiは内包している辞書を元に単語の区切りを計算し、もっともそれっぽく切れる単語の列を出力してくれます。ですので、このような動作になります。
ただし、想定と違う部分は「が」がないことです。
独自のAnalyzerを定義してテストしてみる
Analyzerは実際にはchar_filter/tokenizer/filterの組み合わせで構成されています。
先ほどのkuromoji
analyzerは実際にはkuromoji_tokenizer
というtokenizerとja_stop
というfilterの組み合わせで構成されています。
実際にこの組み合わせでどのようなことが起きているかを_analyze APIでみてみましょう。
analyze APIはexplain
というパラメータを利用することで、tokenizer/filterがそれぞれどのような単語の列を出力するのかを個別に取得できるようになるパラメータです。
リクエストは次のようになります(簡略化のため、attributes
というパラメータで出力結果をtoken
と必須項目だけに限定しています)。
GET _analyze
{
"text": "ラーメンが美味しい",
"tokenizer": "kuromoji_tokenizer",
"filter": ["ja_stop"],
"explain": true,
"attributes": ["token"]
}
レスポンスは次のようになります。
{
"detail": {
"custom_analyzer": true,
"charfilters": [],
"tokenizer": {
"name": "kuromoji_tokenizer",
"tokens": [
{
"token": "ラーメン",
"start_offset": 0,
"end_offset": 4,
"type": "word",
"position": 0
},
{
"token": "が",
"start_offset": 4,
"end_offset": 5,
"type": "word",
"position": 1
},
{
"token": "美味しい",
"start_offset": 5,
"end_offset": 9,
"type": "word",
"position": 2
}
]
},
"tokenfilters": [
{
"name": "ja_stop",
"tokens": [
{
"token": "ラーメン",
"start_offset": 0,
"end_offset": 4,
"type": "word",
"position": 0
},
{
"token": "美味しい",
"start_offset": 5,
"end_offset": 9,
"type": "word",
"position": 2
}
]
}
]
}
}
tokenizer
という項目の中で、kuromoji_tokenizer
によるtokensをみることができます。
この段階では、「が」がまだ残っている状態です。
tokenfilters
という項目のなかで、ja_stop
によるtokensをみることができます。
この段階で「が」が無くなっていますね。ja_stop
filterにより「が」が消されたことがこれでわかりました(実際には「てにをは」などがstopwordとして設定されています)。
検索としてElasticsearchを利用する場合、フィールド毎にこのAnalyzerを自分で設定することが増えて来ます。その時に、explain
パラメータを利用することで、個々のchar_filter/tokenizer/filterがどういった挙動なのかを確認することができます。
_analyze APIを利用することで、実際の単語がどのようになっているのか、どの単語がどのタイミングで消えるのか?という確認をしながら、より良い検索ができるようにAnalyzerを設定していくことができるのです。
想定している検索結果が出てこないといった場合には、ぜひこの_analyze APIを利用して出力される単語が想定しているものかどうかを確認してみてください。