keyword suggest

Elasticsearch キーワードサジェスト日本語のための設計

Google に代表される「キーワードサジェスト」機能を Elasticsearch を前提に日本語向けに設計。


よくある要件

  1. サイト内で過去に検索された有効なキーワードをサジェストしたい。
  2. 入力されたテキストに関連性が高く、過去に検索された回数の多い順でサジェストしたい。
  3. 最初に入力された言葉の後に空白を入力すると、最初の言葉と一緒に検索される複合語の候補サジェストしたい

比較的シンプルな要件のように見えますが。。日本語を対象にしたサジェスト機能を実装する場合、入力途中の日本語のテキスト受けながら、ひらがな、カタカナ(半角・全角)、漢字、ローマ字(大文字・小文字・全角・半角)のコンビネーションを合わせて、関連性の高い言葉を素早くユーザーに提案しなければなりません。以外と難しいのです。。

Elasticsearch にも Completion Suggester と言うサジェスト向けの機能があるのですが、日本語向けのサジェストは以外と複雑なので、Complettion Suggester を使用する場合、インデックスするためのデータ作成時の前処理が多くなったり、チューニングが難しくなったりします。正直「よくある要件」を満たすのは難しいです。

そこで今回は、Completion Suggester を使わずに、Elasticsearch を前提にサジェスト機能を日本語向けに設計してみます。基本的にはデータ加工などの前処理をせずに、バンドルされてるアナライザーやフィルター、公式のアナリシスプラグインのみで利用できるように設計したので、参考になれば幸いです。

インデックス・データ設計

まずはインデックスの設計です。よくある要件1、2を満たし、サジェスト用のデータ作成の前処理をなるべく減らしたいため、インデックスを作成する単位は以下のように設計しました。

  • サジェスト専用のインデックスを作成
  • サジェスト対象のデータは検索履歴単位でデータをストアする

検索履歴をそのまま1データ1ドキュメントとしてインデックスドキュメントを作成して、サジェストで使用するときは Terms Aggregation を使って集計及びソートするイメージです。

また、サジェスト対象のデータが20GB(目安として)を超える場合には、日別や月別にインデックスを作成するなどパフォーマンスや拡張性を考慮してインデックスを作成してください。

古い過去データの削除は、ドキュメントにTTLを設定して自動的に削除するか、インデックスを日別・月別に分けているのであれば、定期的にインデックスごと削除する方法があります。

データスキーマ設計

検索履歴をインデックスするので最小のデータスキーマは、検索されたキーワード (keyword) と、その時の日時(created)の2つのフィールドを用意します。これが Elasticsearch のインデックスに1ドキュメントとしてインデックスされるイメージです。検索された日付を含めることで古くなったデータの削除だけではなく、サジェストする時の期間調整でも使用できます。

{
"created": "2015-12-17T12:19:20",
"keyword": "銀座 ランチ"
}

また、要件を追加する場合、(例えば、「サイト内の検索を開始したカテゴリごとにサジェストも出し分けたい」、「ユーザーのデバイス毎に(モバイル or PC)で出し分けたい」、「検索ヒット数が10件以上だったキーワードをサジェストしたい」など)それぞれそのメタ情報も含めたデータを作成してください。

{
"created": "2015-12-17T12:19:20",
"keyword": "銀座 ランチ",
"category": "東京都",
"hits": 129,
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12A365 Safari/600.1.4"
}

マッピング設計

日本語の場合、「キーワードを集計して表示するためのフィールド」と、「前方一致で検索するフィールド」、さらに「前方一致で読みを検索するフィールド」の最低でも3つのフィールドが必要です。

Elasticsearch は一つのフィールドに対して、異なるアナライザーを定義できるマルチフィールドをサポートしていますので、それを活用しましょう。

// 仮 keyword フィールドマッピング定義
{
"properties": {
"keyword": { // 集計表示用
"type": "string"
"fields": {
"autocomplete": { // 前方一致検索用
"type": "string"
},
"readingform": { // 前方一致検索用(読み)
"type": "string"
}
}
}
}
}

マルチフィールドを活用することで、検索時に keywordkeyword.autocompletekeyword.readingform、と指定することができ、用途に応じて異なるアナライザーで処理したデータを使い分けることができます。

アナライザー設計

次にアナライザー設計では、日本語のひらがな、カタカナ(半角・全角)、漢字、ローマ字(大文字・小文字・全角・半角)のコンビネーションを考慮して、例えば、「ginz」、「ぎんz」、「ギンz」または半角の「ギンn」でも「銀座」にマッチさせる必要があり、さらに、「銀座␣」とキーワード後に空白文字を入力された場合には、「銀座 ランチ」、「銀座 カフェ」「銀座 映画」など、最初の言葉に関連する複合語の候補をサジェストすると言うのも頭に置きながら設計します。

それでは、先ほどのマッピング定義にそれぞれのフィールドで必要なアナライザーを追加してみます。それぞれのアナライザーの内容は、後で定義するので任意の名称で設定しています。

ここで重要なのは、1つのフィールドでもインデックス側とサーチ側で処理のことなるアナライザーが必要であるということです。

実際は、インデックス側の処理を考えてから、必要であればサーチ側を考えるイメージで設計を進めます。

// keyword フィールドマッピング定義
{
"properties": {
"keyword": {
"type": "string",
"analyzer": "keyword_analyzer",
"fields": {
"autocomplete": {
"type": "string",
"analyzer": "autocomplete_index_analyzer",
"search_analyzer": "autocomplete_search_analyzer"
},
"readingform": {
"type": "string",
"analyzer": "readingform_index_analyzer",
"search_analyzer": "readingform_search_analyzer",
}
}
}
}
}

keyword_analyzer
インデックス済みのキーワードの集計と表示に使用することを想定して設計します。基本的には大小文字、全半角文字の統一など文字列の正規化がメインの処理です。

Char Filters:

  • ユニコードの正規化(半角全角などの統一)
  • 空白文字の正規化(2つ以上の空白を1つの空白に置き換え)

Tokenizer:

  • 区切らない(1つの言葉としてトークナイズ)

Token Filters:

  • 小文字化
  • 言葉前後の空白文字を削除
  • 長い文字数の単語を除外
# Example
Input Keyword:
"銀座 ランチ" or "銀座 ランチ"
Analyzed Keyword:
"銀座 ランチ"

autocomplete_index_analyzer
前方一致検索を実現するためのインデックス側のアナライザーです。過去に検索されたキーワードをどうやって加工するかを想定して設計します。文字列の正規化に加え、edge NGram 方式で前方一致用のデータ作成がメインの処理です。

Char Filters:

  • ユニコードの正規化(半角全角などの統一)
  • 空白文字の正規化(2つ以上の空白を1つの空白に置き換え)

Tokenizer:

  • 区切らない(1つの言葉としてトークナイズ)

Token Filters:

  • 小文字化
  • 言葉前後の空白文字を削除
  • 長い文字数の単語を除外
  • edge NGram 方式で文字列を展開
# Example
Input Keyword:
"銀座 ランチ"
Analyzed Keyword:
"銀",
"銀座",
"銀座 ",
"銀座 ラ",
"銀座 ラン",
"銀座 ランチ"

autocomplete_search_analyzer
前方一致検索を実現するためのサーチ側のアナライザーです。ユーザーが入力した言葉をどうやって加工して、インデックス側の autocomplete_index_analyzer のデータにマッチさせるかを想定して設計します。

Char Filters:

  • ユニコードの正規化(半角全角などの統一)
  • 空白文字の正規化(2つ以上の空白を1つの空白に置き換え)

Tokenizer:

  • 区切らない(1つの言葉としてトークナイズ)

Token Filters:

  • 小文字化
  • 言葉前後の空白文字を削除
  • 長い文字数の単語を除外
# Example
Input Keyword:
"銀座 ランチ" or "銀座 ランチ"
Analyzed Keyword:
"銀座 ランチ"

readingform_index_analyzer
前方一致検索(読み)を実現するためのインデックス側のアナライザーです。過去に検索されたキーワードをどうやって加工するかを想定して設計します。

Char Filters:

  • ユニコードの正規化(半角全角などの統一)
  • 空白文字の正規化(2つ以上の空白を1つの空白に置き換え)

Tokenizer:

  • 漢字も読みに変換するため日本語でトークナイズ

Token Filters:

  • 小文字化
  • 言葉前後の空白文字を削除
  • 読み変換(ローマ字)
  • アルファベット変換(hokkaidō → hokkaido)
  • 長い文字数の単語を除外
  • edge NGram 方式で文字列を展開
# Example
Input Keyword:
"銀座 ランチ"
Analyzed Keyword:
“g”,
“gi”,
“gin”,
“ginz”,
“ginza”,
“r”,
“ra”,
“ran”,
“ranc”,
“ranch”,
“ranchi”

readingform_search_analyzer
前方一致検索(読み)を実現するためのサーチ側のアナライザーです。ユーザーが入力した言葉をどうやって加工してインデックス側の readingform_index_analyzer のデータにマッチさせるかを想定して設計します。

Char Filters:

  • ユニコードの正規化(半角全角などの統一)
  • 空白文字の正規化(2つ以上の空白を1つの空白に置き換え)
  • ひらがなをカタカナに変換(ローマ字変換するためにカタカナに統一)
  • カタカナをローマ字に変換

Tokenizer:

  • 漢字も読みに変換するため日本語でトークナイズ

Token Filters:

  • 小文字化
  • 言葉前後の空白文字を削除
  • 長い文字数の単語を除外
  • 漢字をローマ字に変換
  • アルファベット変換(hokkaidō → hokkaido)
# Example
Input Keyword:
"ぎんざ"
Analyzed Keyword:
"ginza"

※ 入力途中の「ぎんz」が「ginz」と変換されるように、形態素解析する前にひらがな、カタカナは Char Filter でローマ字に変換しています。

アナライザー定義

設計の内容を加味したアナライザー定義がこちら。

※ 使用しているプラグインは ICU Analysis Pluginと、Japanese (kuromoji) Analysis Plugin のみです。Elasticsearchのバージョンは2系で確認しています。

{
"settings": {
"analysis": {
"analyzer": {
"keyword_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"whitespaces"
],
"tokenizer": "keyword",
"filter": [
"lowercase",
"trim",
"maxlength"
]
},
"autocomplete_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"whitespaces"
],
"tokenizer": "keyword",
"filter": [
"lowercase",
"trim",
"maxlength",
"engram"
]
},
"autocomplete_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"whitespaces"
],
"tokenizer": "keyword",
"filter": [
"lowercase",
"trim",
"maxlength"
]
},
"readingform_index_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"whitespaces"
],
"tokenizer": "japanese_normal",
"filter": [
"lowercase",
"trim",
"readingform",
"asciifolding",
"maxlength",
"engram"
]
},
"readingform_search_analyzer": {
"type": "custom",
"char_filter": [
"normalize",
"whitespaces",
"katakana",
"romaji"
],
"tokenizer": "japanese_normal",
"filter": [
"lowercase",
"trim",
"maxlength",
"readingform",
"asciifolding"
]
}
},
"char_filter": {
"normalize": {
"type": "icu_normalizer",
"name": "nfkc",
"mode": "compose"
},
"katakana": {
"type": "mapping",
"mappings": [
"ぁ=>ァ", "ぃ=>ィ", "ぅ=>ゥ", "ぇ=>ェ", "ぉ=>ォ",
"っ=>ッ", "ゃ=>ャ", "ゅ=>ュ", "ょ=>ョ",
"が=>ガ", "ぎ=>ギ", "ぐ=>グ", "げ=>ゲ", "ご=>ゴ",
"ざ=>ザ", "じ=>ジ", "ず=>ズ", "ぜ=>ゼ", "ぞ=>ゾ",
"だ=>ダ", "ぢ=>ヂ", "づ=>ヅ", "で=>デ", "ど=>ド",
"ば=>バ", "び=>ビ", "ぶ=>ブ", "べ=>ベ", "ぼ=>ボ",
"ぱ=>パ", "ぴ=>ピ", "ぷ=>プ", "ぺ=>ペ", "ぽ=>ポ",
"ゔ=>ヴ",
"あ=>ア", "い=>イ", "う=>ウ", "え=>エ", "お=>オ",
"か=>カ", "き=>キ", "く=>ク", "け=>ケ", "こ=>コ",
"さ=>サ", "し=>シ", "す=>ス", "せ=>セ", "そ=>ソ",
"た=>タ", "ち=>チ", "つ=>ツ", "て=>テ", "と=>ト",
"な=>ナ", "に=>ニ", "ぬ=>ヌ", "ね=>ネ", "の=>ノ",
"は=>ハ", "ひ=>ヒ", "ふ=>フ", "へ=>ヘ", "ほ=>ホ",
"ま=>マ", "み=>ミ", "む=>ム", "め=>メ", "も=>モ",
"や=>ヤ", "ゆ=>ユ", "よ=>ヨ",
"ら=>ラ", "り=>リ", "る=>ル", "れ=>レ", "ろ=>ロ",
"わ=>ワ", "を=>ヲ", "ん=>ン"
]
},
"romaji": {
"type": "mapping",
"mappings": [
"キャ=>kya", "キュ=>kyu", "キョ=>kyo",
"シャ=>sha", "シュ=>shu", "ショ=>sho",
"チャ=>cha", "チュ=>chu", "チョ=>cho",
"ニャ=>nya", "ニュ=>nyu", "ニョ=>nyo",
"ヒャ=>hya", "ヒュ=>hyu", "ヒョ=>hyo",
"ミャ=>mya", "ミュ=>myu", "ミョ=>myo",
"リャ=>rya", "リュ=>ryu", "リョ=>ryo",
"ファ=>fa", "フィ=>fi", "フェ=>fe", "フォ=>fo",
"ギャ=>gya", "ギュ=>gyu", "ギョ=>gyo",
"ジャ=>ja", "ジュ=>ju", "ジョ=>jo",
"ヂャ=>ja", "ヂュ=>ju", "ヂョ=>jo",
"ビャ=>bya", "ビュ=>byu", "ビョ=>byo",
"ヴァ=>va", "ヴィ=>vi", "ヴ=>v", "ヴェ=>ve", "ヴォ=>vo",
"ァ=>a", "ィ=>i", "ゥ=>u", "ェ=>e", "ォ=>o",
"ッ=>t",
"ャ=>ya", "ュ=>yu", "ョ=>yo",
"ガ=>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",
"ア=>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"
]
},
"whitespaces": {
"type": "pattern_replace",
"pattern": "\\s{2,}",
"replacement": "\u0020"
}
},
"filter": {
"readingform": {
"type": "kuromoji_readingform",
"use_romaji": true
},
"engram": {
"type": "edgeNGram",
"min_gram": 1,
"max_gram": 36
},
"maxlength": {
"type": "length",
"max": 36
}
},
"tokenizer": {
"japanese_normal": {
"mode": "normal",
"type": "kuromoji_tokenizer"
},
"engram": {
"type": "edgeNGram",
"min_gram": 1,
"max_gram": 36
}
}
}
}
}

マッピング定義

最終的なマッピング定義はこちら。

※ Elasticsearch のバージョンは2系で確認しています。

{
"mappings": {
"logs": {
"dynamic_templates": [{
"string_template": {
"match": "*",
"match_mapping_type": "string",
"mapping": {
"type": "string",
"index": "not_analyzed"
}
}
}],
"properties": {
"keyword": {
"type": "string",
"analyzer": "keyword_analyzer",
"fields": {
"autocomplete": {
"type": "string",
"search_analyzer": "autocomplete_search_analyzer",
"analyzer": "autocomplete_index_analyzer"
},
"readingform": {
"type": "string",
"search_analyzer": "readingform_search_analyzer",
"analyzer": "readingform_index_analyzer"
}
}
}
}
}
}
}

クエリー設計

実はマッピング設計?データ設計?をするあたりから、頭の中ではどんなクエリーを投げれば良いか何となく考えています。卵が先かニワトリが先かと聞かれても自分でもよくわかりません。。「何となく」から含めればクエリーから設計しているような気もします。

さてクエリー設計ですが、ユーザーから入力された言葉を受付て検索し、マッチしたキーワード履歴から同じキーワードを集計して検索回数の多い順で結果を返すためのクエリーは以下のようになります。

# Example Query
{
"size": 0,
"query": {
"bool": {
"should": [{
"match": {
"keyword.autocomplete": {
"query": "銀座"
}
}
}, {
"match": {
"keyword.readingform": {
"query": "銀座",
"fuzziness": "AUTO",
"operator": "and"
}
}
}]
}
},
"aggs": {
"keywords": {
"terms": {
"field": "keyword",
"order": {
"_count": "desc"
},
"size": "10"
}
}
}
}

match クエリーを使用して、keyword.autocomplekeyword.readingform フィールドを対象に検索した結果を Aggregation を使って、keyword フィールドの値の集計をし、カウント数の多い順で返却するようにしています。

また、「銀座 らんt」でも、「銀座 らんc」でも「銀座 ランチ」がマッチするように、keyword.readingformfuzziness を有効にしてローマ字の揺らぎに対応しています。

検索結果は以下のようになります。

# Example Result
{
"took": 6,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 91,
"max_score": 0,
"hits": []
},
"aggregations": {
"keywords": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 26,
"buckets": [{
"key": "銀座",
"doc_count": 9
}, {
"key": "銀座三越",
"doc_count": 8
}, {
"key": "銀座 カフェ",
"doc_count": 6
}, {
"key": "銀座 ランチ",
"doc_count": 6
}, {
"key": "銀座 ラーメン",
"doc_count": 6
}, {
"key": "銀座 三越",
"doc_count": 6
}, {
"key": "銀座 寿司",
"doc_count": 6
}, {
"key": "銀座 映画",
"doc_count": 6
}, {
"key": "銀座 松屋",
"doc_count": 6
}, {
"key": "銀座カラー",
"doc_count": 6
}]
}
}
}

Validate Query API を使用して、「ginz」、「ぎんz」、「ギンz」または半角の「ギンz」などが、がどのように検索されるのか見てみましょう。

各種フィールドの「銀座 ランチ」のインデックスデータ例:

keyword field:
"銀座 ランチ"
keyword.autocomplete field:
"銀",
"銀座",
"銀座 ",
"銀座 ラ",
"銀座 ラン",
"銀座 ランチ"

keyword.readingform field:
"g",
"gi",
"gin",
"ginz",
"ginza",
"r",
"ra",
"ran",
"ranc",
"ranch",
"ranchi"

以下は、「銀座 ランチ」のインデックスに対して、入力がマッチする条件を太文字にしています。

入力バリエーションとValidate Query API の結果:

# 「ginz」
+(keyword.autocomplete:ginz keyword.readingform:ginz~1)
# 「ぎんz」
+(keyword.autocomplete:ぎんz keyword.readingform:ginz~1)
# 「ギンz」
+(keyword.autocomplete:ギンz keyword.readingform:ginz~1)
# 「ギンz」
+(keyword.autocomplete:ギンz keyword.readingform:ginz~1)
# 「ぎんざ」
+(keyword.autocomplete:ぎんざ keyword.readingform:ginza~1)
# 「ギンザ」
+(keyword.autocomplete:ギンザ keyword.readingform:ginza~1)
# 「ギンザ」
+(keyword.autocomplete:ギンザ keyword.readingform:ginza~1)
# 「銀座」
+(keyword.autocomplete:銀座 keyword.readingform:ginza~1)
# 「銀座 らんち」
+(keyword.autocomplete:銀座 らんち (+keyword.readingform:ginza~1 +keyword.readingform:ranchi~2))
# 「銀座 ランチ」
+(keyword.autocomplete:銀座 ランチ (+keyword.readingform:ginza~1 +keyword.readingform:ranchi~2))

想定している入力のバリエーションに対してマッチすることが確認できるかと思います。

すでにお気づきかもしれませんが、このままのクエリでは、「銀座 ランチ」と入力した場合でも、「渋谷 ランチ」がサジェストされてしまう可能性がります。さらに複合語によるマッチングを考える必要がります。

複合語によるマッチング

複合語によるマッチングを考えます。

例えば、「銀座␣」と入力された場合には、「銀座 ランチ」、「銀座 カフェ」「銀座 映画」など、最初の言葉に関連する複合語の候補をサジェストすることを考えます。また、「銀座 ランチ」と入力された場合には、「渋谷 ランチ」はサジェストしたくありません。

空白以前の言葉は確定済みと考えられるので、このルールをクエリーに反映させればよさそうです。

クエリーに反映させる方法として、Aggregation の結果から include パラメータを使用して、「銀座␣」で始まるキーワードのみをサジェストするようにクエリーを変更します。

# Example Query
{
"size": 0,
"query": {
"bool": {
"should": [{
"match": {
"keyword.autocomplete": {
"query": "銀座 "
}
}
}, {
"match": {
"keyword.readingform": {
"query": "銀座 ",
"fuzziness": "AUTO",
"operator": "and"
}
}
}]
}
},
"aggs": {
"keywords": {
"terms": {
"field": "keyword",
"include": "銀座 .*",
"exclude": ".{1}",
"order": {
"_count": "desc"
},
"size": "10"
}
}
}
}

このクエリーでは、「銀座」のみの言葉、「渋谷 ランチ」の言葉も除外することができるので、意図した結果が得られます。(※ ついでに exclude を使用して、1文字だけのキーワードを除外しています。)

ただし、この include パラメータに設定は、入力途中のことも考えると、以下の例のように少々動的に設定する必要があります。

# query: "銀座"
"include": ".*"
# query: "銀座"
"include": "銀座␣.*"
# query: "銀座らん"
"include": "銀座␣.*"
# query: "銀座ランチ"
"include": "銀座␣.*"
# query: "銀座ランチ "
"include": "銀座␣ランチ␣.*"

「空白を含む言葉が入力された場合には、最後の空白位置から以前の言葉を include パラメータに設定する」

最後に

実はまだ、解決できていない課題もあるのですが、だいたいこんな感じで、そこそこ精度よくサジェスト機能を提供できるのではないかと思います。一応インデックステンプレートも公開していますので、参考になれば幸いです。

課題その1:
「ランチ」だけの入力で「銀座 ランチ」がヒットしてしまうこと(読み側でヒットします)。ただしこの問題は、複合語よりも1つの単語の方が検索回数が多いと想定されるため、サジェストの一覧には表示されない可能性も高く、表示されたとしても「ランチ」一つの単語よりも下に掲載されるはずなので、機能的にはそれほど問題にならないかもしれません。逆に当たらないよりは良いかもしれません。

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.