10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

オークファングループAdvent Calendar 2021

Day 8

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

Last updated at Posted at 2021-12-07

はじめに

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

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

概要

  • Elasticsearch での全文検索に N-gram tokenizer を使う中で、どの文字がトークン(=単語)に含まれるか(含まれないか)を判別したいことがありました。
  • トークンに含める文字の種類は letter digit symbol などを N-gram tokenizer のパラメータに指定することで選べます。ですが公式ドキュメントを読んでも、各々の値が具体的にどの文字に対応しているかは詳しく書かれていませんでした。
  • そこで ソースコード を読んだところ、これら letter digit symbol などは 一般カテゴリ ( Unicode で規定されている文字の分類 )に対応しているため、この 一般カテゴリ を調べて判別すればよいことが分かりました。

想定環境

  • Windows 10
  • Elasticsearch 7.15.2
  • Kibana 7.15.2

前提知識

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

Elasticsearch の Analyzer とは

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

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

画像1.png

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

画像2.png

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

画像3.png

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

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

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

参考:
Analyzer の構造の公式ドキュメント

N-gram とは

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

参考:
Nグラムとは - コトバンク

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 の公式ドキュメント

Unicode の一般カテゴリとは

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

参考:
Unicode Character Categories 各カテゴリに含まれる文字が調べられます。
PHPマニュアル - Unicode文字プロパティ

本題

ここからが本題です。

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 をご確認ください。)

ソースはこちら です。Elasticsearch 内部で java.lang.Character のフィールドを使って間接的に Unicode の一般カテゴリを利用して条件分岐していることが分かります。

参考: Character クラスの Javadoc

検証

検証内容

上述の対応関係を 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 からそれぞれ1文字ずつ取り出した文字列 \raʰƻDžAः҈̀0Ⅰ¼_-)»«!(£^+© を用意した Analyzer にかけて、生成されるトークンを確認してみます。(Cf, Zl, Zp は飛ばします)

検証結果

Analyze API により、 analyzer_letter にかけて生成されたトークンを見てみます。

GET index-test-token-chars/_analyze
{
  "analyzer": "analyzer_letter",
  "text": "\raʰƻDž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" : "Dž",
      "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 の一般カテゴリを確認すれば判別ができます。

感想

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

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

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

参考資料

10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?