Apache Solrを使った検索サジェスターの作り方まとめ


# 概要
Apache Solr(以下、Solr)で商品検索のサジェスターを作ったので、それを紹介します。
サジェスターを作るにあたり、どのようにスキーマやサーチコンポーネントを定義すれば良いのかを説明します。

なお、この記事はsolr 4.10.4を対象にした記事です。
それ以外のバージョンでは設定項目が変わってくる場合があります。

# サジェスターとは
サジェスターとは、ユーザーが検索用のフォームに単語を入力している途中に、その入力途中の単語を補完する機能です。
例えば、Google検索でサジェスターについて調べようとした時に、「さじぇ」と入力した時点で以下のように「さじぇ」に続く単語が候補として現れます。

![スクリーンショット 2016-08-11 20.45.31.png](https://qiita-image-store.s3.amazonaws.com/0/74551/c9e0d0ad-b0a3-42f7-b182-979eb359980e.png "スクリーンショット 2016-08-11 20.45.31.png")


このような機能を実装することによって、ユーザーがテキストを入力する手間が省けたり、入力間違いをした単語で検索をしてしまうことを防げたりする効果があります。

# 日本語のサジェスターの難しいところ
サジェスターの基本動作は、検索対象のドキュメント中の単語リストを保持し、ユーザーの入力途中の単語で前方一致検索をかけることです。
しかし、日本語に対応したサジェスターを作る場合は、英語などの他の言語にはない、日本語特有の難しさがあります。

主な課題は「単語の抽出」と「漢字の読み」です。

## 単語の抽出
英語の文章は単語と単語との間がスペースや記号などで区切られています。
そのため、文章を単語に分解する処理を簡単に書くことができます。

しかし他方で日本語の文章にはそのような機械的に単語分割できるような目印はありません。
そのため、日本語の文章を単語分割するためには形態素解析という処理を行う必要があります。
Solrにはkuromojiという形態素解析エンジンが組み込まれているため、これを利用して文章を単語に分割します。

## 漢字の読み
さらに、単語に分割した後には「漢字の読み」の問題もあります。
例えば、「とう」と入力した時に「東京」という単語を返すためには、「東京」という単語の読みが「とうきょう」であるという情報が必要です。
kuromojiで形態素解析を行うと単語の読みの情報も取得できるために、これを利用します。

また、未確定の子音やひらがなにも対応する必要があります。
「とうky」と入力した時には、「東京」や「東急」といった単語を候補として表示する必要があります。
これらのことを解決するために検索対象のドキュメント及び、ユーザーが入力中のテキストをローマ字読みに変換した後に前方一致検索をかける必要があります。

それと同時にローマ字読みに変換せずに前方一致検索をかける必要もあります。
これは「東」というテキストを入力した時に「東京」を候補として表示するためです。
「東」のローマ字読みは「higashi」なので、「tokyo」に前方一致しないからです。

# 日本語に対応したサジェスターの処理フロー
上記のような点を考慮してサジェスターを作る場合は次のような処理が必要です。

* 検索対象のドキュメントを単語単位に分割し単語リストを作る。
* その時には各々の単語に対して、その単語のローマ字での読みを併せて保持する。
* ユーザーがテキストを入力する毎に入力されたテキストをローマ字読みに変換し、上記のリストに対して前方一* 致検索を行う。
* また、ローマ字読みに変換せずに前方一致検索も行う。

![スクリーンショット 2016-08-12 12.17.19.png](https://qiita-image-store.s3.amazonaws.com/0/74551/2e62f986-3635-bf9f-d452-68e6689faa4f.png "スクリーンショット 2016-08-12 12.17.19.png")

# Solrの設定ファイル
さて、サジェスターの作り方が分かりましたので、それをSolrの設定ファイルに落とし込みます。

## schema.xml
まずは、スキーマ定義です。
ここではtext_ja_for_suggestとtext_ja_romajiという2つの型を定義しています。

text_ja_for_suggestはサジェスター用に文章を単語に分割するための型です。
検索対象のドキュメントに対して、以下に定義されるanalyzerを通すことでサジェスト用の単語リストを作ります。

検索対象のドキュメントの英数字やカタカナの全角半角の表記揺れをICUNormalizer2CharFilterFactoryで統一した後に、JapaneseTokenizerFactoryで形態素解析を行っています。
検索用にこのtokenizerを使用するときには `mode=search` で使用することが多いですが、この設定をサジェストで使ってしまうと、単語が細かくなりすぎてしまうために `mode=normal` で使用しています。
分割後の単語に対しては、単語単位での表記揺れの統一や、不要な単語のフィルタリングなどを行っています。

```xml
<fieldType name="text_ja_for_suggest" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
  <analyzer>
    <!-- ICU正規化(NFKC) -->
    <charFilter class="solr.ICUNormalizer2CharFilterFactory" name="nfkc"/>
    <!-- 形態素解析(normal mode) -->
    <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
    <!-- 品詞によるフィルタリング -->
    <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja_for_suggest.txt" />
    <!-- 同義語 -->
    <filter class="solr.SynonymFilterFactory" synonyms="synonyms_for_suggest.txt" ignoreCase="true" expand="true" />
    <!-- カタカナ長音の正規化 -->
    <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
    <!-- アルファベットを小文字に正規化 -->
    <filter class="solr.LowerCaseFilterFactory"/>
    <!-- 単語によるフィルタリング -->
    <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja_for_suggest.txt" />
  </analyzer>
</fieldType>
```

text_ja_romajiは単語をローマ字読みに変換するための型です。
上の型で分割された単語のローマ字読み変換に使用するのに加えて、ユーザーが入力途中のテキストをローマ字読み変換するためにも使用します。

前半部分はtext_ja_for_suggestと同じです。
JapaneseReadingFormFilterFactoryを `mode=romaji` で使用することによって、単語のローマ字読みを取得します。
ShingleFilterFactoryはユーザーが入力中のテキストが複数単語に分割された時に、それらを結合してから前方一致検索をするために使用しています。

```xml
<fieldType name="text_ja_romaji" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
  <analyzer>
    <!-- ICU正規化(NFKC) -->
    <charFilter class="solr.ICUNormalizer2CharFilterFactory" name="nfkc"/>
    <!--形態素解析(normal mode) -->
    <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
    <!-- ローマ字の読みに変換 -->
    <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="true"/>
    <!-- トークンNGramの生成 -->
    <filter class="solr.ShingleFilterFactory" minShingleSize="2" maxShingleSize="99" outputUnigrams="false" outputUnigramsIfNoShingles="true" tokenSeparator=""/>
    <!-- アルファベットを小文字に正規化 -->
    <filter class="solr.LowerCaseFilterFactory"/>
  </analyzer>
</fieldType>
```

そして、サジェスト用の単語が格納されるフィールドである、suggestフィールドを作ります。
copyFieldを使い、商品名が入るフィールドであるtitleフィールドの内容をsuggestフィールドにコピーしています。

```xml
<fields>
  <field name="suggest" type="text_ja_for_suggest" indexed="true" stored="true"/>
</fields>

<copyField source="title" dest="suggest"/>
```

## solrconfig.xml
上記のschema.xmlでフィールド及び型の定義が完了したので、次に、それらを使って検索を行うためのモジュールの設定を書きます。
Solrでは検索を行うためのモジュールはSearchComponentと呼ばれています。
ここでは2つのSearchComponentを併用しています。

### SpellCheckComponent
1つ目のSearchComponentがSpellCheckComponentです。
このSearchComponentはローマ字読みに変換した後の単語に対して前方一致検索を行い、その結果を返すSearchComponentです。

suggestAnalyzerFieldTypeとqueryAnalyzerFieldTypeにtext_ja_romajiを指定することでローマ字読みに変換することを指定しています。

```xml
<searchComponent class="solr.SpellCheckComponent" name="suggest_ja">
  <lst name="spellchecker">
    <str name="name">suggest_ja</str>
    <str name="classname">org.apache.solr.spelling.suggest.Suggester</str>
    <str name="lookupImpl">org.apache.solr.spelling.suggest.fst.AnalyzingLookupFactory</str>
    <str name="storeDir">suggest_ja</str>
    <str name="buildOnStartup">true</str>
    <str name="buildOnCommit">true</str>
    <str name="comparatorClass">freq</str>
    <str name="field">suggest</str>
    <str name="suggestAnalyzerFieldType">text_ja_romaji</str>
    <bool name="exactMatchFirst">true</bool>
  </lst>

  <str name="queryAnalyzerFieldType">text_ja_romaji</str>
</searchComponent>
```

### TermsComponent
もう1つのSearchConponentはTermsComponentです。
このSearchComponentはローマ字読みに変換せずに、単純な前方一致検索を行います。

```xml
<searchComponent name="terms" class="solr.TermsComponent"/>
```

### requestHandler
上で定義した2つのSearchComponentをrequestHandlerに登録して、HTTPで叩けるようにします。
サジェスターはユーザーが1文字入力する毎に呼び出されるため、高速に応答する必要があります。
そのため、1回のHTTPリクエストで2つのSearchComponentに対して同時に処理を投げられるように、1つのrequestHanderに2つのSearchComponentをひも付けます。

```xml
<requestHandler name="/suggest_ja" class="org.apache.solr.handler.component.SearchHandler" startup="lazy">
  <lst name="defaults">
    <str name="spellcheck">true</str>
    <str name="spellcheck.dictionary">suggest_ja</str>
    <str name="spellcheck.collate">false</str>
    <str name="spellcheck.count">10</str>
    <str name="spellcheck.onlyMorePopular">true</str>

    <bool name="terms">true</bool>
    <bool name="terms.distrib">false</bool>
    <str name="terms.fl">suggest</str>
  </lst>

  <arr name="components">
    <str>suggest_ja</str>
    <str>terms</str>
  </arr>
</requestHandler>
```

# 未確定のテキストに対応する
上記設定をそのまま使うと、日本語入力の未確定テキストに対しては正しく結果を返さないことがあります。
text_ja_romaji型は一部の単語に対しては正しくローマ字の読みを返すことができないからです。
例えば、ユーザーが「きゃみ」と入力したときには、「kiゃみ」という結果になってしまいます。
これは「きゃみ」という単語が形態素解析エンジンの辞書にないために起こる現象です。

先ほどschame.xmlで定義したCharFilterをさらに追加して解決してもいいですが、アプリケーション側で行う方が簡単でしたので、一部のテキストに対してはSolrにリクエストを投げる前にアプリケーション側でローマ字変換を行いました。
以下の正規表現にマッチするテキストの場合は形態素解析をせずにローマ字読みに変換することが可能ですので、アプリケーション側でローマ字変換を行います。

ローマ字変換にはromajiというgemを使いました。

```ruby
require 'romaji'

if word =~ /^(\p{InHiragana}|\p{InKatakana})+[a-zA-Zａ-ｚＡ-Ｚ]{0,3}$/
  word = Romaji.kana2romaji(word)
end

# きゃみ -> kyami
# 靴 -> 靴
```

# まとめ
Solrを使って日本語に対応したサジェスターを作ることができました。
日本語特有の問題である、単語抽出と漢字の読みの問題も解決し、入力途中のテキストに対してもサジェストを行うことができるようになりました。

# 参考
## Apache Solr
Apache Solrの公式サイトです。
http://lucene.apache.org/solr/

## kuromoji
Javaで形態素解析を行うためのライブラリです。
http://www.atilika.org/

## [改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン
Solrを扱うならば必読書です。
この記事で紹介したサジェスターの基礎的な部分はこの本を参考にしました。

https://www.amazon.co.jp/dp/4774161632

## romaji
rubyでひらがな→ローマ字変換を行うgemです。

https://github.com/makimoto/romaji

# 余談
Qiitaの検索にもサジェスター or インクリメンタルサーチ機能が付いてくれると嬉しいです。

![スクリーンショット 2016-08-18 11.16.37.png](https://qiita-image-store.s3.amazonaws.com/0/74551/f20f0046-b089-e833-d16c-8a3d11e8ed8e.png "スクリーンショット 2016-08-18 11.16.37.png")