はてだBlog(仮称)

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

ElasticsearchのPythonクライアントでPandasを使って手軽にANALYZERの有効PoCをやってみるアドホックツール(の習作)

はじめに

Elasticsearch(6.4)、PythonおよびPandas関連のやってみた系の記事です。あと、ElasticsearchのPythonクライアントを使ってみたという内容も含んでいます。ElasticsearchのANALYZERの話もあまり詳しくは解説していませんが、うっすらそれとなく含まれます。

ただし、Elasticsearchの検索そのものの話かというとちょいと違います。

...ので、そこは次項・次々項に示したストーリーをごらんください。

この記事での演習の背景イメージ

RDBSQLのLIKE検索で頑張っていたところをElasticsearchなど検索エンジンにおきかえるという案件があるとします。

このような場合、RDBで頑張るために、データ中の特定のワードについて辞書を用いたり一律半角全角変換などを行い、LIKEを全文検索に曖昧検索風にヒットするようにその環境なりの工夫をしているということもあると思います。

きっと、置き換え用の対応表なども管理されているでしょう。また致し方ないのかもしれませんが、そのような対応表は数万行にも及ぶという例もあるでしょう。

このような対応表のメンテが限界になり検索エンジンを使うことで対応表のメンテナンスを最小限にしたいという現場もあるかもしれません。

演習ストーリーでのゴール

ここでは、この置き換え用の対応表のうち、どれとどれがElasticsearchでとあるANALYZERを行うことで不要になり、逆にANALYZER設定をその方針で進めるとした場合に、個別の辞書として引き続きメンテした方が良いものかというのを炙りだすことを考えます。

【インプット】

行番号 text0 text1
0 ニュース ニュース
1 ビッグサイティング ビッグサイティング
2 ルヴァヌ ルヴァヌ
3 ヴァニラ バニラ
4 ヴァニラ ヴァニラ
5 バニラハウス レバニラハウス

※text1がこのワードが入力されたら、text0とみなして検索して欲しいというものの例。 バニラで検索したらヴァニラが当たって欲しいよね...ということになります。まあ、この例は今回の設定の範囲では「残念ながらヒットしない」例になるのですが...

※ここでは、どっちが新でどっちが旧か、またそもそも新旧なのかという話はありますが、便宜上、上記の対応表を新旧対応表と呼ぶことにします。

... のような、置き換え用の新旧対応表をインプットに、今後あるANALYZER設定を行ったElasticsearchでの検索に切り替えた以降も引き続き同義語の辞書として残す対象をあぶり出します。

ANALYZER設定にicu系を設定するストーリーだとすると、この例の場合は、次の3と5が引き続き辞書として残す必要がある設定で、他はElasticsearch(とANALYZERの頑張り)により今後はメンテ不要となる想定です。

【期待するアウトプット】

行番号 text0 text1
3 ヴァニラ バニラ
5 バニラハウス レバニラハウス

アプローチ

次のような考え方でアプローチします。

  1. 上記のINPUT相当の対応表CSVをDataFrameに読み込む。手動1件ずつ確認するのは現実的でない件数が対象となる。
  2. DataFrameの要素(対応表にある新旧ワード)に対して繰り返し(ただしapplymapによる宣言的な適用になるため見た目上forループ記述は無し)、ANALYZERをかけて戻り値の「token」のみ抜き出した配列を確保する。
  3. 得られた新旧の「token」の配列を比較して、同じようなANALYZE結果が得られるなら、類義語辞書の個別対応必要無し...とみなせるというやり方で対象を炙り出せるのではないか。

何もないところからやるにはいろいろ段取りが必要ですが、PandasとElasticsearchの公式Pythonクライアントがあれば、これらの公式Rの見よう見まねレベルで組み合わせて味付けすることでやりたいことができそうです。

その他この演習の前提

一応、この演習で前提とした「検索要件」の背景を補足しておきます。読者の方のお立場によっては自明と思われる内容を記載している面もあるので、先にプログラム例を見てから戻ってきていただいて構わないような話です。

  1. AND検索を前提としています。例えば、国際展示場と入力されたら、「国際」かつ「展示場」を共に含むようなデータを検索したいという既存要件を従来どおりカバーできるかというものです。*1
  2. ANALYZERを使う...というストーリーのとおり、match系のクエリでの検索をイメージしています。
  3. ANALYZERは後述のPythonのコード例のコメントに書いてある設定のとおりとします。 また、Elasticsearchのsynonym設定は行なっていない体です*2。→なのでANALYZERをもっと頑張ったり他の工夫次第では、もっと良い結果を得られる伸び代があるという類のものです。

Pythonのプログラム例

上記のPythonのプログラム例です。

from elasticsearch import Elasticsearch, helpers
from elasticsearch.client import IndicesClient
import pandas as pd
from pandas.io.json import json_normalize
import sys


#初期化
es = Elasticsearch(host='localhost', port=9200)
INDEX = "es_index2"
#https://elasticsearch-py.readthedocs.io/en/master/api.html#elasticsearch.client.IndicesClient
ic = IndicesClient(es)
"""
# es_index2は例えば次のようにアナライザー設定されているとします。
# 今回は、my_ja_default_analyzerの効き具合を確認します。

PUT es_index2
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer":{ "type": "kuromoji_tokenizer", "mode": "search" },
        "my_ngram_tokenizer":{ "type": "ngram","min_gram":2,"max_gram":3, "token_chars":["letter","digit" ]  }
      },
      "analyzer": {
        "my_ja_default_analyzer": { 
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": ["icu_normalizer","kuromoji_iteration_mark","html_strip" ],
      "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_number", "kuromoji_stemmer" ]
        },
        "my_kuromoji_readingform_analyzer": {
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [ "icu_normalizer","kuromoji_iteration_mark","html_strip" ],
          "filter": [ "kuromoji_readingform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_stemmer" ]
        },
        "my_ngram_analyzer":{ 
          "type":"custom", "tokenizer":"my_ngram_tokenizer",
          "char_filter": ["icu_normalizer","html_strip"], "filter": [ ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
            "properties":{
        "location":{"type":"geo_point"}
      },
      
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true, "store": true,
              "fields": {
            "readingform":{ "type":"text", "analyzer":"my_kuromoji_readingform_analyzer" },
            "ngram":{ "type":"text","analyzer":"my_ngram_analyzer" },
        "raw": { "type":"keyword" }
              }
            }
          }
        }
      ]
    }
  }
}

"""

COLUMN_LABEL = ["text0","text1"]

def 新旧比較ワード全件読み込み():
    #実際はCSVからそれなりの件数を読み込みする想定
    text0 = "ニュース,ビッグサイティング,ルヴァヌ,ヴァニラ,ヴァニラ,バニラハウス".split(",")
    text1 = "ニュース,ビッグサイティング,ルヴァヌ,バニラ,ヴァニラ,レバニラハウス".split(",")
    df = pd.DataFrame(data=[text0,text1],index=COLUMN_LABEL).T
    print(df,file=sys.stderr)
    return df


df = 新旧比較ワード全件読み込み()

# ランチャー
def ElasticsearchAnalyzerランチャー(ic,analyzer,text,explain=False):
    body = {"analyzer":analyzer,
                "text":text}
    if (explain == True):
        body["explain"] = "true"
    return ic.analyze(index=INDEX, body=body)

# ANALYZERの戻り値から値を抜き出すためのツール
def dict配列のフラット配列変換(dictlist,func=lambda x: x["token"] ):
    """
    [{f1:"aaa",f2:"bbb"},{f1:"ccc",f2:"ddd"},..]のような配列を
    ["aaa","ccc",...]のようなオブジェクトに変換する
    ※実際はfuncで引き渡す関数次第となる。funcのデフォルトは、dictの中に「token」というプロパティが必ず含まれる前提の例。
    """
    dstlist = []
    for v in dictlist:
        dstlist.append(func(v))
    return dstlist        

def Esアナライズ適用(x):
    analyzer = "my_ja_default_analyzer" #CONFIG
    explain = False
    text = x 
    x = ElasticsearchAnalyzerランチャー(ic,analyzer,text,explain)
    xtokens = x["tokens"]
    # 今回は途中経過は標準エラーに出力
    json_normalize(xtokens).to_csv(sys.stderr,sep='\t')
    return dict配列のフラット配列変換(xtokens)

# 一括アナライズ
dfalz = df.applymap(Esアナライズ適用)
#新旧の比較ワードそれぞれでElasticsearchのアナライズで得られた「tokens」の集合を用いた比較
#  ポイント:添え字1のアナライズ結果のtokensが、添え字1のアナライズ結果のtokensにすべて含まれていればOKとみなす。つまりこの例だと、dfalz["chk"]の値が0の場合OKという扱いになる。
dfalz["chk"] = dfalz.apply(lambda s: len(list(set(s[COLUMN_LABEL[1]])-set(s[COLUMN_LABEL[0]]))) ,axis=1) 
# 結果レポート
filterrows = dfalz["chk"] > 0
# dfalz["chk"] が1以上、つまり検索時に同値と見なされないワードが含まれているものを出力
print(dfalz[filterrows],file=sys.stderr) 
print(df[filterrows])


実行結果イメージ

上記を保存して、コマンドラインなどで起動すると次のような結果が出力されます。

      text0      text1  chk
3      ヴァニラ      バニラ    1
5   バニラ, ハウス    レバニラハウス    1
    text0    text1
3    ヴァニラ      バニラ
5  バニラハウス  レバニラハウス

似たようなものを生のPythonの標準モジュールだけ、あるいはhttpを扱いやすいのでNode.jsなどJavaScript系で頑張るという場合に比べて、公式クライアント、Pandasがそろうことで細かいところをショートカットして今回の目的で言えば、取り回しの良いツールになった気がします。どうでしょうか。

この組み合わせは自分にとってはなかなか便利だったのでご紹介したく記事にしてみました。

免責事項というかご注意事項

ElasticsearchやPandasをありあわせで短めの記事としてまとめていますので、例えば上記のサンプルコードですが、Pythonicな例にはなっていないと思います。エラー処理なども甘く、本格的なコードとしてはかけている部分がいくつかありますし、関数アノテーションや型タイプの指定をした方が良いものもあるでしょう。

また、Elasticsearchの真のパワーを引き出す類の例ではなく、本ストーリーのもとのでサンプルコードとなりますのでその点ご注意ください。

関連の「公式」系の情報源など

やってみた系の記事を書いておいていうのもなんですが、やはり「公式」をあたってみるのが一番近道です。

ということで公式R関連の記事をリンクしておきます。

elasticsearch-py.readthedocs.io

pandas.pydata.org

www.elastic.co

また、ElasticsearchのANALYZERの「公式」関連というところで、Elastic日本の中の人が作成されている便利なプラグインがあるので、こちらをリンクさせていただきます。

discuss.elastic.co

*1:もちろん、「国際」または「展示場」の例も簡単な応用で確認できると思います。

*2:今回はむしろ類義語辞書にsynonym設定した方が良いワードをあぶり出すいとなみですので...