Solrを使ったAuto Completeに挑戦



以前Solr4.0の記事を書いてからはや半年以上、いつの間にやらSolrが4.4になっていました。追いつけたもんじゃない。

Solr関係の調べ事をしていると[Solr Wiki](http://wiki.apache.org/solr/FrontPage)をよく見ることになるんですが、その中で[solr.EdgeNGramFilterFactory](http://wiki.apache.org/solr/AnalyzersTokenizersTokenFilters#solr.EdgeNGramFilterFactory)がずっと気になっていたわけです。これを使えばいい感じのAuto Completeができるんじゃないか？というかそのために作られたようなFilterにしか見えない！という感じで。

そんなわけで、この`solr.EdgeNGramFilterFactory`を使って日本語対応のAuto Completeを実装してみました。

簡単な動作確認しかしていませんが、うまく動いているような・・・？どうでしょう。

以下は全てSolr4.4での実装メモです。


## `solr.EdgeNGramFilterFactory`について

まずこの`solr.EdgeNGramFilterFactory`がどのようなものかというと、各トークンを片側から切り刻んでNGram化するものです。

[Solr Wiki](http://wiki.apache.org/solr/AnalyzersTokenizersTokenFilters#solr.EdgeNGramFilterFactory)の設定例は以下のような感じです。

```
<filter class="solr.EdgeNGramFilterFactory" minGramSize="2" maxGramSize="15" side="front"/>
```

どんな動作をするのか？はたぶん実例が一番わかりやすいと思うので、文字列 `solr.EdgeNGramFilterFactory' に適用してみました。

```
solr.EdgeNGramFilterFactory
  |-> so
  |-> sol
  |-> solr
  |-> solr.
  |-> solr.E
  |-> solr.Ed
  |-> solr.Edg
  |-> solr.Edge
  |-> solr.EdgeN
  |-> solr.EdgeNG
  |-> solr.EdgeNGr
  |-> solr.EdgeNGra
  |-> solr.EdgeNGram
  |-> solr.EdgeNGramF
```

こんな感じで文字列を左(front)から2文字、3文字、・・・、15文字と区切ってくれるようです。

やっぱりAuto Complete向きな気がしますね。

## 設定例

そんなわけで、日本語対応の前方一致フィールド用の`fieldType`を定義してみました。

```
<fieldType name="text_ja_start_with" class="solr.TextField" positionIncrementGap="100">
  <analyzer type="index">
    <tokenizer class="solr.JapaneseTokenizerFactory" mode="search" userDictionary="lang/userdict_ja.txt"/>
    <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
    <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
    <filter class="solr.CJKWidthFilterFactory"/>
    <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" side="front"/>
  </analyzer>
  <analyzer type="query">
    <tokenizer class="solr.JapaneseTokenizerFactory" mode="search" userDictionary="lang/userdict_ja.txt"/>
    <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
    <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
    <filter class="solr.CJKWidthFilterFactory"/>
    <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
    <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="false"/>
    <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
    <filter class="solr.LowerCaseFilterFactory"/>
  </analyzer>
</fieldType>
<fieldType name="text_start_with" class="solr.TextField" positionIncrementGap="100">
  <analyzer type="index">
    <tokenizer class="solr.JapaneseTokenizerFactory" mode="search" userDictionary="lang/userdict_ja.txt"/>
    <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
    <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
    <filter class="solr.CJKWidthFilterFactory"/>
    <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" side="front"/>
  </analyzer>
  <analyzer type="query">
    <tokenizer class="solr.WhitespaceTokenizerFactory"/>
    <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25" side="front"/>
    <filter class="solr.CJKWidthFilterFactory"/>
    <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
    <filter class="solr.LowerCaseFilterFactory"/>
  </analyzer>
</fieldType>
```

いやー、長いですね！

ちょっと動作上の都合で1つの`fieldType`にまとめきれず、2つ定義しています(これ1つでいけるよ！という設定例があれば教えてください。。)。

とりあえずレビューも兼ねて1つずつ見てみることにします。

### `analyzer type="index"`

まず`type="index"`属性のついた`analyzer`要素ですが、実は2つ定義している`fieldType`の`text_ja_start_with`と`text_start_with`で全く同じ定義をしています(つまり冗長。直したいですね。。アイデアをください)。

定義の内容を抜き出すと、以下のようになっています。

```
<analyzer type="index">
  <tokenizer class="solr.JapaneseTokenizerFactory" mode="search" userDictionary="lang/userdict_ja.txt"/>
  <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
  <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
  <filter class="solr.CJKWidthFilterFactory"/>
  <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" side="front"/>
</analyzer>
```

ちょっと`filter`が多いので箇条書きにします。

- `solr.JapaneseTokenizerFactory`で分かち書き
  - `すもももももももものうち`が`すもも` `も` `もも` `も` `もも` `の` `うち`になるようなアレですね
- `solr.JapanesePartOfSpeechStopFilterFactory`で余計な品詞を落とす
  - 接続詞とかが前方一致でマッチしてきたら嫌かなあということで
- `solr.StopFilterFactory`で余計な単語を落とす
  - 一応入れていますが、デフォルトの辞書はほぼコメントアウトしています
  - 例えば「これは酷い」の「これ」がインデックスされなくなったりするので、ひらがな1文字や放送禁止用語などのみを落とすとか？
- `solr.CJKWidthFilterFactory`で半角カナと全角カナの表記ゆれを統一
  - どちらでもマッチしてほしいですよね
- `solr.JapaneseKatakanaStemFilterFactory`でカタカナ末尾の長音を統一
  - あってもなくてもな感じですが、「コンピューター」と入力して「コンピュータ」がAuto Completeされてもいいですよね
- `solr.JapaneseReadingFormFilterFactory`で漢字を読みに変換
  - これのおかげで`fieldType`が2つになった気が(すぐ下に書いてます)
- `solr.ICUTransformFilterFactory`でカタカナひらがなを統一
  - ひらがなで検索してもカタカナで検索してもヒットしてほしい
- `solr.LowerCaseFilterFactory`でアルファベットを小文字化
  - ケースインセンシティブの方が嬉しいかなと
- `solr.EdgeNGramFilterFactory`でトークンをNGram化
  - 今回の主役！

この`analyzer`を通すと、例文`追いオリーブオイル`は`追い` `オリーブ` `オイル`に分かち書きされ、最終的に`お` `おい` `お` `おり` `おりい` `おりいぶ` `お` `おい` `おいる`という形でインデックスされます。

### `analyzer type="query"`

次に`type="query"`属性のついた`analyzer`要素ですが、これは`text_ja_start_with`と`text_start_with`で違う定義をしています。

定義を分けた理由は`solr.JapaneseReadingFormFilterFactory`です。
このFilterはどうやら形態素解析後の各トークンの読みがなフィールドにアクセスしているだけで、自分で解析などをするわけではない？みたいなので(ちょびっとだけソースも覗いた)、`solr.JapaneseTokenizerFactory`などで形態素解析した後にしか使えなさそうでした。

今回想像していたAuto Completeは

- `おいお`で`追いオリーブオイル`がヒットする
- `追い`で`追いオリーブオイル`がヒットする
- `オリーブ`で`追いオリーブオイル`がヒットする

といったものだったので、`おいお`のような未知語を左側からNGram化したり`追い`を読みがなに変換したりする必要があったわけです。

ここで言う`おいお`をカバーする`fieldType`が`text_start_with`で、`追い`をカバーするのが`text_ja_start_with`だったりします。ちなみに`オリーブ`はどちらでもいけそうな気がします。

#### `text_start_with`の`analyzer type="query"`

これはスペース区切りで入力されたクエリを左側からNGram化していくものですね。

とりあえず定義を再掲します。

```
<analyzer type="query">
  <tokenizer class="solr.WhitespaceTokenizerFactory"/>
  <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25" side="front"/>
  <filter class="solr.CJKWidthFilterFactory"/>
  <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
  <filter class="solr.LowerCaseFilterFactory"/>
</analyzer>
```

この動作を箇条書きすると以下のようになります。

- `solr.WhitespaceTokenizerFactory`で空白区切りで単語を分割
  - 未知語に対応できる最強の区切り空白で単語を区切ります
- `solr.EdgeNGramFilterFactory`で単語を左側からNGram化
  - 前方一致のためですね
- `solr.CJKWidthFilterFactory`で半角全角を統一
  - `analyzer type="index"`と同様
- `solr.ICUTransformFilterFactory`でカナの長音を統一
  - `analyzer type="index"`と同様
- `solr.LowerCaseFilterFactory`でケースインセンシティブ化
  - `analyzer type="index"`と同様

この`analyzer`に`おいお`を渡すと`お` `おい` `おいお`のようになり、`analyzer type="index"`でインデックス化された`追いオリーブオイル`は`お` `おい` `お` `お` `おい`で一致します。ちょっと例文のせいか色んなところにマッチしてますね。これはいいのか。。

そして`追い`だと`追` `追い`になってヒットしません。

ちなみに`オリーブ`は`お` `おり` `おりい` `おりいぶ`になって、`お` `お` `おり` `おりい` `おりいぶ` `お`でヒットします。

#### `text_ja_start_with`の`analyzer type="query"`

日本語形態素解析ベースでマッチさせるためのものです。漢字変換をした後の単語でもAuto Completeされることを目指しています。

これも定義を再掲。

```
<analyzer type="query">
  <tokenizer class="solr.JapaneseTokenizerFactory" mode="search" userDictionary="lang/userdict_ja.txt"/>
  <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
  <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
  <filter class="solr.CJKWidthFilterFactory"/>
  <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
  <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="false"/>
  <filter class="solr.ICUTransformFilterFactory" id="Katakana-Hiragana"/>
  <filter class="solr.LowerCaseFilterFactory"/>
</analyzer>
```

各定義での動作を箇条書きすると以下のようになります。

- `solr.JapaneseTokenizerFactory`で日本語を分かち書き
  - ちゃんとした単語(漢字変換済みなど)で入力されたものを取り出せる
- `solr.JapanesePartOfSpeechStopFilterFactory`
  - `analyzer type="index"`と同様
- `solr.StopFilterFactory`
  - `analyzer type="index"`と同様
- `solr.CJKWidthFilterFactory`で半角全角を統一
  - `analyzer type="index"`と同様
- `solr.JapaneseKatakanaStemFilterFactory`
  - `analyzer type="index"`と同様
- `solr.JapaneseReadingFormFilterFactory`
  - `analyzer type="index"`と同様
- `solr.ICUTransformFilterFactory`でカナの長音を統一
  - `analyzer type="index"`と同様
- `solr.LowerCaseFilterFactory`でケースインセンシティブ化
  - `analyzer type="index"`と同様

同様が多いのは手抜きではなくて、実際に`filter`の組み方が`analyzer type="index"`と同じためです。実際の差分は一番最後の`solr.EdgeNGramFilterFactory`があるかないかです。

この`analyzer`を`追い`に適用すると`おい`になって、`追いオリーブオイル`の`おい`2つにマッチします。

## まとめ

こんな感じで思いつきレベルでSolrでのオートコンプリートを実装してみましたが、動きそうな気配はしています。`text_start_with`の方のマッチし過ぎに一抹の不安を覚えますが。。

ただ、どちらにせよ日本語でしっかりとマッチする`text_ja_start_with`に重みを付けた検索が妥当かなと思います。こちらの方が精度も高いはずなので、自然かなと。

あとはそれなりの量のインデックスを作ってクエリを投げてみて〜といった感じで改善をしていけば実用レベルになりそうな気がしています。というか「マッチ率が高いものが上に来るのさ！」という割り切りもアリかなと個人的には思ってます。はい。

こんな感じですね！


[元記事](http://www.ari-hiro.com/blog/2013/09/01/solr-autocomplete/)