はてだBlog(仮称)

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

Elasticsearchで辞書で頑張らずにN-Gramでやりくりするちょっとした例(それがうまくいく利用シーンを選んだだけとも言う)

そろそろきりの良いエディション、バージョンでのElasticsearchに関する俺々ポエムを吐き出し切っておきたいと思う今日この頃です。

itdepends.hateblo.jp

さて、上記の過去記事あたりで「マイフェイバリット」としてAnalyze設定について講釈を述べましたが、形態素解析優遇のN-Gramとのハイブリッド推しとしていました。

しかし、実のところ未知語が多く形態素解析(kuromoji)のデフォルトの辞書では太刀打ちしづらく、なんらか辞書の仕掛けを用意することも難しい場合は、基本に立ち返って分かち書きN-Gram一本で頑張ることになると思います。

話やすくかつ腹落ちしやすい例かは自信がありませんが、「薬」の情報を「うろ覚えの薬名」で検索といったようなケースは形態素解析裏目にでることも多いので、形態素解析やそれとのハイブリッドはあきらめて、N-Gram中心に組み立てることになる一例としてあげられそうです*1

仕込み

では、さっそく例をあげてみたいと思います。

mapping設定

knというフィールドにちょっとだけトリックありの2,3-Gramを適用したアナライズをかける設定です。

PUT k
{
  "settings": {
    "similarity": {
      "scoring": {
        "type": "scripted",
        "script": {
          "source": "return query.boost * doc.freq + 1.0/(doc.length + 1.0)  + 0.00001/(term.docFreq + 2.0);"
        }
      }
    },
    "analysis": {
      "char_filter": {
        "m2b": {
          "type": "mapping",
          "mappings": [
            "ッ => ツ",
            "ョ => ヨ"
          ]
        }
      },
      "tokenizer": {
        "ng": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit",
            "symbol"
          ]
        }
      },
      "analyzer": {
        "anlz": {
          "type": "custom",
          "tokenizer": "ng",
          "char_filter": [
            "icu_normalizer",
            "m2b"
          ],
          "filter": [
            "icu_folding"
          ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "kn": {
          "type": "text",
          "analyzer": "anlz",
          "similarity":"scoring"
        }
      }
    }
  }
}

補足

  1. m2b というchar_filterを設定しています。小さいヨと大きいヨを同じとみなしましょうという意味合いです。うろおぼえ検索の場合は、発声の都合等で拗音の区別をあえてしないことで覚え間違いなどを救済するという狙いです。ここでは「ツ」と「ヨ」のみの例ですが、他にも全ての拗音を設定したり、すべての拗音を設定するのはノイズが増えるので1文字ずつではなく「ショウ => シヨウ」というように実際の薬名だとこういう傾向があるというものを意識して味付けしても良さそうです。なお、「救済」といっても、一方で美容院(ビヨウイン)と病院(ビョウイン)の区別が必要な場合は利用に注意が必要です。
  2. icu_normalizerもchar_filterに使っています。これは過去記事で紹介したマイフェイバリットでも使っているのですが、カタカナ・ひらがな・アルファベットおよび全角半角あたりのユニコード的標準化をしてくれるので、引き続き採用です。
  3. icu_foldingですが、ここでは濁音・半濁音を清音と区別しないという用途につかっています。アカデミック的に正しい用語の使い方かは自信がないですが、いわゆる「ミニマルペア」の混同をあえて許す検索スタイルといえるでしょう。もっとシンプルに説明すると「ベッドをベットと言い間違い」「バッグをバックと言い間違い」というようなケースの救済を意図しています。過去記事のマイフェイバリットではこのfilterは使わない方を推しているのですが今回は使います。
  4. similarityを独自設定していますが、これは後述。
  5. Elasticsearchのver 7系だと、おそらく"_doc"の層は不要ですが、それ以外は多分このままでエラーにならないと思います。

データのインポート

厚労省が公開しているこちらの医薬品マスター を使ってみます。

診療報酬情報提供サービス

ダウンロードするとy.csvが入っていますので、これをy.utf.csvという名前のUTF8に変換して、5項目目の薬名をElasticsearchのインデックスにバルクインポートしてみます。 ↓

*バルクロードするスクリプト(Python)

import pandas as pd
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk

df_ = pd.read_csv('y.utf.csv')
df_.columns = [str(i) for i in range(0,35)]
df =  df_[['4']]
df.columns=['kn']
endpoint = 'http://localhost:9200'
es = Elasticsearch(endpoint)
df['_index'] = 'k'
df['_type'] = '_doc'
bulk(client=es,actions=df.to_dict(orient='records'))

*ロード結果の確認

GET k/_search
{"query": {"match_all": {}}}

↓

  "hits" : {
    "total" : 21596,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "k",
        "_type" : "_doc",
        "_score" : 1.0,
        "_source" : {
          "kn" : "バリトップHD 99%"
        }
      },

ひとまずうまくインポートできてそう、21596件ですか。

検索例

クエリ

続いてクエリ例です。

想定検索シーンは、「正解は【バリトップ】という薬がよしなにヒットするで、そこに対してUIの検索窓で【ハリトツプ】で検索してみたら」のイメージです。

補足は後ろに書きます。

なお、薬ドメインに詳しくないので、この薬名がこの例の本来のベンチマークとしてはうまい例になっていないかもしれませんがご容赦ください。

GET k/_search?filter_path=*.*._explanation,*.*._source,*.*._score
{
  "query": {
    "dis_max": {
      "tie_breaker": 0.7,
      "boost": 1.2,
      "queries": [
        {
          "multi_match": {
            "query": "ハリトツプ",
            "fields": [
              "kn"
            ],
            "operator": "and",
            "boost":10000
          }
        },
        {
          "multi_match": {
            "type":"phrase", 
            "query": "ハリトツプ",
            "fields": [
              "kn"
            ],
            "operator": "or",
            "minimum_should_match": 2,
            "boost":100
          }
        },
        {
          "multi_match": {
            "query": "ハリトツプ",
            "fields": [
              "kn"
            ],
            "operator": "or",
            "minimum_should_match": 2
          }
        }
      ]
    }
  }
}

検索結果

{
  "hits" : {
    "hits" : [
      {
        "_score" : 84595.41,
        "_source" : {
          "kn" : "バリトップP 94.6%"
        }
      },
      {
        "_score" : 84595.17,
        "_source" : {
          "kn" : "バリトップHD 99%"
        }
      },
      {
        "_score" : 84594.766,
        "_source" : {
          "kn" : "バリトップCT 1.5%300mL"
        }
      },
      {
        "_score" : 84594.69,
        "_source" : {
          "kn" : "バリトップゾル150 150%"
        }
      },
      {
        "_score" : 3.9000025,
        "_source" : {
          "kn" : "バリトゲン 98.47%"
        }
      },
      {
        "_score" : 3.8307729,
        "_source" : {
          "kn" : "バリトゲンHD 98.6%"
        }
      },
      {
        "_score" : 3.800003,
        "_source" : {
          "kn" : "バリトゲンSHD 99.0%"
        }
      },
      {
        "_score" : 3.776473,
        "_source" : {
          "kn" : "バリトゲン-デラックス 97.98%"
        }
      },
      {
        "_score" : 3.7500029,
        "_source" : {
          "kn" : "バリトゲン消泡内用液2%"
        }
      },
      {
        "_score" : 2.5052636,
        "_source" : {
          "kn" : "ドンペリドン1%シロップ用"
        }
      }
    ]
  }
}

悪くないですね。

これだけで判断できるほどの十分な検証と言えるかはともかく。

クエリの補足

後述しますとしたsimilarityとそもそものクエリのコンセプトの補足です。

  1. operatorがANDのmatch、ORのmatch phrase(minimum should matchを2)、ORのmatch(minimum should matchを2)で、この順に確実な序列になるようにboostをかけて、dismaxで結んでいます。
  2. つまるところ、ヒューリスティックではありますが、検索語に近いものがよりもっともらしくヒットするようにしてあります。また、同じ検索語で欲張りで3種検索するところがこだわりです。
  3. mappingのfoldingなどでで揺らぎをカバーしているので、またANDだけでなく、ORでも次善の策として当てに行くので、N-Gramの狙いどおり、多少うろ覚えや正解に対する文字の欠損などもカバーできそうです。
  4. 今回の例ではそこまで重要ではないのですが、settingに(デフォルトのBM25ではなく)独自のsimilarity評価式を設定して、マッチしたトークンが多い方が単純に整数値で加点されよりストレートに反映するようにしています。dismaxの各クエリに明確な序列を設定したので少し埋もれていますが、スコアの左端の1桁目だけに注目すると切り刻んだトークンのうち何項目ぐらいが一致したかを比較的にストレートにあらわしているといえるかもしれません。【バリトップゾル】が"8"、【バリトゲン】が"3”で検索語の【ハリトツプ】から見ると、両者の間に納得感のある境界線が引けたようにも思います*2。Fuzzy検索のレーベンシュタイン距離のチューニングっぽく作用しているといいたいところです。
  5. similarityの計算式では、 1.0/(doc.length + 1.0) + 0.00001/(term.docFreq + 2.0) の項をつけていますが、前者は文字数が短いドキュメントほど検索語に対する密度が濃い(適合度が高い)、後者はドキュメント集合全体でレアなトークンであればわずかながら優先するという意図の項です。ただ、小数値を基本とし、最重視する整数値の評価が同点だった場合に限りそれとなく綺麗にならぶように意図したものです。ここではexplainの確認はしませんが、バリトップ兄弟のうち、バリトップPが最初で、バリトップゾルが最後なのはこの味付けの効果ですかね。
  6. 本例での独自similarityではBM25を(少なくともスコアの整数値部分で)使わない分、というかマッチした部分が多いほど整数値が素直に加算されていくタイプのスコアリングとすることで(またminimum should matchをひとまず2としたことで)、あくまで感覚的ですが、2点を超えるものが検索結果にヒットし、3点に満たないものは少しびみょうかも...といったことを定性的ながら定量的に見極めやすくなります。よって、通常は一旦計算したスコアの値で一定スコアに満たないものを間引くことはしないと思いますが、この例で言うような、ドンペリドンは微妙と感じる検証結果が他にも多く得られるようであれば、例外的にスコア閾値による選抜もいたずらにクエリを複雑にしないためのやり方として使えるかもしれません。

過去記事

itdepends.hateblo.jp

itdepends.hateblo.jp

*1:自信がない理由としては医療系のデータ検索等ではもっとガチでやらざるを得ないので、未知語がどうのこうのいっておられず辞書を作ることから始めることになるのでは?というところ。

*2:あくまでこの一例だけだとフェアではありませんが