Elasticsearchで社内ナレッジを全文検索するためにやったこと

# はじめに
社内のドキュメントやナレッジ情報を全文検索するために、Elasticsearchを利用しました。
構築の際に工夫した点や、現在課題に感じている点を共有します。

# 事前知識

### 全文検索とは
複数の文書（ファイル）から特定の文字列を検索することです。
全文検索するためには、どの単語がどのファイルにあるかを探すための索引が必要です。
Elasticsearchではファイルの文字列から転置インデックスと呼ばれる索引を作成します。

### n-gram解析
文字列をN文字毎に分割する方法です。
検索語句に忠実な検索ができるため、検索漏れが少なくなるというメリットがあります。
一方で、意味を無視して機械的に転置インデックスを作成するので検索ノイズは増えます。
例えば、「京都」で検索すると、「京都府」と「東京都」の両方がヒットしてしまいます。

### 形態素解析
辞書などを用いて、意味のある単語で単語を分割する方法です。
品詞情報を利用して分割するため、関連度の高いデータが検索にヒットしやすくなります。
一方で、辞書にない単語の転置インデックスは作成できず未知の単語の検索は不向きです。
社内用語や特定の業界の専門用語などは、検索にヒットしない可能性があります。

# 実現したいこと
今回、Elasticsearchで実現したいことは以下の通りです。
- 社内独自の用語が含まれているドキュメントを検索したい。
- 表記揺れや類似の単語が含まれている場合も検索にヒットしてほしい。
- ドキュメントにタグ情報を付与して、フィルタ検索したい。

# 対応方針
### 社内独自の用語が含まれているドキュメントを検索したい。

社内用語は辞書に登録されていないため、検索漏れが発生する可能性があります。
そのため、今回は形態素解析とn-gram解析の2つのフィールドを利用することにしました。

#### インデックス定義
今回は、ID・タイトル・本文をフィールドに持つインデックスを作成します。
IDはkeyword型、タイトルと本文はtext型のフィールドとして定義します。
```diff_json:PUT /my-index/_mapping
{
  "properties": {
    "id": {"type": "keyword"},
    "title" : {"type": "text"},
    "contents": {"type": "text"}
  }
}
```
タイトルと本文を検索するため、n-gram解析と形態素解析のフィールドを追加します。
```diff_json:PUT /my-index/_mapping
{
  "properties": {
    "id": {"type": "keyword"},
    "title" : {
+     "type" : "text",
+     "analyzer": "my_custom_kuromoji_index_analyzer",
+     "fields": {
+       "ngram": {
+         "type" : "text",
+         "analyzer": "my_custom_ngram_index_analyzer"
+       }        
      }
    },
    "contents": {
+     "type": "text",
+     "analyzer": "my_custom_kuromoji_index_analyzer",
+     "fields": {
+       "ngram": {
+         "type": "text",
+         "analyzer": "my_custom_ngram_index_analyzer"
+       }
      }
    }
  }
}
```

``my_custom_ngram_index_analyzer`` はn-gram解析、
``my_custom_kuromoji_index_analyzer``は形態素解析のアナライザです。
各アナライザとそれらの構成要素は、以下のように定義しています。

- ``normalize`` : 文字の正規化を行います。[icu_normarilzer](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu-normalization-charfilter.html) を利用します。
- ``ja_ngram_tokenizer`` : n-gram解析のトークナイザ。今回はＮ=2で利用します。
- ``ja_kuromoji_tokenizer`` : 形態素解析のトークナイザ。今回は [kuromoji](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html) を利用します。

```json:PUT /my-index
{
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        }
      },
      "tokenizer": {
        "ja_ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 2,
          "token_chars": [
            "letter",
            "digit"
          ]
        },
        "ja_kuromoji_tokenizer": {
          "mode": "search",
          "type": "kuromoji_tokenizer",
          "discard_compound_token": true
        }
      },
      "analyzer": {
        "my_custom_ngram_index_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize"
          ],
          "tokenizer": "ja_ngram_tokenizer",
          "filter": [
            "lowercase"
          ]
        },
        "my_custom_kuromoji_index_analyzer": {
          "type": "custom",
          "char_filter": [
            "normalize"
          ],
          "tokenizer": "ja_kuromoji_tokenizer",
          "filter": [
            "kuromoji_baseform",
            "kuromoji_part_of_speech",
            "cjk_width",
            "ja_stop",
            "kuromoji_stemmer",
            "lowercase"
          ]
        }
      }
    }
  }
}
```


続いて、同義語検索をするため、検索用のアナライザ ``search_analyzer`` を追加します。

```diff_json:PUT /my-index/_mapping
{
  "properties": {
    "id": {"type": "keyword"},
    "title" : {
      "type" : "text",
+     "search_analyzer": "my_custom_kuromoji_search_analyzer",
      "analyzer": "my_custom_kuromoji_index_analyzer",
      "fields": {
        "ngram": {
          "type" : "text",
+         "search_analyzer": "my_custom_ngram_search_analyzer",
          "analyzer": "my_custom_ngram_index_analyzer"
        },        
      }
    },
    "contents": {
      "type": "text",
+     "search_analyzer": "my_custom_kuromoji_search_analyzer",
      "analyzer": "my_custom_kuromoji_index_analyzer",
      "fields": {
        "ngram": {
          "type": "text",
+         "search_analyzer": "my_custom_ngram_search_analyzer",
          "analyzer": "my_custom_ngram_index_analyzer"
        },
      }
    }
  }
}
```

``my_custom_ngram_search_analyzer`` はn-gram解析、
``my_custom_kuromoji_search_analyzer``は形態素解析のアナライザです。
これらのアナライザには、``ja_search_synonym``というシノニムを登録しています。
今回の例では、「Qiita」と「キータ」が同義語であると定義しています。

```json:PUT /my-index/_settings
{
  "analyzis": {
    "filter": {
      "ja_search_synonym": {
          "type": "synonym_graph",
          "lenient": false,
          "synonyms": [
             "Qiita, キータ"
          ]
      }
    },
    "analyzer": {
      "my_custom_ngram_search_analyzer": {
        "type": "custom",
        "char_filter": [
          "normalize"
        ],
        "tokenizer": "ja_ngram_tokenizer",
        "filter": [
          "lowercase",
          "ja_search_synonym"
        ]
      },
      "my_custom_kuromoji_search_analyzer": {
        "type": "custom",
        "char_filter": [
          "normalize"
        ],
        "tokenizer": "ja_kuromoji_tokenizer",
        "filter": [
          "kuromoji_baseform",
          "kuromoji_part_of_speech",
          "cjk_width",
          "ja_stop",
          "kuromoji_stemmer",
          "lowercase",
          "ja_search_synonym"
        ]
      }
    }
  }
}
```

#### 検索クエリ
n-gram解析のフィールドを必須条件(must)、形態素解析のフィールドを任意条件(should)として検索することで、検索漏れを防ぎ、関連度の高い結果を検索結果の上位に表示します。

タイトルと本文の両方を検索するため、``multi_match``クエリを利用します。
また、形態素解析の検索結果を反映しやすくするため、スコアに3倍の補正をかけました。

```diff_json:GET my-index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "(検索語句)",
            "fields": [
              "title.ngram^1",              
              "contents.ngram^1"
            ]
          }
        },
      ],
      "should": [
        {
          "multi_match": {
            "query": "(検索語句)",
            "fields": [
              "title^3",
              "contents^3"
            ]
          }
        }
      ]
    }
  }
}
```
### 表記揺れや類似の単語が含まれている場合も検索にヒットしてほしい。

textフィールドでは、``search_analyzer``で検索語句を複数の単語に分割したのち、各単語の検索を実施しています。今回は、分割後の各単語のいずれかを本文に含むドキュメントを検索できるようにしました。

#### インデックス定義
変更は特にありません。

#### 検索クエリ
``multi_match`` クエリの ``type`` に ``best_fields``を指定しました。

```diff_json:GET my-index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "(検索語句)",
            "fields": [
              "title.ngram^1",              
              "contents.ngram^1"
+           ],
+            "type": "best_fields"
          }
        },
      ],
      "should": [
        {
          "multi_match": {
            "query": "(検索語句)",
            "fields": [
              "title^3",
              "contents^3"
+           ],
+            "type": "best_fields"
          }
        }
      ]
    }
  }
}
```

### ドキュメントにタグ情報を持たせて、フィルタ検索できるようにしたい。
指定されたタグを持つドキュメントを検索対象にするため、新しくフィールドを作ります。
複数のタグを指定した場合は、それら全てのタグを持つドキュメントを検索対象とします。

#### インデックス定義

タグ用のフィールドをマッピングに追加します。
```diff_json:PUT /my-index/_mapping
{
  "properties": {
    "id": {"type": "keyword"},
+   "tag": {"type": "keyword"},
    "title" : {
      "type" : "text",
      "search_analyzer": "my_custom_kuromoji_search_analyzer",
      "analyzer": "my_custom_kuromoji_index_analyzer",
      "fields": {
        "ngram": {
          "type" : "text",
          "search_analyzer": "my_custom_ngram_search_analyzer",
          "analyzer": "my_custom_ngram_index_analyzer"
        },        
      }
    },
    "contents": {
      "type": "text",
      "search_analyzer": "my_custom_kuromoji_search_analyzer",
      "analyzer": "my_custom_kuromoji_index_analyzer",
      "fields": {
        "ngram": {
          "type": "text",
          "search_analyzer": "my_custom_ngram_search_analyzer",
          "analyzer": "my_custom_ngram_index_analyzer"
        },
      }
    }
  }
}
```
#### 検索クエリ
``filter`` 内に、タグの検索条件を指定します。
タグが複数指定される場合は、各タグに対応する``term``クエリをmust句で定義します。

```diff_json: GET my-index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "(検索語句)",
            "fields": [
              "title.ngram^1",              
              "contents.ngram^1"
            ],
            "type": "best_fields"
          }
        }
      ],
      "should": [
        {
          "multi_match": {
            "query": "(検索語句)",
            "fields": [
              "title^3",
              "contents^3"
            ],
            "type": "best_fields"
          }
        }
      ],
+     "filter": [
+       {
+         "bool": {
+           "must": [
+             {
+               "term": {
+                 "tag": "(検索対象のタグ1)"
+               }
+             },
+             {
+               "term": {
+                 "tag": "(検索対象のタグ2)"
+               }
+             }
+           ]
+         }
+       }
+     ]
    }
  }
}
```

# 課題に感じている点
### 関係のない結果が検索結果に表示されることがある。
n-gram解析のフィールドが必須条件のため、関連度の低い検索結果も表示されます。
形態素解析の転置インデックスに未登録の単語を検索した場合に、特に顕著に表れます。
スコアに何かしらの条件を設定すれば解決できそうですが、スコア周りの勉強がまだできていないのでおいおい検討していきたいです。

**2024/05/13 追記**
minimum_should_matchというパラメータを利用することで解決できました。
実現方法を以下の記事にまとめたので、興味のある方はご覧ください。

https://qiita.com/s-itou/items/73da18d6ba53e7ef9685

### 完全一致検索がしたい。
実際に検索システムを利用していく中で、特定の単語が含まれている単語のみを検索したいシーンが何度か出てきました。
現在の設定では完全一致検索はできないので、``multi_match`` クエリの ``type`` の変更や、ワイルドカード検索などを検討してみようと考えています。

**2024/05/13 追記**
``multi_match`` クエリの ``type`` を変更することで解決できました。
実現方法を以下の記事にまとめたので、興味のある方はご覧ください。

https://qiita.com/s-itou/items/5f07c6e02d9ac558bbdc

# 参考

[第6回 N-gramと形態素解析との比較 | gihyo.jp](https://gihyo.jp/dev/serial/01/make-findspot/0006)

[How to implement Japanese full-text search in Elasticsearch | Elastic Blog](https://www.elastic.co/jp/blog/how-to-implement-japanese-full-text-search-in-elasticsearch)