はてだBlog(仮称)

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

続・Elasticsearchのひらがなでの検索時のトリックについて雑談(漢字ひらがな混在の場合について深掘り と  Multiplexer filterの練習もかねて)

はじめに

この記事は、次の記事の続きです。

itdepends.hateblo.jp

前の記事では、ひらがな(読み仮名)→漢字にフォーカスしましたが、ここでは、ひらがなの単語の複合語や漢字とひらがなの混合の複合語などででヒットさせるにはということで膨らませてみます。

... という体裁で、Elasticsearchのアナライザーのfilterである、「Multiplexerというやつを使ってみた」紹介記事にもなっています。

www.elastic.co

[補足] Multiplexerでのfilter処理イメージ

Multiplexerは次のようなことができます。

f:id:azotar:20200126145201p:plain

目次

この記事の「読み仮名」での「検索テーマ」

先の記事では、Elasticsearch((実際はElasticsearchに限らないかもなーという気はしますが。))での、ひらがな→漢字の検索はちょっとしたトリックの案をご紹介したという話でした。

ただ、語っておいていうのもなんですが、実は、先の記事で紹介した方法は、弱点...というのともちょっと違いますが、ひらがなの単語を複数合わせたようなワードになると、ヒトの目だとヒットしても良いかもと思うような例ででヒットしなくなるケースが増える傾向があります。

例えば、先の記事のやり方の場合、検索語が「マサクニ」であれば、「渡邊正邦」がヒットするようにできます。

一方、「ワタナベマサクニ」の場合は、「ワタナベマサクニ」で「ワタナベ」と「マサクニ」を探しに行くために、ヒットしません。

また、「渡邊マサ」のような漢字が混在する検索語のケースも苦手な傾向があります。

ではもっとオーソドックスに検索語を分かち書きをかけるようにした場合どうなるかというと、次の図のような扱いになるため、残念ながらなにやら当たりそうで当たらないということになります。

f:id:azotar:20200126145400p:plain

何か工夫が必要ですね。

泣かぬなら鳴かせてみせよう...の前に

この件は、読み仮名を引き出すには形態素解析による分かち書きが必要、ただし分かち書きを機能させると「ひらがな」中心のテキストは必ずしも都合の良い分かち書きがされるとも限らないというジレンマです。

本題に入る前にもう一度考えるべきことがあります。

ここで、本当にこのような読み仮名から漢字のワードを導き出すことが、サジェストやオートコンプリートといったあるあるも含め必要か、(場合によっては検索ノイズの温床になるリスクを犯してまで)注力する範囲でしょうか。

また、本当に必要という場合は、正式な辞書を用意するという正攻法や、この件を難しくしている「トークン分割」を再結合するような直接的な方法を考えた方が良さそうです。具体的には、自前のfilterを作成・カスタマイズすることで可能です。

以下の方がElasticsearchでのfilterのプラグインを自作して、ひらがな(例はローマ字)で素直に漢字をヒットさせる方法を紹介されてますので、お求めのものがこちらという人のためにリンクさせていただきます。

qiita.com

以降、漢字ひらがな混在、複数のひらがな単語の複合語などでぼちぼちヒットさせる、ただし、できるだけElasticsearchの標準の仕組みのみで対応するとして、百本ノック的にやり方を検討してみます。

本題

さて、この記事の本題に戻ります。

分かち書きされたトークンを結合させることができれば、万事うまくいきそうですが、ぱっと見、独自のフィルターを作成する以外にトークンの結合はできなさそうです。

まあ、トークン化を否定することになりますしね。

よって、結合できないなら、当たる範囲を広げてやるようなアナライズを行うことになるでしょう。

この際、多少の「当たりすぎ」やちょっと違和感のある検索結果になる場合があることはやむなしと考えざるをえません。

幸い、検索エンジンではスコアスコアリングの調整ができるので、このようなギャップは、もっともらしいものを上位にする並び順にするワークアラウンドとしましょう。

とした上で、多少乱れ打ちでヒットさせてやる作戦として、「アナライズ」について、次の図のようなものを考えます。

f:id:azotar:20200126145845p:plain

f:id:azotar:20200126145905p:plain

ポイントとしては、Multiplexerを使って、複合語を意識した元の漢字の単語と読み仮名の単語を複数同時に同じフィールドの転置インデックスの部屋に格納してやり、それのどれかとマッチすればOKという考え方です。

また、普通はやらないであろう1gramのフィルターを用いるとともに、より過激な分かち書きの1gramも活用します。

元のデータを様々に切り刻んでおいて、検索でマッチングさせる候補とする転置インデックスのエントリを幅広く作ってやります。

これにより次のような当たり方をすればマッチさせられるハズという寸法です。

f:id:azotar:20200126150238p:plain

◆実験

それでは実験です。

確認に使った、Elasticsearch のバージョンは6.8です。よって、kuromojiプラグインはこのバージョンと対になるバージョンですので、それの辞書での結果になります。

(1) setting/mapping

先のコンセプトに合わせたsetting/mappingの一例はこちら。

PUT anx2
{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                },
                "my_eng_tk": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 1
                },
                "my_1g_tk": {
                    "type": "ngram",
                    "min_gram": 1,
                    "max_gram": 1
                }
            },
            "analyzer": {
                "my_ja_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk"
                },
                "my_noop_anlz": {
                    "type": "custom",
                    "tokenizer": "keyword",
                    "filter":[
                      "hiragana_2_katakana"
                      ]
                },
                "my_rf_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter":[
                      "kuromoji_readingform",
                      "hiragana_2_katakana"
                      ]
                },

                "my_1g_anlz": {
                    "type": "custom",
                    "tokenizer": "my_1g_tk"
                },
                "my_1gkana_anlz": {
                    "type": "custom",
                    "tokenizer": "my_1g_tk",
                    "filter": [
                        "hiragana_2_katakana"
                    ]
                },
                "my_rf_x_1g_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter": [
                        "kuromoji_readingform",
                        "hiragana_2_katakana",
                        "1gram_filter"
                    ]
                },
                "mp1_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter": [
                        "mp1"
                    ]
                },
                "mp2_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter": [
                        "mp2"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                },
                "katakana_2_hiragana": {
                    "type": "icu_transform",
                    "id": "Katakana-Hiragana"
                },
                "e_ngram_filter": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 10
                },
                "1gram_filter": {
                    "type": "ngram",
                    "min_gram": 1,
                    "max_gram": 1
                },
                "mp1":{
                  "type": "multiplexer",
                  "filters": ["e_ngram_filter", "kuromoji_readingform, e_ngram_filter", "kuromoji_readingform,katakana_2_hiragana,e_ngram_filter" ]
                 },
                 "mp2":{
                  "type": "multiplexer",
                  "filters": [ "1gram_filter","kuromoji_readingform, 1gram_filter",  "kuromoji_readingform, katakana_2_hiragana, 1gram_filter"  ],
                    "preserve_original":false
                 }
                
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "my_ja_anlz",
                            "fielddata": true,
                            "store": true,
                            "fields": {
                                "mp1": {
                                    "type": "text",
                                    "analyzer": "mp1_anlz"
                                },
                                "mp2": {
                                    "type": "text",
                                    "analyzer": "mp2_anlz"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

(2) 試験用データ

次のデータを流し込みます。

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

POST anx2/_doc/
{  "A":"渡邊ひろまさ"}

POST anx2/_doc/
{  "A":"渡邊まさくに"}

POST anx2/_doc/
{  "A":"渡邊くにひろ"}

POST anx2/_doc/
{  "A":"渡邊ひろし"}

POST anx2/_doc/
{  "A":"渡邊まさし"}

POST anx2/_doc/
{  "A":"渡邊広正"}

POST anx2/_doc/
{  "A":"渡邊正邦"}

POST anx2/_doc/
{  "A":"渡邊国広"}

POST anx2/_doc/
{  "A":"渡邊博史"}

POST anx2/_doc/
{  "A":"渡邊雅志"}

POST anx2/_doc/
{  "A":"渡哲夫"}


POST anx2/_doc/
{  "A":"世露死苦"}

POST anx2/_doc/
{  "A":"阿多羅奈伊代"}

検索クエリ

POST anx2/_search
{
  "query": {
    "dis_max": {
      "queries": [
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp1": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_ja_anlz"
                }
              }
            },
            "boost": 4
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp1": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_noop_anlz"
                }
              }
            },
            "boost": 3
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp1": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_rf_anlz"
                }
              }
            },
            "boost": 2
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp2": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_1g_anlz"
                }
              }
            },
            "boost": 1
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp2": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_rf_x_1g_anlz"
                }
              }
            },
            "boost": 0
          }
        }
      ]
    }
  }
}


(3) 検索結果

{
  "hits" : {
    "total" : 2,
    "max_score" : 4.0,
    "hits" : [
      {
        "_score" : 4.0,
        "_source" : {
          "A" : "渡邊まさくに"
        }
      },
      {
        "_score" : 1.0,
        "_source" : {
          "A" : "渡邊正邦"
        }
      }
    ]
  }
}

「ワタナベマサクニ」で「渡邊正邦」が当たるようになりました。

また、この場合は、「渡邊正邦」より「ワタナベマサクニ」に近いという考え方にした「渡邊まさくに」(別レコード)が上位にヒットしていますね。

試験用の全体の集合のデータに偏りもありますし、他の検索語との比較での当たり具合/あたってほしくなさ具合は紙面の都合で割愛していますが、「当たって欲しいな...」という例にあげたようなものはそれなりにあたるようになります。

あらためのて注意事項

紹介した例では、なんとか、「ワタナベクニマサ」でヒットできるようになりました。

ただし、光あるところには影があというとことで、トレードオフがあります。

方式からすると最初から折り込み済みではありますが、端的なものとしては、「またくさたにくわたべな」のような「渡邊邦正」のアナグラム風のワードであれば確実にヒットしてしまいます。

これは次の図のように「潜在的なOR(この記事での便宜上の造語)」が作用するためです。

f:id:azotar:20200126150136p:plain

アナグラム風の例だけでいえば、実際に使われる検索語の傾向からいうとそれほど問題にならないかもしれません。

ところが、今回の検索側アナライズの「my_rf_x_1g_anlz」では、禁断の検索時のfilterに1gramを用いるという禁断のテクニックを使っています。

この場合、検索側のアナライザーのfilterでの1gramの方の「潜在的OR(造語)」とインデックス側の「潜在的OR(造語)」の掛け合わせにより、ああこれも当たっちゃうのねという組み合わせがより発生しやすくなります。

例えば、次のようなケースでも、「渡邊正邦」がヒットしてしまいます。

  1. わんたん (分かち書きで、「わん/たん」 となり、分かち書きトークンごとの代表がわりに「わ」と「た」を含むためヒット)
  2. ワンダフル (分かち書きは発生せず「ワンダフル」となり、その後filterの1-Gramにより、「ワ」「ン」「ダ」「フ」「ル」のどれかの1文字だけでも含まれていればヒットする。)

<<潜在的OR(造語)のイメージ>>

f:id:azotar:20200126151610p:plain

これが示唆するのは、「my_rf_x_1g_anlz」と「mp2」の組み合わせでの検索を行うことにより、他の組み合わせ以上に、含まれていない文字を多く含むような検索語でヒットしてしまうケースが増えそうだということです。

また、今回は実際にそのようなうまい(?)例を見つけられていませんが、辞書により漢字から読み仮名の読み替えのされ方によっては、次のように声に出してみても1文字もかさなるものが無いような、初見では全く因果関係に納得しづらい、検索結果が含まれてしまう可能性もありそうです。

検索側の分かち書き〜期待されるものとは別の読み仮名があてがわれてしまった。例えば、長辺→ナガ/ナベ とアナライズされた*1

加えて、1gramフィルターで、「ナ・ガ/ナ・ベ」扱いとなった。

「ナ or ガ」および「ナ or べ」がどこかにあれば良いので「渡邊正邦」のインデックス側の1文字刻み「ワ・タ・ナ・ベ/マ・サ・ク・ニ」でもマッチする。

流れを追えば仕組みのとおりで不思議ではありませんが、「長辺」で「渡邊正邦」がヒットしたという大外だけみると何でそうなったかわからないということがおきます。

実際にはこのようなケースがあるので、特に検索時に1Gramフィルターを入れるような検索ニーズの場合をマジメに考慮する場合は、スコアリングによる門前払いや二段階選抜も必要かもしれません。

私の経験上、通常のユースケースでは、スコアリングによる二段階選抜(スコアの低いものを間引く)はしないことをお勧めしています*2

しかし、本件のような例に限っては、AND検索を主軸としつつ、途中からOR検索相当の検索結果も含まれるような検索モデルになりますので、Elasticsearchの検索後えられた結果のうち、スコアの高いものが一定数ヒットしている状況においては、取り決めたスコア閾値より低い検索結果はクライアントにああ戻さないといった、アプリ側での対応も視野に入れた方が良さそうです。

まとめ

Multiplexerを使って、「読み仮名」を意識した、複合語→複合語の検索方法の例をゆるゆる考察してみました。

本記事の例がMultiplexerの開発時にイメージされたの本来の用途かは、勉強不足のためわかりません。

しかし、日本語のような単語の切れ目がスペースでなく、読みが発音としてだけでなく通常の文章内に存在するような言語では本記事の用法が使えそうです。

本記事の他にも、複数の表記揺れを同時に扱うなどの場合に、Multiplexerは使えるかもしれません。

付録

ベンチマークにあげた検索語のアナライズ結果


POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_ja_anlz",
    "text":"渡邊正邦"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_ja_anlz",
    "text":"わたなべ"
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "渡邊"
    },
    {
      "token" : "正邦"
    }
  ]
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "わ"
    },
    {
      "token" : "た"
    },
    {
      "token" : "なべ"
    }
  ]
}



POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_noop_anlz",
    "text":"渡邊マサ"
}






# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "渡邊マサ"
    }
  ]
}




POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_rf_anlz",
    "text":"渡辺まさ"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_rf_anlz",
    "text":"渡辺まさくに"
}



# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワタナベ"
    },
    {
      "token" : "マサ"
    }
  ]
}

# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワタナベ"
    },
    {
      "token" : "マサ"
    },
    {
      "token" : "クニ"
    }
  ]
}




POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_1g_anlz",
    "text":"ワタナベマサクニ"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_1g_anlz",
    "text":"わたなべマサクニ"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_1g_anlz",
    "text":"渡なべまさくに"
}




# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワ"
    },
    {
      "token" : "タ"
    },
    {
      "token" : "ナ"
    },
    {
      "token" : "ベ"
    },
    {
      "token" : "マ"
    },
    {
      "token" : "サ"
    },
    {
      "token" : "ク"
    },
    {
      "token" : "ニ"
    }
  ]
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "わ"
    },
    {
      "token" : "た"
    },
    {
      "token" : "な"
    },
    {
      "token" : "べ"
    },
    {
      "token" : "マ"
    },
    {
      "token" : "サ"
    },
    {
      "token" : "ク"
    },
    {
      "token" : "ニ"
    }
  ]
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "渡"
    },
    {
      "token" : "な"
    },
    {
      "token" : "べ"
    },
    {
      "token" : "ま"
    },
    {
      "token" : "さ"
    },
    {
      "token" : "く"
    },
    {
      "token" : "に"
    }
  ]
}



POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_rf_x_1g_anlz",
    "text":"渡なべ魔サくに"
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワ"
    },
    {
      "token" : "タ"
    },
    {
      "token" : "リ"
    },
    {
      "token" : "ナ"
    },
    {
      "token" : "ベ"
    },
    {
      "token" : "マ"
    },
    {
      "token" : "サ"
    },
    {
      "token" : "ク"
    },
    {
      "token" : "ニ"
    }
  ]
}

*1:実際は「長辺」は「チョウヘン」になる傾向が高く、このようにはなりませんが、ここでは説明の例としてそのように分割されたとします。→ 実際は「チョウ/アタリ」でしたが、「ナガ/ナベ」にはひとまずなりませんが、いずれにせよひとまずこういう可能性があるということで。

*2:間引くのになぜそもそも検索させるのかという意味で...