Elasticsearchで辞書取り込みエラーが発生したので、Luceneで遊んでみた話

はじめに

こんにちは。
アイスタイルで、検索基盤担当のmiyaharasです。
この記事はアイスタイル Advent Calendar 2018 の15日目の記事です。

今回は、
Elasticsearchの内部で使用されている検索ソフトウェアである、
Apache Lucene について、書いていきます。

実際に、LuceneをLocal環境で構築方法から書いていきますので、
少々長くなりますが、ご了承ください。

この記事のざっくりストーリー

スタックトレースを読んで、Luceneのコードを読んで、原因を突き止めて
実際にLuceneを動かしてみて、確認してみた。
他にも、Luceneを動かしてみたいっていう、奇特な人いるんじゃないかと思って、
構築手順まとめたよ。

作りがよろしくないと思ったので、改修して、PRなげてみた。
PRの送り方もついでにまとめたよ。

という記事です。

背景

さて、弊社では、検索精度改善PJTが進行中です。

というのも、とあるサービスでは、検索レスポンスにノイズが多い状態です。
これは、ngramのみでトークン化されているためです。

そこで、形態素解析を導入することになりました。

が、既存のkuromojiの辞書では、不十分な可能性があるため、
ユーザー辞書を定義して、対応することとなりました。

※「ngram」、および「形態素解析」等がなんだかわからないというかたは、
shiohatakさんの記事が参考になりますので、御覧ください。

自然言語処理の手法と応用技術

ユーザー辞書については、既存のサービスのDBから、「名前」と「読み」を抽出して作っていくことになったのですが、
とある文字列が含まれていたため、index作成時に、
下記のエラーが発生しました。

スタックトレースは下記になります。

java.lang.ArrayIndexOutOfBoundsException: 1
        at org.apache.lucene.analysis.ja.dict.UserDictionary.<init>(UserDictionary.java:107) ~[?:?]
        at org.apache.lucene.analysis.ja.dict.UserDictionary.open(UserDictionary.java:81) ~[?:?]
        at org.elasticsearch.index.analysis.KuromojiTokenizerFactory.getUserDictionary(KuromojiTokenizerFactory.java:65) ~[?:?]
        at org.elasticsearch.index.analysis.KuromojiTokenizerFactory.<init>(KuromojiTokenizerFactory.java:52) ~[?:?]
        at org.elasticsearch.index.analysis.AnalysisRegistry.buildMapping(AnalysisRegistry.java:342) ~[elasticsearch-5.3.1.jar:5.3.1]
        at org.elasticsearch.index.analysis.AnalysisRegistry.buildTokenizerFactories(AnalysisRegistry.java:176) ~[elasticsearch-5.3.1.jar:5.3.1]
        at org.elasticsearch.index.analysis.AnalysisRegistry.build(AnalysisRegistry.java:154) ~[elasticsearch-5.3.1.jar:5.3.1]
        at org.elasticsearch.index.IndexService.<init>(IndexService.java:145) ~[elasticsearch-5.3.1.jar:5.3.1]
        at org.elasticsearch.index.IndexModule.newIndexService(IndexModule.java:363) ~[elasticsearch-5.3.1.jar:5.3.1]

事象発生時、この原因がググってもなかなか出てこずでして、
結局ライブラリのコードを読んで解決しました。

で、今回、実際にLuceneをUnitテストレベルでも、動かして確認してみたら、
ネタとして面白いんじゃないかと思い、試してみることにしました。

エラー原因一覧

さて、実際にLuceneの環境構築をしていく前に、
indexの辞書取り込みにおいて、発生したエラー一覧を下記に記載しておきます。

<text>,<token 1> ... <token n>,<reading 1> ... <reading n>,<part-of-speech tag>

下記で記載している text というのは、上記に記載した辞書ファイルの1列目のことを指しています。
詳細は、公式ドキュメントを参照してください。

原因
tokenとreadingの半角スペース数に差異がある場合 hogehoge,hoge hgoe,ほげほげ,カスタム名詞
text等に、#がある場合 ABC#DEF,ABC # DEF,ABC # DEF,カスタム名詞

他にもあれば、随時追記していきます。

結論を先に書いてしまうと、上記のスタックトレースは、
文字列中に、「#」 があることが原因でした。

下記からは、「#」があることで、なぜ、エラーが発生するのかを、
実際にLuceneを構築しながら、解説していきます。

Luceneの環境構築

https://wiki.apache.org/lucene-java/HowtoConfigureIntelliJ
Macでなおかつ、intellij前提で、上記ドキュメントにそって、構築していきます。

  1. 下記スクリプトを実行
  git clone git@github.com:apache/lucene-solr.git
  cd lucene-solr
  brew install ant
  ant ivy-bootstrap
  ant idea
  1. intellijを立ち上げ、File->Open から該当のリポジトリを開く
    • File->New->Project from Existing Source でリポジトリを開くと、
      Groovyのコンパイルエラーが発生しました。お気をつけください。
  2. 何かしらのTestクラスを開き、unitテストを実行してみてください。
    • 正常に終了すれば、環境構築は終了です。

それでは、実際にエラーの該当箇所を見ていきます。

エラーの該当箇所の解説

スタックトレースを見てみると、UserDictionary.javaの107行目なので、
下記になります。
https://github.com/apache/lucene-solr/blob/1d85cd783863f75cea133fb9c452302214165a4d/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java#L107

さて、

ArrayIndexOutOfBoundsException

が発生しているということは、

String[] segmentation = values[1].replaceAll("  *", " ").split(" ");

の配列のindexが存在しないことになります。

で、コードを追っていくと、

String values[]

は、featureEntriesというListコレクションを拡張for文で、ループさせている様です。

featureEntriesは、どこで定義されているかというと、
https://github.com/apache/lucene-solr/blob/1d85cd783863f75cea133fb9c452302214165a4d/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java#L63

63行目ですね。

三度、コードを追っていくと、
どうやら、staticなopenメソッドで渡された、Readerオブジェクトをバッファリングして、
66行目のwhileループの中で、1行ずつ、読み込んで加工しているようです。

そして、ループの中で、怪しい処理があります。

// Remove comments
line = line.replaceAll("#.*$", "");

こちら、#以降の文字列を空文字に置き換えています。

つまり、用意した、辞書に下記のような行がある場合、

テスト#テスト,テスト # テスト,テスト # テスト,カスタム名刺

下記だけになってしまいます。

テスト

ここが怪しそうです。

Unitテストを動かしてみる

それでは、実際に確認してみましょう。
lucene-solrには、
幸いにもUserDictionaryに紐づく、UserDictionaryTestクラスが存在しますので、
こちらを動かしながら確認していきます。

TestJapaneseTokenizerのreadDictメソッドの中で、
userdict.txt を読み込んでいるようなので、
こちらに、

テスト#テスト,テスト # テスト,テスト # テスト,カスタム名刺

を追加します。

それでは、デバッグしながら動かしてみます。

テスト#テスト,テスト # テスト,テスト # テスト,カスタム名刺


テスト


になり、featureEntriesにaddされています。

userdict.txtには、下記のコメントと思しき行と、
正常に処理されるであろう、行が存在するので、
続けて見て行きます。

# Custom segmentation for long entries
日本経済新聞,日本 経済 新聞,ニホン ケイザイ シンブン,カスタム名詞

コメント行は、


featureEntriesにaddされず、skipされるようです。

次は、正常と思しき行を見ていきます。

CSVUtil.parse(line)
で、カンマ区切りの行を、String型配列に変換し、
featureEntriesにaddされているようです。

で、動かし続けると、下記のスタックトレースが発生

java.lang.ArrayIndexOutOfBoundsException: 1

    at __randomizedtesting.SeedInfo.seed([B9772A6F920CCCCC:D233C36C220C79FB]:0)
    at org.apache.lucene.analysis.ja.dict.UserDictionary.<init>(UserDictionary.java:107)
    at org.apache.lucene.analysis.ja.dict.UserDictionary.open(UserDictionary.java:81)
    at org.apache.lucene.analysis.ja.TestJapaneseTokenizer.readDict(TestJapaneseTokenizer.java:55)
    at org.apache.lucene.analysis.ja.dict.UserDictionaryTest.testLookup(UserDictionaryTest.java:30)

index取り込み時におけるスタックトレースと同一なのが見て取れると思います。

予想したとおり、#以降がtrimされ、
String型配列のindexが一つしか作られていないことが原因でした。

CSVについての余談

CSVにコメントをつけられるという仕様を、初めて知りました。

一応、RFC4180という標準仕様があるそうですが、
こちらに、コメントについての記載はありませんでした。

Luceneがどういう経緯で、
ユーザー辞書(csv)にコメントを許容する仕様にしたのかは、わかりません。

が、ググってみると、会社の文化とかによるところが多く、
コメントを許容する仕様もあるようですね。。。
(個人的には、コメントなんていらないだろうとは思っています。)

解決策

  1. #を全角に置き換えて、辞書登録をする。
  2. アプリケーション側で、検索されたキーワードに、半角#があった場合、全角に変換する

で、回避できます。

ただ、これがベストな設計かは、わかりません。
もっとスマートな方法があれば、随時修正していこうと思います。

せっかくなので、PRを送ってみる

今回の事象、とりあえず回避はできました。

今回の事象を整理してみると、
ユーザー定義辞書にコメントを許容する仕様があったため、
エラーが発生しました。

ただ、一般的なCSVにおけるコメントを定義すると、
行の先頭#から始まること
だと思われます。

// Remove comments
line = line.replaceAll("#.*$", "");

で、こちらのロジックを見てみると、
「先頭が#である」
という正規表現がありません。

行中の、#が見つかった位置から空文字に置き換えるというロジックです。

なので、

// Remove comments
line = line.replaceAll("^#.*$", "");

こちらが、より良いのではないかと思います。

以上より、こちらに修正した上でPRを送ってみました。

PRの送り方

こちらに沿って、行います。

We accept GitHub Pull Requests (PR). To submit one, first fork the project, then make changes in a feature branch which you push to GitHub and use as the basis for the PR. PRs should be linked to a JIRA issue in the appropriate project here:
Solr Jira or here: Lucene Jira. To link your PR to the Jira issue simply include the Jira issue id in the title of the PR (first create the Jira issue if one doesn’t exist).

どうやら、forkして、jira作って、PRにJiraのissue_idを記載して、PRを作るみたいです。

最後に

多少時間は、かかりますが、
たまには、OSSを使うだけではなく遊んでみるのも、中々乙なものですよ!

それでは、良いクリスマスを!

検索基盤担当してます。たまに、別の基盤APIに出張してます。 シマコー好きです。