Elasticsearch のテキスト解析について調べてみた!

前回の記事でマッピングについて調べてみたことを書きましたが、調べていく中でテキスト解析の単語がちらほらと出てきていたので理解しておこうかなと。

テキスト解析とは

公式サイトのドキュメントにて、テキスト解析は以下のように説明されています。日本語に私が訳したので、もしかしたら正確ではないかもしれません。

テキスト解析とは非構造なテキスト、例えばEメールの本文や商品の説明文など、を検索のために最適化された構造に変換するプロセスの事です。

Elasticsearch はテキスト解析を、text フィールドの検索をするとき、またはインデックス化を行うときにするそうです。 text フィールドを含まない場合はこのドキュメント読まなくていいと書いてあるので、テキスト解析が行われるのは text フィールドに限定されているみたいですね。

それで重要になってくるのがトークン化と正規化。ちなみに、これらは英語でそれぞれ Tokenization 、 Normalization と言います。ざっくり説明するとこんなかんじ。

  • トークン化 - テキストを小さい意味のあるまとまりに分解すること。分解された一つの塊をトークンという。
  • 正規化 - それぞれのトークンに対して標準的なフォーマットにすること。例えば大文字を小文字に揃えたり、進行形の形を現在形にしたりして、検索時に完全に一致せずとも、意味として近ければ一致するようにする。

Elasticsearch の Analyzer

Elasitcsearch は Analyzer といわれるものを使って前述のテキスト解析を行います。

Analyzer にはいくつか種類があります。自分で作ることもできるようですが、すでに用意されているものをここでは使ってみます。

Standard analyzer を使ってテキストの解析を行うにはこのようにします。 _analyze に対して POST するとテキストの解析結果を返してくれます。

POST _analyze
{
  "analyzer": "standard",
  "text": "2 birds flew into the Sky."
}
{
  "tokens" : [
    {
      "token" : "2",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<NUM>",
      "position" : 0
    },
    {
      "token" : "birds",
      "start_offset" : 2,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "flew",
      "start_offset" : 8,
      "end_offset" : 12,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "into",
      "start_offset" : 13,
      "end_offset" : 17,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "the",
      "start_offset" : 18,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "sky",
      "start_offset" : 22,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

返ってきた情報にはトークンや、文字の位置、種類などがありますね。

Standard Analyzer 含め、 Analyzer は以下のような構造になっているみたいです。

f:id:bau1537:20200704203147j:plain

Character filter でテキストストリームの変換、 Tokenizerトークン化、 Token filterトークンの変換をして、解析終了といった流れでしょうか。

このようなテキスト解析はドキュメントが保存されるときのテキストフィールド、または、検索するときに全文検索文字列に対して行われます。前者を Index time analysis 後者を Search time analysis と言うようです。それぞれに別の analyzer を使用することができるようですが、殆どの場合において同じものを使うでしょうね。

f:id:bau1537:20200704203205j:plain

Analyzer の検証

上述した REST API を使えば Analyzer の検証をすることができます。

エンドポイントに使用したい Analyzer または、フィルターなどを組み合わせて、その動き方を素早く確認できます。 standard Tokenizer に html_strip Character filter を指定するにはこうします。 standard Tokenizer は文法をベースにしたトークン化を行い、 html_strip はテキストからHTML要素を抜き取ります。

POST _analyze
{
  "tokenizer": "standard",
  "char_filter": ["html_strip"],  
  "text": ["this is a <b>test</b>"]
}
{
  "tokens" : [
    {
      "token" : "this",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "is",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "a",
      "start_offset" : 8,
      "end_offset" : 9,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "test",
      "start_offset" : 13,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 3
    }
  ]
}

HTML タグを消してテキスト解析されていますね。

このときの Analyzer のイメージはこんな感じ。

f:id:bau1537:20200704203224j:plain

このエンドポイントを使って Stemmer というトークンフィルターも試してみましょう。 Stemmer は複数の言葉に対応している、言葉をその原型に変換するフィルターです。例えば Books から Book に、 walking から walk に変換してくれたりします。

GET /_analyze
{
  "tokenizer": "standard",
  "filter": ["stemmer"], 
  "text": "I had a lot of books."
}
{
  "tokens" : [
    {
      "token" : "I",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "had",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "a",
      "start_offset" : 6,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "lot",
      "start_offset" : 8,
      "end_offset" : 11,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "of",
      "start_offset" : 12,
      "end_offset" : 14,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "book",
      "start_offset" : 15,
      "end_offset" : 20,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

booksbook に変換されてますね。

このときの Analyzer のイメージはこんな感じ。

f:id:bau1537:20200704203238j:plain

シノニム

Analyzer にシノニムの設定をすることで、同義語を変換できるようになります。公式のドキュメントでは domain name system を dns に変換する例が載っていますね。

実際に変換してみるとこんな感じ。

GET /_analyze
{
  "tokenizer": "standard",
  "filter": [{"type": "synonym_graph", "synonyms":["domain name system => dns"]}], 
  "text": "domain name system is fragile"
}
{
  "tokens" : [
    {
      "token" : "dns",
      "start_offset" : 0,
      "end_offset" : 18,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "is",
      "start_offset" : 19,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "fragile",
      "start_offset" : 22,
      "end_offset" : 29,
      "type" : "<ALPHANUM>",
      "position" : 2
    }
  ]
}

解析結果で dns が SYNONYM 型として返ってきていますね。上記の例ではシノニムの定義を直接書きましたが、外部ファイルから読み取ることもできます。

また、フィルターを synonym_graph に設定しましたが、他に synonym というフィルターもあるみたいです。これらの違いはドキュメントに説明があったのですが、正直良くわかりませんでした。 Tokenizer はテキストを Token graph と言われる指向性非循環グラフに変換するのですが、「synonym は複数のトークンを特定の箇所に追加できるけれど Tokeng graph の positionLength という値が不正になる可能性があるので検索するときには気をつけようね」、みたいなことが書いてあります。

検索には synonym_graph 、インデックスの作成には synonym を使用すれば一旦問題はないようです。詳しく書いてある記事はこのあたりでした。

使用したアイコン

Icons made by Freepik from www.flaticon.com