Elasticsearchで日本語のサジェストの機能を実装する

サジェストは、優れた検索エクスペリエンスにおける重要な要素です。一方で、この機能は一部の言語では実装が難しい場合があり、日本語もそのような言語の1つです。このブログでは、日本語のサジェスト機能を実装する際の課題と、Elasticsearchを使用してこれらの課題を克服する方法をご紹介します。

日本語のサジェストの特徴

次の図にはGoogleの日本語サジェスト候補を表示しています。この例では、キーワードは「日本」です。


Googleでの日本語の自動入力候補


日本語のサジェスト機能の実装が英語よりも困難であることには、いくつかの要因があります。

単語の区切りがわかりにくい

サジェストの機能を実装するには、単語を分割するためのアナライザーが必要です。英語を含む大半のヨーロッパ言語では、単語がホワイトスペースで区切られるため、容易に文章を単語に分割できます。しかし、日本語では個々の単語をホワイトスペースで分割することはありません。そのため、日本語の文章を個々の単語に分割するには形態素解析を行う必要があります。

漢字とその読み方の両方を考慮する必要がある

日本語の表記体系は、ひらがなとカタカナの2種類の五十音(総称して仮名)で構成され、表語文字である漢字も使用されます。1つの漢字には、複数の異なる読み方(発音の仕方)が存在する場合があります。そのため、サジェスト機能を実装するときは、漢字そのものだけでなく、漢字の読み方も検討する必要があります。

通常、ユーザーが日本語を入力するときは、アルファベットの文字(ローマ字)を入力して、仮名に変換してから漢字に変換していきます。たとえば、k、eの順に入力すると、「け」(「ke」と発音)という仮名文字になります。 サジェスト機能を実装するときは、入力中のまだ仮名に変換されていない不完全な入力を考慮する必要があります。たとえば、「けんs」(kens-)というクエリに対するサジェスト候補には、「検索」(kensaku)や「検査」(kensa)などの単語を含める必要があります。この場合、「けんs」という入力をローマ字(kens-)に変換することで、これらのサジェスト候補に結びつく語幹が得られます。

もう1つの検討事項は、漢字は単体で表示される場合と他の文字と組み合わせて表示される場合とでは読み方が異なるという点です。たとえば、「東」という文字は単体では「ひがし」と読みますが、「東京」という単語では同じ漢字を「とう」と読みます。 一般的に、ユーザーが「東」を検索するときは、非常によく使われている単語「東京」をサジェスト候補として提案する必要があります。しかし、「東」単体の読み方をもとにすると、この結果は得られません。この場合、求められるサジェスト候補を提案するには、テキストをローマ字に変換する作業と、漢字をローマ字に変換せず文字として検索する作業の両方を行う必要があります。

Elasticsearchの既定のサジェスト機能を使用しない理由

これらの要件を満たすアナライザーを設計する場合、通常は複数のアナライザーを用意する必要があります。そのため、複数のフィールドを使用することも求められます。しかし、Elasticsearchの既定のサジェスト機能は1つのフィールドにのみ対応するため、この日本語特有の要件には最適とは言えません。この問題に対処するには、multi-fieldまたはマッピングパラメーターのcopy_toを使用する必要があります。

一般的に、(入力途中に候補を提案するなど)要件が複雑になるほど、サジェスト機能の実装は難しくなります。そのため、Elasticsearchの予測機能をCJK言語(中国語、日本語、韓国語)のサジェスト候補に使用することは一般的には推奨されません。

代わりに、ほとんどの場合は、通常の検索クエリを使用することが推奨されます。

日本語のサジェストの例

詳細な説明に入る前に、日本語のサジェスト機能の実装例を見てみましょう。 

主な要件

  • ユーザーが検索キーワードを入力すると、関連する候補が表示される。例:「日本」と入力すると、「日本」、「日本 地図」、「日本 人口」などが提案される。
  • 不完全な検索キーワードを入力した場合でも、関連する候補が表示される。例:「にほn」と入力すると、「日本」、「日本 地図」、「日本の人口」などが提案される。
  • タイプミスした場合でも、意味の通る候補が提案される。例:「にhん」、「にっほん」、「日本ん」と入力すると、「日本」、「日本 地図」、「日本の人口」などが表示される。
  • 候補となる単語が、キーワードが検索された回数が多い順に一覧表示される。

実装の準備

一般的には、通常のインデックスとは別に、サジェストを目的とする専用インデックスを作成すると望ましいです。この例では、my_suggestという名前の候補インデックスを作成します。また、my_fieldを候補フィールドとして使用します。

さらに、サジェスト用のインデックスに、キーワードの検索履歴を使用します。

例:インデックス設定とマッピングの構成

インデックス設定とマッピングの詳細については後ほど説明します。まずは、これらを作成するためのPUTコマンドをご覧ください。

PUT my_suggest 
{ 
  "settings": { 
    "analysis": { 
      "char_filter": { 
        "normalize": { 
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose" 
        },
        "kana_to_romaji": { 
          "type": "mapping",
          "mappings": [ 
            "あ=>a",
            "い=>i",
            "う=>u",
            "え=>e",
            "お=>o",
            "か=>ka",
            "き=>ki",
            "く=>ku",
            "け=>ke",
            "こ=>ko",
            "さ=>sa",
            "し=>shi",
            "す=>su",
            "せ=>se",
            "そ=>so",
            "た=>ta",
            "ち=>chi",
            "つ=>tsu",
            "て=>te",
            "と=>to",
            "な=>na",
            "に=>ni",
            "ぬ=>nu",
            "ね=>ne",
            "の=>no",
            "は=>ha",
            "ひ=>hi",
            "ふ=>fu",
            "へ=>he",
            "ほ=>ho",
            "ま=>ma",
            "み=>mi",
            "む=>mu",
            "め=>me",
            "も=>mo",
            "や=>ya",
            "ゆ=>yu",
            "よ=>yo",
            "ら=>ra",
            "り=>ri",
            "る=>ru",
            "れ=>re",
            "ろ=>ro",
            "わ=>wa",
            "を=>o",
            "ん=>n",
            "が=>ga",
            "ぎ=>gi",
            "ぐ=>gu",
            "げ=>ge",
            "ご=>go",
            "ざ=>za",
            "じ=>ji",
            "ず=>zu",
            "ぜ=>ze",
            "ぞ=>zo",
            "だ=>da",
            "ぢ=>ji",
            "づ=>zu",
            "で=>de",
            "ど=>do",
            "ば=>ba",
            "び=>bi",
            "ぶ=>bu",
            "べ=>be",
            "ぼ=>bo",
            "ぱ=>pa",
            "ぴ=>pi",
            "ぷ=>pu",
            "ぺ=>pe",
            "ぽ=>po",
            "きゃ=>kya",
            "きゅ=>kyu",
            "きょ=>kyo",
            "しゃ=>sha",
            "しゅ=>shu",
            "しょ=>sho",
            "ちゃ=>cha",
            "ちゅ=>chu",
            "ちょ=>cho",
            "にゃ=>nya",
            "にゅ=>nyu",
            "にょ=>nyo",
            "ひゃ=>hya",
            "ひゅ=>hyu",
            "ひょ=>hyo",
            "みゃ=>mya",
            "みゅ=>myu",
            "みょ=>myo",
            "りゃ=>rya",
            "りゅ=>ryu",
            "りょ=>ryo",
            "ぎゃ=>gya",
            "ぎゅ=>gyu",
            "ぎょ=>gyo",
            "じゃ=>ja",
            "じゅ=>ju",
            "じょ=>jo",
            "びゃ=>bya",
            "びゅ=>byu",
            "びょ=>byo",
            "ぴゃ=>pya",
            "ぴゅ=>pyu",
            "ぴょ=>pyo",
            "ふぁ=>fa",
            "ふぃ=>fi",
            "ふぇ=>fe",
            "ふぉ=>fo",
            "ふゅ=>fyu",
            "うぃ=>wi",
            "うぇ=>we",
            "うぉ=>wo",
            "つぁ=>tsa",
            "つぃ=>tsi",
            "つぇ=>tse",
            "つぉ=>tso",
            "ちぇ=>che",
            "しぇ=>she",
            "じぇ=>je",
            "てぃ=>ti",
            "でぃ=>di",
            "でゅ=>du",
            "とぅ=>tu",
            "ぢゃ=>ja",
            "ぢゅ=>ju",
            "ぢょ=>jo",
            "ぁ=>a",
            "ぃ=>i",
            "ぅ=>u",
            "ぇ=>e",
            "ぉ=>o",
            "っ=>t",
            "ゃ=>ya",
            "ゅ=>yu",
            "ょ=>yo",
            "ア=>a",
            "イ=>i",
            "ウ=>u",
            "エ=>e",
            "オ=>o",
            "カ=>ka",
            "キ=>ki",
            "ク=>ku",
            "ケ=>ke",
            "コ=>ko",
            "サ=>sa",
            "シ=>shi",
            "ス=>su",
            "セ=>se",
            "ソ=>so",
            "タ=>ta",
            "チ=>chi",
            "ツ=>tsu",
            "テ=>te",
            "ト=>to",
            "ナ=>na",
            "ニ=>ni",
            "ヌ=>nu",
            "ネ=>ne",
            "ノ=>no",
            "ハ=>ha",
            "ヒ=>hi",
            "フ=>fu",
            "ヘ=>he",
            "ホ=>ho",
            "マ=>ma",
            "ミ=>mi",
            "ム=>mu",
            "メ=>me",
            "モ=>mo",
            "ヤ=>ya",
            "ユ=>yu",
            "ヨ=>yo",
            "ラ=>ra",
            "リ=>ri",
            "ル=>ru",
            "レ=>re",
            "ロ=>ro",
            "ワ=>wa",
            "ヲ=>o",
            "ン=>n",
            "ガ=>ga",
            "ギ=>gi",
            "グ=>gu",
            "ゲ=>ge",
            "ゴ=>go",
            "ザ=>za",
            "ジ=>ji",
            "ズ=>zu",
            "ゼ=>ze",
            "ゾ=>zo",
            "ダ=>da",
            "ヂ=>ji",
            "ヅ=>zu",
            "デ=>de",
            "ド=>do",
            "バ=>ba",
            "ビ=>bi",
            "ブ=>bu",
            "ベ=>be",
            "ボ=>bo",
            "パ=>pa",
            "ピ=>pi",
            "プ=>pu",
            "ペ=>pe",
            "ポ=>po",
            "キャ=>kya",
            "キュ=>kyu",
            "キョ=>kyo",
            "シャ=>sha",
            "シュ=>shu",
            "ショ=>sho",
            "チャ=>cha",
            "チュ=>chu",
            "チョ=>cho",
            "ニャ=>nya",
            "ニュ=>nyu",
            "ニョ=>nyo",
            "ヒャ=>hya",
            "ヒュ=>hyu",
            "ヒョ=>hyo",
            "ミャ=>mya",
            "ミュ=>myu",
            "ミョ=>myo",
            "リャ=>rya",
            "リュ=>ryu",
            "リョ=>ryo",
            "ギャ=>gya",
            "ギュ=>gyu",
            "ギョ=>gyo",
            "ジャ=>ja",
            "ジュ=>ju",
            "ジョ=>jo",
            "ビャ=>bya",
            "ビュ=>byu",
            "ビョ=>byo",
            "ピャ=>pya",
            "ピュ=>pyu",
            "ピョ=>pyo",
            "ファ=>fa",
            "フィ=>fi",
            "フェ=>fe",
            "フォ=>fo",
            "フュ=>fyu",
            "ウィ=>wi",
            "ウェ=>we",
            "ウォ=>wo",
            "ヴァ=>va",
            "ヴィ=>vi",
            "ヴ=>v",
            "ヴェ=>ve",
            "ヴォ=>vo",
            "ツァ=>tsa",
            "ツィ=>tsi",
            "ツェ=>tse",
            "ツォ=>tso",
            "チェ=>che",
            "シェ=>she",
            "ジェ=>je",
            "ティ=>ti",
            "ディ=>di",
            "デュ=>du",
            "トゥ=>tu",
            "ヂャ=>ja",
            "ヂュ=>ju",
            "ヂョ=>jo",
            "ァ=>a",
            "ィ=>i",
            "ゥ=>u",
            "ェ=>e",
            "ォ=>o",
            "ッ=>t",
            "ャ=>ya",
            "ュ=>yu",
            "ョ=>yo" 
          ] 
        } 
      },
      "tokenizer": { 
        "kuromoji_normal": { 
          "mode": "normal",
          "type": "kuromoji_tokenizer" 
        } 
      },
      "filter": { 
        "readingform": { 
          "type": "kuromoji_readingform",
          "use_romaji": true 
        },
        "edge_ngram": { 
          "type": "edge_ngram",
          "min_gram":1,
          "max_gram":10 
        },
        "synonym": { 
          "type": "synonym",
          "lenient": true,
          "synonyms": [ 
            "nippon, nihon" 
          ] 
        } 
      },
      "analyzer": { 
        "suggest_index_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase",
            "edge_ngram" 
          ] 
        },
        "suggest_search_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase" 
          ] 
        },
        "readingform_index_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize",
            "kana_to_romaji" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase",
            "readingform",
            "asciifolding",
            "synonym",
            "edge_ngram" 
          ] 
        },
        "readingform_search_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize",
            "kana_to_romaji" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase",
            "readingform",
            "asciifolding",
            "synonym" 
          ] 
        } 
      } 
    } 
  },
  "mappings": { 
    "properties": { 
      "my_field": { 
        "type": "keyword",
        "fields": { 
          "suggest": { 
            "type": "text",
            "search_analyzer": "suggest_search_analyzer",
            "analyzer": "suggest_index_analyzer" 
          },
          "readingform": { 
            "type": "text",
            "search_analyzer": "readingform_search_analyzer",
            "analyzer": "readingform_index_analyzer" 
          } 
        } 
      } 
    } 
  } 
}

例:データの準備

次に、入力データとして、検索履歴と検索日付をPOSTして、Elasticsearchにインデックスします。このデータは、次の検索回数を想定しています。

  • 「日本」の検索回数は6回
  • 「日本 地図」の検索回数は5回
  • 「日本 郵便」の検索回数は3回
  • 「日本の人口」の検索回数は2回
  • 「日本 代表」の検索回数は1回
POST _bulk 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本", "created":"2016-11-11T11:11:11"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本", "created":"2017-11-11T11:11:11"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本", "created":"2018-11-11T11:11:11"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本", "created":"2019-11-11T11:11:11"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本", "created":"2020-11-11T11:11:11"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本", "created":"2020-11-11T11:11:11"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 地図", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 地図", "created":"2020-03-11T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 地図", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 地図", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 地図", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 郵便", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 郵便", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 郵便", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本の人口", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本の人口", "created":"2020-04-07T12:00:00"} 
{"index": {"_index": "my_suggest"}}
{"my_field": "日本 代表", "created":"2020-04-07T12:00:00"}

例:検索クエリの準備

検索キーワードが「日本」、「にほn」、「にhん」、「にっほん」、「日本ん」の場合のクエリを以下に示します。 これらのクエリの違いは、my_field.suggestおよびmy_field.readingformqueryの内容のみです。

検索キーワードが「日本」の場合

GET my_suggest/_search 
{ 
  "size":0,
  "query": { 
    "bool": { 
      "should": [ 
        { 
          "match": { 
            "my_field.suggest": { 
              "query": "日本" 
            } 
          } 
        },
        { 
          "match": { 
            "my_field.readingform": { 
              "query": "日本",
              "fuzziness":"AUTO",
              "operator": "and" 
            } 
          } 
        } 
      ] 
    } 
  },
  "aggs": { 
    "keywords": { 
      "terms": { 
        "field": "my_field",
        "order": { 
          "_count": "desc" 
        },
        "size":"10" 
      } 
    } 
  } 
}

検索キーワードが「にほn」の場合

GET my_suggest/_search 
{ 
  "size":0,
  "query": { 
    "bool": { 
      "should": [ 
        { 
          "match": { 
            "my_field.suggest": { 
              "query": "にほn" 
            } 
          } 
        },
        { 
          "match": { 
            "my_field.readingform": { 
              "query": "にほn",
              "fuzziness":"AUTO",
              "operator": "and" 
            } 
          } 
        } 
      ] 
    } 
  },
  "aggs": { 
    "keywords": { 
      "terms": { 
        "field": "my_field",
        "order": { 
          "_count": "desc" 
        },
        "size":"10" 
      } 
    } 
  } 
}

検索キーワードが「にhん」の場合

GET my_suggest/_search 
{ 
  "size":0,
  "query": { 
    "bool": { 
      "should": [ 
        { 
          "match": { 
            "my_field.suggest": { 
              "query": "にhん" 
            } 
          } 
        },
        { 
          "match": { 
            "my_field.readingform": { 
              "query": "にhん",
              "fuzziness":"AUTO",
              "operator": "and" 
            } 
          } 
        } 
      ] 
    } 
  },
  "aggs": { 
    "keywords": { 
      "terms": { 
        "field": "my_field",
        "order": { 
          "_count": "desc" 
        },
        "size":"10" 
      } 
    } 
  } 
}

検索キーワードが「にっほん」の場合

GET my_suggest/_search 
{ 
  "size":0,
  "query": { 
    "bool": { 
      "should": [ 
        { 
          "match": { 
            "my_field.suggest": { 
              "query": "にっほん" 
            } 
          } 
        },
        { 
          "match": { 
            "my_field.readingform": { 
              "query": "にっほん",
              "fuzziness":"AUTO",
              "operator": "and" 
            } 
          } 
        } 
      ] 
    } 
  },
  "aggs": { 
    "keywords": { 
      "terms": { 
        "field": "my_field",
        "order": { 
          "_count": "desc" 
        },
        "size":"10" 
      } 
    } 
  } 
}

検索キーワードが「日本ん」の場合

GET my_suggest/_search 
{ 
  "size":0,
  "query": { 
    "bool": { 
      "should": [ 
        { 
          "match": { 
            "my_field.suggest": { 
              "query": "日本ん" 
            } 
          } 
        },
        { 
          "match": { 
            "my_field.readingform": { 
              "query": "日本ん",
              "fuzziness":"AUTO",
              "operator": "and" 
            } 
          } 
        } 
      ] 
    } 
  },
  "aggs": { 
    "keywords": { 
      "terms": { 
        "field": "my_field",
        "order": { 
          "_count": "desc" 
        },
        "size":"10" 
      } 
    } 
  } 
}

例:検索(サジェスト)の結果

検索キーワードが「日本」の場合の検索(サジェスト)結果は次のようになります。 検索キーワードが「にほn」、「にhん」、「にっほん」、「日本ん」の場合の検索結果も、「日本」の場合と同じであるため省略します。

{ 
  "took" :3,
  "timed_out" : false,
  "_shards" : { 
    "total" :1,
    "successful" :1,
    "skipped" :0,
    "failed" :0 
  },
  "hits" : { 
    "total" : { 
      "value" :17,
      "relation" : "eq" 
    },
    "max_score" : null,
    "hits" : [ ] 
  },
  "aggregations" : { 
    "keywords" : { 
      "doc_count_error_upper_bound" :0,
      "sum_other_doc_count" :0,
      "buckets" : [ 
        { 
          "key" : "日本",
          "doc_count" :6 
        },
        { 
          "key" : "日本 地図",
          "doc_count" :5 
        },
        { 
          "key" : "日本 郵便",
          "doc_count" :3 
        },
        { 
          "key" : "日本の人口",
          "doc_count" :2 
        },
        { 
          "key" : "日本 代表",
          "doc_count" :1 
        } 
      ] 
    } 
  } 
}

使用したマッピングとアナライザーについて詳しく見てみましょう。

マッピング構成

マッピングに関する主な決定事項を次に示します。

  • Elasticsearchではmulti-fieldsがサポートされるため、実装ではこれを使用します。
  • サジェスト候補リストを表示するためにterms aggregationを使用して、フィールドタイプをkeywordに設定しました。また、この操作はデータセットの規模が大きくなるとコストが高くなる場合があるため、専用のインデックスを作成すると望ましいです。または、提案目的に特化した専用クラスターを用意するとさらに効果的です。
  • 今回は漢字のさまざまな読み方を吸収するためのアナライザーを使用するsuggestというマルチフィールドを用意しました。例:「東」と入力した場合は「東京」が、「日」と入力した場合は「日本」が候補として表示されます。
  • さらに、未確定の仮名を処理するためのアナライザーを使用するreadingformというマルチフィールドも用意しています。例:「にほn」と入力した場合は、「日本」、「日本 地図」、「日本の人口」などが候補として表示されます。
"mappings": { 
    "properties": { 
      "my_field": { 
        "type": "keyword",
        "fields": { 
          "suggest": { 
            "type": "text",
            "search_analyzer": "suggest_search_analyzer",
            "analyzer": "suggest_index_analyzer" 
          },
          "readingform": { 
            "type": "text",
            "search_analyzer": "readingform_search_analyzer",
            "analyzer": "readingform_index_analyzer" 
          } 
        } 
      } 
    }

サジェストフィールドのアナライザーの設定

検索とインデックスでそれぞれ使用するために、2種類のアナライザーを用意しました。

検索アナライザー

  • char_filterICU normalizer(nfkc)を使用して、検索対象における英数字とカタカナ文字の全角と半角表記の混在を吸収できるようにしました。例:全角の「1」は半角の「1」に変換されます。
  • 形態素解析にはkuromoji tokenizerを使用しました。また、モードをnormalに設定して、単語を無駄に分割しすぎないようにしました。たとえば、「東京大学」はそのまま「東京大学」と表示されます。
  • lowercase token filterを使用して、アルファベットを小文字に正規化するようにしました。例:「TOKYO」は「tokyo」に変換されます。
        "suggest_search_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase" 
          ] 
        }

インデックスアナライザー

前述の設定に加え、edge ngram token filterを使用して、ユーザーの入力内容が複数の要素に分かれている場合に接頭辞検索を実行するようにしました。

        "suggest_index_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase",
            "edge_ngram" 
          ] 
        }

アナライザーの分析結果の例

「suggest_search_analyzer」の分析結果の例 

# request 
GET my_suggest/_analyze 
{ 
  "analyzer": "suggest_search_analyzer",
  "text": ["日本 地図"] 
} 
# response 
{ 
  "tokens" : [ 
    { 
      "token" : "日本",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "地図",
      "start_offset" :3,
      "end_offset" :5,
      "type" : "word",
      "position" :1 
    } 
  ] 
}

「suggest_index_analyzer」の分析結果の例  

# request 
GET my_suggest/_analyze 
{ 
  "analyzer": "suggest_index_analyzer",
  "text": ["日本 地図"] 
} 
# response 
{ 
  "tokens" : [ 
    { 
      "token" : "日",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "日本",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "地",
      "start_offset" :3,
      "end_offset" :5,
      "type" : "word",
      "position" :1 
    },
    { 
      "token" : "地図",
      "start_offset" :3,
      "end_offset" :5,
      "type" : "word",
      "position" :1 
    } 
  ] 
}

「readingform」フィールドのアナライザーの設定

検索とインデックスでそれぞれ使用するために、2種類のアナライザーを用意しました。

検索アナライザー

  • char_filterICU normalizer(nfkc)を使用して、検索対象における英数字とカタカナ文字の全角と半角表記の混在を吸収できるようにしました。例:全角の「1」は半角の「1」に変換されます。
  • readingform token filterでは、一部の仮名変換が正しく行われません(例:「きゃぷてん」が「ki」と「ゃぷてん」に変換される)。そのため、仮名からローマ字に変換するchar_filterを別途定義しました(このブログでは「kana_to_romaji」との名前にする)。
  • 形態素解析にはkuromoji tokenizerを使用しました。また、モードをnormalに設定して、単語を無駄に分割しすぎないようにしました。たとえば、「東京大学」はそのまま「東京大学」と表示されます。
  • lowercase token filterを使用して、アルファベットを小文字に正規化するようにしました。例:「TOKYO」は「tokyo」に変換されます。
  • その後、readingformトークンフィルターを使用しました。また、漢字をローマ字に変換するために、use_romajiを「true」に設定します。例:「寿司」は「sushi」に変換されます。
  • readingformトークンフィルターでは、一部の変換が長音符(マクロン)付きの文字に変換される場合があります(「とうきょう」が「tōkyō」に変換されるなど)。長音符付きの文字を正規化(「tōkyō」を「tokyo」に変換)するために、asciifoldingトークンフィルターを使用します。 
  • 読み方が2種類以上ある場合(たとえば、「日本」は「nippon」または「nihon」と読む)は、synonym token filterを使用して異なる読み方を吸収します。
        "readingform_search_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize",      <= icu_normalizer nfkc compose mode 
            "kana_to_romaji"  <= convert Kana to Romaji 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase",
            "readingform",
            "asciifolding",
            "synonym" 
          ] 
        }

インデックスアナライザー

前述の設定に加え、edge ngram token filterを使用して、ユーザーのインプットの内容が複数の要素に分かれている場合に接頭辞検索を実行するようにしました。

        "readingform_index_analyzer": { 
          "type": "custom",
          "char_filter": [ 
            "normalize",
            "kana_to_romaji" 
          ],
          "tokenizer": "kuromoji_normal",
          "filter": [ 
            "lowercase",
            "readingform",
            "asciifolding",
            "synonym",
            "edge_ngram" 
          ] 
        },

アナライザーの分析結果の例

「readingform_search_analyzer」の分析結果の例  

# request 
GET my_suggest/_analyze 
{ 
  "analyzer": "readingform_search_analyzer",
  "text": ["にほn"] 
} 
# response 
{ 
  "tokens" : [ 
    { 
      "token" : "nihon",
      "start_offset" :0,
      "end_offset" :3,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "nippon",
      "start_offset" :0,
      "end_offset" :3,
      "type" :"SYNONYM",
      "position" :0 
    } 
  ] 
}

「readingform_index_analyzer」の分析結果の例  

# request 
GET my_suggest/_analyze 
{ 
  "analyzer": "readingform_index_analyzer",
  "text": ["日本"] 
} 
# response 
{ 
  "tokens" : [ 
    { 
      "token" : "n",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "ni",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "nip",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "nipp",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "nippo",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "nippon",
      "start_offset" :0,
      "end_offset" :2,
      "type" : "word",
      "position" :0 
    },
    { 
      "token" : "n",
      "start_offset" :0,
      "end_offset" :2,
      "type" :"SYNONYM",
      "position" :0 
    },
    { 
      "token" : "ni",
      "start_offset" :0,
      "end_offset" :2,
      "type" :"SYNONYM",
      "position" :0 
    },
    { 
      "token" : "nih",
      "start_offset" :0,
      "end_offset" :2,
      "type" :"SYNONYM",
      "position" :0 
    },
    { 
      "token" : "niho",
      "start_offset" :0,
      "end_offset" :2,
      "type" :"SYNONYM",
      "position" :0 
    },
    { 
      "token" : "nihon",
      "start_offset" :0,
      "end_offset" :2,
      "type" :"SYNONYM",
      "position" :0 
    } 
  ] 
}

サジェスト用クエリの設定

通常の検索クエリを使用してサジェストの候補リストを生成します。これを行う方法は2通りあります。

  1. match queryとterms aggregationを使用して、検索回数の多い順に候補リストを表示します。
  1. match query + field 重複除外処理(collapse)を使用し、かつソートをかけて候補リストを表示します。

このブログでは、terms aggregationを使用したクエリの実装を説明しました。

検索履歴のインデックスに基づいてサジェストを実装する場合は、terms aggregation(上記オプション1)を使用する方が簡単です。ただし、(Elasticsearchの既定のサジェスト機能に比べ、)この方法がパフォーマンス的に良くない点も挙げられます。その理由として、候補となる文をサジェストというより、ドキュメントをサジェスト(候補としてではなく、ドキュメントを直接返答)しているからです。

さらに、大規模なサジェストが要件である場合、候補となるデータをあらかじめ準備しておき、要件に基づいてソートをかける(上記オプション2)といった方法も考えられます。

クエリ設計

GET my_suggest/_search 
{ 
  "size":0,
  "query": { 
    "bool": { 
      "should": [ 
        { 
          "match": { 
            "my_field.suggest": { 
              "query": "にほn" 
            } 
          } 
        },
        { 
          "match": { 
            "my_field.readingform": { 
              "query": "にほn",
              "fuzziness":"AUTO",
              "operator": "and" 
            } 
          } 
        } 
      ] 
    } 
  },
  "aggs": { 
    "keywords": { 
      "terms": { 
        "field": "my_field",
        "order": { 
          "_count": "desc" 
        },
        "size":"10" 
      } 
    } 
  } 
}

チューニング メモ

すでにご説明したとおり、上述の例ではいくつかのチューニングを行いました。他にもチューニングの余地があります。実際の要件に応じて自由に行ってください。

パート1: 同義語を使用して辞書にない単語を処理する

例:検索キーワードににほnと入力した場合に「日本」を提案する

同義語を使用しない場合、「日本」は、readingform_index_analyzerの結果に基づいて、「n、ni、nip、nipp、nippo、nippon」に分割されます。 一方、同義語を使用しない場合、「にほn」は、readingform_search_analyzerの結果、「nihon」に変換されるため、上記のトークンに一致しません。

これは、「日本」の読み方が「nippon」として辞書に登録されており、「nihon」として登録されていないといった、kuromojiの制限によるものです。 対処するには、同義語を使用し、「nihon」を「nippon」の同義語として定義します。

パート2: 入力中の揺れを吸収する

例:検索キーワードとしてにhんと入力した場合に「日本」をサジェストする

(上述の同義語の定義を使用した)readingform_index_analyzerの結果、「日本」は「n、ni、nip、nipp、nippo、nippon、nih、niho、nihon」に分割されます。 readingform_search_analyzerでは、icu_normalizerchar_filterによって、全角の「h」が「にhん」の半角の「h」に変換されます。 その後、kana_to_romajichar_filterによって「にhん」が「nihn」に変換されます。 さらに、一致クエリでfuzziness: autoを使用することにより、「nihn」が「niho」に一致して期待されるサジェスト候補が提示されます。

「にっほん」や「日本ん」の場合も同様になります。このように、可能な限り入力中の差異を吸収できるようにアナライザーを設計しました。

パート3: 人気の検索キーワードを上位に表示する

このブログでは、単純に最も検索回数の多い単語を候補の一番上に表示する例を紹介しました。実際には、検索キーワードの人気度を記録する専用フィールドを用意して、検索トレンドに基づいて定期的に人気度を更新するのもおすすめです。人気度のフィールドに基づいて候補をソートすると、より良いユーザーエクスペリエンスにつながる可能性があります。

まとめ

この記事では、日本語のサジェスト機能を実装するのが英語よりもかなり難しい理由を説明しました。また、日本語のサジェスト機能を実装するための検討事項と実装例を詳細に説明し、最後にいくつかの細かいチューニング方法をご紹介しました。

ご自分で実装を試したいものの、お手元にElasticsearchクラスターがない場合は、Elastic Cloudの無料トライアルにご登録いただき、analysis-icuとanalysis-kuromojiのプラグインを追加すれば、このブログでご紹介した設定例とデータをご利用いただけます。よろしければ、Elasticのフォーラムにフィードバックをお寄せください

ぜひ日本語のサジェスト機能をお試しください。