はじめに
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 tokenizer
と Unicodeの一般カテゴリ
について簡単に説明します。
すでにご存じの方は読み飛ばしてください。
Elasticsearch の Analyzer とは
Elasticsearch は索引(インデックス)型の全文検索を行っています。
この節では tokenizer の位置づけを説明するために、索引型検索について簡単に説明します。
まず Elasticsearch に登録される文章(正確にはドキュメント中の Text型field )は、 Analyzer によりトークンに分割されて、トークンとドキュメントの対応関係としてインデックスに登録されます。
検索の際には、同じく検索フレーズが Analyzer によりトークン化されて、インデックスを参照して該当するドキュメントを探します。文字通り索引を引くように検索をするわけです。
この2枚の図でトークン化をしている Analyzer は、詳しく見ると次のような構造をしています。
3つコンポーネントが出てきました。それぞれ次のような役割を持っています。
-
Char filter
: Tokenizer の前処理をする。例) HTMLをブラウザから見た文字列にする -
Tokenizer
: トークン化をする。例) 形態素解析をする、N-gram化をする -
Token filter
: Tokenizer の後処理をする。例) 活用形を原型に直す
この記事で取り上げる N-gram tokenizer
は Tokenizer の一つです。
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 の一般カテゴリを利用して条件分岐していることが分かります。
検証
検証内容
上述の対応関係を 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 を使うエンジニアの皆さんの助けになれば幸いです。