CLOVER🍀

That was when it all began.

Apache Solr 5.xで、サジェストを実装することを考える

ちょっと、Apache Solrを使ってサジェストを作ることを考えていまして。

こちらの本ですと、SpellCheckComponent/Suggesterが紹介されています。

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)

Suggester
https://cwiki.apache.org/confluence/display/solr/Suggester

これでもよいのですが、別の方法もないのかなぁと思って調べて見つけたのがこちら。

Solrを使ったAuto Completeに挑戦
http://qiita.com/hiroara@github/items/e1c0e868a0a412ba9b0e

Advanced autocomplete with Solr Ngrams
http://www.andornot.com/blog/post/Advanced-autocomplete-with-Solr-Ngrams-and-Twitters-typeaheadjs.aspx

EdgeNGramを使うと…なるほど、一案ですね。

EdgeNGramについては、自分も過去にエントリを書いたことがあります。

NGramとEdgeNGramなTokenizerとTokenFilterを使ってみる
http://d.hatena.ne.jp/Kazuhira/20130725/1374764552

で、今回は先ほどの参考エントリを見つつ、EdgeNGramを使ってサジェスト用インデックスを定義してみます。
※基本的に参考エントリにかなり近いものになっているので、そちらを見られたことのある方にはあまり参考にならないかも…

通常、サジェストといえばクライアントサイドからのAjaxも考慮に含めるかと思いますが、今回はサーバーサイドのみの実装とします。動作確認は、curlで行う程度です。

インデックスの定義

まずは、インデックスの定義から。

schema.xmlに定義したのは、こちら。

    <fieldType name="text_suggest_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
      <analyzer type="index">
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/>
      </analyzer>
      <analyzer type="query">
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>

    <fieldType name="text_suggest_reading_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
      <analyzer type="index">
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="false"/>
        <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/>
      </analyzer>
      <analyzer type="query">
        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/>
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>

通常の形態素解析結果をEdgeNGramで分解するfieldTypeと、形態素解析結果の読みをEdgeNGramで分解するfieldTypeの2種類。

参考サイトからは少し変えているので、一応意図を。まずは、こちらから。名前は、「text_suggest_ja」としました。

    <fieldType name="text_suggest_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
      <analyzer type="index">
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/>
      </analyzer>
      <analyzer type="query">
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>

インデックス時もクエリ時もほぼ同じAnayzer定義で、以下のような定義です。

  • JapaneseTokenizerFactory … Kuromojiによる形態素解析。今回は単語を長く取るように「normal」で考えました
  • JapanesePartOfSpeechStopFilterFactory … 日本語で不要な品詞除去。今回はデフォルトのままですが、参考エントリを見ると確かにカスタマイズの余地はありそうですね
  • CJKWidthFilterFactory … 半角カナ、全角カナを統一
  • StopFilterFactory … ストップワード除去。こちらも、カスタマイズの余地はあり
  • JapaneseKatakanaStemFilterFactory … 4文字以上の場合の末尾の長音除去
  • LowerCaseFilterFactory … 大文字/小文字の統一(Case Insensitive)
  • EdgeNGramFilterFactory … 前方一致のための、EdgeNGram。とりあえず、最大25文字まで

ただ、クエリ時のみEdgeNGramFilterFactoryは外しています。不必要にヒットするからです…。

続いて、読みを意識した定義。名前は、「text_suggest_reading_ja」としています。

    <fieldType name="text_suggest_reading_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="true">
      <analyzer type="index">
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="false"/>
        <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/>
      </analyzer>
      <analyzer type="query">
        <tokenizer class="solr.WhitespaceTokenizerFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/>
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>

インデックス時のAnalyzerは「text_suggest_ja」と基本的には同じですが、JapaneseKatakanaStemFilterFactoryの後に以下の2つが入っています。

  • JapaneseReadingFormFilterFactory … 形態素解析結果から、読みを取得
  • ICUTransformFilterFactory … カタカナ→平仮名変換

これで、読みをインデックスに登録します。

なお、icu系のものを使うには、Solrにextraなライブラリを追加する必要があります。Solrのインストールディレクトリから、solr-webappにコピーすればOKです。

$ cp /opt/solr-5.3.0/contrib/analysis-extras/lucene-libs/lucene-analyzers-icu-5.3.0.jar /opt/solr-5.3.0/server/solr-webapp/webapp/WEB-INF/lib
$ cp /opt/solr-5.3.0/contrib/analysis-extras/lib/icu4j-54.1.jar /opt/solr-5.3.0/server/solr-webapp/webapp/WEB-INF/lib

一応、この後でSolrは再起動しました。

クエリ時のAnalyzerは、こんな感じ。

  • WhitespaceTokenizerFactory … スペースでトークン分割
  • EdgeNGramFilterFactory … 前方一致のための、EdgeNGram。とりあえず、最大25文字まで
  • CJKWidthFilterFactory … 半角カナ、全角カナを統一
  • ICUTransformFilterFactory … カタカナ→平仮名変換
  • LowerCaseFilterFactory … 大文字/小文字の統一(Case Insensitive)

これで、平仮名/カタカナ入力されたクエリに対して、前方一致で検索となります。

これを使用したフィールドを定義。

    <field name="content" type="string" indexed="true" stored="true" required="true" multiValued="false" />
    <field name="content_ja" type="text_suggest_ja" indexed="true" stored="false" multiValued="false" />
    <field name="content_reading_ja" type="text_suggest_reading_ja" indexed="true" stored="false" multiValued="false" />

    <uniqueKey>content</uniqueKey>

※よくよく考えたら、「content」フィールドからのcopyFieldを定義すればよかった気がします!

contentフィールドのfieldTypeは、デフォルトで定義されている以下のものになります。

    <fieldType name="string" class="solr.StrField" sortMissingLast="true" />

contentフィールドが登録するデータそのもので、こちらがサジェスト時に取得するフィールドになります。その他が先ほど定義した「text_suggest_ja」および「text_suggest_reading_ja」fieldTypeを使用したフィールドで、こちらはインデックスのみの保持です(stored=false)。

データを登録して動かしてみる

では、データ登録。対象のデータはこちら。
data.json

[
  {
    "content": "吾輩は猫である",
    "content_ja": "吾輩は猫である",
    "content_reading_ja": "吾輩は猫である"
  },
  {
    "content": "我が名は青春のエッセイドラゴン",
    "content_ja": "我が名は青春のエッセイドラゴン",
    "content_reading_ja": "我が名は青春のエッセイドラゴン"
  },
  {
    "content": "下町ロケット",
    "content_ja": "下町ロケット",
    "content_reading_ja": "下町ロケット"
  },
  {
    "content": "北斗の拳",
    "content_ja": "北斗の拳",
    "content_reading_ja": "北斗の拳"
  },
  {
    "content": "進撃の巨人",
    "content_ja": "進撃の巨人",
    "content_reading_ja": "進撃の巨人"
  }
]

※copyFieldで定義しなかったがゆえに、ムダなデータ送信が…

登録。

$ curl 'http://localhost:8983/solr/mycore/update?commit=true' --data-binary @data.json

それでは、データ登録。

クエリを投げてみます。
「わ」

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "content_ja: わ, content_reading_ja:わ" }'
{
  "responseHeader":{
    "status":0,
    "QTime":3,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"content_ja: わ, content_reading_ja:わ\" }",
      "wt":"json"}},
  "response":{"numFound":2,"start":0,"docs":[
      {
        "content":"吾輩は猫である",
        "_version_":1512094399003099136},
      {
        "content":"我が名は青春のエッセイドラゴン",
        "_version_":1512094399009390592}]
  }}

「わが」

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "content_ja: わが, content_reading_ja:わが" }'
{
  "responseHeader":{
    "status":0,
    "QTime":0,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"content_ja: わが, content_reading_ja:わが\" }",
      "wt":"json"}},
  "response":{"numFound":2,"start":0,"docs":[
      {
        "content":"吾輩は猫である",
        "_version_":1512094399003099136},
      {
        "content":"我が名は青春のエッセイドラゴン",
        "_version_":1512094399009390592}]
  }}

一見、うまくいってそうに見えますが…。

「わがな」にすると

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "content_ja: わがな, content_reading_ja:わがな" }'
{
  "responseHeader":{
    "status":0,
    "QTime":1,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"content_ja: わがな, content_reading_ja:わがな\" }",
      "wt":"json"}},
  "response":{"numFound":2,"start":0,"docs":[
      {
        "content":"吾輩は猫である",
        "_version_":1512094399003099136},
      {
        "content":"我が名は青春のエッセイドラゴン",
        "_version_":1512094399009390592}]
  }}

「吾輩は猫である」が残ったままです。これは、「わ」「わが」「わがな」のORになっているからですね…。

最長一致的な感じでワイルドカードにすると

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "content_ja: わがな*, content_reading_ja:わがな*" }'
{
  "responseHeader":{
    "status":0,
    "QTime":7,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"content_ja: わがな*, content_reading_ja:わがな*\" }",
      "wt":"json"}},
  "response":{"numFound":0,"start":0,"docs":[]
  }}

今度は0件になります。これは、「我が名」が「我が」と「名」に分かれて形態素解析されているので、「わがな」ではヒットしないからです。

また、実はサジェストっぽい感じの挙動に見えるかもしれませんが、実際にはこれは、サジェストするのは「単語」ではなく「ドキュメントの値」そのものです。インデックスに登録されている単語も、ドキュメントの値を形態素解析した結果から成っています。

よって、中途半端な位置の単語を入力しても、ヒットしてしまいます。

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "content_ja: ねこ, content_reading_ja:ねこ" }'
{
  "responseHeader":{
    "status":0,
    "QTime":1,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"content_ja: ねこ, content_reading_ja:ねこ\" }",
      "wt":"json"}},
  "response":{"numFound":1,"start":0,"docs":[
      {
        "content":"吾輩は猫である",
        "_version_":1512094399003099136}]
  }}

むー。

そういえば、読みばかりで漢字を試していませんでしたね。こちらも、読みと同じように動作します。

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "content_ja: 進, content_reading_ja:進" }'
{
  "responseHeader":{
    "status":0,
    "QTime":1,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"content_ja: 進, content_reading_ja:進\" }",
      "wt":"json"}},
  "response":{"numFound":1,"start":0,"docs":[
      {
        "content":"進撃の巨人",
        "_version_":1512094399115296768}]
  }}

なお、こちらの定義について、「content_ja」フィールドにはEdgeNGramは適用されないため、読みでのヒット時のような事象は発生しません。

まとめ

一見できているような感じがしますが、ちょっと不十分ですね。

特に読みの方は、本当に前方一致っぽく見せたければ、EdgeNGramの前にトークナイズされた単語をひとつにまとめるとかする必要がある気がします。

また、そもそもドキュメントに登録するのを単語ベースにして、サジェストするのは単語の単位にするのでは?とかいろいろ悩ましいです。
※TermsComponent使うんでしょうねー

サジェストって、大変ですね!!