Elasticsearch多言語化その2
本投稿は、Elastic stack (Elasticsearch) Advent Calendar 2016 の2日目の記事かつ、以前書いた以下の投稿の続編。
背景等
以前書いた内容と重なる部分もあるが、背景等について説明しておく。
Elasticsearch を、各種開発者向けサービスの横串検索用に使用
GitHub, Slack, Google Drive 等のデータを API 経由で取ってきて、Elasticsearch に入れて、それを横串・一括検索出来るようなツールを作っている。元々は内部向けのツールだったが、ぼちぼち体裁等が整って来たので、現在β版的な感じでひっそり公開中。(今年中にはちゃんと公開したい。)
詳細はこちら → GitHub も、Slack も、まとめて検索 | Commet
検索対象の特性は、メイン言語+英語 or 英語のみ
開発チームまたはプロジェクトが1つの大きな単位で、そのチーム・プロジェクトで使っている GitHub のレポジトリ、Slack のチャンネル、Google Drive のフォルダを指定すると、その配下のデータが Commet 上に取り込まれる。
私の(そして、想定しているターゲット)のユースケースとしては、オフショアを利用していたり、メンバーが多国籍だったりする場合もあるが、使われている言語は、基本的には以下のどちらか。
- メインとして使われている言語(日本語等)+英語
- 英語オンリー
環境
- Elasticsearch 2.3.5
- elastic4s というライブラリ経由で Scala から使用
Elasticsearch で多言語を格納する方法
詳細は前回の投稿を見て欲しいが、ここではざっくりまとめたい。
1文章1言語 or 1文章複数言語で、処理方法は異なってくる
Wikipedia の用に、1つの文章(※)で1つの言語しか使用していない場合と、1つの文章に複数の言語が混じるかで、異なってくる。
1つの文章に複数言語が混じる場合は、以下の2通りの処理の仕方がある。
- 言語毎にフィールドを分割する
- 混在した文章を1つのフィールドで扱う
まとめると以下のパターンがある。
- 1文章1言語
- 1文章複数言語
- 複数フィールドに分割
- 1つのフィールドとして扱う
※Elasticsearch 用語の document とは異なるという点を強調するため、あえて曖昧な用語を使用させてもらった
格納方法は大雑把に3つ
Elasticsearch で複数言語に対応したデータの格納方法としては、以下の3つの方法が考えられる。それぞれ、公式ドキュメントへのリンクを貼っておいたので、詳細はそちらを参照。
- 1言語1インデックス: One Language per Document
- 1言語1フィールド: One Language per Field
- 複数言語1フィールド(サブフィールドを使う): Mixed-Language Fields
前項との関連性は、以下の通り。
- 1, 2-a の場合は、i か ii の方法で格納
- 2-b の場合は、iii の方法で格納
個人的な意見としては、i, ii は、本質的にはそれほど大きな違いはない。長所短所を前回の投稿より抜粋しておく。
- 1言語1インデックス (複数インデックス同一フィールド)
- 言語の追加は、インデックスを作成するだけなので楽
- 1言語1フィールド (単一インデックス複数フィールド)
- (公式ドキュメントには記載がないが)複数インデックスに比べると、単一インデックスの方が管理が楽だと思う。snapshot & restore, reindex など。
- フィールドの追加は後からでも出来るが、フィールドに analyzer を指定するのは、インデックス作成時にしか出来ないので、何らかの workaround が必要
長かったが、この辺までが前振りで、ここから、実際にやったことなどを記載していく。
現状のデータの説明
基本は、1チーム=1インデックス
Commet では、当然ながら複数チームが使用することを想定している。現状では、以下のようなインデックス構成になっている。
例えば、A社の開発チームのチームID = 10 だとすると、以下のようなインデックス・aliasが存在する。
- “10_2_a” (又は “10_2_b”) という名前のインデックス
- “10” -> “10_2_a” (又は “10_2_b”) への alias
インデックス名にスキーマバージョンを入れる
先程の “2” という数字は何かというと、これはスキーマのバージョン番号。今回の多言語対応のように、マッピング定義が変わるという事が今後もあり得るが、全てのインデックスを新しい形式に一気に移行するのは無理なので、バージョン毎にインデックスを作成することにしている。
現在 “10_2_a” を使用しているが、移行スクリプトなどで “10_3_a” にデータを移行して、問題なく終わったら alias を張り替え、RDB に保存している管理情報を更新する。
reindex 用に、2面持たせる
“10_2_a” の “a” や “b” の用途だが、analyzer の定義を変更した時などで reindex したい場合に使用する。
“10_2_a” -> “10_2_b” に reindex して、終わったら alias を張り替える。次回の reindex は “10_2_b” -> “10_2_a” となる。
前項及び本項は、あまり多言語化には関係ないが、一応説明した。
既存のマッピングの説明
現在のマッピングだが、1つの document に title, body, 添付ファイル(複数の可能性あり)の field があり、それらの analyzer として “ja_kuromoji_neologd” という名前のものを使用している。
以下、elastic4s のマッピング定義を抜粋。DSLなので、何となくは分かってもらえると思う。
val messageMapping = mapping("message").fields( "title" typed StringType analyzer "ja_kuromoji_neologd", "body" typed StringType analyzer "ja_kuromoji_neologd", "attached_files" nested ( "attached_file" typed AttachmentType fields ( "content" typed StringType analyzer "ja_kuromoji_neologd" termVector ("with_positions_offsets") store (true) ) ) includeInRoot (true) )
“ja_kuromoji_neologd” をどのようなものかは、前回の投稿を見てほしいが、大雑把にまとめると、以下の通り。
- analysis-kuromoji-neologd をベースに使用
- 英語系の stemmer を追加
ここからが、多言語化に向けての修正。
マッピングの修正
データの性質について、おさらい
以降、話を単純化するために、メイン言語(=日本語) + 英語というパターンを考える。
GitHub の issue, PR などは日本語でやりとりされるが、リファレンスや Stack Overflow などの引用などで英語が混じることも多い。
ということで、格納パターンとしては「複数言語1フィールド(サブフィールドを使う): Mixed-Language Fields」となる。
サブフィールドを使用するように修正
前の方で紹介したマッピング定義を、以下のように修正したかった。
val messageMapping = mapping("message").fields( "title" typed StringType fields( "en" typed StringType analyzer "english", "ja" typed StringType analyzer "ja_kuromoji_neologd", ), "body" typed StringType fields( "en" typed StringType analyzer "english", "ja" typed StringType analyzer "ja_kuromoji_neologd", ), "attached_files" nested ( "attached_file" typed AttachmentType fields ( "content" typed StringType fields( "en" typed StringType analyzer "english" termVector ("with_positions_offsets") store (true), "ja" typed StringType analyzer "ja_kuromoji_neologd" termVector ("with_positions_offsets") store (true), ) ) ) includeInRoot (true) )
が、以下のような例外が発生した。
org.elasticsearch.transport.RemoteTransportException: [Answer][192.168.33.12:9300][indices:admin/create] Caused by: org.elasticsearch.index.mapper.MapperParsingException: Failed to parse mapping [message]: Mapper for [body] conflicts with existing mapping in other types: [mapper [body] has different [analyzer], mapper [body] is used by multiple types. Set update_all_types to true to update [search_analyzer] across all types., mapper [body] is used by multiple types. Set update_all_types to true to update [search_quote_analyzer] across all types.] at org.elasticsearch.cluster.metadata.MetaDataCreateIndexService$1.execute(MetaDataCreateIndexService.java:332) at org.elasticsearch.cluster.ClusterStateUpdateTask.execute(ClusterStateUpdateTask.java:45) at org.elasticsearch.cluster.service.InternalClusterService.runTasksForExecutor(InternalClusterService.java:468) at org.elasticsearch.cluster.service.InternalClusterService$UpdateTask.run(InternalClusterService.java:772) at org.elasticsearch.common.util.concurrent.PrioritizedEsThreadPoolExecutor$TieBreakingPrioritizedRunnable.runAndClean(PrioritizedEsThreadPoolExecutor.java:231) at org.elasticsearch.common.util.concurrent.PrioritizedEsThreadPoolExecutor$TieBreakingPrioritizedRunnable.run(PrioritizedEsThreadPoolExecutor.java:194) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at java.lang.Thread.run(Thread.java:745) Caused by: java.lang.IllegalArgumentException: Mapper for [body] conflicts with existing mapping in other types: [mapper [body] has different [analyzer], mapper [body] is used by multiple types. Set update_all_types to true to update [search_analyzer] across all types., mapper [body] is used by multiple types. Set update_all_types to true to update [search_quote_analyzer] across all types.] at org.elasticsearch.index.mapper.FieldTypeLookup.checkCompatibility(FieldTypeLookup.java:153) at org.elasticsearch.index.mapper.FieldTypeLookup.copyAndAddAll(FieldTypeLookup.java:115) at org.elasticsearch.index.mapper.MapperService.merge(MapperService.java:340) at org.elasticsearch.index.mapper.MapperService.merge(MapperService.java:289) at org.elasticsearch.cluster.metadata.MetaDataCreateIndexService$1.execute(MetaDataCreateIndexService.java:329) ... 8 common frames omitted
従って、以下のようにした。
val messageMapping = mapping("message").fields( "title_v3" typed StringType fields( "en" typed StringType analyzer "english", "ja" typed StringType analyzer "ja_kuromoji_neologd", ), "body_v3" typed StringType fields( "en" typed StringType analyzer "english", "ja" typed StringType analyzer "ja_kuromoji_neologd", ), "attached_files_v3" nested ( "attached_file" typed AttachmentType fields ( "content" typed StringType fields( "en" typed StringType analyzer "english" termVector ("with_positions_offsets") store (true), "ja" typed StringType analyzer "ja_kuromoji_neologd" termVector ("with_positions_offsets") store (true), ) ) ) includeInRoot (true) )
なお、この投稿を書いている時点では、以下の2点がよく分かっていない。
- attached_files.attached_file についていた termVector (“with_positions_offsets”) store (true) を、サブフィールド(attached_files_v3.attached_file.ja|en)に定義すべきなのか、親フィールド(attached_files_v3.attached_file)に定義すべきなのか
- includeInRoot を使うのを止めるべきか (参考 → Deprecate `include_in_root` and `include_in_parent`? · Issue #12461 · elastic/elasticsearch)
2016/12/07 追記: 1点目は、サブフィールドに定義するのが良さそう。2点目は、include_in_root 無しでもうまくいく方法が分かったので、使用をやめた。詳しくは以下の投稿を参照。
Elasticsearch の nested 型のハイライト – K blog
データの投入
データの投入は、今までと同じような感じで “title_v3”, “body_v3”, “attached_files_v3.attached_file” に値をセットすると、異なる analyzer で処理された値がサブフィールドに入る。(この辺の挙動が詳しく載ってるドキュメントがすぐには見つからなかった。)
検索
ドキュメントに記載がある基本事項
検索だが、以下に対する multi match クエリーを、boolean クエリーの should でつなげば良い。
- title_v3.*
- body_v3.*
- attached_files_v3.attached_file.content.*
いやー、簡単簡単。
なわけがない。
日本語の検索キーワードがどう処理されるか
ここでは、話を単純化するために、”body_v3.ja”, “body_v3.en” の2つのフィールドについてだけ考える。
例えば、利用者が日本語で “画面遷移” というキーワードを入力したとする。これを “body_v3.*” に対する multi match query として単純に処理すると、
- “body_v3.ja” に対しては、検索タームが “ja_kuromoji_neologd” analyzer で処理されるので、 “画面”, “遷移” の2つのトークンに分割されて検索が実行される
- “body_v3.en” に対しては、検索タームが “english” analyzer で処理されるので、 “画”, “面”, “遷”, “移” の4つのトークンに分割されて検索が実行される
となる。結果、”画面”, “遷移” という単語が含まれていなくても、その4種類の文字が沢山含まれている document が検索結果として上の方に来てしまう。
対策
(このブログを書く前に実装を終えたかったが、まだ終わってないので、以下は実際に試した訳ではない。)
入力された検索キーワードを元に、検索対象のサブフィールドを変える、あるいは重み付けを変える、というのが1つの方法。
多分、日本語+英語の場合は簡単で、文字種によって判別すれば割と良い結果が出ると思う。
2016/12/07 追記: 言語判定に関しては、別途エントリーを書いた。
同じ文字種を使う異なる言語の場合
同じ文字種を使う言語の場合(例えば、メイン言語がフランス語の場合)はどうかというと、文字種による判別は使えない。
検索キーワードに対して、言語の自動判定ライブラリを使うというのが良さそうにも思えるが、言語の自動判定というのは入力文が長ければ精度が高いが、検索キーワードのように数語の場合、精度はあまり高くなさそう。
ただ、今回のユースケースに限って言うと、実際問題としては、
- フランス語
- 検索対象文章に広範囲の単語が含まれる
- 文章数多い
- 英語
- 検索対象文章に使われる単語は比較的少ない
- 文章数は少ない
という傾向があり、また、両言語で同じ綴りかつ全く違う意味の単語というのはかなり少ないので、何も考えずに同じ検索キーワードを multi match クエリーで “body_v3.en” と “body_v3.fr” に投げてしまって問題無さそう。
余談: 同じ文字種を使う異なる言語に関して
これが、Wikipedia の文章の検索とかだと、ちょっと事情が違ってくる。
例えば、フランス語+英語のケースで検索キーワードとして car grand prix と入れた場合を考える。この場合、F1グランプリだったり、そういう車のグランプリの情報が出てきて欲しい。
当たり前だが、英語として扱った場合は、3語として認識される。”body_v3_en” にこの3語が含まれるものが候補となる。
フランス語として扱う場合だが、どのような analyzer の設定にするかにもよるが、”car” は接続詞なので、stop word として弾かれる場合もあり得る。その場合、grand prix の2語による検索と同じになる。
フランス語の場合、grand, prix は、それぞれ「大きい」と「値段」という意味。英語の prix が grand prix などの限られた場面でしか使わないのに対して、フランス語の prix は grand prix とかでも使われるが、それ以上に「値段」という意味で使われるので、何かの製品に関するページが検索結果として出て来てしまい、欲しい情報が得られない。
今後のTODO
以下、メモ。
データの移行
インデックスを新規で作成する場合は問題ないが、古い形式のインデックスを今回の形式に移行する部分はまだ出来ていない。reindex のドキュメントを見ると、スクリプトとかを使えば比較的簡単にできそうではあるが・・・
他の言語の対応
日本語+英語以外の対応。これは単に、インデックス作成時にメイン言語(日本語、フランス語、ポルトガル語、スペイン語、中国語 etc.)を選ばせればいいかなと思っている。Elasticsearch は、標準でいろんな言語の analyzer がついてるので、それをそのまま使ったり、無いものはプラグインを使えば良さそう。
attachment が・・・ハイライトが・・・→解決
今回の対応により、添付ファイルは nested fields の中にさらに sub fields が含まれる形になったせいか、ハイライト処理が効かなくなってしまった。この部分を直す。
そして、mapper attachment プラグインは 5.0 で deprecated なので、ingest プラグインに移行する・・・面倒。
2016/12/5 追記:解決した。↓
Elasticsearch の nested 型のハイライト – K blog
dynamic template?
以前の投稿でも紹介した、以下の記事で、dynamic template を使っているが、これを使うともう少し簡単に色々出来るのかもしれないし、そうでないかもしれない。
Elasticsearch 日本語スキーマレス環境構築と、ついでに多言語対応 // Speaker Deck
こうしたテンプレート的な処理は、プログラム言語が得意とすることだし、Scala は、結構表現力が高く簡潔に書けるので、ついついプログラムで書いてしまいがち。
いずれにしても、dynamic template について調べてみる。
まとめ
多言語対応は、one size fits all な方法は無いので、ドキュメントに書かれている基本を押さえつつ、色々試してみる必要がありそう。
そして、Elasticsearch は進化が速すぎて大変。
日付が変わってしまった。
Speaker Deck のスライド紹介ありがとうございます!(ちょっと古くなってしまってますが…)言語処理は難しいですよねぇ〜。10年くらい試行錯誤してるw
コメントありがとうございます。スライドはちょくちょく読んで参考にさせてもらってます。
10年位この辺の分野をやっているんですね!確かに難しいですが、やりがいはありますよね。