1. はじめに
この記事は、ある検索系の問題設定において、できるだけElasticsearchの機能に閉じて*1手軽にやれるかどうか、という、よくある100本ノック風のチャレンジ(1本だけですが)のメモです。
図らずも、Elasticsearchのアナライズの解説っぽいところにもなったので記事にしてみました。
問題設定
それなりの数のドキュメント群があります。 ここで、各ドキュメントの題名(漢字中心のもの)を(漢字を含めた文字セットの文字コードではなくて)読み仮名順に並べてみる...とします。 ただし、ここで、題名の読み仮名情報は持っていないとします。
対応方法キーワード
上記の問題にあたっての、おおよその登場人物/キーワードです。
- kuromojiプラグイン(手元のElasticsearchインストール済みの環境であれば、「elasticsearch-plugin install analysis-kuromoji」でインストールできました。 )
- kuromojiの「readingformフィルター」
- ICU Analysis プラグイン(「elasticsearch-plugin install analysis-icu」でインストールできます。)のHiragana-Katakana
- sort
- painless script
- predicate_token_filter
- インデックスのmapping/settingでの、「"fielddata" : true」
なお、私が最初にこの「問題設定」を考えた時ですが、上記のキーワードのいくつかを漠然とと思い浮かべました。
そして、私でも知っているような比較的単純な設定の組み合わせで簡単にできると思っていました。
しかし、いざ念の為に試してみるかと設定しはじめたところ、それぞれの機能の役割分担からいうと思い通りにいかず、難航しました。
最終的には製品の標準の比較的シンプルな設定の組み合わせで実現できたのですが、試行錯誤した分をなんらか形にしたくてこの記事に残しています。
確認したElasitcsearchのバージョンは6.8です。
2. 考え方
アイディア
kuromojiは日本語の辞書を持っており、Elasticsearchのアナライズで言えば、readingformフィルターを使えば、ヨミガナも分かる。 つまり、(あらかじめそのようなデータを作り込まなくても)readingformフィルターしたフィールドを検索クエリのsort条件で使えばいいじゃん(!?)。
なお、辞書に無い単語、辞書にあるものの前後の単語との関係によっては、正しく読みがつかない場合があるが、そこは割り切りとしましょう。
前提知識などの参考情報
上記のアイディアに関連する、公式の各リファレンスのページへのリンクをまとめて貼り付けておきます。
3. 実例
以下、アナライズの設定、試験用データのインポート、実際の検索の例です。
3-1. setting/mapping
こんな感じです。少し冗長な設定も入っています。
PUT an { "settings": { "analysis": { "tokenizer": { "my_kuro_tk": { "type": "kuromoji_tokenizer", "mode": "search" } }, "analyzer": { "my_ja-default_anlz": { "type": "custom", "tokenizer": "my_kuro_tk", "char_filter": [ "icu_normalizer", "kuromoji_iteration_mark", "html_strip" ], "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "uppercase", "kuromoji_number", "kuromoji_stemmer" ] }, "initial": { "type": "custom", "tokenizer": "my_kuro_tk", "char_filter": [ "icu_normalizer", "html_strip" ], "filter": [ "kuromoji_readingform", "uppercase", "hiragana_2_katakana", "get_initial_filter" ] }, "initial_shippai1": { "type": "custom", "tokenizer": "my_kuro_tk", "char_filter": [ "icu_normalizer", "html_strip" ], "filter": [ "kuromoji_readingform", "uppercase", "hiragana_2_katakana" ] } }, "filter": { "hiragana_2_katakana": { "type": "icu_transform", "id": "Hiragana-Katakana" }, "e_ngram_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 10 }, "ngram_filter": { "type": "ngram", "min_gram": 2, "max_gram": 3, "token_chars": [ "letter", "digit", "symbol" ] }, "get_initial_filter": { "type": "predicate_token_filter", "script": { "source": "token.getStartOffset() === 0" } } } } }, "mappings": { "_doc": { "properties": { "location": { "type": "geo_point" } }, "dynamic_templates": [ { "my_hybrid_style_for_string": { "match_mapping_type": "string", "mapping": { "analyzer": "my_ja-default_anlz", "fielddata": true, "store": true, "fields": { "ini": { "type": "text", "analyzer": "initial", "fielddata": true }, "ini_NG": { "type": "text", "analyzer": "initial_shippai1", "fielddata": true }, "raw": { "type": "keyword" } } } } } ] } } } 注!!! initial_shippai1 ini_NG は付録で述べている、うまく行かなかった場合のコンフィグなので、このプロパティの設定は不要です。
解説
fielddataはデフォルトではfalseですが、明示的にtrueにしています。fielddataは様々な意味あいがありますが、今回の問題においては、これをtrueとすることにより、sort条件にこのフィールドを指定できるようになります。
get_initial_filterを「predicate_token_filter」を利用して定義しています。 これは、sourceで指定した条件式に該当するようなトークンのみ、「フィルター」後に残すというフィルターです。 少し似た使い勝手のものに、「condition」というフィルターもあり、これは、sourceで指定した条件式に該当するトークンについて、指定のfilterを適用するというものです。
source部分の、「token.getStartOffset() === 0」は 形態素解析や分かち書きなどのトーカナイザーの処理の結果の「1つめのトークン」に該当する場合、trueと判定(それ以外はfalse)するという意味になります。
token.getStartOffset() ↓ www.elastic.co ※1つめのトークンという意味だと、getPosition()メソッドの方がより厳密かもしれません。
この結果、例えば、「購買部」という単語であれば、次のようにアナライズされます。
POST an/_analyze { "analyzer": "my_ja-default_anlz", "text": "購買部" } ↓ アナライズ { "tokens" : [ { "token" : "購買", "start_offset" : 0, "end_offset" : 2, "type" : "word", "position" : 0 }, { "token" : "部", "start_offset" : 2, "end_offset" : 3, "type" : "word", "position" : 1 } ] } 一方 POST an/_analyze { "analyzer": "initial", "text": "購買部" } ↓ アナライズ { "tokens" : [ { "token" : "コウバイ", "start_offset" : 0, "end_offset" : 2, "type" : "word", "position" : 0 } ] }
分かち書きやカナへの読み替えはともかく、1つめのトークンのみ残すというのは、ここではなぜわざわざという感想もありますが、「1つめのトークン」部分のみ残すところがポイントです。ここは、付録でもう少し補足します。
さて、このsetting/mappingの設定では、「initial」アナライザーは、get_initial_filterを使い、インプットのテキストの最初の単語のカナ読みを転置インデックスに格納することになります。
その上で、元のフィールド名に拡張子「.ini」をつけた名前でアクセスできるという設定をしています。
こにより、検索クエリでもともとのフィールド名が「title」で「購買部」あれば、「title.ini」で「コウバイ」というワードが取得できることになります。
3-2 確認
確認用データのインポート
検索対象のデータです。 少々雑ですが、kibanaに貼り付け用の形式で示します。
POST an/_doc/ { "A":"耳鼻科"} POST an/_doc/ { "A":"購買部"} POST an/_doc/ { "A":"三澤屋"} POST an/_doc/ { "A":"部屋"} POST an/_doc/ { "A":"眼科"} POST an/_doc/ { "A":"内科"} POST an/_doc/ { "A":"心療内科"} POST an/_doc/ { "A":"技術部"} POST an/_doc/ { "A":"総務部"} POST an/_doc/ { "A":"営業部"} POST an/_doc/ { "A":"経営企画部"} POST an/_doc/ { "A":"研究開発部"} POST an/_doc/ { "A":"居酒屋"} POST an/_doc/ { "A":"酒屋"} POST an/_doc/ { "A":"居酒や"}
検索クエリ
本題の、読み順に並べるクエリです。
この記事では、読み順に並べることが目的ですので、検索条件はmatch_allとしています。
アナライズの結果として生成した、「A.ini」をソート条件に指定しています。
POST an/_search { "query": {"match_all": {}}, "size":100, "sort": [ { "A.ini": { "order": "asc" } } ] }
結果
前項の検索クエリの結果です。
{ "took" : 2, "timed_out" : false, "_shards" : { "total" : 5, "successful" : 5, "skipped" : 0, "failed" : 0 }, "hits" : { "total" : 15, "max_score" : null, "hits" : [ { "_index" : "an", "_type" : "_doc", "_id" : "Zbchvm8BDQCF7Saowypw", "_score" : null, "_source" : { "A" : "居酒屋" }, "sort" : [ "イザカヤ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Yrchvm8BDQCF7SaowypQ", "_score" : null, "_source" : { "A" : "営業部" }, "sort" : [ "エイギョウ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Xbchvm8BDQCF7Saowir8", "_score" : null, "_source" : { "A" : "眼科" }, "sort" : [ "ガンカ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Z7chvm8BDQCF7SaowyqF", "_score" : null, "_source" : { "A" : "居酒や" }, "sort" : [ "キョ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "YLchvm8BDQCF7Saowyo1", "_score" : null, "_source" : { "A" : "技術部" }, "sort" : [ "ギジュツ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Y7chvm8BDQCF7Saowypb", "_score" : null, "_source" : { "A" : "経営企画部" }, "sort" : [ "ケイエイ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "ZLchvm8BDQCF7Saowypm", "_score" : null, "_source" : { "A" : "研究開発部" }, "sort" : [ "ケンキュウ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Wrchvm8BDQCF7SaowirH", "_score" : null, "_source" : { "A" : "購買部" }, "sort" : [ "コウバイ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Zrchvm8BDQCF7Saowyp6", "_score" : null, "_source" : { "A" : "酒屋" }, "sort" : [ "サカヤ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "X7chvm8BDQCF7Saowyoi", "_score" : null, "_source" : { "A" : "心療内科" }, "sort" : [ "シンリョウ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Wbchvm8BDQCF7Saowiqx", "_score" : null, "_source" : { "A" : "耳鼻科" }, "sort" : [ "ジビ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Ybchvm8BDQCF7SaowypF", "_score" : null, "_source" : { "A" : "総務部" }, "sort" : [ "ソウム" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "Xrchvm8BDQCF7SaowyoV", "_score" : null, "_source" : { "A" : "内科" }, "sort" : [ "ナイカ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "XLchvm8BDQCF7Saowirq", "_score" : null, "_source" : { "A" : "部屋" }, "sort" : [ "ヘヤ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "W7chvm8BDQCF7Saowirb", "_score" : null, "_source" : { "A" : "三澤屋" }, "sort" : [ "ミサワ" ] } ] } }
「居酒や」が「キョ」になっていることはご愛嬌*2ですが、「アイウエオ」順にうまく並んでいるようです。
4.まとめ
漢字タイトルデータをElasticsearchの設定だけで並べてみる...という頭の体操の例を述べてみました。
これが本番に適用できるかという点では他にも考えるべきことがあるでしょうが、逆に、このような実現レベルでも良いので、手間をかけずにある程度並べられるとうれしいという場合には、ありかなと思いました。
◆付録
ボツ案集
実は、最初のころは、次のような方法でより簡単に実現できるのではと考えて試行錯誤したのでしたが、以下の方法では(私の考えの及ぶ範囲では)無理でした。
(1) sort句で次のようなpainless scriptを利用。→ 1単語目の読みを取得できるのでは?
ダメでした。 (painless scriptによる「_script」のソートは、「function_score*3」などの加点型のスコアリングと同じシンタックスで表記できて、これ自体は動作してそうということは確認できるのですが、docで取得できる値がそうそう都合の良いものではなさそうです。
例えば、
POST an/_search { "query": {"match_all": {}}, "size":100, "sort": [ { "A.ini_NG": { "order": "asc" } }, { "_script":{ "type":"string", "script":{ "lang":"painless", "source":"doc['A.ini'][0]" } } } ] }
ですが、
次のようになり、思うように並びません。というのも、この例でいうと「sort」の中で戻ってくる値が、その単語の最初のトークンの読みではないためです。
例えば、耳鼻科は、「ジビ」「カ」で、doc['A']でもdoc['A'][0]でも良いので、「ジビ」が戻ってくることを期待するものの、「カ」が戻ってくるという結果になりました。
{ "_index" : "an", "_type" : "_doc", "_id" : "SrcVvm8BDQCF7SaoJCq6", "_score" : null, "_source" : { "A" : "耳鼻科" }, "sort" : [ "カ", "カ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "VbcVvm8BDQCF7SaoJSpe", "_score" : null, "_source" : { "A" : "研究開発部" }, "sort" : [ "カイハツ", "カイハツ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "TrcVvm8BDQCF7SaoJCr8", "_score" : null, "_source" : { "A" : "眼科" }, "sort" : [ "ガンカ", "ガンカ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "SLcSvm8BDQCF7Sao8yoR", "_score" : null, "_source" : { "A" : "眼科" }, "sort" : [ "ガンカ", "ガンカ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "VLcVvm8BDQCF7SaoJSpT", "_score" : null, "_source" : { "A" : "経営企画部" }, "sort" : [ "キカク", "キカク" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "UbcVvm8BDQCF7SaoJSoz", "_score" : null, "_source" : { "A" : "技術部" }, "sort" : [ "ギジュツ", "ギジュツ" ] }, { "_index" : "an", "_type" : "_doc", "_id" : "RbcSvm8BDQCF7Sao8irm", "_score" : null, "_source" : { "A" : "購買部" }, "sort" : [ "コウバイ", "コウバイ" ] }
つまり、転置インデックスのサガか、 doc[分かち書きされたフィールド][0] は、必ずしも1トークン目を戻すわけではなさそう。
(2) トークン化して読みをあてたあと、それを結合するしかけはないのか? → 転置インデックスを作るためにアナライズしているので、目的からするとそのような仕組みはなさそう/あっても目的外使用になりそうなので、深い追いはしないでおこう。
(3) そもそも、トークンの振る舞いがややこしいので、kuromojiをトーカナイザーに使わず、readingformフィルターだけ適用する。→ 当たり前かもだが、kuromoji_readingformはkuromojiのトーカナイザー前提なのでこの案はボツ。