RSS2.0

ElasticSearch の全文検索での analyzer について

前回の ElasitcSearch での文書検索についての記事の中で、ElasticSearch の中核となる機能である全文検索クエリについて触れました。
ElasticSearch の全文検索クエリでは、文書を単語に分割する分かち書きという工程を経て、その単語が含まれる文書が検索されます。しかし、はたして何を単語とみなすかというのは難しい問題です。普段人間が使っている言葉の種類は国や言語、時代によって膨大な量になりますし、常に新しい言葉、失われていく言葉が存在しています。また同じ言葉でも文脈や使われる分野によって意味が変わったり、1 つの言葉なのに文字で表現しようとすると何通りも書き方があったりします。現時点ではあらゆる単語を分かち書きできるような技術は残念ながら存在していないので、全文検索を適用したい分野にあわせてどのように分かち書きしていくかを自分で設計していく必要があります。
ElasticSearch では単語分割は analyzer によって処理されます。この analyzer には単語分割に関わる言語処理機能を必要に応じて組みあわせて使えるようになっています。今回はこの analyzer がどのように分かち書きを行っていくのか、ElasticSearch の analyzer による言語処理機能の具体例を見ていこうと思います。

analyzer による単語分割の流れ

ElasticSearch における analyzer は、分かち書きする文字列中の各文字を追加/変更/削除する character filtercharacter filter から受け取った文字列を実際に単語に分割する tokenizertokenizer が分割した単語の列から単語単位で追加/変更/削除する token filter の 3 つによって構成されています。analyzer には必ず 1 つの tokenizer が設定されていなければなりませんが、character filter と token filter はオプションとなっていて、任意の数を設定することができます。

一応定義どおりに analyzer の構成要素を説明するとこうなるのですが、感覚としては、中心となる tokenizer が単語分割しやすいように tokenizer の前処理 / 後処理として必要なフィルターを設定していく、くらいで良いと思います。ElasticSearch にあらかじめ組み込まれているビルトインの token filter には、どちらかというと character filter としてあるべきなのではと思うものもあったりします。また高機能な tokenizer には、他のビルトインのフィルターと同等の機能を内包していたりするものもあります。

character filter

character filter は tokenizer が分かち書きする文字列を単語に分割する前に、tokenizer が単語分割しやすいように文字列中の文字を調整します。

あらかじめ組み込まれているフィルターとしては、分かち書きする文字列から HTML タグを取り除く HTML Strip Char Filter、設定したマッピング表からある文字を別の文字に変換する Mapping Char Filter、Java の正規表現によって変換規則を設定できる Pattern Replace Char Filter があります。

実際に Mapping Char Filter を使って、前回も少しさわった Analyze API から単語分割の確認をしてみます。材料として以下のインデックスを作ってみました。
$ curl -XPUT http://localhost:9200/char-filter-sample -H "Content-type: application/json" -d '{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "keyword",
          "char_filter": [
            "my_char_filter"
          ]
        }
      },
      "char_filter": {
        "my_char_filter": {
          "type": "mapping",
          "mappings": [
            "零 => 0",
            "壱 => 1",
            "弐 => 2",
            "参 => 3",
            "肆 => 4",
            "伍 => 5",
            "陸 => 6",
            "漆 => 7",
            "捌 => 8",
            "玖 => 9"
          ]
        }
      }
    }
  }
}'
今回は Analyze API で analyzer の動作確認をするだけなので、インデックスのドキュメントタイプは書かず、インデックスの設定を書く settings 要素内に analyzer の定義のみ記述しています。settings.analysis.char_filter 内の my_char_filter というのが設定した Mapping Char Filter の名前で、settings.analysis.analyzer 内の my_analyzer というのが 今回利用する analyzer の名前になります。
Mapping Char Filter を使うにはフィルターの type 値を mapping とし、また mappings 値として変換する文字のマッピングを記載します。"壱 => 1" と書いた場合、 が 1 に変換されます。今回は数字の大字を半角数字にマッピングするようにしています。
analyzer には char_filter 値として適用する character filter の名前を指定します。analyzer には必ず 1 つの tokenizer が必要になるので、Keyword Tokenizer を意味する keyword を指定しています。Keyword Tokenizer は、受け取った文字列をそのまま 1 単語に分かち書きする tokenizer です。

作成した analyzer を Analyze API にかけて、どのように分かち書きされるか確認してみます。POST する JSON オブジェクトの analyzer 値に先程インデックス内で定義した analyzer の名前を、text 値に単語分割したい文字列を記載します。
$ curl -XPOST http://localhost:9200/char-filter-sample/_analyze -H "Content-type: application/json" -d '{
  "analyzer": "my_analyzer",
  "text": "玖頭龍閃"
}'
この Analyze API のレスポンスが以下のようになります。Mapping Char Filter のマッピングで設定したとおり、数字の大字の  が半角数字の 9 に変換されました。
{
  "tokens": [
    {
      "token": "9頭龍閃",
      "start_offset": 0,
      "end_offset": 4,
      "type": "word",
      "position": 0
    }
  ]
}
ちなみに分割した単語が 1 つだけなのは、先程ふれたとおり tokenizer として Keyword Tokenizer が使われているためです。
Mapping Char Filter の動作はシンプルにある文字を別の文字に変換することなので、character filter をイメージするのにはわかりやすいかと思います。HTML Strip Char Filter では、取り除く HTML タグが単語っぽく見えがちなので、tokenizer の前処理であるという側面を無視すると token filter っぽく見えてしまいます。 

tokenizer

tokenizer は文字列を実際に単語に分割し、分かち書きを行う役割を持ちます。公式ドキュメントには多数の組み込み tokenizer が記載されています。tokenizer こそが単語分割の中核となるため、公式ドキュメントに記載されているものの他にも、サードパーティ製のプラグインとして多くの tokenizer が公開されています。

先程は文字列をそのまま 1 単語とする Keyword Tokenizer を使いましたが、ここではデフォルトで適用される Standard Tokenizer と Whitespace Tokenizer を試してみたいと思います。

Analyze API で tokenizer を動作確認する場合には、インデックスを作る必要はありません。リクエスト URL にインデックス名を指定せず、/_analyze エンドポイントに以下のような JSON オブジェクトを POST します。
JSON オブジェクトの tokenizer 値には Standard Tokenizer を意味する standard を、text 値には分かち書きする文字列を指定します。
$ curl -XPOST http://localhost:9200/_analyze -H "Content-type: application/json" -d '{
  "tokenizer": "standard",
  "text": "What is the不殺"
}'
そしてこちらが Standard Tokenizer による分かち書きの結果です。
{
  "tokens": [
    {
      "token": "What",
      "start_offset": 0,
      "end_offset": 4,
      "type": "",
      "position": 0
    },
    {
      "token": "is",
      "start_offset": 5,
      "end_offset": 7,
      "type": "",
      "position": 1
    },
    {
      "token": "the",
      "start_offset": 8,
      "end_offset": 11,
      "type": "",
      "position": 2
    },
    {
      "token": "不",
      "start_offset": 11,
      "end_offset": 12,
      "type": "",
      "position": 3
    },
    {
      "token": "殺",
      "start_offset": 12,
      "end_offset": 13,
      "type": "",
      "position": 4
    }
  ]
}
基本的には半角スペースによって単語に分割されていますが、単語『the不殺』内の漢字はさらに 1 字ずつに分割されています。この挙動は前回も見た通りです。
公式ドキュメントによると Standard Tokenizer は文法ベースで単語に分割すると書いてありますが、漢字を 1 字ずつ分割するというのにはさすがに文法ベースでの分割とは言えないと思います。

続いて Whitespace Tokenizer を試してみます。Whitespace Tokenizer を使うには、Analyze API に送る JSON オブジェクトの tokenizer 値には whitespace を指定します。
$ curl -XPOST http://localhost:9200/_analyze -H "Content-type: application/json" -d '{
  "tokenizer": "whitespace",
  "text": "What is the不殺"
}'
そしてこちらが Whitespace Tokenizer による分かち書きの結果です。
{
  "tokens": [
    {
      "token": "What",
      "start_offset": 0,
      "end_offset": 4,
      "type": "word",
      "position": 0
    },
    {
      "token": "is",
      "start_offset": 5,
      "end_offset": 7,
      "type": "word",
      "position": 1
    },
    {
      "token": "the不殺",
      "start_offset": 8,
      "end_offset": 13,
      "type": "word",
      "position": 2
    }
  ]
}
Whitespace Tokenizer は単純にスペース文字によってのみ単語分割を行うので、Standard Tokenizer では漢字毎に分割されていた the不殺 も、1 単語として分割されています。

この結果だけ見ると Standard Tokenizer よりも Whitespace Tokenizer の方が日本語の文書には向いているようにも見えますが、日本語文ではスペース文字によって単語を分割するというルールがないため、やはり Whitespace Tokenizer は適していません。どちらにしても実用性がいまいちです。ElasticSearch の組み込み tokenizer としてスマートな解決策が用意されていないのは、特に表意文字を扱う言語は、それだけ分かち書きは難しいからだと思います。これについては世の中には N グラムや形態素解析という手法があるので、またの機会に詳しく扱いたいと思います。

token filter

token filter は tokenizer が文字列を単語に分かち書きした後に、分割した単語を調整します。公式ドキュメントには多数の組み込み token filter が記載されています。わかりづらいですが、ページの右サイドメニューの Token Filters 以下に token filter がいっぱい並んでいます。

分割した単語中の小文字を大文字に変換する Uppercase Token Filter や、分割した単語の長さを一定長で切り詰める Truncate Token Filter など、本当にさまざまな token filter が用意されています。今回はその中から、全角英数文字を半角英数文字(ASCII 文字コード表の先頭 127 文字)に変換する ASCII Folding Token Filter を試してみたいと思います。

材料として以下のインデックスを作ってみました。
$ curl -XPUT http://localhost:9200/token-filter-sample -H "Content-type: application/json" -d '{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "keyword",
          "filter": [
            "my_asciifolding"
          ]
        }
      },
      "filter": {
        "my_asciifolding": {
          "type": "asciifolding",
          `preserve_original` : true
        }
      }
    }
  }
}'
送信する JSON オブジェクトの settings.analysis.filter 下に書いているのが、利用する ASCII Folding Token Filter の名前とその設定です。type 値には ASCII Folding Token Filter を意味する asciifolding を、設定項目 preserve_original 値には false を指定しています。また、settings.analysis.analyzer.{analyzer 名}.filter 値には定義した ASCII Folding Token Filter の名前 を記載しています。
ASCII Folding Token Filter は ASCII 文字の先頭 127 文字に丸める変換をしますが、preserve_original 値を true にしておくと、変換前のオリジナルの単語も別途返してくれるようになります。デフォルトでは preserve_original 値は false になります。

さっそく Analyze API で動作を見てみます。
$ curl -XPOST http://localhost:9200/token-filter-sample/_analyze -H "Content-type: application/json" -d '{
  "analyzer": "my_analyzer",
  "text": "HUTAE NO KIWAMI"
}'
こちらがレスポンスです。
{
  "tokens": [
    {
      "token": "HUTAE NO KIWAMI",
      "start_offset": 0,
      "end_offset": 15,
      "type": "word",
      "position": 0
    },
    {
      "token": "HUTAE NO KIWAMI",
      "start_offset": 0,
      "end_offset": 15,
      "type": "word",
      "position": 0
    }
  ]
}
ASCII Folding Token Filter によって ASCII 文字が丸められた『HUTAE NO KIWAMI』と、preserve_original 値を true にしたことで返ってくるようになった『HUTAE NO KIWAMI』の 2 単語が返されました。preserve_original 値が false のままであれば、返される単語は変換された『HUTAE NO KIWAMI』のみになります。

実運用で ASCII Folding Token Filter の preserve_original 値を true にすることはあまりない気もしますが、tokenizer が分かち書きした後で token filter が単語単位での追加/変更/削除を行う、という意味ではわかりやすいかと思って試してみました。

インデックスに analyzer を設定する

ここまで動作確認として Analyze API を使ってきましたが、実際にマッピング内のフィールドに対して analyzer を設定してみます。

マッピングで analyzer を定義する場合、フィールド毎に個別に適用する analyzer を指定する方法と、個別の analyzer が指定されていないフィールドに対して適用するデフォルトの analyzer を指定する方法があります。

材料として以下のようなインデックスを用意しました。
$ curl -XPUT http://localhost:9200/analyzer-sample -H "Content-type: application/json" -d '{
  "settings": {
    "analysis": {
      "analyzer": {
        "default": {
          "tokenizer": "whitespace"
        },
        "my_analyzer": {
          "tokenizer": "keyword"
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "text01": {
          "type": "text"
        },
        "text02": {
          "type": "text",
          "analyzer": "my_analyzer"
        }
      }
    }
  }
}'
送信する JSON オブジェクトでは、settings.analysis.analyzer 下の default 値として、デフォルトですべてのフィールドに適用される Whitespace Tokenizer を指定しています。また、それとは別に名前つきの analyzer として Keyword Tokenizer を定義しました。

また、マッピング定義では 2 つの text 型フィールドを用意しています。text01 には特に analyzer を指定していませんが、text02 には analyzer として Keyword Tokenizer をもつ名前付きの analyzer を指定しています。これにより、text01 に対する分かち書きではデフォルト設定の Whitespace Tokenizer が適用され、text02 に対する分かち書きではフィールドに個別設定された Keyword Tokenizer が適用されます。

このインデックスに以下のドキュメントを追加しておきます。
$ curl -XPOST http://localhost:9200/analyzer-sample/_doc -H "Content-type: application/json" -d '
{"text01": "緋村 剣心", "text02": "緋村 剣路" }'

それでは動作を確認してみます。まずはデフォルト設定の Whitespace Tokenizer によって分かち書きされたフィールドに全文検索してみます。
$ curl -XGET http://localhost:9200/analyzer-sample/_search -H "Content-type: application/json" -d '{
  "query": {
    "match" : { "text01" : "緋村" }
  }
}'
この全文検索クエリでは作成したドキュメントが返されてきます。全文検索クエリの対象フィールド text01 にはデフォルト設定の Whitespace Tokenizer が適用されているため、分かち書きされた単語としては『緋村』と『剣心』が保持されているからです。全文検索クエリの検索キーワードがフィールド値に含まれているため、このドキュメントはヒットします。

一方で、以下の全文検索クエリでは、作成したドキュメントは返されません。
$ curl -XGET http://localhost:9200/analyzer-sample/_search -H "Content-type: application/json" -d '{
  "query": {
    "match" : { "text02" : "緋村" }
  }
}'
全文検索クエリの対象フィールド text02 には個別に Keyword Tokenizer が適用されているため、分かち書きされた単語としては『緋村 剣路』のみが保持されているからです。これには全文検索クエリの検索キーワードがふくまれていません。
もしこのフィールドに対する全文検索クエリで該当ドキュメントを返してほしいならば、検索キーワードは『緋村 剣路』にする必要があります。
  ElasticSearch  コメント (0)  2018/10/11 19:07:40


公開範囲:
プロフィール HN: ももかん
ゲーム作ったり雑談書いたり・・・していた時期が私にもありました。
カレンダー
<<2018, 10>>
30123456
78910111213
14151617181920
21222324252627
28293031123