【Elasticsearch】N-gram tokenizer が単語に含める文字種はUnicodeの一般カテゴリから判断できる


# はじめに

４月に新卒エンジニアとして入社した @af_ty です。技術的にも仕事一般の能力的にもまだ至らない点ばかりですが、先輩方に助けてもらいながら、やり応えのある業務に関わらせてもらっています。

さて、弊社は大量のオークションデータを扱うために、検索・分析エンジンの Elasticsearch を活用しています。この記事ではその Elasticsearch の全文検索機能について、調査に時間がかかった情報があったのでまとめておこうと思います。

## 概要
- Elasticsearch での全文検索に `N-gram tokenizer` を使う中で、どの文字がトークン（＝単語）に含まれるか（含まれないか）を判別したいことがありました。
- トークンに含める文字の種類は `letter` `digit` `symbol`  などを `N-gram tokenizer` のパラメータに指定することで選べます。ですが公式ドキュメントを読んでも、各々の値が具体的にどの文字に対応しているかは詳しく書かれていませんでした。
- そこで [ソースコード]( https://github.com/elastic/elasticsearch/blob/0a2232694d0cf508a3b6bd878c4dd916d7aa9429/modules/analysis-common/src/main/java/org/elasticsearch/analysis/common/CharMatcher.java#L37 ) を読んだところ、これら `letter` `digit` `symbol` などは `一般カテゴリ` （ Unicode で規定されている文字の分類 ）に対応しているため、この `一般カテゴリ` を調べて判別すればよいことが分かりました。

## 想定環境
- Windows 10
- Elasticsearch 7.15.2
- Kibana 7.15.2

# 前提知識
この記事のテーマを理解するうえで必要な知識として、 `N-gram tokenizer` と `Unicodeの一般カテゴリ` について簡単に説明します。
すでにご存じの方は読み飛ばしてください。

## Elasticsearch の Analyzer とは
Elasticsearch は索引（インデックス）型の全文検索を行っています。
この節では tokenizer の位置づけを説明するために、索引型検索について簡単に説明します。

まず Elasticsearch に登録される文章（正確にはドキュメント中の Text型field ）は、 Analyzer によりトークンに分割されて、トークンとドキュメントの対応関係としてインデックスに登録されます。

![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2299296/49cc2400-ffd9-180d-3eb4-ba49ec189058.png)

検索の際には、同じく検索フレーズが Analyzer によりトークン化されて、インデックスを参照して該当するドキュメントを探します。文字通り索引を引くように検索をするわけです。


![画像2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2299296/a34a9bd7-0e4b-992d-45dd-8e943a723012.png)


この２枚の図でトークン化をしている Analyzer は、詳しく見ると次のような構造をしています。

![画像3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2299296/f320d283-2936-36c6-ff21-9a9b2db044b4.png)

３つコンポーネントが出てきました。それぞれ次のような役割を持っています。

- `Char filter` : Tokenizer の前処理をする。例) HTMLをブラウザから見た文字列にする
- `Tokenizer` : トークン化をする。例) 形態素解析をする、N-gram化をする
- `Token filter` : Tokenizer の後処理をする。例) 活用形を原型に直す

この記事で取り上げる `N-gram tokenizer` は Tokenizer の一つです。

参考：
[Analyzer の構造の公式ドキュメント]( https://www.elastic.co/guide/en/elasticsearch/reference/current/analyzer-anatomy.html )

## N-gram とは
あるテキストに含まれる、連続するN文字のことです。例えば `オークファン` の bigram (N = 2 の場合)は `オー` `ーク` `クフ` `ファ` `ァン` です。
検索用の索引を作成するために応用されます。

参考：
[Nグラムとは - コトバンク]( https://kotobank.jp/word/N%E3%82%B0%E3%83%A9%E3%83%A0-1702302 )

## N-gram tokenizer とは
N-gram 化をする Tokenizer のことです。以下のパラメータを受け付けます。

- `min_gram` : N-gram の N の最小値
- `max_gram` : N-gram の N の最大値
- `token_chars` : どの文字種をトークンを構成する文字とするか。指定されなかった文字種はトークンの区切り文字として扱われる。次の値から複数指定できる
    - `letter`
    - `digit`
    - `whitespace`
    - `punctuation`
    - `symbol`
- `custom_token_chars` : `token_chars` 以外にトークンを構成する文字を追加できる


参考：
[N-gram tokenizer の公式ドキュメント]( https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-ngram-tokenizer.html )

## Unicode の一般カテゴリとは
文字通り、 Unicode における文字のカテゴリ分類です。
例）Nl	数値を表す文字 (Letter number)

参考：
[Unicode Character Categories]( https://www.compart.com/en/unicode/category ) 各カテゴリに含まれる文字が調べられます。
[PHPマニュアル - Unicode文字プロパティ]( https://www.php.net/manual/ja/regexp.reference.unicode.php )

# 本題
ここからが本題です。

## token_chars と Unicode の一般カテゴリ
結論から書くと、 `token_chars` の各値は、 Unicode の一般カテゴリに対応しています。
対応を次の表にまとめています。

|   | C | L | M | Nd | Nl | No | P | S | Z |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| letter |  | O |  |  |  |  |  |  |  |
| digit |  |  |  | O |  |  |  |  |  |
| whitespace | ※ |  |  |  |  |  |  |  | O※ |
| punctuation |  |  |  |  |  |  | O |  |  |
| symbol |  |  |  |  |  |  |  | O |  |

（※whitespace は `java.lang.Character.isWhitespace()` で判定されています。
おおよそ一般カテゴリZに該当しますが、一般カテゴリCに含まれるタブ文字`\t`が該当するなど、完全には一致しません。詳しくは [isWhitespace()のJavadoc]( https://docs.oracle.com/javase/jp/8/docs/api/java/lang/Character.html#isWhitespace-char- ) をご確認ください。）

[ソースはこちら]( https://github.com/elastic/elasticsearch/blob/0a2232694d0cf508a3b6bd878c4dd916d7aa9429/modules/analysis-common/src/main/java/org/elasticsearch/analysis/common/CharMatcher.java#L37 ) です。Elasticsearch 内部で `java.lang.Character` のフィールドを使って間接的に Unicode の一般カテゴリを利用して条件分岐していることが分かります。

参考：[ Character クラスの Javadoc ]( https://docs.oracle.com/javase/jp/8/docs/api/java/lang/Character.html )


## 検証
### 検証内容
上述の対応関係を Elasticsearch に実際にクエリを投げて検証してみます。

まず異なる `token_chars` の値を指定した Analyzer を用意して、インデックスに登録します。

```
PUT index-test-token-chars
{
  "settings": {
    "analysis": {
      "analyzer": {
        "analyzer_all": {
          "type": "custom", 
          "tokenizer": "ngram_all"
        },
        "analyzer_letter": {
          "type": "custom", 
          "tokenizer": "ngram_letter"
        },
        "analyzer_digit": {
          "type": "custom", 
          "tokenizer": "ngram_digit"
        },
        "analyzer_whitespace": {
          "type": "custom", 
          "tokenizer": "ngram_whitespace"
        },
        "analyzer_punctuation": {
          "type": "custom", 
          "tokenizer": "ngram_punctuation"
        },
        "analyzer_symbol": {
          "type": "custom", 
          "tokenizer": "ngram_symbol"
        }
      }, 
      "tokenizer": {
        "ngram_all" : {
            "token_chars": [
            ],
            "min_gram": "1",
            "max_gram": "1",
            "type": "ngram"
        },
        "ngram_letter" : {
            "token_chars": [
              "letter"
            ],
            "min_gram": "1",
            "max_gram": "1",
            "type": "ngram"
        },
        "ngram_digit" : {
            "token_chars": [
              "digit"
            ],
            "min_gram": "1",
            "max_gram": "1",
            "type": "ngram"
        },
        "ngram_whitespace" : {
            "token_chars": [
              "whitespace"
            ],
            "min_gram": "1",
            "max_gram": "1",
            "type": "ngram"
        },
        "ngram_punctuation" : {
            "token_chars": [
              "punctuation"
            ],
            "min_gram": "1",
            "max_gram": "1",
            "type": "ngram"
        },
        "ngram_symbol" : {
            "token_chars": [
              "symbol"
            ],
            "min_gram": "1",
            "max_gram": "1",
            "type": "ngram"
        }
      }
    }
  }
}
```

Unicode の一般カテゴリ `Cc, Ll, Lm, Lo, Lt, Lu, Mc, Me, Mn, Nd, Nl, No, Pc, Pd, Pe, Pf, Pi, Po, Ps, Sc, Sk, Sm, So, Zs` からそれぞれ１文字ずつ取り出した文字列 `\raʰƻǅAः҈̀0Ⅰ¼_-)»«!(£^+© ` を用意した Analyzer にかけて、生成されるトークンを確認してみます。(Cf, Zl, Zp は飛ばします)



### 検証結果

[Analyze API](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html) により、 `analyzer_letter` にかけて生成されたトークンを見てみます。

```
GET index-test-token-chars/_analyze
{
  "analyzer": "analyzer_letter",
  "text": "\raʰƻǅAः҈̀0Ⅰ¼_-)»«!(£^+© "
}
```

結果はこちらのようになりました。

```
{
  "tokens" : [
    {
      // 一般カテゴリ Ll
      "token" : "a",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      // 一般カテゴリ Lm
      "token" : "ʰ",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      // 一般カテゴリ Lo
      "token" : "ƻ",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "word",
      "position" : 2
    },
    {
      // 一般カテゴリ Lt
      "token" : "ǅ",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "word",
      "position" : 3
    },
    {
      // 一般カテゴリ Lu
      "token" : "A",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "word",
      "position" : 4
    }
  ]
}
```

一般カテゴリLに該当しない文字はトークンに含まれていないことが確認できました。

同様に見ていきます。

`analyzer_digit`

```
{
  "tokens" : [
    {
      // 一般カテゴリ Nd
      "token" : "0",
      "start_offset" : 9,
      "end_offset" : 10,
      "type" : "word",
      "position" : 0
    }
  ]
}
```

`analyzer_whitespace`

```
{
  "tokens" : [
    {
      // 一般カテゴリCc キャリッジリターン
      "token" : """
""",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "word",
      "position" : 0
    },
    {
      // 一般カテゴリ Zs
      "token" : " ",
      "start_offset" : 23,
      "end_offset" : 24,
      "type" : "word",
      "position" : 1
    }
  ]
}
```

`analyzer_punctuation`

```
{
  "tokens" : [
    {
      // 一般カテゴリ Pc
      "token" : "_",
      "start_offset" : 12,
      "end_offset" : 13,
      "type" : "word",
      "position" : 0
    },
    {
      // 一般カテゴリ Pd
      "token" : "-",
      "start_offset" : 13,
      "end_offset" : 14,
      "type" : "word",
      "position" : 1
    },
    {
      // 一般カテゴリ Pe
      "token" : ")",
      "start_offset" : 14,
      "end_offset" : 15,
      "type" : "word",
      "position" : 2
    },
    {
      // 一般カテゴリ Pf
      "token" : "»",
      "start_offset" : 15,
      "end_offset" : 16,
      "type" : "word",
      "position" : 3
    },
    {
      // 一般カテゴリ Pi
      "token" : "«",
      "start_offset" : 16,
      "end_offset" : 17,
      "type" : "word",
      "position" : 4
    },
    {
      // 一般カテゴリ Po
      "token" : "!",
      "start_offset" : 17,
      "end_offset" : 18,
      "type" : "word",
      "position" : 5
    },
    {
      // 一般カテゴリ Ps
      "token" : "(",
      "start_offset" : 18,
      "end_offset" : 19,
      "type" : "word",
      "position" : 6
    }
  ]
}
```

`analyzer_symbol`

```
{
  "tokens" : [
    {
      // 一般カテゴリ Sc
      "token" : "£",
      "start_offset" : 19,
      "end_offset" : 20,
      "type" : "word",
      "position" : 0
    },
    {
      // 一般カテゴリ Sk
      "token" : "^",
      "start_offset" : 20,
      "end_offset" : 21,
      "type" : "word",
      "position" : 1
    },
    {
      // 一般カテゴリ Sm
      "token" : "+",
      "start_offset" : 21,
      "end_offset" : 22,
      "type" : "word",
      "position" : 2
    },
    {
      // 一般カテゴリ So
      "token" : "©",
      "start_offset" : 22,
      "end_offset" : 23,
      "type" : "word",
      "position" : 3
    }
  ]
}
```

いずれも上記の表の通りであることが確認できました。

# まとめ

`token_chars` のそれぞれの値は `java.lang.Character` のフィールドを基に条件分岐されており、それらは Unicode の一般カテゴリに対応しています。そのため、 Unicode の一般カテゴリを確認すれば判別ができます。

# 感想 

この結論にたどり着くのには苦戦しました。
公式ドキュメントを読んだりググったりしてもなかなか情報がなく、結局１日半かけてソースコードを読みました（これも良い経験にはなりましたが）。もし自分が見逃していた既存の情報源があればコメントで指摘いただけるととても嬉しいです。

（また、この記事では取り上げませんでしたが、実際のユースケースでは Char filter や Token filter を設定すると思います。そのためもっと分かりにくい挙動をするかもしれません。）

この記事が N-gram tokenizer を使うエンジニアの皆さんの助けになれば幸いです。


# 参考資料
- [Elasticsearchの全文検索の公式ドキュメント]( https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis.html )
- [Unicodeの一般カテゴリ一覧]( https://www.compart.com/en/unicode/category )
- [Elasticsearchのソースコードの該当箇所]( https://github.com/elastic/elasticsearch/blob/0a2232694d0cf508a3b6bd878c4dd916d7aa9429/modules/analysis-common/src/main/java/org/elasticsearch/analysis/common/CharMatcher.java#L37 )