Solr による サジェスト検索実装

## はじめに
しばらくSolrに触らないうちにサジェスト検索コンポーネントが追加され、以下のような機能が実装されていたので試してみました。

* 複数辞書への問い合わせ
* 指定した重みによるソート
* 指定したフィールド値によるフィルタリング

## 実験環境構築
https://github.com/ft28/practice/tree/master/solr/suggest の [README.md](https://github.com/ft28/practice/tree/master/solr/suggest/README.md) に従うと以下の実験環境を構築出来ます。

環境構築が完成するとサジェスト結果を以下のURLで確認できるようになります。
http://localhost:8983/suggest/

## 実験内容
駅名データをサジェスト対象としてsolrのサジェスト検索コンポーネントを試してみました。

### 対象データ(sample01/sample01.tsv)

|駅名|路線ID|スコア|駅名 路線名|
|---|------|-----|----------|
|大崎|1|627|大崎 山手線|
|五反田|1|578|五反田 山手線|
|目黒|1|304|目黒 山手線|
|恵比寿|1|868|恵比寿 山手線|
|渋谷|1|20|渋谷 山手線|
|...|...|...|...|...|

#### 路線IDと路線名

|路線ID|路線名|
|-----|------|
|1|山手線|
|2|京浜東北線|
|3|埼京線|
|4|総武線|
|5|東海道本線|
|6|中央線|
|7|京葉線|

## 設定ファイル

schema.xml のfieldsはsample01.tsvに合わせて以下のように定義します。

```xml:schema.xml
  <fields>
    <field name="id"        type="string"   indexed="true"  stored="true"  multiValued="false" required="true" />
    <field name="keyword"   type="text_ja"  indexed="true"  stored="true"  multiValued="false" required="true" />
    <field name="context"   type="int"      indexed="true"  stored="true"  multiValued="false" required="true" />
    <field name="weight"    type="int"      indexed="true"  stored="true"  multiValued="false" required="true" />
    <field name="payload"   type="string"   indexed="false" stored="true"  multiValued="false" required="true" />
    <field name="_version_" type="long"     indexed="true"  stored="true"/>
  </fields>

  <uniqueKey>id</uniqueKey>
```

schema.xmlのfieldtype にサジェスト検索で使う型を定義します。

```xml:schema.xml
   <!-- テキスト系フィールド -->
    <fieldType name="text_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
      <analyzer>
        <!-- トークン化 -->
        <tokenizer class="solr.KeywordTokenizerFactory"/>
        <!-- 全角カナ半角カナの表記ゆれを統一 -->
        <filter class="solr.CJKWidthFilterFactory"/>
        <!-- アルファベットを小文字に統一 -->
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>

    <!-- ローマ字読み検索用1 -->
    <fieldType name="text_ja_roman1" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
      <analyzer>
        <!-- トークン化 -->
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal" />
        <!-- 全角カナ半角カナの表記ゆれを統一 -->
        <filter class="solr.CJKWidthFilterFactory"/>
        <!-- 4文字以上の場合カタカナの末尾の長音を除外 コンピューター -> コンピュータ -->
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <!-- トークンをローマ字化 -->
        <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="true"/>
        <!-- 上でローマ字化出来なかったひらがなカタカナをローマ字化 -->
        <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
        <filter class="solr.ICUTransformFilterFactory" id="Katakana-Latin"/>
        <!-- トークンを結合して新しいトークン作成 -->
        <filter class="solr.ShingleFilterFactory" minShingleSize="2" maxShingleSize="100" outputUnigramsIfNoShingles="true" tokenSeparator=""/>
        <!-- tōkyō -> tokyo -->
        <filter class="solr.ASCIIFoldingFilterFactory" preserveOriginal="false" />
        <!-- アルファベットを小文字に統一 -->
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>
```
solrconfig.xml に、サジェスト検索用の設定を定義します。

```xml:solrconfig.xml
  <searchComponent name="suggest" class="solr.SuggestComponent">
    <lst name="suggester">
      <str name="name">default</str>
      <str name="lookupImpl">AnalyzingInfixLookupFactory</str>
      <str name="dictionaryImpl">DocumentDictionaryFactory</str>
      <str name="field">keyword</str>
      <str name="weightField">weight</str>
      <str name="contextField">context</str>
      <str name="payloadField">payload</str>
      <str name="buildOnStartup">false</str>
      <str name="indexPath">store_default</str>
      <str name="suggestAnalyzerFieldType">text_ja</str>
    </lst>

    <lst name="suggester">
      <str name="name">roman1</str>
      <str name="lookupImpl">AnalyzingInfixLookupFactory</str>
      <str name="dictionaryImpl">DocumentDictionaryFactory</str>
      <str name="field">keyword</str>
      <str name="weightField">weight</str>
      <str name="contextField">context</str>
      <str name="payloadField">payload</str>
      <str name="buildOnStartup">false</str>
      <str name="indexPath">store_roman1</str>
      <str name="highlight">false</str>
      <str name="suggestAnalyzerFieldType">text_ja_roman1</str>
    </lst>

  </searchComponent>

  <requestHandler name="/suggest" class="solr.SearchHandler" startup="lazy">
    <lst name="defaults">
      <str name="suggest">true</str>
      <str name="suggest.count">10</str>
    </lst>
    <arr name="components">
      <str>suggest</str>
    </arr>
  </requestHandler>
```

ローマ字検索時は、一致部分のハイライトがうまく動かないので、highlight機能をオフにしておきます。
```
 <str name="highlight">false</str>
```

## 実験結果1
ブラウザ経由で、サジェスト検索を実行してみます。

### 辞書指定なし:q=新
http://localhost:8983/solr/sample01/suggest?suggest=true&suggest.count=3&suggest.q=新

```json
suggest: {
  default: {
    新: {
      numFound: 3,
      suggestions: [
        {
          term: "<b>新</b>検見川",
          weight: 915,
          payload: "新検見川 総武線"
        },
        {
          term: "<b>新</b>習志野",
          weight: 844,
          payload: "新習志野 京葉線"
        },
        {
          term: "<b>新</b>宿",
          weight: 835,
          payload: "新宿 埼京線"
        }
      ]
    }
  }
}
```
辞書指定しない場合は、最初に書いた辞書が使われ、マッチした文字がhighlightされた結果(\<b\>新\</b\>)が返ってきます。weightの大きいものから並んでいることが分かります。

### 複数辞書指定: q=新
http://localhost:8983/solr/sample01/suggest?suggest=true&suggest.dictionary=default&suggest.dictionary=roman1&suggest.count=3&suggest.q=新

```json
suggest: {
  default: {
    新: {
      numFound: 3,
      suggestions: [
        {
          term: "<b>新</b>検見川",
          weight: 915,
          payload: "新検見川 総武線"
        },
        {
          term: "<b>新</b>習志野",
          weight: 844,
          payload: "新習志野 京葉線"
        },
        {
          term: "<b>新</b>宿",
          weight: 835,
          payload: "新宿 埼京線"
        }
      ]
     }
  },
  roman1: {
    新: {
      numFound: 3,
      suggestions: [
        {
          term: "新検見川",
          weight: 915,
          payload: "新検見川 総武線"
        },
        {
          term: "新習志野",
          weight: 844,
          payload: "新習志野 京葉線"
        },
        {
          term: "新宿",
          weight: 835,
          payload: "新宿 埼京線"
        }
      ]
    }
  }
}
```

suggest用の辞書を2つ(default, roman1)を指定したクエリにしたところ、defaultに加えて、roman1の結果も返ってきました。roman1の方はhighlightがオフになっています。

### 複数辞書指定：q=s
http://localhost:8983/solr/sample01/suggest?suggest=true&suggest.dictionary=default&suggest.dictionary=roman1&suggest.count=3&suggest.q=s

```json
suggest: {
  default: {
    s: {
      numFound: 0,
      suggestions: [ ]
    }
  },
  roman1: {
    s: {
      numFound: 3,
      suggestions: [
        {
          term: "新検見川",
          weight: 915,
          payload: "新検見川 総武線"
        },
        {
          term: "新習志野",
          weight: 844,
          payload: "新習志野 京葉線"
        },
        {
          term: "新宿",
          weight: 835,
          payload: "新宿 埼京線"
        }
      ]
    }
  }
}
```
検索クエリをsとしたところ、defaultの方ではヒットしなくなりました。

### フィルタリング:q=s&suggest.cfg=1
http://localhost:8983/solr/sample01/suggest?suggest=true&suggest.dictionary=default&suggest.dictionary=roman1&suggest.count=3&suggest.q=s&suggest.cfq=1

```json
suggest: {
  default: {
    s: {
      numFound: 0,
      suggestions: [ ]
    }
  },
  roman1: {
    s: {
      numFound: 3,
      suggestions: [
        {
          term: "新橋",
          weight: 817,
          payload: "新橋 山手線"
        },
        {
          term: "品川",
          weight: 735,
          payload: "品川 山手線"
        },
        {
          term: "新宿",
          weight: 681,
          payload: "新宿 山手線"
        }
      ]
    }
  }
}
```
フィルタリング条件suggest.cfq=1(山手線) を追加すると、山手線の結果だけが表示されるようになりました。

## 課題
いい感じに動いているようでしたが様々なクエリでチェックしたところ、以下のような問題が発生していました。

### 課題1.「東京」はヒットするが、「toukyou」がヒットしない 
http://localhost:8983/solr/#/sample01/analysis で確認したところ、以下のようにインデックス登録していることが分かりました。このため、「toukyou」ではヒットせず、「tokyo」ではヒットするという現象が発生します。

* JapaneseReadingFormFilterFactoryが、「東京」を「tōkyō」に変換
* ASCIIFoldingFilterFactoryが、「tōkyō」を「tokyo」に変換

### 課題2.「品川」、「shinagawa」はヒットするのに、「しながわ」がヒットしない
http://localhost:8983/solr/#/sample01/analysis で確認したところ、以下のようになっていました。

  * インデックス登録時: 「品川」 -> 「shinagawa」
  * 検索時:「しながわ」 -> 「shina」,「shianga」,「shinawa」,「ga」,「gawa」, 「wa」

サジェスト検索で使っている、AnalyzingInfixLookupFactory は、以下のように動作します。

登録時の動作
 
|元ワード    |指定方式インデックス|追加インデックス|
|-----------|----------|---------|
|12345 abcde|12345|1|
|           ||12|
|           ||123|
|           ||1234|
|           |abcde|a|
|           ||ab|
|           ||abc|
|           ||abcd|

検索時の動作

* 入力をトークン化
* トークンが1つの場合
    * トークン長が4文字以下の場合
        * 追加インデックスで完全一致検索
    * トークン長が5文字以上の場合
        * 指定方式インデックスで前方一致検索
* トークンが複数の場合
    * 最後のトークン以外
        * 指定方式インデックスで完全一致検索
    * 最後のトークン
        * 最後のトークン長が4文字以下の場合
            * 追加インデックスで完全一致検索
        * 最後のトークン長が5文字以上の場合
            * 指定方式インデックスについで前方一致検索
* 全てのトークンがヒットしたものだけを最終的な結果として返す

「品川」を「しながわ」で検索する際に起きていること

インデックスの状態

|元ワード    |指定方式インデックス|追加インデックス|
|-----------|----------|---------|
|品川        |shinagawa|s|
|           ||sh|
|           ||shi|
|           ||shin|

検索時の動作

|元ワード|トークン1|トークン2|トークン3|トークン4|トークン5|トークン6|
|-------|-----|-------|---|---|---|---|
|しながわ|shina|shinaga|shinagawa|ga|gawa|wa|

前方一致検索が行われるのは、「wa」だけになり、「shina」は指定方式インデックスには出現しないので、「品川」に対して「しながわ」がヒットしないという結果になります。

## 対策
問題を解決するため、独自の ConcatenateJapaneseReadingFilter を作成しました。組み込み方法は、github の [README.md](https://github.com/ft28/practice_sol) を参照してください。docker 環境ではコンパイル済のモジュールが既に読み込まれるようになっています。

* ConcatenateJapaneseReadingFilter の特徴
    * 入力された複数トークンをつなぎあわせて新しく1つのトークンを作成
    * 「東京」->「toukyou」となるローマ字変換

* 今回作成したモジュールを使うと以下のようなトークンが作成されます
    * 登録時:「品川」
    
|モジュール名|トークン1|トークン2|トークン3|
|----------|-----|-------|---|
|JapaneseTokenizer|しな|が|わ|
|ConcatenateJapaneseReadingFilter(mode=2)|shinagawa|sinagawa|||

    
　　* 検索時:「しながわ」
    
|モジュール名|トークン1|トークン2|トークン3|
|----------|-----|-------|---|
|JapaneseTokenizer|しな|が|わ|
|ConcatenateJapaneseReadingFilter(mode=1)|shinagawa||||

JapaneseTokenizer で細かくトークンに分解されてしまったのを結合させて1つのトークンにしています。

* 追加設定

ConcateneteJapaneseReadingFilter を使う新しいfiledTypeを定義して、サジェスト検索でそのfiledTypeを使うよう設定を追加します。インデックス作成時は、mode="2" で 「新宿」-> 「shinjuku」、「sinzyuku」と複数トークンを作成し、検索時は、mode="1" で「shinjuku」だけ出力します。

```xml:schema.xml
    <!-- ローマ字読み検索用2 -->
    <fieldType name="text_ja_roman2" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
      <analyzer type="index">
        <!-- トークン化 -->
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal" />
        <!-- 全角カナ半角カナの表記ゆれを統一 -->
        <filter class="solr.CJKWidthFilterFactory"/>
        <!-- オリジナルのフィルタ -->
        <filter class="org.apache.lucene.analysis.ja.ConcatenateJapaneseReadingFilterFactory" mode="2" />
        <!-- アルファベットを小文字に統一 -->
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
      <analyzer type="query">
        <!-- トークン化 -->
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal" />
        <!-- 全角カナ半角カナの表記ゆれを統一 -->
        <filter class="solr.CJKWidthFilterFactory"/>
        <!-- オリジナルのフィルタ -->
        <filter class="org.apache.lucene.analysis.ja.ConcatenateJapaneseReadingFilterFactory" mode="1" />
        <!-- アルファベットを小文字に統一 -->
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>
```

```xml:solrconfig.xml
    <lst name="suggester">
      <str name="name">roman2</str>
      <str name="lookupImpl">AnalyzingInfixLookupFactory</str>
      <str name="dictionaryImpl">DocumentDictionaryFactory</str>
      <str name="field">keyword</str>
      <str name="weightField">weight</str>
      <str name="contextField">context</str>
      <str name="payloadField">payload</str>
      <str name="buildOnStartup">false</str>
      <str name="indexPath">store_roman2</str>
      <str name="highlight">false</str>
      <str name="suggestAnalyzerFieldType">text_ja_roman2</str>
    </lst>
```

* 確認

http://localhost:8983/solr/sample01/suggest?suggest=true&suggest.dictionary=default&suggest.dictionary=roman2&suggest.count=3&suggest.q=しながわ

```json
suggest: {
  default: {
    しながわ: {
      numFound: 0,
      suggestions: [ ]
    }
  },
  roman2: {
    しながわ: {
      numFound: 3,
      suggestions: [
        {
          term: "品川",
          weight: 735,
          payload: "品川 山手線"
        },
        {
          term: "品川",
          weight: 517,
          payload: "品川 東海道本線"
        },
        {
          term: "品川",
          weight: 408,
          payload: "品川 京浜東北線"
        }
      ]
    }
  }
}
```

## まとめ
Solr のサジェスト検索コンポーネントを使って、それっぽく動く日本語サジェスト検索環境が作れました。
これまでは、検索の入力を Solr でトークン化するまえに、ひらがな・カタカナをローマ字変換して、入力が必要以上に細かいトークンに分解される問題に対応をしていたのですが、JapaneseReadingFormFilterFactory のローマ字変換結果を私好みに変更するついでに、トークンを結合するという処理を追加してみました。

LIFULL(旧ネクスト）に在籍した時に書いた [独自基準のソートを実現するためのsolr plugin](http://www.lifull.blog/entry/2014/02/25/145220) に続き、独自のsolr pluginを作成してみましたが、solr pluginは、意外と簡単に実装できました。

## 参考リンク
* [Suggester | Apache Solr Reference Guide 7.2](https://lucene.apache.org/solr/guide/7_2/suggester.html)
   * 今回試した AnalyzingInfixLookupFactory 以外にも様々な方式が実装されているようです。用途によっては別の方式がマッチするかもしれません。

* [Elasticsearch キーワードサジェスト日本語のための設計 – Hello! Elasticsearch. – Medium]
(https://medium.com/hello-elasticsearch/elasticsearch-%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E3%82%B5%E3%82%B8%E3%82%A7%E3%82%B9%E3%83%88%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E8%A8%AD%E8%A8%88-352a230030dd)
   * わざわざ苦労してSolrのサジェスト検索コンポーネントを使わなくても、こちらに記載されている方法をsolrに当てはめて使えば、十分な気がしなくもないです…。

* [Apache Solr入門 ~オープンソース全文検索エンジン]
(https://www.amazon.co.jp/dp/4774189308)
  * 大変ありがたい日本語で読めるsolrの本です。機械学習系の本ばかりで検索系の本が少ないのはニーズが無いからなんでしょうか...。