Elasticsearchのkuromoji tokenizerと同義語辞書の挙動

# これは何？

Elasticsearchで日本語のトークナイズに良く使われる[kuromoji_tokenizer](https://www.elastic.co/guide/en/elasticsearch/plugins/7.9/analysis-kuromoji-tokenizer.html)と、同義語辞書としてよく使われる[Synonym token filter](https://www.elastic.co/guide/en/elasticsearch/reference/7.9/analysis-synonym-tokenfilter.html)および[Synonym graph token filter](https://www.elastic.co/guide/en/elasticsearch/reference/7.9/analysis-synonym-graph-tokenfilter.html)の挙動についてまとめたものです。

記事中ではSynonym graph token filterを使用していますが、Synonym token filterを利用しても同じ結果になるようです。

# TL; DR（一言でまとめると）

ElasticsearchのSynonym graph token filterに複合語を含んだ同義語（synonyms）を設定する場合は、以下の2つの対処法のどちらかを取る。

- 同義語辞書に登録したキーワードとピッタリ一致したときだけ一致扱いにしたい場合は[対処方法1](対応方法1:-kuromoji_tokenizerのmodeをnormalにする)
- 一部の形態素だけ一致した場合も一致扱いにしたい場合は[対処方法2](対応方法2:-kuromoji_tokenizerのdiscard_compound_tokenをtrueにセットする)
- [対処方法3](対応方法3:-同義語辞書エントリを全部ユーザー辞書にセットする)は面倒な割に対処方法1と結果が変わらないので対象外

| 対処方法 | 「東京大学」の解析結果 | 検索キーワード「大学」は「東京大学」の文書にマッチするか |
|:--|:-:|:-:|
| 1: mode=normal| 「東京大学」 | × |
| 2: discard_compound_token=true| 「東京」「大学」 | ○ |
| 3: 同義語を全てユーザー辞書に追加| 「東京大学」| × |

# 前提

Elasticsearch 7.9.1での検証結果です。

# 現象

Synonym graph token filterに日本語の複合語とみなされる文字列（例: 東京大学）を渡すと、以下のようなエラーが出る。

下記のページで説明されている現象と同じ：

[ElasticsearchのSynonym追加において一部の日本語の文字でillegal_argument_exceptionが出る問題 - Qiita](https://qiita.com/RukaFunaki/items/0bbadd4605aa50900e7c)

## インデックス作成リクエスト

```json
PUT test 
{
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        }
      },
      "tokenizer": {
        "ja_kuromoji_tokenizer": {
          "mode": "search",
          "type": "kuromoji_tokenizer"
        }
      },
      "filter": {
        "ja_search_synonym": {
          "type": "synonym_graph",
          "synonyms": ["米国, アメリカ", "東京大学, 東大"]
        }
      },
      "analyzer": {
        "ja_kuromoji_index_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer"
        },
        "ja_kuromoji_search_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer",
          "filter": [
            "ja_search_synonym"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "text": {
        "type": "text",
        "search_analyzer": "ja_kuromoji_search_analyzer",
        "analyzer": "ja_kuromoji_index_analyzer"
      }
    }
  }
}
```

上記のリクエストに対しては、以下のように400 Bad Requestが返ってくる。

```json
{
  "error" : {
    "root_cause" : [
      {
        "type" : "illegal_argument_exception",
        "reason" : "failed to build synonyms"
      }
    ],
    "type" : "illegal_argument_exception",
    "reason" : "failed to build synonyms",
    "caused_by" : {
      "type" : "parse_exception",
      "reason" : "Invalid synonym rule at line 2",
      "caused_by" : {
        "type" : "illegal_argument_exception",
        "reason" : "term: 東京大学 analyzed to a token (東京大学) with position increment != 1 (got: 0)"
      }
    }
  },
  "status" : 400
}
```

# 考えられる原因

kuromoji_tokenizerのmodeをsearchに設定した場合、東京大学は以下のように「東京」「東京大学」「大学」と複合語と単語が混ざった状態で解析される。

```json
{
  "tokens" : [
    {
      "token" : "東京",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "東京大学",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "word",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "大学",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "word",
      "position" : 1
    }
  ]
}
```

エラーメッセージと下記の記事から推測すると、この「東京」と「東京大学」でpositionが同じ0になっているのが問題のようだ。

[Elasticsearch7.2でkuromoji_ipadic_neologd_tokenizerのsearchモードとsynonym_token_filterを一緒に使うとエラーが出る - 焼肉が食べたい](http://chie8842.hatenablog.com/entry/2019/09/29/124500)

# 対応方法1: kuromoji_tokenizerのmodeをnormalにする

前述の2つのWeb上の記事でも紹介されている方法。

## インデックス作成リクエスト

問題が起こるリクエストからja_kuromoji_tokenizerのmodeのみ変更。問題ないレスポンスが返ってくる（200 OK)

```json
PUT test 
{
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        }
      },
      "tokenizer": {
        "ja_kuromoji_tokenizer": {
          "mode": "normal",
          "type": "kuromoji_tokenizer"
        }
      },
      "filter": {
        "ja_search_synonym": {
          "type": "synonym_graph",
          "synonyms": ["米国, アメリカ", "東京大学, 東大"]
        }
      },
      "analyzer": {
        "ja_kuromoji_index_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer"
        },
        "ja_kuromoji_search_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer",
          "filter": [
            "ja_search_synonym"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "text": {
        "type": "text",
        "search_analyzer": "ja_kuromoji_search_analyzer",
        "analyzer": "ja_kuromoji_index_analyzer"
      }
    }
  }
}
```

## 複合語の解析結果

東京大学は「東大」と「東京大学」に解析される。「東京」とか「大学」は出てこない。

```json
GET test/_analyze
{
  "analyzer" : "ja_kuromoji_search_analyzer",
  "text" : "東京大学"
}

# 結果
{
  "tokens" : [
    {
      "token" : "東大",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "東京大学",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "word",
      "position" : 0
    }
  ]
}
```

## 検索時の挙動

同義語辞書が効いているため、「東大」という検索キーワードに対して「東京大学」を含む文書がヒットするようになる（検索例2）。一方で、「大学」や「青山学院大学」（内部的には青山/学院/大学と解析されている）にはヒットしなくなる（検索例1, 3）

```json
# 文書登録
POST _bulk
{"index": {"_index": "test", "_id": 1}}
{"text": "東京大学は本郷と駒場にキャンパスがあります。"}

# 検索例１
GET /test/_search
{
  "query": {
    "match": {
      "text": "日本の有名な大学"
    }
  }
}

# 検索結果１（ヒットなし）
{
  "took" : 423,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

# 検索例２
GET /test/_search
{
  "query": {
    "match": {
      "text": "東大に行きたい"
    }
  }
}

# 検索結果２（上記の文書がヒット）
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.5753642,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.5753642,
        "_source" : {
          "text" : "東京大学は本郷と駒場にキャンパスがあります。"
        }
      }
    ]
  }
}

# 検索例３
GET /test/_search
{
  "query": {
    "match": {
      "text": "青山学院大学"
    }
  }
}

# 検索結果３（ヒットなし）
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}
```

# 対応方法2: kuromoji_tokenizerのdiscard_compound_tokenをtrueにセットする

下記の記事での対応方法。

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

discard_compound_tokenをtrueにすると、searchモードのときに複合語が出力されなくなる。デフォルトはfalse。

[参考: kuromoji_tokenizerの説明](https://www.elastic.co/guide/en/elasticsearch/plugins/7.9/analysis-kuromoji-tokenizer.html)

kuromoji_tokenizerの説明。

## インデックス作成リクエスト

問題が起こるリクエストのja_kuromoji_tokenizerにdiscard_compound_token: trueを追加。このオプションはデフォルトではfalseになっている。問題ないレスポンスが返ってくる（200 OK)

```json
PUT test 
{
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        }
      },
      "tokenizer": {
        "ja_kuromoji_tokenizer": {
          "mode": "search",
          "discard_compound_token": true,
          "type": "kuromoji_tokenizer"
        }
      },
      "filter": {
        "ja_search_synonym": {
          "type": "synonym_graph",
          "synonyms": ["米国, アメリカ", "東京大学, 東大"]
        }
      },
      "analyzer": {
        "ja_kuromoji_index_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer"
        },
        "ja_kuromoji_search_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer",
          "filter": [
            "ja_search_synonym"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "text": {
        "type": "text",
        "search_analyzer": "ja_kuromoji_search_analyzer",
        "analyzer": "ja_kuromoji_index_analyzer"
      }
    }
  }
}
```

## 複合語の解析結果

「東京大学」は「東大」「東京」「大学」と解析される。「東京大学」は含まれない。

```json
GET test/_analyze
{
  "analyzer" : "ja_kuromoji_search_analyzer",
  "text" : "東京大学"
}

# 結果
{
  "tokens" : [
    {
      "token" : "東大",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "東京",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "大学",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "word",
      "position" : 1
    }
  ]
}
```

## 検索時の挙動

「東京大学」を含む文書は、「東京大学」という検索キーワードにも（検索例2）、「大学」という検索キーワードにもヒットするようになる（検索例1, 3）。

```json
# 文書登録
POST _bulk
{"index": {"_index": "test", "_id": 1}}
{"text": "東京大学は本郷と駒場にキャンパスがあります。"}

# 検索例１
GET /test/_search
{
  "query": {
    "match": {
      "text": "日本の有名な大学"
    }
  }
}

# 検索結果１（ヒット）
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.2876821,
        "_source" : {
          "text" : "東京大学は本郷と駒場にキャンパスがあります。"
        }
      }
    ]
  }
}

# 検索例２
GET /test/_search
{
  "query": {
    "match": {
      "text": "東大に行きたい"
    }
  }
}

# 検索結果２（ヒット）
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.8630463,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.8630463,
        "_source" : {
          "text" : "東京大学は本郷と駒場にキャンパスがあります。"
        }
      }
    ]
  }
}

# 検索例３
GET /test/_search
{
  "query": {
    "match": {
      "text": "青山学院大学"
    }
  }
}

# 検索結果３（ヒットなし）
{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.2876821,
        "_source" : {
          "text" : "東京大学は本郷と駒場にキャンパスがあります。"
        }
      }
    ]
  }
}
```

# 対応方法3: 同義語辞書エントリを全部ユーザー辞書にセットする

同義語辞書エントリをユーザー辞書にセットすることで上述の同義語辞書のエラーを防ぐこともできる。

## インデックス作成リクエスト

エラーが発生するリクエストのkuromoji_tokenizerにuser_dictionary_rulesを追加。問題ないレスポンスが返ってくる（200 OK)

```json
PUT test 
{
  "settings": {
    "analysis": {
      "char_filter": {
        "normalize": {
          "type": "icu_normalizer",
          "name": "nfkc",
          "mode": "compose"
        }
      },
      "tokenizer": {
        "ja_kuromoji_tokenizer": {
          "mode": "search",
          "type": "kuromoji_tokenizer",
          "user_dictionary_rules": [
            "東京大学,東京大学,トウキョウダイガク,カスタム名詞"
          ]
        }
      },
      "filter": {
        "ja_search_synonym": {
          "type": "synonym_graph",
          "synonyms": ["米国, アメリカ", "東京大学, 東大"]
        }
      },
      "analyzer": {
        "ja_kuromoji_index_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer"
        },
        "ja_kuromoji_search_analyzer": {
          "type": "custom",
          "char_filter": ["normalize"],
          "tokenizer": "ja_kuromoji_tokenizer",
          "filter": [
            "ja_search_synonym"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "text": {
        "type": "text",
        "search_analyzer": "ja_kuromoji_search_analyzer",
        "analyzer": "ja_kuromoji_index_analyzer"
      }
    }
  }
}
```

## 複合語の解析結果

「東京大学」は「東大」「東京大学」と解析される。対処方法1でmode=normalに設定したときと同じ挙動。

```json
GET test/_analyze
{
  "analyzer" : "ja_kuromoji_search_analyzer",
  "text" : "東京大学"
}

# 結果
{
  "tokens" : [
    {
      "token" : "東大",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "東京大学",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "word",
      "position" : 0
    }
  ]
}
```

## 検索時の挙動

対処方法1と同じ。「東京大学」を含む文書は、「東大」という検索キーワードにはヒットするが（検索例2）、「大学」という検索キーワードにはヒットしない（検索例1, 3）

```json
# 文書登録
POST _bulk
{"index": {"_index": "test", "_id": 1}}
{"text": "東京大学は本郷と駒場にキャンパスがあります。"}

# 検索例１
GET /test/_search
{
  "query": {
    "match": {
      "text": "日本の有名な大学"
    }
  }
}

# 検索結果１（ヒットなし）
{
  "took" : 423,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

# 検索例２
GET /test/_search
{
  "query": {
    "match": {
      "text": "東大に行きたい"
    }
  }
}

# 検索結果２（上記の文書がヒット）
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.5753642,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.5753642,
        "_source" : {
          "text" : "東京大学は本郷と駒場にキャンパスがあります。"
        }
      }
    ]
  }
}

# 検索例３
GET /test/_search
{
  "query": {
    "match": {
      "text": "青山学院大学"
    }
  }
}

# 検索結果３（ヒットなし）
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}
```

# まとめ

対処方法3は同義語エントリが多くなると面倒。どれが複合語扱いされるかひと目ではわからないので、おそらくすべての同義語エントリをユーザー辞書登録する必要がある。

そう考えると、完全一致重視（Precision重視）にするなら対処方法1、部分一致重視（Recall重視）にするなら対処方法2、と言えそう。

なお、下記の記事ではさらにn-gram analyzerを利用して文字バイグラムもインデックスに追加することで、Recallを増す方法が紹介されています。

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