この記事はElasticsearch2.4系の設定で解説しています
こんにちは。最近Fablicで検索まわりのチューニングを行っている岸です。
最近このポストを見て触発され、滝沢カレンさんの迷文を題材にしてみました。
FRILでは商品の全文検索エンジンにElasticsearchを使っています。 6 Betaも出ている昨今、当社ではまだ2系を使っているのですが、 Analyzerについての基本的な概念は変わりないと思うので今回はその入門編をお届けします。
Analyzerについて、一枚の絵でまとめると、こういう感じです。
では、このAnalyzerの仕組みを今話題の滝沢カレンさんの迷文を題材に見ていきましょう。
端正な容姿なのに機械翻訳のような意味が通るか通らないかギリギリの長文、それに対照的な絵文字…
不思議と中毒性があるもので「もう一度あのポストを見たい!」とついつい検索したくなりますよね。
例えばこんなフレーズがあります。
本日は現実感を出したいのであえての「今」なのでゴタゴタ叫ばないでくださいね
意味が分かるかどうかはこの際置いておいて、この文章にもう一度出会いたい!と思って検索するときどういうワードを使うでしょうか。
「ゴチャゴチャ」?「リアリティ」?「現実感」?「叫ぶ」?
ユーザーはふと正確なワーディングを忘れてしまうこともあると思います。 それでもちゃんとヒットさせるように、Analyzerをチューニングしていきましょう。
Elasticsearchにdocument登録する
では、まずはゴタゴタ叫ばずに以下のコマンドで滝沢カレンさんの迷言用indexを作ってみましょう。
${ES_HOME}/config/
に synonyms.txt, user_dictionary.txt が必要なのですが、
以下のようにファイルを配置しておいて下さい。
- user_dictionary.txt
現実感,現実感,げんじつかん,カスタム名詞
- synonyms.txt
現実感,リアリティ ゴタゴタ,ゴチャゴチャ,ガタガタ
以下2つのpluginが必要になりますので、予めインストールしておいてくださいね。
さて、ではローカルマシンのElasticsearchにindexを作成してみましょう。
$ curl -XPUT localhost:9200/instagram -d '{ "mappings": { "post": { "properties": { "message": { "type": "string", "analyzer": "ja_analyzer" } } } }, "settings" : { "index": { "analysis": { "analyzer": { "ja_analyzer": { "type": "custom", "char_filter": [ "icu_normalizer" ], "tokenizer": "ja_tokenizer", "filter": [ "cjk_width", "part_of_speech", "kuromoji_baseform", "synonym" ] } }, "filter":{ "part_of_speech":{ "type": "kuromoji_part_of_speech", "stoptags": [ "副詞-助詞類接続" ] }, "synonym":{ "type": "synonym", "synonyms_path": "synonyms.txt" } }, "tokenizer": { "ja_tokenizer":{ "type": "kuromoji_tokenizer", "mode": "search", "user_dictionary": "user_dictionary.txt" } } } } } }'
これは message
というプロパティを1つだけ持っているindexで、今回 ja_analyzer
と名付けたAnalyzerで解析するように設定してあります。
このAnalyzerが何をしてくれるのかは、追って説明していきます。
では、次に先程挙げた文章をPOSTしましょう。
$ curl -XPOST localhost:9200/instagram/post -d '{ "message": "本日は現実感を出したいのであえての「今」なのでゴタゴタ叫ばないでくださいね" }'
条件無しで検索してみましょう。
$ curl 'http://localhost:9200/instagram/_search'
ちゃんと登録されていることが確認できました。Elasticsearchのドクドク感(カレン語)を感じますね。
{ : "hits" : { "total" : 1, "max_score" : 1.0, "hits" : [ { : "_source" : { "message" : "本日は現実感を出したいのであえての「今」なのでゴタゴタ叫ばないでくださいね" } } ] } }
検索してみよう!
user_dictionary.txt, synonyms.txtを作った時点である程度お通じ(カレン語)かもしれませんが、色んなワードでヒットするようになっています!
まず、「現実感」は文中にある言葉だからヒットしますよね。
$ curl -XPOST localhost:9200/instagram/_search -d '{ "query": { "match": { "message": "現実感" } } }'
「リアリティ」はsynonym(類義語)というファイル名に「現実感」と並べたので、予想通りヒットしちゃいます。
$ curl -XPOST localhost:9200/instagram/_search -d '{ "query": { "match": { "message": "リアリティ" } } }'
「叫ぶ」はどうでしょう。おお、本文は「叫ばない」なのにヒットするんですねー。
$ curl -XPOST localhost:9200/instagram/_search -d '{ "query": { "match": { "message": "叫ぶ" } } }'
「ゴタゴタ」はいちばん目立つ単語なのでヒットするはず。
$ curl -XPOST localhost:9200/instagram/_search -d '{ "query": { "match": { "message": "ゴタゴタ" } } }'
・・・あれ、ヒットしない?
じゃあ、synonym.txtに並べた他の単語は?
$ curl -XPOST localhost:9200/instagram/_search -d '{ "query": { "match": { "message": "ガタガタ" } } }'
$ curl -XPOST localhost:9200/instagram/_search -d '{ "query": { "match": { "message": "ゴチャゴチャ" } } }'
ヒットしませんねぇ。もちろん半角にしてもダメです。 (伏線)
なんででしょう。
ja_analyzerを試す
今回indexに定義したAnalyzerを通して、文章がどのように処理されるのか以下のリクエストで試すことができます。
$ curl localhost:9200/instagram/_analyze?analyzer=ja_analyzer -d \ '本日は現実感を出したいのであえての「今」なのでゴタゴタ叫ばないでくださいね'
- フィルタを通さずに形態素に分けた原文(Kuromojiで分解した場合)
本日 / は / 現 / 実感 / を / 出し / たい / ので / あえて / の / 「 / 今 / 」 / な / ので / ゴタゴタ / 叫ば / ない / で / ください / ね /
- Analyzerを通したToken
本日 / は / 現実感 / リアリティ / を / 出す / たい / ので / あえて / の / 今 / だ / ので / 叫ぶ / ない / で / くださる / ね /
Analyzerを通すと「リアリティ」が入っていたり「ゴタゴタ」が抜けていたり、「叫ば」が「叫ぶ」に変わっていたりします。
先程の検索結果に非常に関係がありそうな解析結果です。 では、この変化の原因を突き止めていきましょう。
ja_analyzerの構成
先程のindex作成時に指定した設定を詳しく見てみましょう。
本来JSONですが、コンパクトにするためYAML形式で説明します。
先程見たように、ja_analyzerはカレンさんの迷文が格納された message プロパティのAnalyzerとして指定されています。
:mappings: :post: :properties: :message: :type: string :analyzer: ja_analyzer # ★1を指す
そのja_analyzerは settings.index.analysis
の下に独自に定義されているものです。
:settings: :index: :analysis: :ja_analyzer: # ★1 :type: custom :char_filter: - icu_normalizer :tokenizer: ja_tokenizer # ★2を指す :filter: - cjk_width - part_of_speech # ★3を指す - kuromoji_baseform - synonym # ★4を指す
一番最初の図の通りAnalyzerはCharFilter(char_filter), Tokenizer(tokenizer), TokenFilter(filter) を持ちますが、 その構成は以上のようになっています。
なお、★の付いていないものについては、Elasticsearchに組み込まれていたりpluginとしてインストールできるCharFilter, Tokenizer, TokenFilterがあり、ja_analyzerはそれらをうまく活用しています。
- icu_normalizer : https://medium.com/hello-elasticsearch/elasticsearch-c98fd9ce6a18 に詳しい
- cjk_width : 半角⇔全角変換。例)カナ→カナ、ABC→ABC
- kuromoji_baseform : 活用形を終止形に戻す。例)飲み→飲む
その他、Tokenizerには日本語を句(Token)に区切るために ja_tokenizer (★2) を定義して使い、 Tokenを加工するのにTokenFilterとして part_of_speech (★3) や synonym (★4) を定義して使っています。
ja_tokenizer (Tokenizer)
これはkuromoji_tokenizer をtypeの値に取り パラメータでカスタマイズしたTokenizerです。
Tokenizeとは、一定の法則に従って細切れにされた文字列です。 kuromojiという形態素解析器を使っているので、このTokenizerは 形態素 と呼ばれる、日本語文法上で意味を成す最小単位まで区切ります。
:tokenizer: :ja_tokenizer: # ★2 :type: kuromoji_tokenizer :mode: search :user_dictionary: user_dictionary.txt
ここでのカスタマイズ内容は以下です。
- modeはnormalではなく、searchモードを使っている。
- user_dictionary.txtをユーザー辞書として使っている。
ユーザー辞書を使うと、普通なら細切れにされてしまう語句を、ひとつづきのまま維持させることができます。1
今回の例では、本来なら「現実」と「感」は形態素に分割されてしまうのですが、あとで「リアリティ」と同義語にするためにひとつづきにしています。
現実感,現実感,げんじつかん,カスタム名詞
part_of_speech (TokenFilter)
kuromoji_part_of_speechをtypeの値に取りパラメータでカスタマイズしたTokenFilterです。
形態素の種類によってフィルタリングします。
:filter: :part_of_speech: # ★3 :type: kuromoji_part_of_speech :stoptags: - 副詞-助詞類接続
stoptags
に除外されるべき品詞名を配列で渡すことで、検索に必要な品詞の形態素のみに限定することができます。2
今回の例では「ゴタゴタ」がstoptagに設定してある「副詞-助詞類接続」だったため、「ゴタゴタ」ではヒットしませんでした。
一度stoptagsの設定を外して、indexとdocumentを作り直すことで「ゴタゴタ」でヒットするようになります。 逆に、「の」や「が」のような検索には不必要そうな品詞があれば積極的にstoptagsに追加して、indexサイズを減らすこともできます。
なお、一度作ったindexの削除は以下のようにします。
$ curl -XDELETE localhost:9200/instagram
synonym
synonymをtypeの値に取り、パラメータでカスタマイズしたTokenFilterです。
synonymとは、同義語という意味です。
:synonym: # ★4 :type: synonym :synonyms_path: synonyms.txt
synonyms_path
で指定された設定ファイルに基づき、類義語が存在すればその単語と意味が同様であるとみなします 3
ちょっと例が無理矢理ですが、今回は「現実感」と「リアリティ」を同義であるとみなしています。
現実感,リアリティ ゴタゴタ,ゴチャゴチャ,ガタガタ
その他プラグインの働き
ここまでで「現実感」「リアリティ」「叫ぶ」「ゴタゴタ」「ゴチャゴチャ」「ガタガタ」全てでヒットするようになりました。
ちなみに「叫ば」という句が「叫ぶ」でも検索できるのはTokenFilterの kuromoji_baseform(活用形を終止形に戻す)のおかげで、 カタカナを半角にしても検索できるのはicu_normalizerのおかげです。
まとめ
CharFilter, Tokenizer, TokenFilterには様々な種類があり、全文検索する言語や 対象の特性に応じて多くのオプションが与えられています。 それらを組み合わせた一つのカスタムAnalyzerの例として、ja_analyzer を紹介しました。
これは特定のフィールドに対する検索キーワードを十分に正規化して(半角・全角・記号・大文字・小文字などの表記ゆれ)、 形態素と呼ばれる品詞に分解して構造化することで検索を効率化するのに使われています。
課題に応じて、どのようなプラグインが利用できるか知っておくことで、 検索結果をチューニングすることができるはずです。
おまけ - 手元で動くkuromoji環境を10分で作成する
日本語学を修めた人でないと、厳密な品詞を判別するのは難しいことだと思います。
- 手軽に形態素解析を試したい!
- mecabじゃなくてElasticsearchで実際に使っているKuromojiを使いたい!
- ユーザー辞書を使ってみたい!
以上を簡単に実践できる方法をお伝えします!
まず、Groovyをインストールします。MacならHomebrewでどうぞ。
$ brew install groovy
次に、以下のGroovyスクリプトを実行権限を付けて保存します(kuromoji-tokenizeというファイル名とする)
#!/usr/bin/env groovy @Grab('com.atilika.kuromoji:kuromoji-ipadic:0.9.0') import com.atilika.kuromoji.ipadic.Token import com.atilika.kuromoji.ipadic.Tokenizer import java.nio.charset.StandardCharsets // ユーザー辞書を使いたい場合は、Tokenizerに辞書を登録する // def dictionary = new File('ユーザー辞書へのパス').newInputStream() // def tokenizer = new Tokenizer.Builder().userDictionary(dictionary).build() def tokenizer = new Tokenizer() tokenizer.tokenize(System.in.text).each { println "${it.surface}\t${it.allFeatures}" }
解析したい文章を標準入力として渡して実行すると、無事品詞情報付きで形態素に分解されました。
$ echo '本日は現実感を出したいのであえての「今」なのでゴタゴタ叫ばないでくださいね' \ kuromoji-tokenize 本日 名詞,副詞可能,*,*,*,*,本日,ホンジツ,ホンジツ は 助詞,係助詞,*,*,*,*,は,ハ,ワ 現 接頭詞,名詞接続,*,*,*,*,現,ゲン,ゲン 実感 カスタム名詞,*,*,*,*,*,*,ジッカン,* を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ 出し 動詞,自立,*,*,五段・サ行,連用形,出す,ダシ,ダシ たい 助動詞,*,*,*,特殊・タイ,基本形,たい,タイ,タイ ので 助詞,接続助詞,*,*,*,*,ので,ノデ,ノデ あえて 副詞,一般,*,*,*,*,あえて,アエテ,アエテ の 助詞,連体化,*,*,*,*,の,ノ,ノ 「 記号,括弧開,*,*,*,*,「,「,「 今 名詞,副詞可能,*,*,*,*,今,イマ,イマ 」 記号,括弧閉,*,*,*,*,」,」,」 な 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ ので 助詞,接続助詞,*,*,*,*,ので,ノデ,ノデ ゴタゴタ 副詞,助詞類接続,*,*,*,*,ゴタゴタ,ゴタゴタ,ゴタゴタ 叫ば 動詞,自立,*,*,五段・バ行,未然形,叫ぶ,サケバ,サケバ ない 助動詞,*,*,*,特殊・ナイ,連用デ接続,ない,ナイ,ナイ で 助詞,接続助詞,*,*,*,*,で,デ,デ ください 動詞,非自立,*,*,五段・ラ行特殊,命令i,くださる,クダサイ,クダサイ ね 助詞,終助詞,*,*,*,*,ね,ネ,ネ
KuromojiがJava製なのでGroovyから呼び出せるのと、
Groovyではソース中に書いた @Grape
がMvenリポジトリから依存性を解決してくれるという特性が
今回のケースでは有効活用できました。
-
ユーザー辞書をはじめとする設定ファイルは
${ES_HOME}/config
に配置します。 setup-dir-layout↩ -
すべての品詞一覧は、 lucene-kuromojiリポジトリのstoptags.txtで管理されているとおり↩
-
詳しい仕様は設定ファイルの書き方 synonym-formats / analysis-synonym-tokenfilter 参照 ↩