Elasticsearchを用いた日本語サジェスト ~公式で紹介されている方法から逆引きによる理解~

## はじめに

この記事では、少し興味のあった、かつ今後業務で利用する可能性のあるElasticsearchに関する理解をまとめたものです。

目的として、以下を理解しようと考えました。

1. Analyzer と Mapping
2. Elasticsearch の実装例・できること

方法としては、[Elasticsearchの公式ページで紹介されている日本語サジェストの記事](https://www.elastic.co/jp/blog/implementing-japanese-autocomplete-suggestions-in-elasticsearch)を参考に、逆引きで理解をしようと思います。なお日本語サジェストのAnalyzerの詳細については説明していません。

また個人的にElasticsearchを理解する上でのキーポイントは、Analyzerにおける **検索時とindex 時それぞれで定義** できる点とMappingにおける **multi-field** だと思いました。 (RDBと全文検索の違いかな)

## Analyzer と Mapping の概要

それぞれについて簡単に解説

### Analyzer

ドキュメントindexする前に走る処理 (正確には転地インデックスを張る前に走る処理)

以下の3つの手順に分解される

1. Character Filter
    
    文章を分かち書きする前に行う処理。不要な文字列の削除(`<li></li>`など)や検索しやすいように文字列を置換する(色々→色色)などを行う
    
2. Tokenizer
    
    文章を分かち書きする際に、分かち書きのルールを定義する。検索において重要な処理であり、様々なルールが提供されている。日本語は区切りが分かりづらいため、形態要素分析を行う必要があり、そのためにコンポーネントであるkuromojiが提供されている
    
3. Token Filter
    
    分かち書きした後、それぞれの単語に対する個別の処理を行う 単語を小文字化するや品詞を削除するなど。
    

Analyzerの一連の処理は、ドキュメントをindexする際だけでなく、検索リクエストの際にも処理が走るようになっている (indexされたドキュメントに対して、同じルールで検索をかけよぜ、という話)

### Mapping

マッピングはドキュメントのスキーマ定義であり、フィールド名や型定義、どのAnylizerを利用するかなどのメタ情報を宣言できます (multi-fieldも)。

```json
// 具体例
PUT blogs
{
  "mappings": {
    "properties": {
      "content" : {
        "type": "text",
        "analyzer": "kuromoji"
      },
      "url" : {
        "type": "keyword"
      },
      "timestamp": {
        "type": "date"
      },
      "author" : {
        "type": "text",
        "fields": {
          "raw" : {
            "type" : "keyword"
          }
        }
      }
    }
  }
}
```
上記のマッピングからは以下のようにindexされます。
| DOCUMENT/blogs | 型 | マッピング | メモ |
| --- | --- | --- | --- |
| content | text | kuromoji | 日本語記事が入ることを想定し、アナライザに　kuromojiを指定 |
| url | keyward |  |  |
| timestamp | date |  |  |
| author | text |  | text型での検索→andやorで検索可 |
| (auther.row) | keyword |  | [マルチフィールド定義]完全一致に対応すべくkeyward型検索も可能に |

## Elasticsearch 公式サジェストのAnalyzer と Mappingの解説

Elαsticsearchの公式ページで紹介されている、日本語サジェストから、逆引きでAnalyzerとMappingの定義方法を理解していきます

以下3つの節に分かれていますが、下に行くほど単純化しており、Elasticsearchの構文に近くなっています

なおAnylyzern役割の詳細は解説されておりませんので、ご了承ください。

<details><summary>Elasticsearch公式ページで紹介されている、日本語サジェストのAnalyzerとMapping例 (コピペ)</summary>

```json
PUT my_suggest
{
	// カスタムAnalzer の定義
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {　/* 省略*/ },
        "kana_to_romaji": {　/* 省略*/ }
      },
      "tokenizer": {
        "kuromoji_normal": {　/* 省略*/ }
      },
      "filter": {
        "readingform": {　/* 省略*/ },
        "edge_ngram": {　/* 省略*/ },
        "synonym": {　/* 省略*/ }
      },
      "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"]
        }
      }
    }
  },
  // カスタムAnalzerを利用したMappingの定義
  "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"
          }
        }
      }
    }
  }
}
```
</details>

### 少し単純化: AnalyzerとMapping方法理解

```json
PUT my_suggest
{
	// カスタムAnalzer の定義
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {　/* 省略*/ },
        "kana_to_romaji": {　/* 省略*/ }
      },
      "tokenizer": {
        "kuromoji_normal": {　/* 省略*/ }
      },
      "filter": {
        "readingform": {　/* 省略*/ },
        "edge_ngram": {　/* 省略*/ },
        "synonym": {　/* 省略*/ }
      },
      "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"]
        }
      }
    }
  },
	// カスタムAnalzerを利用したMappingの定義
  "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"
          }
        }
      }
    }
  }
}
```

| DOCUMENT/my_suggest | 型 | index時Analyzer | 検索時Analyzer | メモ |
| --- | --- | --- | --- | --- |
| my_field | keyward |  |  |  |
| my_field.suggest | text | suggest_index_analyzer | suggest_search_analyzer | my_fieldのマルチフィールド定義 |
| my_field.reading_form | text | readingform_search_analyzer | readingform_index_analyzer | my_fieldのマルチフィールド定義 |

### さらに単純化: Elasticsearch のカスタムAnalyzer, Mappingの構文理解

```json
PUT my_suggest
{
	// カスタムAnalzer の定義
  "settings": {
    "analysis": {
      "char_filter": {
        // カスタムCharacter Filter
      },
      "tokenizer": {
        // カスタムTokenizer を定義
      },
      "filter": {
	      // カスタムToken Filter を定義
      },
			// 上記で定義した3つのコンポーネントを利用して、カスタムAnalyzerを定義する
      "analyzer": {
				// カスタムAnalyzer 'suggest_index_analyzer' を定義
        "suggest_index_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "kuromoji_normal",
          "filter": ["lowercase", "edge_ngram"]
        },
        "suggest_search_analyzer": {
          /* 同上 */
        },
        "readingform_index_analyzer": {
          /* 同上 */
        },
        "readingform_search_analyzer": {
          /* 同上 */
        }
      }
    }
  },
	// カスタムAnalzer を利用し、my_suggestというドキュメントのMappingの定義
  "mappings": {
    "properties": {
      "my_field": {
        "type": "keyword",
				// 以下はマルチフィールド定義
        "fields": {
          "suggest": {
            "type": "text",
            "search_analyzer": "suggest_search_analyzer", // ここで検索時のAnalyzerとして、上で定義したものを利用
            "analyzer": "suggest_index_analyzer"          // ここでindex時のAnalyzerとして、上で定義したものを利用
          },
          "readingform": {
	          /* 同上 */
          }
        }
      }
    }
  }
}
```

## 日本語サジェストのAnalyzer理解

これ以前の章で、カスタムAnaylzerとカスタムMappingの方法が理解できたと思います。

だいぶ抽象化すると、最終的に検索サジェストの為に以下のようなドキュメントの定義になりました

最後のこの章では、それぞれのIndexと対応するAnalzerの役割について、ざっくりと理解していきます

| DOCUMENT/my_suggest | 型 | index時Analyzer | 検索時Analyzer | メモ |
| --- | --- | --- | --- | --- |
| my_field | keyward |  |  |  |
| my_field.suggest | text | suggest_index_analyzer | suggest_search_analyzer | my_fieldのマルチフィールド定義 |
| my_field.reading_form | text | readingform_search_analyzer | readingform_index_analyzer | my_fieldのマルチフィールド定義 |

### マッピングの構成の理解

今回のサジェスト用indexでは、Elasticsearchのmulti-fieldsを利用して、my_fieldというkeywardフィールドに対して、sugesstとreading_formという2つのmulti-fieldを定義しています

suggestフィールドでは、漢字の読み方を吸収するsuggestというアナライザーを定義。

reading_formフィールドでは、未確定の仮名文字 (”にほn”など) を吸収するsuggestというアナライザーを定義。

それぞれのAnalzerに関する、詳細の説明 (Character Filter, Tokenizer, Token Filter)についてはElasticsearch公式のドキュメントの説明をご覧ください、以下簡易的な説明です

- index時は半角小文字に変換し、形態素解析で細かく文字を分割しすぎないようAnalyze
- reading_formではそれに加え、正規化や異なる読み方の違いを吸収
- 検索時は上記に加え、接頭辞検索を利用し、形態素解析の欠点をフォロー(恐らく)

## おわりに

個人的にはElasticsearchの使い方・できることがなんとなく理解できた気がしました。

ただスコアのチューニングとかは、やはり実際に手を動かさないことには理解が進まないな、と思います。

### 参考

- [日本語サジェスト | Elasticesearh公式](https://www.elastic.co/jp/blog/implementing-japanese-autocomplete-suggestions-in-elasticsearch)
- [search_analyzer | Elasticesearh公式 (検索時とindex時それぞれでAnalyzerを定義する)](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-analyzer.html)
- [カスタムAnalyzerの定義方法](https://baubaubau.hatenablog.com/entry/2020/07/10/215208)
- [形態素解析とN-Gram](https://atmarkit.itmedia.co.jp/ait/articles/1507/29/news010.html)