はてだBlog(仮称)

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

Elasticsearchで検索時のアナライザーとインデックス時のアナライザーを変えてみる実験

はじめに

この記事は、Elasticsearchの日本語検索のアナライザー周りの話の雑談&ちょっとした実験です。図らずもPythonのPandasとPythonのElasticsearch公式クライアントのちょいサンプル紹介にもなっています。

Elasticsearchのバージョンは6.4です。

経緯

私は、Elasticsearchの日本語検索時の全文検索のアナライザーのmodeについては、「search」派で、かつ検索時もインデックス時も共に「search」派です。

他のmodeの「normal」「extended」はともかく、検索時とインデックス時のアナライザーを変えることは、Elasticsearchの仕様上可能でも、他の言語かより特殊なシーンだろうという想像こそしたものの、特に用途が思いつかなかったので、雰囲気で「search」で「search」に統一するものと思っていました。

ただし、最近ふと立て続けに次のページに辿りついて、考察&試行されている例を参考にさせてもらいました。

blog.chocolapod.net

christina04.hatenablog.com

ああ、なるほど、これは大事なことを見逃していて、ちょっと損しているかもと思ったので、検索時とインデックス時のアナライザーを別にして見て、ヒットの仕方に何かTIPSが潜んでいないか確認して見ました。

結論を言うと、多少機微はあれど、私のお気に入りは、デフォルトのsearchでインデックスしてsearchで検索するゾ、という前の記事 https://itdepends.hateblo.jp/entry/2019/05/04/181542の考えに再び落ち着いています。 ただ、検索時とインデックス時のアナライザーを変えて何がおきるかを試す(?)スクリプトを作成してみたので、だれかの役にたつかもということで貼り付けしてみます。

実験用のアナライザーの設定

こちらの記事に、アナライザーの設定例を記述しているのでこちらを参考にしてください。

itdepends.hateblo.jp

インデックスのデータ

次のインデックスデータを登録しています。 5件PUTしていますが、実は最後の「関西国際空港周辺」のデータしか使っていません。

PUT aa/_doc/1
{
  "text":"東京都"
}

PUT aa/_doc/2
{
  "text":"東京都中央区"
}

PUT aa/_doc/3
{
  "text":"東京都中央区銀座"
}

PUT aa/_doc/4
{
  "text":"東京都江戸川区中央"
}

PUT aa/_doc/5
{
  "text":"関西国際空港周辺"
}

お試しスクリプト(Python)

共通ライブラリのファイルとメインのスクリプトのファイルに分けています。

まず、共通ライブラリ(analyzelib.py)

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

#初期化
INDEX = "aa"
es = Elasticsearch(host='localhost', port=9200)
ic = IndicesClient(es)
FIELD = "text"


# アナライズランチャー
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)

# 検索ランチャー
def ElasticsearchMatchクエリランチャー(es,analyzer="n_a",text="",fieldname="text",operator="and",s_analyzer="n_a",explain=False):
    fname = fieldname + "." + analyzer
    body = {"query": {
              "match": {
                  fname : {
                       "query": text,
                       "operator":operator,
                       "analyzer":s_analyzer
                  }
              }
            },
            "_source":[fieldname]
           }
    return es.search(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アナライズ適用コンパクト版(text, analyzer="n_a"):
    explain = False
    rslt = ElasticsearchAnalyzerランチャー(ic,analyzer,text,explain)
    tokens = rslt["tokens"]
    json_normalize(tokens).to_csv(sys.stderr,sep='\t')
    return dict配列のフラット配列変換(tokens,func=lambda x: x["token"] + ":" + str(x["start_offset"]))

def Es検索適用(text,analyzer="n_a",fieldname="text",operator="and",s_analyzer="n_a",explain=False):
    rslt = ElasticsearchMatchクエリランチャー(es,analyzer=analyzer,text=text,fieldname=fieldname,operator=operator,s_analyzer=s_analyzer)
    hits = rslt["hits"]["hits"]
#    json_normalize(rslt).to_csv(sys.stderr,sep='\t')
    return dict配列のフラット配列変換(hits,func=lambda x: x["_source"]["text"])

つづいてメインのスクリプトです。 でも、これ今見ると、pandasはあまり使っていませんね。いやいや、to_csvメソッドだけでも使う価値があるってことで。

import pandas as pd
import sys
from analyzelib import *

S_WORD = ["関西国際空港周辺","関西国際空港","国際空港関西","関西国際空"]
IDX_WORD = "関西国際空港周辺"
ANALYZER_LIST  = ["nrm","ext","sch","eng","2ng"]
STIME_ANALYZER_LIST  = ["noop","nrm","ext","sch","eng","2ng"]

tidyrslt = []

# 一括アナライズ と 検索組み合わせお試し
for op in ["and","or"]:
  for sw in S_WORD:
    for an in ANALYZER_LIST:
      for s_an in STIME_ANALYZER_LIST:
        tidy = []
        tidy.append(op)
        tidy.append(sw)
        tidy.append(s_an)
        tidy.append(an)
        tidy.append(Esアナライズ適用コンパクト版(sw,analyzer=s_an))
        tidy.append(Esアナライズ適用コンパクト版(IDX_WORD,analyzer=an))
        tidy.append(Es検索適用(sw,analyzer=an,fieldname=FIELD,operator=op,s_analyzer=s_an))
        tidyrslt.append(tidy)

# 結果レポート

dfrslt = pd.DataFrame(tidyrslt)

print(dfrslt.to_csv(sys.stdout, sep='\t'))

何をやっているかの解説

  1. S_WORDに試して見る検索語を設定します。
  2. IDX_WORDは、実際にインデックスに登録されているドキュメントのワードを設定します。
  3. ANALYZER_LISTとSTIME_ANALYZER_LISTが、組み合わせて見るアナライザーの名称になっており、今回は固定設定で良いと思います。先述のアナライザーの設定に従って組み合わせたループで繰り返しチェックします。後述の実行結果を見てみると意図がわかると思いますのでまずは動かして見てください(手抜き)。
  4. 後半のループのところで、アナライズした結果、検索時にアナライザーを「s_an」に設定し、インデックス時のアナライザー設定が「an」としてあるフィールドを検索し、結果を取得しています。(ヒットしない場合は空の配列になる)
  5. 上記の結果を整然データ風の配列の配列に押し込み、それをタブ区切りのCSVとして出力します。

実行結果

標準出力に次のような結果が出力されます。(標準エラーにもごちゃごちゃ出ます)

0    and 関西国際空港周辺    noop    nrm ['関西国際空港周辺:0']  ['関西国際空港:0', '周辺:6']    []
1   and 関西国際空港周辺    nrm nrm ['関西国際空港:0', '周辺:6']    ['関西国際空港:0', '周辺:6']    ['関西国際空港周辺']
2   and 関西国際空港周辺    ext nrm ['関西:0', '国際:2', '空港:4', '周辺:6']    ['関西国際空港:0', '周辺:6']    []
3   and 関西国際空港周辺    sch nrm ['関西:0', '関西国際空港:0', '国際:2', '空港:4', '周辺:6']    ['関西国際空港:0', '周辺:6']    ['関西国際空港周辺']
4   and 関西国際空港周辺    eng nrm ['関西:0', '関西国:0', '関西国際:0', '関西国際空:0', '関西国際空港:0', '関西国際空港周:0', '関西国際空港周辺:0'] ['関西国際空港:0', '周辺:6']    []
5   and 関西国際空港周辺    2ng nrm ['関西:0', '関西国:0', '西国:1', '西国際:1', '国際:2', '国際空:2', '際空:3', '際空港:3', '空港:4', '空港周:4', '港周:5', '港周辺:5', '周辺:6']  ['関西国際空港:0', '周辺:6']    []
...

左から、1.通番、2.andかorか、3.検索語そのまま 、4.検索時アナライザー、 5.インデックス時アナライザー、  6.検索語のアナライズ結果、 7.「関西国際空港周辺」文書のアナライズ結果、8. このアナライズ組み合わせの検索結果

です。

normalでアナライズされた「関西国際空港周辺」というドキュメントを、「関西国際空港周辺」という検索語を「keyword(あるがまま)」でアナライズして検索しても、ヒットしなかった...というような結果になっています。 一方、通番3の結果のように、 searchでnormalを検索するとヒットするんですね。

実行結果の見栄えの補足

上記でアナライズの結果を配列で示していますが、これはアナライズ時のtoken情報とstart_offsetだけ抜き出しています。 今回は、matchでoperatorはandなので、おおまかには、検索語アナライザー配列の要素が、インデックス時アナライザーの配列の集合に含まれることで、ヒットするという見方になります。

ただし、長くなるので説明しませんが、実際には特にsearchの場合、出力に含めていないpositionLengthなどもよしなに考慮されて検索されるので、ご注意ください。通番3は典型的なその例です。 ご注意と言いましたが、安心してください。ありがとう検索エンジン!という方がニュアンスとしては正しいですね。

◆参考:searchでアナライズした戻り値(特に、関西と関西国際空港のoffsetやposition、positionLengthの違い等に注目してください)

{
  "tokens": [
    {
      "token": "関西",
      "start_offset": 0,
      "end_offset": 2,
      "type": "word",
      "position": 0
    },
    {
      "token": "関西国際空港",
      "start_offset": 0,
      "end_offset": 6,
      "type": "word",
      "position": 0,
      "positionLength": 3
    },
    {
      "token": "国際",
      "start_offset": 2,
      "end_offset": 4,
      "type": "word",
      "position": 1
    },
    {
      "token": "空港",
      "start_offset": 4,
      "end_offset": 6,
      "type": "word",
      "position": 2
    },
    {
      "token": "周辺",
      "start_offset": 6,
      "end_offset": 8,
      "type": "word",
      "position": 3
    }
  ]
}

ひとまず終了

長くなったので一旦終わり。結局、冒頭引用のブログから学ばせていただいた気づきについても特に記載できていない。 上記の結果の解釈もふくめてそのうち追記したい(主に自分の自己満足のために)。

付録

以下、上記の実験ツールの実験結果の標準出力そのまま出力です。