はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

Elasticsearchのひらがなでの検索時のトリックについて雑談

はじめに

Elasticsearch(kuromoji)では、アナライザーに「kuromoji_readingform」というものがあり、これを使うと「読み」に関して、表記揺れや曖昧検索相当に対応できます。

ただし、この「読み」部分については、実は、kuromojiの形態素解析(分かち書き/token化)とセットになる話なので、一番ベーシックなアナライズの設定組み合わせの範囲では実は次のようなことが発生します。


「渡辺」「渡邊」のような「端」「橋」のような、(そのようになるように検索クエリを用いれば)期待どおり互いに検索時にヒットする

一方で、

ひらがな「わたなべ」で、漢字の「渡辺」や「渡邊」を(ただしく読めていると思われるにもかかわらず)これらをヒットさせられない


これは、「読み」は当てられているものの、形態素解析トークン化されたものに対して、転置インデックスの各エントリに対して当てはまるかどうかで、そのドキュメントがマッチしたかどうかを判定するためです。

f:id:azotar:20200121235359p:plain

あたったり当たらなかったり

前項で述べた件のElasticsearch(ここでは、Elasticsearch 6.8 で確認。)の例を示します。

◆ 比較的ベーシック(?)なreadingformをからめたanalyzeの設定

PUT tmpx
{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                }
            },
            "analyzer": {
                "my_ja-readingform_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                    ],
                    "filter": [
                        "kuromoji_readingform",
                        "hiragana_2_katakana"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                }
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "keyword",
                            "fields": {
                                "rf": {
                                    "type": "text",
                                    "analyzer": "my_ja-readingform_anlz"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

filterのkuromoji_readingformで読み仮名を付与している。

◆データのインポート

POST tmpx/_doc/
{  "A":"渡邊"}

◆検索例

「渡辺」で検索

POST tmpx/_search
{
  "query": {"match": {
    "A.rf": {
      "query": "渡辺",
      "operator": "and"
    }}   
  }
}

↓ 検索結果(渡邊がヒットする)


{
  "hits" : {
    "total" : 1,
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "tmpx",
        "_type" : "_doc",
        "_id" : "r7cYyG8BDQCF7SaoZSpT",
        "_score" : 0.2876821,
        "_source" : {
          "A" : "渡邊"
        }
      }
    ]
  }
}  


「わたなべ」で検索

POST tmpx/_search
{
  "query": {"match": {
    "A.rf": {
      "query": "わたなべ",
      "operator": "and"
    }}   
  }
}

↓ ヒットしない

{
    "hits" : {
    "total" : 0,
    "max_score" : null,
    "hits" : [ ]
  }
}

◆わたなべ・渡辺・渡邊 のアナライズの実績

POST an/_analyze
{
  "analyzer": "my_ja-readingform_anlz",
  "text": "渡邊"
}

POST an/_analyze
{
  "analyzer": "my_ja-readingform_anlz",
  "text": "渡辺"
}

どちらも
{
  "tokens" : [
    {
      "token" : "ワタナベ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}

とトークン化される。
...なのでこれらは互いにヒットする。

では、

POST an/_analyze
{
  "analyzer": "my_ja-readingform_anlz",
  "text": " わたなべ"
}

だが、

次のようにトークン化される(4トークンに分割)。

{
  "tokens" : [
    {
      "token" : "ワ",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "タ",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "ナベ",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 2
    }
  ]
}

漢字は、ひらがなに比べると文字数の割に情報量が多いため、形態素解析がヒトの目で見たのと同じで自然になる傾向があるが、 ひらがなは分析の手がかりが少なく、ひらがなの並びの中になんらか単語を見出した単位で分割される

...と言えそうです。

まあ、形態素解析の仕組みからいうと当たり前かもしれませんが、あらためて実感されます。

テーマの絞り込み

この類は問題はノイズとのトレードオフとの戦いです。

ですが、この記事ではそこは問わず、

「ひらがな」で「漢字」をヒットさせるには、

ということでシンプルに考えます。

また、「漢字」の方は、ある程度、辞書に載っている意味を持つ(≒ よみがなが決まる)ものだとします。そのような検索ケースを念頭において、そのようなパターンにまずはうまく行くような方法を考えます。

この他、簡単のため「ひらがな」および「漢字」の側ともに、複数の単語の組み合わせではないようなものを考えます。

そこそこうまく行く方法案

ここでは次のように考えます。

着目点

  1. readingformの結果、文字面としては合致してもトークン化された場合はマッチしない。(事実)
  2. それでも、漢字の方はある程度自然にトークン化されている。(おそらく多くの場合にあてはまる事実)
  3. 複合語だとより複雑になるが、今回の考察の検索モデルでは「漢字」の側は、1トークンとなる。
  4. 一方、ひらがなの方は、かなりきまぐれだが、「漢字」側のトークン化されているものよりは、「ひらがな」側の方が1トークンあたりの文字数が少ない傾向がある。(おそらく多くの場合にあてはまる事実)

つまり、どうやってやるかはともかく、入力の「ひらがな」の前方一致の文字列が、「漢字」側のトークンに(できれば前方一致の形で)出現するかという検索方法であれば、 どうにか「ヒットさせる」ことができそうです。

Elasticsearchのアナライザー、検索クエリ(ひらがな→漢字)

もったいぶりましたが、形態素解析でreadingformを使いつつ、filterでも通常のN-GramとEdge N-Gramが利用できるのでこれをつかいます。

日本語環境周辺だと、分かち書き(トークナイズ)は、形態素解析 vs N-Gramというイメージから、N-Gram系の処理は、トーカナイザーでしか利用できないのかと思いがちですが*1N-Gram系はfilterにも対応できるのでした。

www.elastic.co

www.elastic.co

つまり、kuromojiでトークン化した上で、それぞれのトークンの読みをあてたのち、それを部分一致させるように分割できます。

この方式を図解すると、

f:id:azotar:20200121235758p:plain

とした転置インデックスが作られます。ここで、「渡邊」というワードに対して、「わ」〜「わたなべ」というワードでヒットさせられる土台ができました。

加えて、(もっと高度な方法も考えられるかもしれませんが、)入力側の「ひらがな」はカタカナに変換するものの、下手なトークン化は行わず、入力された文字を転置インデックスにそのまま当てに行くことにします。

これにより、何がヒットして何がヒットしないのかをイメージしやすくします。

具体的には、先の転置インデックスの例を再掲するとともに、次の図のようなものを実現します。

f:id:azotar:20200121235836p:plain

ということで、この設定がこちら。

ひらがな→漢字検索用のmapping/setting例

{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                }
            },
            "analyzer": {
                "my_ja-default_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "kuromoji_iteration_mark",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_baseform",
                        "kuromoji_part_of_speech",
                        "ja_stop",
                        "lowercase",
                        "kuromoji_number",
                        "kuromoji_stemmer"
                    ]
                },
                "my_ja-readingform_x_e-ngram_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_readingform",
                        "lowercase",
                        "hiragana_2_katakana",
                        "e_ngram_filter"
                    ]
                },
                "my_almost_noop": {
                    "type": "custom",
                    "tokenizer": "keyword",
                    "filter": [
                        "hiragana_2_katakana"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                },
                "e_ngram_filter": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 10
                }
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "my_ja-default_anlz",
                            "fielddata": true,
                            "store": true,
                            "fields": {
                                "rf_eng": {
                                    "type": "text",
                                    "analyzer": "my_ja-readingform_x_e-ngram_anlz"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

my_ja-readkingform_x_e-ngram_anlz が冒頭の例と比べると追加になっています。

また、my_almost_noopというアナライザーですが、トークン化は行わず、カナに変換するだけのアナライザーです。後述の検索クエリでは、「analyzer」プロパティにこれを指定し、検索語自体は「カナ」に置き換えした1ワードで転置インデックスを検索するようにします。

ひらがな→漢字検索用の検索クエリの例

POST an/_search
{
  "query": {"match": {
    "A.rf_eng": {
      "query": "わたなべ",
      "operator": "and",
      "analyzer": "my_almost_noop"
    }}   
  }
}

あるいは

POST an/_search
{
  "query": {"match": {
    "A.rf_eng": {
      "query": "わたな",
      "operator": "and",
      "analyzer": "my_almost_noop"
    }}   
  }
}

↓ 検索結果

  "hits" : {
    "total" : 1,
    "max_score" : 1.0166159,
    "hits" : [
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "trctyG8BDQCF7SaoJCqM",
        "_score" : 1.0166159,
        "_source" : {
          "A" : "渡邊"
        }
      }
    ]

無事、ヒットするようになりました。

まとめ

ワークアラウンドというレベルですが、あらかじめ読み仮名のデータを用意することなく、アナライザーのトリックの重ねがけにより、「よみがなのひらがな」→「漢字」での検索ができるような例を示してみました。

実のところ、setting/mappingの紹介という側面もあり記事を長文化したくなかったため(それでも長くなってしまいましたが)、その分だけ検索モデルをシンプルにしたから、うまく行ったという面もあります。

例えば、本来は「複合語」、特に「かなと漢字の複合語」、スペース区切りの複数ワードといった検索語、およびドキュメントデータの組み合わせの時にどこまでうまく機能させられるかというより難しい話があります。

また、今回は、ひとまずあたらないよりは当てる方法をという目的で話を進めましたが、おそらくこの方式によってノイズが増える傾向があるので、形態素でシンプルに検索させた場合に比べ、この例でのマッチングは加点を低めにするといったスコアの工夫も必要でしょう。

いや、そもそも、一部のオートコンプリートやサジェストの例以外で、「ひらがな」→「漢字」の検索を充実させるところに力をかけるかという話も(それを言ってはこの記事の自己否定ですが)あるにはあります。

この手の例についてはまた機会があれば(逃げ足の音)。

ややテーマがすりかわりましたがつ続きをかきました。

itdepends.hateblo.jp

*1:というか私は比較的最近までそう思っていました。