はてだBlog(仮称)

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

Elasticsearchの検索スコアリング(boost_mode、score_modeのsumとmultiplyの噛み合わせについてもう少し)

はじめに

この記事は次の記事のちょっとだけ補足(つづき)です。

itdepends.hateblo.jp

記事の概要

前の記事では、ひとことでいうとスコアリングは文字通り加点方針(条件に該当するとプラスが重なる方針)がオススメみたいなところを示唆しておりました。

スコアリングには、掛け算や平均をとったりということもできますが、加点の方が感覚的にわかりやすく・傾向も予測しやすいし、思ったより調子が悪い場合に、他の良いところを損なわずに調整するということも比較的やりやすいかなという思いからでした。

ただ、どこまで複雑にするかはともかく、やっぱり掛け算方式などにしたい場合もあるよね、ということで、私の場合、どんな時に掛け算方式にするかここに書きなぐってみます...というのがこの記事です。

boost_modeとscore_mode

この加算型か掛け算型かというのは、boost_modeとscore_modeです。*1

score_modeは、function句に並べ立てた「条件に該当した場合は、点数アップする」に該当したものどおしの掛け合わせの点数です。

score_modeがsumなら、条件に該当すればするほど、スコアがアップします。

multiply設定なら、条件に該当すればするほど、スコアが掛け算の倍々ゲームでスコアがアップします。ただし、掛け算なので、weightを1以下に設定したものの場合、実は減点相当として機能するということにも使えるかもしれません。

一方、boost_modeは、function句側のスコアリングの合計点ともともとの検索クエリのマッチングのスコアの合計方式になります。

前の記事のように、単純にsum設定なら合計ですし、ここをmultiplyにすると、検索クエリのマッチングのスコアとfunction句の「条件に該当した場合は、点数アップ」の掛け算が最終スコア値になります。

掛け算なので、特徴を際立たせる傾向がありそうですね。

なんども言いますが、multiply、sum以外にavg、first、maxなどいろいろありますので、もっとできるはずだという方は公式Rをあらためてご覧下さい.。

www.elastic.co

わがうんちく(boost_mode=multiply、score_mode=sum とするケースの例)

さて、ここから持論です。

スコアリングを掛け算方式とする例(ここではboost_modeの方のみmultiply)ですが、冒頭の先の記事で、スポットを検索するのに

①駅> ②ランドマーク> ③住所表記のエリア(市区郡町村レベル)

という3階級の序列があってという架空のシナリオを示しました。

が、それに加えて、

検索のマッチング度合い(例. 完全一致でヒットした、形態素解析でヒットした、N-gramでヒットした)やフィールドの序列も全て混在で、全体の(ほぼ)厳格な優先度を決めたい時

に使うと良いと感じています。

例えば、

形態素解析でヒットした①駅>

形態素解析でヒットした②ランドマーク>

形態素解析でヒットした③住所表記のエリア(市区郡町村レベル)> 

N-gramでヒットした①駅 > 

N-gramでヒットした②ランドマーク > 

N-gramdでヒットした③住所表記のエリア(市区郡町村レベル)

のような6階級の序列をもうけるといった具合です。

実のところ、私の2つの人格のうちより本音の人格の方は、3種間の序列を設けた時点である程度恣意的になるし、あとは検索のマッチング度で多少振れ幅があるぐらいの方が面白い結果になるのではという感覚です。

ただ、世の中にはなんでも「決定論」的に扱いたい世界もあるでしょうし、その方がバグも仕様として説明がつくというメリット(?)あるかもしれません。

なので、この組み合わせ6段階の例も、ユル要件でもまあある範囲かなと思います。

話を元に戻すと、形態素解析の境界のところにいる、

形態素解析でヒットした③住所表記のエリア(市区郡町村レベル) > N-gramでヒットした①駅

が成り立つように、形態素解析当てとN-gram当ての配点の比率を、①と③の配点の逆数(ちょっと数学的に正確ではないかもしれません)にしてやれば良いです。

この時に、2つの軸を掛け算で組み合わせる、multiplyが指定できることが生きてくるのかなと思います。

multi_matchの例でいうと、①駅の持ち点(フィールドCにプリセット済み。field_value_factorでコントロール)が1000、③住所表記エリアの持ち点が10という状況なら、 形態素解析フィールドをフィールドA、N-gramフィールドをフィールドNとすると次のような例になるでしょうか。

POST /myidx/_search
{
  "query": {
    "function_score": {
      "boost": "1",
      "boost_mode": "multiply",
      "score_mode": "sum",
      "functions": [
        {
          "field_value_factor": {
            "field": "C",
            "factor": 1,
            "missing": 1
          }
        }
      ],
      "query": {
        "multi_match": {
          "query": "中央",
          "fields": [
            "A^1000",
            "N^10"
          ]
        }
      }
    }
  }
}

これにより、Aにヒットした時点で、1000でそれと③住所表記エリアの持ち点10をかけて10000、AにはヒットせずNでヒットしたが10点で、それと駅の持ち点をかけるとこちらも10000となり、おおよそ同等のスコアになる...という理屈になります(注記の※1参照)。

※1 厳密には、Aの1000、Bの10はもともとのBM25/TF-IDFなどで得られた数値に対する重みづけなので、裸のスコア値の傾向をつかまえておいて、もう少し加減してやる必要がありますね。 また、数学的に常に成り立つ大小関係を保証するのは難しいかもしれません。が、ここぐらいまでやっておけば、序列として成り立っていると言える状況が作れるでしょう。

まとめ

Elasticsearchの検索スコアリングの加算方式か乗算方式かというところについて少し述べてみました。

数式を書いてみれば数学に強い人だと当たり前でより良い採点方式を導けるかもしれません。

また、ML的なアプローチなどもあるでしょう。

一方で、このあたりのUXはスジの良い人のヒューリスティックでグッといい感じに近づけておいて、あるいは実験・評価をしてみて微調整というのが実際はうまくいく進め方のように思います。

このブログでは、微調整がより楽チンな「加算型」をオススメするのですが、この記事では、「乗算型」もこれぐらいのバランス感で使うと良いのではないか(というか単に私のこのみですが)というところを述べみました。

以上、ではでは。

*1:他にも似た様なパラメータがあるかもしれませんが、そもそもこの2つだけでも、いつもどっちがどっちかわからなくなるので、一応クエリを作って見てスコア値を確認することにしています。

Elasticsearchの検索スコアリングの見定めに関する考察

はじめに

この記事は次の記事のアナザーバージョン(切り口は少し違うが同じことを別の表現で表したもの)です。

itdepends.hateblo.jp

itdepends.hateblo.jp

とあるタイプの検索サイトのElasticsearchを使ったサービス設計などに関する私見(2019年改訂版) - はてだBlog(仮称)

記事執筆にあたり、Elasticsearch6.4で試していますが、それ以上のバージョンでも動作する範囲にとどめています。

この記事の概要

検索エンジンによる検索サービスは、実際のサービスに照らし合わせると、文書検索としてのピュアな指標であるBM25やTF-IDFなどの客観的な適合度相当だけでは推し量れない面があります。

標準のスコアリング以外に、サービスに合わせた加点をしてやる必要もあるでしょう。というか大半がそのような例になるのではないでしょうか。

例えば、スポンサー型の検索サービスの場合はスポンサーをなんらか優遇(※もちろん利用者に役立つ範囲でですが)したり、ユーザーの性別や想定される嗜好や知識に合わせて、全文検索のスコア以外の要素を織り込んで、上位表示するものをサイト側で調整してやることで、利用者の求める検索結果あるいはそれ以上のものを(無い袖はふれないものの)提供できればなお良いでしょう。

ということで、この記事では次のようなものについて私の考察を述べてみます。

ただし、ふんわり考察であり、スキだらけの甘いロジックなのはご容赦ください。

  • 「適合度」を下敷きにしつつ、Elasticsearchのスコアリングを操作して、利用者向けあるいは検索サイト側の見せたいものを意識したスコアリングを調整する。
  • Elasticsearchの様々なスコアリング調整方法の中でも、筆者が使い勝手が良いと思ったクエリの紹介。
  • 上記を見い出すための、検討プロセスの例。目の付け所など。

目次

★1 あえて実例を最初にあげる

実例を見て察する方が早いかもしれないので、この記事では、コピペしていくとなんとなく動作するという例を最初に示します。

なお、Elasticsearchはひとまず動作していて、Kibanaで接続できていることとします。

この記事ではどんなサービスをイメージしたか、検索か?

  • サービス: 何かの観光情報などを紹介するサイトとします。
  • 検索シーン: ある観光情報の記事を探すにあたり、「スポット」名を検索して、そのスポットや周辺情報を芋づる式に辿っていくというような導線です。
  • 検索文書: スポット情報そのものです。スポットの表現方法は様々で施設を示すランドマークや(旅行や観光の探索の起点となる)駅、地域を表す「XX県YY市」のようなものが雑多に詰め込みされた半構造化データです。(後述)

データの仕込み

mapping設定
PUT myidx
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer": {
          "type": "kuromoji_tokenizer",
          "mode": "search"
        }
      },
      "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"
          ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true,
              "store": true,
              "fields": {
                "raw": {
                  "type": "keyword"
                }
              }
            }
          }
        }
      ]
    }
  }
}


注: kibanaのDevToolにコピペして実行できるスタイルとしました。

今回は形態素解析とかその辺は紙面の都合で直接の主題ではありませんので、後述の事例を扱う範囲の雑な設定です。

データのPOST

こんなデータを用意します。ちょっとだけ後で解説します。

POST /myidx/_doc/                                                         
{    "A": "東京都中央区"                         ,"C":    200                 }
POST /myidx/_doc/                                                         
{    "A": "東京都港区中央町"                           ,"C":    110                 }
POST /myidx/_doc/                                                         
{    "A": "東京都荒川区西日暮里中央"                           ,"C":    100                 }
POST /myidx/_doc/                                                         
{    "A": "長崎県中央市"                         ,"C":    100                 }
POST /myidx/_doc/                                                         
{    "A": "鹿児島中央駅(鹿児島県鹿児島市)"                           ,"C":    1000                    }
POST /myidx/_doc/                                                         
{    "A": "中央駅(神奈川県横浜市)"                           ,"C":    1100                    }
POST /myidx/_doc/                                                         
{    "A": "中央スポーツセンター(秋田県秋田市)"                         ,"C":    100 ,"D":    "SPORTS"         }
POST /myidx/_doc/                                                         
{    "A": "中央周辺エリア"                          ,"C":    101 ,"D":    ["OUTDOORS","ACTIVE"]           }
POST /myidx/_doc/                                                         
{    "A": "千葉県千葉市XXX区YYY町" ,"B":    ["△△駅","中央駅"]                   ,"C":    100                 }


注: KibanaのDevToolにコピペして実行できるスタイルとしました。

冒頭に、観光情報がなんとか...と大きく出ましたが、全国いろんなところに「中央」と入っているスポットデータがあるという体裁のデータです。

住所っぽいものがいくつか入っていますが、架空の例です。長崎県中央市なんてのは存在しないですね。

検索クエリの例

AフィールドもしくはBフィールドに「中央」と入っているスポットデータを検索します。

ここで、Cフィールドにサイト側のおすすめ順に相当するスポット情報の序列・階級を表す持ち点が設定されているとして、ここが高いものほど上位にきます。 さらに、Dフィールドに、「SPORTS」という指定を含むものを持ち上げます。

POST /myidx/_search
{
  "query": {
    "function_score": {
      "boost": "1",
      "boost_mode": "sum",
      "score_mode": "sum",
      "functions": [
        {
          "field_value_factor": {
            "field": "C",
            "factor": 1,
            "missing": 1
          }
        },
        {
          "filter": {
            "term": {
              "D.raw": "SPORTS"
            }
          },
          "weight": 10000
        }
      ],
      "query": {
        "multi_match": {
          "query": "中央",
          "fields": [
            "A^5",
            "B^0.01"
          ]
        }
      }
    }
  }
}

ちょっとだけ解説。

www.elastic.co

  1. field_value_factor: あるフィールドの値そのものをスコア値として使います。
  2. script_score: 複数のフィールドの値を使うなどfield_value_factorの高度版と私は捉えることにしています。それ以上のことも工夫次第ではできそうな気がします。 https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-score-context.html ただし、今回は使っていません。
  3. filter + 検索クエリDSL: 検索クエリDSLで指定の検索条件にマッチする場合、加点するというものです。
  4. 全文検索の重み付け(ここではmulti_matchを使っている): multi_matchはfields指定で、複数の対象フィールドを指定できますが、加えて重みを指定できます。ここでは、フィールドAを5、Bを0.01としているので、 フィールドAにヒットすると、フィールドBにヒットする
  5. boost_modeとscore_modeともにsum(加算)にしてあります。他にもいくつかありますが、「加算」モデルで要件を満たせそうな場合は、「加算」をベースに考えるとわかりやすいので個人的にはおすすめです。

検索結果(のスコアリングによる並び順)の例

前項のクエリの結果の最初の1件です。

中央スポーツセンターというのが上位ヒットしました。

{
  "hits": {
    "total": 10,
    "max_score": 10100.82,
    "hits": [
      {
        "_index": "myidx",
        "_type": "_doc",
        "_id": "2f4WYWsBSVSNflQF4HH1",
        "_score": 10100.82,
        "_source": {
          "A": "中央スポーツセンター(秋田県秋田市)",
          "C": 100,
          "D": "SPORTS"
        }
      },
      {
        "_index": "myidx",
        "_type": "_doc",
        "_id": "2P4WYWsBSVSNflQF4HHh",
        "_score": 1100.8427,
        "_source": {
          "A": "中央駅(神奈川県横浜市)",
          "C": 1100
        }
      },
explain表示(抜粋)

前述のクエリに、"explain": true をつけて検索すると、スコアリングの過程が見て取れます。(以下参照)

ボロが出るので解説はしませんが、出てくる数字をなぞって見ると、100 + 10000 + 0.82 という合計として、10100.82というスコアリングがされていそうです。

    "hits": [
      {
        "_score": 10100.82,
        "_source": {
          "A": "中央スポーツセンター(秋田県秋田市)",
          "C": 100,
          "D": "SPORTS"
        },
        "_explanation": {
          "value": 10100.82,
          "description": "sum of",
          "details": [
            {
              "value": 0.8201082,
              "description": "max of:",
              "details": [
                {
                  "value": 0.8201082,
                  "description": "weight(A:中央 in 1) [PerFieldSimilarity], result of:",
                  "details": [
                    {
                      "value": 0.8201082,
                      "description": "score(doc=1,freq=1.0 = termFreq=1.0\n), product of:",
                      "details": [
                        {
                          "value": 5,
                          "description": "boost",
                          "details": []
                        },
                        {
                          "value": 0.18232156,
                          "description": "idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from:",
                          "details": [
                            {
                              "value": 2,
                              "description": "docFreq",
                              "details": []
                            },
                            {
                              "value": 2,
                              "description": "docCount",
                              "details": []
                            }
                          ]
                        },
                        {
                          "value": 0.8996283,
                          "description": "tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from:",
                          "details": [
                            {
                              "value": 1,
                              "description": "termFreq=1.0",
                              "details": []
                            },
                            {
                              "value": 1.2,
                              "description": "parameter k1",
                              "details": []
                            },
                            {
                              "value": 0.75,
                              "description": "parameter b",
                              "details": []
                            },
                            {
                              "value": 5.5,
                              "description": "avgFieldLength",
                              "details": []
                            },
                            {
                              "value": 7,
                              "description": "fieldLength",
                              "details": []
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ]
            },
            {
              "value": 10100,
              "description": "min of:",
              "details": [
                {
                  "value": 10100,
                  "description": "function score, score mode [sum]",
                  "details": [
                    {
                      "value": 100,
                      "description": "field value function: none(doc['C'].value?:1.0 * factor=1.0)",
                      "details": []
                    },
                    {
                      "value": 10000,
                      "description": "function score, product of:",
                      "details": [
                        {
                          "value": 1,
                          "description": "match filter: D.raw:SPORTS",
                          "details": []
                        },
                        {
                          "value": 10000,
                          "description": "product of:",
                          "details": [
                            {
                              "value": 1,
                              "description": "constant score 1.0 - no function provided",
                              "details": []
                            },
                            {
                              "value": 10000,
                              "description": "weight",
                              "details": []
                            }
                          ]
                        }
                      ]
                    }
                  ]
                },
                {
                  "value": 3.4028235e+38,
                  "description": "maxBoost",
                  "details": []
                }
              ]
            }
          ]
        }
      },

★2 考察というかうんちく編

ここからは、考察というかうんちく編としてつれづれ述べてみます。

検索チューニングにもいろいろある

主にfunction_scoreクエリに条件をぶら下げることで、スコアリングの味付けができるということが見て取れたと思います。

Elasticsearchは高機能のため、他にもありますし、もっと高度のなこともできるのはそれとなく見てとれたのではないでしょうか。

ここで、ひとえにスコアリングといっても、上記の簡単な例だけでもこのような観点があると言えます。

  1. ワードに対する、文書の適合度
  2. あるセクション・フィールドのどこに入力ワードがヒットするかによる重みづけ
  3. 文書自体があらかじめ持つ序列、階級 ※ヒットしなければ序列や階級がどれほど高いものでも検索結果に現れないが、ヒットしてしまいさえすれば、上位表示すべきというもの。
  4. ユーザー側のコンテキストに応じたパーソナライズに伴う、加点(通常この例だと、序列や階級の追い越し、飛び級が発生する。)

1は興味深い世界ではあるのですが、俗な(必ずしも悪い意味ではない)世界では、1以外の観点での味付けがサービスの体感の鍵を握ることが多いように思います。

1自体および2について、私の場合は、multi_matchの複数フィールド間の配点バランスでざっくり最初の世界観を決めて、1以外のスコアリングの味付けがおおよそ定まってきてから、検索フィールド間の配点のバランスを見たり、対象フィールドを増やしたり減らしたりということを行います。

逆に言うと、1はある程度、検索エンジンエンジニア主導で整理していくものの、1以外の観点の味付けの部分は、そのサイトオーナーの「要望」やそのデータドメインに関する「知見」やユーザーにサイトをどう見せたいかが現れる部分なんだろうなーと思う次第です。

スコアリングの味付けを見極める

前項でいうところの、1以外、2はハザマの観点だとして、3、4などはどうやって見極めていくのが良いのかな〜といつも考えています。

私は次のようなものを心の片隅において、チームの皆やこのドメインに詳しい人にヒアリング〜ハンドリングすることを心がけています。

f:id:azotar:20190617204503p:plain

味付けとスコアリング方針、サービスの整合性確認

続いて、スコアリングの味付け具合とサービスの整合性を確かめます。

整合性といってもそんなかしこまったものではなく、スコアリング寄りの視点で検索サービス部分を絵にしたり、シナリオをかいてみたりということを行います。

実際は前項の営みと行ったり来たりしながら、試行錯誤の過程です。

冒頭に述べたスポット名を起点にした観光情報サイトのスポット検索導線であれば次のような感じです。

f:id:azotar:20190617204606p:plain

RDBの検索と違うのは、駅と住所とランドマークとといった多少異質なものも同じスポット情報のくくりにして検索できてしまうのが面白いですね。

なお、あまり具体的なことを書くと何かの事例をお漏らししてしまった扱いになってしまう可能性があるので、ここでは架空の例で、かつ若干強引な例になっています。ご了承ください。

(i) (ii)の視点の補足

f:id:azotar:20190617204945p:plain

(iii)の視点の補足

f:id:azotar:20190617204956p:plain

(iv)の視点の補足

f:id:azotar:20190617205009p:plain

続いて、どのようなインデックスデータを作ると良いか、インデクサーで計算した方が良いのか、検索時に計算するのか・加点するのかといったことを見極めます。

私は、断言こそできませんが、「序列や階級」にあたるところは、データ収集やメンテナンスの時点でしっかり意識した方が良いということもあり、インデックス時には複数の条件を織り込んで、合計値を持ち点フィールドに配置して、ユーザーコンテキストに合うかといった、検索時にしかできないことを検索時につまりクエリで調整する派です。

STEP1: 検索(のスコアリング)に必要なフィールドを見極める

◆検索&スコアリングに使うフィールド棚卸し

f:id:azotar:20190617205059p:plain

観点として何があるか、何をフィールドとして用意すれば良いのかというところを見定めましょう。

RDBの正規化を突き詰めていく方向とはやや異なりますね。あえて正規化を崩すのさらにもう一歩先の検索で使う、スコアリングで使うフィールドとして何があれば都合が良いかというところを洗い出すことになります。

STEP2: 検索対象のフィールドとフィールド間の序列、利用クエリを確認

f:id:azotar:20190617205254p:plain

STEP3: インデクサー時に設定するフィールドとその序列・階級の表現の検討

f:id:azotar:20190617205733p:plain

駅を高めに配置するかな〜、でもサービスの性質からいうと地方の駅とたまたま名称の似た都市部の地域だと後者を優先したいよね〜といった序列と階級を見定めてやります。

ちなみに、例を先にあげましたが、まさに、前述の「Cフィールド」に持ち点設定しました。

また、今回は、階級越えは無いとしました。

STEP4: 検索時の加点事項について検討

f:id:azotar:20190617205422p:plain

ユーザーがスポーツ好きというプロフィールの持ち主の場合は、同じ「中央」でも中央スポーツセンターをヒットさせてやります...という話。

この類の例でいま一番よく利用されそうな観点としては、現在地に近いものを優先するロジックですかね。

GEO系のクエリもしくはGEO系の距離ソートが活かせそうです。

★3 さいごに

もうちょっとしっかり説明しなければと思いつつかなりボリューミーになったのと少々息切れしたので、尻すぼみ気味ですが、ここまでとします。

途中うっすら触れたboost_modeとscore_modeについて、アナログなこだわりがあるのでそのうち、自分の考え整理がてらまとめてみたいところです。 (と書いておけば、いつかやるゾという自分の気合いのため。)

ElasticsearchのPainless Scriptのひとまず簡単な記述例

はじめに

Elasticsearch のPainless Scriptを「Score」コンテキストで、どんな記述ができそうかのさわりの部分を確認してみました。

という記事を書いておいてアレですが、この手のものは公式のリファレンスをしっかり読みましょう。

www.elastic.co

本格的に公式リファレンスを読む前に、ひとまず動作する例を見て準備運動になればというつもりで例をざっくり並べています。

サンプルデータ

こちらの冒頭の https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-examples.html のbulkロードデータを入れておくと良いでしょう。

なお試した環境はElasticsearch 6.4です。今回の範囲であれば、mapping等の設定は不要です。

//kibanaで実行。上記の引用元は、hockey/_bulk...となっているが、Elasticsearchの最近のバージョンだと「_doc」を入れた方が良いと思うというかここに何か入れないとエラーになる。

PUT hockey/_doc/_bulk?refresh
{"index":{"_id":1}}
{"first":"johnny","last":"gaudreau","goals":[9,27,1],"assists":[17,46,0],"gp":[26,82,1],"born":"1993/08/13"}
{"index":{"_id":2}}
{"first":"sean","last":"monohan","goals":[7,54,26],"assists":[11,26,13],"gp":[26,82,82],"born":"1994/10/12"}
{"index":{"_id":3}}
...

// 次のデータはこの記事オリジナル(numberは背番号を意識した数字)
{"index":{"_id":12}}
{"first":"太郎","last":"山田","number":18, "rand": "1234567890", goals":[7,54,26],"assists":[11,26,13],"gp":[26,82,82],"born":"1994/10/12"}

// 

値を取得

doc['フィールド名'] .valueで値が取得できる。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "doc['number'].value"
        }
      }
    }
  }
}
// ここではテーマが違うので説明しないが、検索条件なし(全件抽出)でfunction_scoreなので、要はスコアリングに、データ中の「number」の値を使うという意味になっている。// 検索結果イメージ(抜粋)

  "hits": {
    "total": 3,
    "max_score": 18,
    "hits": [
      {
        "_index": "hockey",
        "_type": "_doc",
        "_id": "12",
        "_score": 18,
        "_source": {
          "first": "太郎",
          "last": "山田",
          "number": 18,
          "rand": "1234567890",
          "goals": [
            7,
            54,
            26
          ]
        }
      }
  }

検索結果のscore値が id=12のデータは「18」、他は0になっている。... のでnumberの値が取得できた模様。

取得した値を使って計算

取得した値を使って計算はできるのかな。

できる。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "doc['number'].value * 2 + 5"
        }
      }
    }
  }
}

18 * 2 + 5 = 41 のようなデータが戻ってくる。
(18はそのドキュメントのnumberの値。numberフィールド自体を持たない文書の場合は、doc['number'].valueは0扱いの模様。エラーにはならない。)

文字列を取得できるか

ひとまず動く例。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "def x = doc['rand.keyword'].value; return Integer.parseInt(x)"
        }
      }
    }
  }
}

Scoreコンテキストなので、数字を返さなければならないので、def宣言で一度変数に入れる。
https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-types.html#dynamic-types

見てのとおり、複文を記述できる。

このElasticsearchのバージョンの場合、特にマッピング設定をしない時には、ドキュメントのデータ登録時に、「field名.keyword」でアナライズされない生のデータがインデックスに持ち込まれ、これを取り出すことになるので、「doc['rand.keyword']」のような指定方法になる。

また、defを使っているが、この例の場合は、

String x = doc['rand.keyword'].value;

とした方が良いだろう。

値が存在しない場合を考慮した記述

実は上記の場合、「山田太郎」のデータは良いのだが、他のrand.keywordを持たないデータはエラーになっている。

なので、値が存在しない場合を考慮してやる。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "def x = doc['rand.keyword'].size() == 0 ? '0': doc['rand.keyword'].value; return Integer.parseInt(x);"
        }
      }
    }
  }
}

ここでは3項演算子を用いた。 https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-examples.html#_missing_values

配列の値の取得(ひとまず実験)

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "doc['goals'].value"
        }
      }
    }
  }
}

↓

エラーにはならないが、配列中の最小の値が取得されるようだ。

doc['goals'][2]とすると、3番目に小さい値が取得される。

転置インデックスらに保持されている順序にアレンジされているのかな。

ちなみに、doc['goals'][2].valueはエラーになる。

続いてこれはどうだ。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "doc['goals'].value"
        }
      }
    }
  }
}

↓

エラーにはならないが、配列中の最小の値が取得されるようだ。

配列の値の取得(全項目取得後集計など)

実際は配列の何個目だけを取りたいというのは、この界隈の用途ではあまり発生しないかもしれない。

ひとまずJavaの配列なのは配列のようなので、次のように数を数えてみる。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "List l = new ArrayList(doc['goals']); return l.stream().count();"
        }
      }
    }
  }
}

→ 各ドキュメントのgoalsの要素数がscoreとして設定。

合計はこちら。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "List l = new ArrayList(doc['goals']); return l.stream().map(x -> x).mapToLong(x -> Long.valueOf(x)).sum();"
        }
      }
    }
  }
}

→ 各ドキュメントのgoalsの合計値がscoreとして設定。

forループなど

ここまで式っぽい書き方を志向していたが、文というかforループなども書けるし、(これはJavaがどうかというより、クエリがJSON形式なのでというところだが)ヒアドキュメント風に改行を入れてインデントなども行って書ける。

https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-examples.html#_accessing_doc_values_from_painless

文字列操作

前項では諦めたが、配列の要素の並びをインデックス時の値のまま取りたいことはそう無いと言ったものじ実はぼちぼちある。

そこでここでは、文字列のX桁目を抜き出すというやり方で代用する。

さて、文字列操作の関数などは使えるだろうか。

GET hockey/_search
{
  "query": {
    "function_score": {
      "script_score": {
        "script": {
          "source": "def x = doc['rand.keyword'].value; return Integer.parseInt( x.substring(8,9) + '' +  x.substring(7,8))"
        }
      }
    }
  }
}"hits": [
      {
        "_index": "hockey",
        "_type": "_doc",
        "_id": "12",
        "_score": 98,
        "_source": {
          "first": "太郎",
          "last": "山田",
          "number": 18,
          "rand": "1234567890",
          "goals": [
            7,
            54,
            26
          ],
          "assists": [
            11,
            26,
            13
          ],
          "gp": [
            26,
            82,
            82
          ],
          "born": "1994/10/12"
        }

使えた!

scoreに「1234567890」の9と8が入った。

※ただしこれはmissingデータ処理を考慮していないので上記以外の文書の分のエラーは出ます。

この記事では以上。

検索サイトの検索メニューまわりでしばしば議論されるかもしれない画面遷移パターンなどに関する自分まとめ

はじめに

検索サイトの検索への導線を議論している時に、これはこのパターンだね〜のような意見交換の際に、特に定義しなくても意思疎通がしやすいようなボキャブラリーってなんじゃろなと考えたものを雑多にまとめてみました。

「やっぱ、XX検索は、シンプルにAA選択、BB選択の2STEPウィザードだよね。どう思います?」のような会話ができたらな〜という話です。

導線のパターン

ウィザードなどはわざわざここで定義するものではないですが、対比もかねてUターンという呼び名はいかがでしょうかということで分類してみました。

f:id:azotar:20190516163149p:plain

ウィザードでのギミック

f:id:azotar:20190516163219p:plain

ウィザードの分岐ありバリエーション

f:id:azotar:20190516163310p:plain

自分は、分岐があるならウィザードはやめた方が良い派です。

というか、そのような場合、分岐の設定や、STEPを振り分ける観点を間違えている、はたまた分類学や何らかの体系としては正しいが、このウィザードの目的を果たすためには官僚主義や間違ったユニバーサル思考に落ちいっているのではないかと疑いたくなる性分です。

検索結果の周遊

f:id:azotar:20190516163646p:plain

この図は、Elasticsearchでいうと、post_filterなのか通常のfilterなのかという話を見出すために、要件が何なのか解き明かすための用語定義のようなものですな。

もっと簡潔に要点を切り取った「つたわる」表現を模索したいところですが、今のところはひとまずコレ。

それは階層カテゴリなのか実はタグにすぎないのではないか

f:id:azotar:20190516164142p:plain

検索結果の周遊の話にも近いのですが、階層カテゴリみたいなものはやり過ぎない方が良いし、キレイなツリーにならないなら、「タグ」と捉えた方が良いよという主張のためのボキャ貯め資料かな。

どうも検索結果の絞り込み系のインタフェースが辻褄とれているかどうかがわからずもやもやするという時は、この辺の話が関連しているかもしれません。

ひとまずここまで

まとめというか本当に雑多に貼り付けただけになってしまいました。

なお、このまとめ方だと、Uターンよりはウィザードかな...みたいな筆者の好みが透けて見えますが、実のところ私はいきなり検索結果もしくはなんらかの成り行きの検索結果一覧相当を出して、キーワード(BOX)での検索なら動的フィルタリング、選択型の検索ならサイト側がオススメしてくれるタブやタグクラウドでの直感的なブラウジング型の検索というか検索であることをさほど感じさせないようなダッシュボード風のものの方が好みです。

ただ、いろいろ事情もありますし、最終的にどのパターンになろうがなんでもかんでもA/Bテストしていくわけにもいかないですし、ここでまとめてみたことが誰かのなんらかの役にたったらいいなと思います。

PandasをElasticsearch検索インデクサープレ処理ETLに見立てた場合の「設計の共有」のためのモジュールファイル分割・分配方針

はじめに

最近Python、Pandasに入門していろいろ遊びながら、ETLにおける「設計の共有」が捗る&しかけってなんだろうなと思案しております。

ETLは汚れ仕事も多いので、他の分野以上にある種のスパゲティ化しがちで、実際はそうではなくとも、時が経ってなんでこうしているだっけというのが分かりにくいものだと思います。

そんななかで、関数やライブラリなどプログラムの共有やプログラミング効率化という面もないではないですが、あくまで「設計の共有」です。

これが結論!と断言するほどでもないのですが、このブログでもうっすらテーマにしているElasticsearch周辺や検索サイトにおいて、少しこじれた元情報をよろしく検索しやすいカタチに加工するインデクサーの前処理的なETLの場合、これぐらいがいいんじゃないかという例を編み出して見ましたので、それをぶちまけてみます。

DataFrameを軸にしたモジュールファイルの分割(関数の分配)による駆動

ということで、コレです。

f:id:azotar:20190513210617p:plain

余裕があれば、これのイメージにそってオレオレElasticsearch検索インデクサープレ処理のPandasのコードを公開できたらと思っていますが、現在したためているものはイメージ例のものとはいえもう一声必要かなと思って控えております。

...ので、以下、ちょっとだけ解説。

これは何かというと...

特にフレームワークなどは考えるに至りませんでした。もともとオレオレルールとか僕の考えたさいきょうのなんとかに近いのですが、重厚なものは結局向かなくて、モジュールファイルへの分配ルールです。

ちょっとだけ解説

私がキモだと思っているのは、下手にオブジェクトやエンティティ、あるいはビジネスやソリューションの単位でクラス化やモジュール分割するのではなくて、上記のように取り扱うデータの型、特にDataFrameとの関わり方によって、同じ立場のものを同じモジュールファイルに分配してくように采配するということです。

(以下、項番号と上述のイメージ絵の項番号は対応させています。)

  1. DataFrameにあまり関係ない、そもそも処理を走らせるための制御は、「ランナー」とする。また、契機となるデータもしくはキュー情報などはここで制御する。
  2. 生のDataFrameのカタチは意識せず、インプットとアウトプット、および対象とするデータ変換のビジネスフローのみ意識する「ジョブ」として処理を定義する。
  3. インプットのデータをストレージから取り出して、データパイプラインの本流や太めの支流を流れるデータセットを表すDataFrameのカタチを整える処理を「Dataset」として定義する。 → オンラインアプリなどではClassになることも多いケースだが、おそらくDataFrameとして抽象化した方がETLの場合はうまくいく。もし手グセでClassにしてしまった場合はETLには持ち込まない方が良い類の思い込みが入っているかもしれない。なお、実際にストレージとの接続などは、7のAdapterに配置して、インフラ系のコードを追い出す。
  4. ビジネスルールを外部から受け取って、(2のジョブのパイプライン上を流れる主流の)DataFrameについて、次項5の関数をもしくは次項5の関数を組み合わせてラッパー関数を作って、(通常は)DataFrameの全項目に宣言的に変換をほどこしていく処理を関数としてこのモジュールファイルに固める。今回の中では、他のものに比べると、対象とする変換のサブドメインが別の場合は、さらに別ファイルにする。... ので、これらは XxxPlugins.pyのような名前にする。
  5. 抽象的なDataFrameをインプットもしくはアウトプットにする、単独で動作できるユーティリティ関数群をモジュールにまとめる。前項と後述の文字列Utilsとの住み分けがポイント。
  6. 文字列操作、プリミティブな型に関するなんらかの操作の関数をまとめる→Utils.pyモジュールファイル。ここにはDataFrameを引数や戻り値に入れる関数は配置しない。
  7. ストレージなどからデータをロードするといったインフラ層のコード。ただし、今回はPandasを利用しているストーリーなので、DataFrameを返すものやDataFrameを受け取るものはここにいるが、DataFrameのカラム名は意識しないで処理するタイプのもののみとなっている(ハズ)。

つまるところ、ETLにおいては、Pandasで言う所のDataFrameのような二次元の表のデータのシェイプをこねこねしていくというところこそが共通のアプリケーションドメインであり、DataFrameを通してもの事を見た方が、コードの見通しとともに設計のエッセンスが可視化されやすくなり、設計の共有に繋がるという主張なのでした。

また、加えて、結果的にプログラムコードの記述の効率化や適度な疎結合も進むのではないか、というある意味当たり前かもしれないという気づきでした。

以上です。

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
    }
  ]
}

ひとまず終了

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

付録

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

Elasticsearchのアナライザーtypeのsearch、normal、extendedについてのなんとなくの話

はじめに

Elasticsearchの形態素解析(kuromoji)による分かち書き時のアナライザーのtype設定にsearch、normal、extendedというものがあります。

日本語環境で、このブログで記事を書く時に仮置きしている「レストラン検索サイト風サイト」などをイメージすると、私の中ではsearch一択ではと思っており&このブログでも勢いでそのように断言していたりしたのですが、ある方達のブログを見ていてオヤと思うことがあったのと、実のところ「雰囲気でsearchを使っていた」ので、改めて自分なりに咀嚼しなおしてみることにしました。

確認したElasticsearchのバージョンは6.4です。また、kuromojiは6.4.3です。

形態素解析関連の分かち書き+α

拙い説明よりもイメージ図ということで、search、normal、extendedによる分かち書きの例です。

f:id:azotar:20190504180822p:plain

なお、実際には辞書は使われないと思いますが、whitespaceとkeywordの例も入れておきます。

  • whitespace: スペースが入っている箇所を分かち書きの区切れとみなします。私が確認した範囲では全角スペースも区切りと認識されました。まあ日本語圏では使いがってがよくないかもしれません。
  • keyword: 分かち書きしない、つまりnoopですね。どちらかと言えば、当該フィールドは、あらかじめこの単位に扱って欲しいというワードのみ元ドキュメントに入っているというものを想定しているという方が正しいかもしれません。また、外面の動きで言うとmatch系のクエリでterm系クエリっぽいことができるということになるかもしれません。

アナライザーの設定例

この記事ではこの後に直接続くものはないのですが、今回のようにいくつかのアナライザーを比べながら試してみるという時に、とりまわしやすいかなという設定方法の例を示します。 *1

PUT aa
{
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "dtmpl": {
            "match_mapping_type": "*",
            "match": "*",
            "mapping": {
              "fielddata": true,
              "analyzer": "sch",
              "store": true,
              "fields": {
                "nrm": {
                  "type": "text",
                  "analyzer": "nrm"
                },
                "sch": {
                  "type": "text",
                  "analyzer": "sch"
                },
                "ext": {
                  "type": "text",
                  "analyzer": "ext"
                },
                "2ng": {
                  "type": "text",
                  "analyzer": "2ng"
                },
                "eng": {
                  "type": "text",
                  "analyzer": "eng"
                }
              }
            }
          }
        }
      ]
    }
  },
  "settings": {
    "analysis": {
      "analyzer": {
        "nrm": {
          "type": "custom",
          "tokenizer": "normal_t"
        },
        "sch": {
          "type": "custom",
          "tokenizer": "search_t"
        },
        "ext": {
          "type": "custom",
          "tokenizer": "extended_t"
        },
        "2ng": {
          "type": "custom",
          "tokenizer": "ngram"
        },
        "eng": {
          "type": "custom",
          "tokenizer": "e_ngram"
        },
        "noop":{
          "type":"custom",
          "tokenizer":"noop"
        },
        "ws":{
          "type":"custom",
          "tokenizer":"ws"
        }
      },
      "tokenizer": {
        "normal_t": {
          "type": "kuromoji_tokenizer",
          "mode": "normal"
        },
        "search_t": {
          "type": "kuromoji_tokenizer",
          "mode": "search"
        },
        "extended_t": {
          "type": "kuromoji_tokenizer",
          "mode": "extended"
        },
        "ngram": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3
        },
        "e_ngram": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 10
        },
        "noop":{
          "type":"keyword"
        },
        "ws":{
          "type":"whitespace"
        }
      }
    }
  }
}

search、normal、extendedおよびその他の分かち書きの擬人化

何か少し根拠になる例を示そうと思ったのですが、随分退屈になりそうな予感がしたので、少し飛躍していきなり結論めいたものを示すことにします。

ここでは、search、normal、extendedによる分かち書きを擬人化して示して見ました。

ただし、最近の擬人化は、お絵かきも含むニュアンスになるみたいですが、ここではそのようなスキルもないので、「人」に例えて見ましたぐらいの意味です。

f:id:azotar:20190504180848p:plain

ngramとedge_ngramも合わせて配置しています。

例え話になった時点で厳密ではない部分もあるのですが、比較的うまく例えられているように自分では感じています。

活躍しどころや編成を考える

例え話を「定理」「定説」風に使ってさらに別の例えというのは我ながらアレなのですが、先の擬人化キャラクターの見定めがボチボチあっているとして、これらのアナライザーの活躍しどころを見定めてみます。

原理原則的なもの

まずは相性が良い悪いで言うと、この組み合わせは悪いだろうというところを表現してみます。

話が前後しますが、Elasticsearchではデフォルトでは、検索時の検索語をあるフィールドにぶつける際のアナライズとインデックス時のドキュメントの当該フィールドのアナライズは同じものになるというのがデフォルトです。

ただし、検索時のアナライズとインデクス時のアナライズを別のものにすることもできます。この記事のモチベーションはこれらのアナライズを別にすることで、ちょっと面白い調整ができるのではというものだったのですが、正直そこまでは至っていません。

とにかく、検索時のアナライズとインデクス時のアナライズを別のものにするという探求以前に、あきらかに別の組み合わせにした場合に相性が悪いパターンがあります。

その組み合わせについて、なんとなく表現してみたのが次の図です。

注:ここでは、前提として、複数ワード時あるいは複数の単語を組み合わせたひとつの単語の場合の検索では、「AND検索」の戦略であるとします。 例えば、細かい当たり方はともかく、「関西国際空港」の場合、「関西の国際(的)な空港」を探しているという理解で述べています。*2

注:アナライズの話なので、全文検索系のクエリ、もっというとmatch系のクエリを念頭に置いています。ふと忘れがちですが、match系のクエリは、アナライズされたワードに対して「完全一致」相当でないと「ヒット」しません。

f:id:azotar:20190504180912p:plain

まあ、この図だけでは説明しきれていませんが、例えば、「関西国際空港」ドキュメントを、「関西国際空港」で検索したとして、検索時ngramで、インデクス時にsearchとく組み合わせの場合は、

ngram ['関西', '関西国', '西国', '西国際', '国際', '国際空', '際空', '際空港', '空港', '空港周', '港周', '港周辺', '周辺']

の全部について

search ['関西', '関西国際空港', '国際', '空港', '周辺']

の組み合わせが一致するかの検索になるので、

実際はヒットしないですね。 (なんかヒットしそうな気もしましたが、AND検索ですし。)

アカデミックな方からは説明の仕方がおかしいとか例えるまでもなく例えたことで逆にまぎらわしいくなっているではないか?みたいなご指摘をうけそうですが、まあ、ご容赦ください。

組み合わせではなく分業で相乗効果を出すパターンはあるか

前項は検索時・インデックス時のアナライザーの組み合わせの噛み合わなそうなパターンを消去法的に示しました。

本来なら、実は噛み合うかもしれない隠れたパターンの考察が続くべきなのですが、そこは一旦諦めて、それぞれのキャラクターを踏まえて、「分業」することでうまく機能する例を考えます。

... といっても、例えの例えにさらに組織論・チーム論の怪しい例えの重ねがけなので、納得してもらうというよりは、もし前提があっているなら確かにそうかもしれんという例になります。

で、その例がこちら↓

f:id:azotar:20190504180932p:plain

以下、上図を怪しい論法で解説をしてみます。

アプローチ1

searchさんタイプのチームはおおよそいい感じで検索してくれて大きな不満もないです。ただ、この事業部としてはもっと上を狙うために、searchさんチームが苦手な「新語」や雑なお願い・言い回しをしてくるクライアントの対策として、別行動のngramさんチームに、ある程度事務的に徹してもらって、もろもろすくい上げることで、検索漏れをカバーするという鉄壁の布陣になるのではという話です。 (まあ、形態素解析N-gramのハイブリッド検索で、前者のスコアリングを高めにするという話ですね。)

アプローチ2

アプローチ1は少しコストがかかります。事業の規模から言うと今は我慢の時です。といっても、極端にサービス品質は下げたくないです。また、アプローチ1の時に、なんでヒットしているかわからない検索結果が紛れ込んでいるというクレームがしばしば発生しており、このまぎれ込みは実はそれなりに正当な理由ではあったものの、いらぬ心配は無い方が良いのでなんらか対応した方が良いのかもしれません。 ということで、考えすぎずかといって雑になることもなくコンスタントに対応できるextendedさんのチームに運営をお願いしてみることにしましょうか、という話です。 レストラン検索サイトだと迷うのですが、実はビジネスユーザー向けの食材検索などある程度規格化されたワードが中心になりそうなサイトでの全文検索であれば、アプローチ1よりこのアプローチの方がはまるかも?と机上の理屈では思いますがさて。

アプローチ3

アプローチ2をもっとコストがシビアな場合に寄せたらのアプローチです。 また、term検索だと厳密すぎるので全文検索は入れたいぐらい...の検索要件や、「関西国際」で「関西国際空港」はヒットさせてほしくないんじゃというような場合に、アプローチ1や2よりもこのアプローチに寄せても良いかもしれないというものです。

edge_ngram

ここは他と組み合わせるというよりは専任ですかね。 メインの検索よりも、検索ワードのサジェスト・オートコンプリートの使い勝手がよさそうです。 match系でも、match_phraseやmatch_phrase_prefixをメインの検索で使う実質のあいまい検索要件に対応させて、edge_ngramは順序や「関西国際空港」のようにヒトの目で見ると明らかに1単語である...というような固有名詞などを意識してと言う用途からに向いている気がします。加えて、関西国際空港のような例では、1語増えると急激に検索で求められている内容が絞り込まれてくる場合もあります。(違うよと言われるかもしれませんが)データのドメインによっては、「関西国際空」で「関西国際空港」に一意にきまるので、これでヒットさせないという手はないというところかと思います。

ひとまずおしまい

例えに例えなので少ししまらない感じですが、この記事はひとまずここまで。

あとで、やや雑な、続きの記事を書きなぐる予定。

*1:aaというインデックス名はあまりに手抜きですね...

*2:ORは面白みがないですし。