ElasticsearchでRettyのサジェスト検索を作ったときの苦労話

この記事は[Retty Inc. Advent Calendar 2017](https://qiita.com/advent-calendar/2017/retty) 14日目です。

こんにちは！Rettyで検索を担当している@r4-keisukeです。
昨日は@akiさんの[多言語リソース管理がめんどくさいので、どうにかして楽がしたいと思った話 iOS編](https://qiita.com/aki/items/2e1e767af8993964151e)でした。

去年は[Athenaを早速試してみた](https://qiita.com/r4-keisuke/items/59f15dc1bef7ee0b0aed)と[Elasticsearchのスコアリングを眺めてみる](https://qiita.com/r4-keisuke/items/d653d26b6fc8b7955c05)を書きました。

今日は、Elasticsearchでサジェスト機能を構築して運用しながら直面した難点の話を語っていきたいと思います。もしこれからサジェスト機能を実装する予定の方々に少しでも役に立てれば幸いです。

# 前置き
- 実務で体験したものを中心にした記述になりますので、例え方がかなり地域・飲食店などに偏る可能性があります。
- 私自身、外国出身で日本生活は10年目に突入したもので、日本語自体に対する理解が不足してるところもあるかと思いますが、そこはご指摘・ご指導いただけると幸いです。
- Elasticsearchを用いた検索の基本的な知識が必要なところもありますが、今回はそういう部分の説明は省略しました。

# 日本語ならではの難点
日本語は世界的に見ても珍しいぐらいいろんな文字が混ざった形で活用される言語で有名ですよね。
漢字、ひらがな、カタカナ、ローマ字、アラビア数字。。。
 **「Lサイズ靴下3足でなんと500円！」** のような表現が自然と使われていて、これを検索で引っ掛けるにはそれなりの対策が必要になってきます。

それだけじゃありません。漢字は音読みと訓読みがあり、またあて字とか出てくると~~キラキラネーム~~もはや規則のない無法状態です。

さっと羅列してみますと、

- 漢字、ひらがな、カタカタ、アラビア数字、ローマ字が混ざってて、区切りもわからない
- 漢字は訓読み、音読み、あて字などから読み方が一定ではない
- ローマ字入力がベースの文字入力（スマホは例外）

などが日本語検索での難点だといえます。

# 漢字、ひらがな、カタカタ、アラビア数字、ローマ字が混ざってて、区切りもわからない

これ、日常生活には問題ではありません。日本で生まれ、教育を受け、生活をしていれば正直不自由しないと思います。むしろいろんな遊びが出来て良かったりもします。
ただ、そういういろんな活用が出来てしまう点が、日本語検索を難しくしています。

 **「博多らぁめん屋ウマウマTONKOTSU〜豚骨〜246沿い店」** という店名があるとしましょう。
この店名はどういう検索語にヒットするのが自然なのか考えてみます。
「博多」「博多ラーメン」「らぁめん」「ラーメン」「ラーメン屋」「うまうま」「ウマウマ」「とんこつ」「豚骨」「TONKOTSU」「うまうま豚骨」・・・
よく見ると検索語は規則があるように見えます。
検索語は、意味をもつ単語で投げられることが多いということに気づきます。いきなり「まうま」とか「骨〜」で検索することは少ないはず。

ということは、文字列を意味をもつ単語単位で分解してインデックスを作ればいいですよね。
そうです、それが形態素解析でそのために使うのが辞書ですが、今回の文字列の中には強敵が何個かあります。
「らぁめん」「ウマウマ」「TONKOTSU」「246沿い店」
これらは辞書に登録されてない可能性が高いと思います。
「らぁめん」の場合は「ラーメン」の類似語、シノニムに設定してしまえばOKですが、一旦ここではシノニムは忘れて考えます。
辞書にないってことは、形態素解析出来ないということなので、Elasticsearchのanalyzeを使ってやってみると

```json
GET test/_analyze
    {
      "text":"博多らぁめん屋ウマウマTONKOTSU〜豚骨〜246沿い店",
      "analyzer": "neologd_analyzer"
    }
    
    {
      "tokens": [
        {
          "token": "博多",
          "start_offset": 0,
          "end_offset": 2,
          "type": "word",
          "position": 0
        },
        {
          "token": "ら",
          "start_offset": 2,
          "end_offset": 3,
          "type": "word",
          "position": 1
        },
        {
          "token": "ぁめん",
          "start_offset": 3,
          "end_offset": 6,
          "type": "word",
          "position": 2
        },
        {
          "token": "屋",
          "start_offset": 6,
          "end_offset": 7,
          "type": "word",
          "position": 3
        },
        {
          "token": "ウマ",
          "start_offset": 7,
          "end_offset": 9,
          "type": "word",
          "position": 4
        },
        {
          "token": "ウマ",
          "start_offset": 9,
          "end_offset": 11,
          "type": "word",
          "position": 5
        },
        {
          "token": "tonkotsu",
          "start_offset": 11,
          "end_offset": 19,
          "type": "word",
          "position": 6
        },
        {
          "token": "豚",
          "start_offset": 20,
          "end_offset": 21,
          "type": "word",
          "position": 7
        },
        {
          "token": "骨",
          "start_offset": 21,
          "end_offset": 22,
          "type": "word",
          "position": 8
        },
        {
          "token": "246",
          "start_offset": 23,
          "end_offset": 26,
          "type": "word",
          "position": 9
        },
        {
          "token": "沿い",
          "start_offset": 26,
          "end_offset": 28,
          "type": "word",
          "position": 10
        },
        {
          "token": "店",
          "start_offset": 28,
          "end_offset": 29,
          "type": "word",
          "position": 11
        }
      ]
    }
```

予想通りというか、豚骨もダメとは涙が出ます。
「ngramで全カバーでいいじゃん」とも思ってみました。
しかし、「ターバ」という検索語で「ターバンおじさんのインドカレー」というお店を検索したいのに「スターバックス」をたくさんサジェストしてしまう検索になってしまうので、やっぱり違います。

結局この問題では、単語を意味をもつ単位で区切ってその単語を前方一致させることが最も重要だなーと気づきました。他の全角半角とか、ひらがな・カタカナが混ざってることはCharacter filterを使ってどうにでもなります。しかし区切れる基準が何もないので、ここはひとつ、手で区切りを入れます。
 **「博多 らぁめん屋 ウマウマ TONKOTSU 豚骨 246沿い店」** 

これでElasticsearchのedge ngramでインデックスを作ったり検索クエリをmatch_phrase_prefixで投げたりすれば、区切られた文字列の前方一致でのヒットは問題ないです。

でも、出来た！！！と喜べないのが日本語検索です。

# 漢字は訓読み、音読み、あて字などから読み方が一定ではない

漢字は漢字自体を入力するものではないので、変換機能を使って入力します。
PCの場合、ローマ字タイピング→ひらがな入力→漢字変換の流れです。
スマホの場合はフリックを使うと仮定したらひらがな入力→漢字変換になりますね。

でも漢字の読み方は一定ではなく、特定ひらがな文字列を漢字に変換することも、漢字をひらがなに変換することも容易ではありません。ましてや固有名詞になると、付けたもの勝ちです。
これは日本人でもよく混乱する所でして、昔からその解決方法として、読み仮名やルビをつけることで正しい読み方を伝えています。読み仮名やルビ。読み仮名。。。

そうです。 **手でつけるのです。** 

お店の名前とは別途、読み仮名を格納したfieldを同じtypeの中に入れます。
analyzerはedge ngram analyzerで良さげな気がしますね。
その読み仮名が入ってるfieldと、先程対応したfieldにmulti_matchを使ったりしてクエリを投げるようにすると良いと思います。

 **「はかた らぁめんや うまうま とんこつ とんこつ 246ぞいてん」** 
これで漢字変換前の「はかた」でも、「らぁめん」でも、「うまうま」「とんこつ」でもヒットします。

 **面倒臭い** と思うかもしれませんが、「La Elastica Searche」という店舗名があるとして、これを「ラ エラスティカ シアルシェ」と読むかどうか、付けた人しか確信出来ない状態で確実に検索にヒットさせる正しい情報を提供するには、実際に調べてどう読むのか、読み仮名を確保するほかありません。
ここで改めて、 **日本語には色んな種類の文字が混ざっていて、その読み方は様々** ということを思い出しましょう。

幸い、固有名詞以外の代名詞や地域名などは機械的に変換出来たり、そもそも色んなところから公式データなどが確保出来るので、それを利用すればそこまで難しくはありません。
都道府県名とか、駅名などは特にそうですね。

# ローマ字ベースの文字入力

これが残ってましたね。PCで入力する場合、「あざｂ」ぐらいの入力で「麻布十番」をサジェストしてほしいなーというニーズがあったりします。実際に「ひらがなから変換したりするのも面倒くさいからローマ字でもサジェスト出来てほしい」という話も開発中に意見としてありました。
これは、ElasticsearchのCharacter filterのmapping char filterを使って、「あ→a」「ざ→za」などでマッピングを行えば、入力検索語にも同じanalyzerを適用することで、「azabujyuban」でインデックスされてる「麻布十番」に対して「あざｂ」で検索したら「azab」で検索することになり、結果としてヒットさせることが出来ます。

しかし、実際に使われる場面がそんなに多いとは思えなかったので、analyzerを複雑にしたくもなかったので、ローマ字での検索は対応しませんでした。


# 実際に運用して悩んだところ

今までの技術的部分は解決したとしても、実際に検索は色んな人が様々な期待を持って使うもので、求める体験も様々です。以下の内容は、実際開発・運用中に出てきた悩みどころを並べたもので、どっちが正しいとかではなく、どっちが多くのユーザさんに満足できる体験を提供できるか？の観点でそれぞれ考えていただきたいです。

## 中目黒は目黒で検索されるべきなのか
部分一致と前方一致の話で、柔軟性を持った検索の部分一致か、検索ノイズを減らす前方一致かでかなり悩みました。辞書によっては中目黒が「中目黒」のまま一つの形態素だったり、「中」「目黒」で分けられたりして、辞書にも傾向があったりします。NEologdのように造語・新語に強い辞書の場合は細く分解するより、よく使われる言葉は一つの形態素として吸収してるイメージがあります。
Rettyでは、検索されるユーザさんも色んな世の中のサービスに触れているので、検索というものをどう使うと求める結果にたどり着けるかが自然と身に付いてることが多いと考え、柔軟性を持ってノイズが増えた結果を提供するより、明確に入力された言葉に充実な検索結果の提供が可能な前方一致を採用してます。

## サジェストは文字列だけでは完成しない
単純に文字列比較でサジェストするには、サジェストする対象、つまり検索結果がたくさん存在して、枠に入り切らないことがあります。飲食店の場合は、色んな所に同じ名前で店舗が存在したりするため、実際検索で求める結果が入る枠がなくなってしまうことがあります。
例えば、店舗名を10件までサジェスト出来る枠があるとして、全国に30店舗展開中の「エラエララーメン」というお店を「エラエラ」まで入力されたら、30店舗全て、候補としてサジェスト対象になり、検索で出てきてほしい内容が表示されないこともありえます。
サジェスト枠は限られている中、ユーザさんが求めてる店舗はどれなのかを明確にするためには、検索語以外の情報を利用する必要があります。
現在地の座標や検索しようとしている場所や地域の座標で近い店舗を優先的にサジェストするとか、検索ログをためてよく検索されるものを優先的にサジェストするなどの工夫になるでしょう。
どの情報を用いてサジェストするか？という部分は何を提供するかによって変わってくるもので、正解があるわけではないと思いますので、悩みどころです。

# 終わりに

サジェスト機能はElasticsearchを使うと意外と簡単に実装可能ですが、実際運用していくために必要なのは何より手元にあるデータがいかに充実しているかによってやれることが増えたり減ったりします。
細かいロジックの作り込みに時間をかけるより、単純作業になるかもしれませんがより多くのデータの充実と精度上げに時間をかけたほうが、より良いサジェスト・検索体験に役立つと思います。

社内の先輩エンジニアから、「検索は育てるもの」という話を聞きましたが、まさにその通りだと思います。データの整備やユーザさんの使い方をウォッチしてニーズに応えられる機能改善、新しい辞書やシノニム対応などなど、手のかかる子ですが、ユーザさんの反応が非常にわかりやすく、その質も厳しく問われる機能なので、育て甲斐のある、楽しい機能だと思います。

改めて、この記事が皆さんの検索育てに少しでも約立てればと思います。

明日は@isaoekaの OS X アプリケーション開発(Today Extension)に触れてみる です！お楽しみに〜