Taste of Tech Topics

Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

Elasticsearchのクエリ変更による影響度をオフライン評価する

概要

こんにちは、shin0higuchiです😊
この記事はElastic Stack (Elasticsearch) Advent Calendar 2019 - Qiita20日目です。

皆さんはElasticsearchの検索クエリのランキングをチューニングする際、どのようなプロセスで実施していますか?
変更したクエリを最初からABテストにかけるのではなく、オフライン評価をおこなうことが多いのではないでしょうか?
今回はElasticsearchクエリを変更した時の、検索結果(ランキング)への影響度をオフライン評価する方法についてです。

f:id:acro-engineer:20191220013401p:plain:w600

バージョン情報など

  • Elasticsearch : 7.5.0
  • Python:3.8.0

下準備

クエリ変更云々の前に、まずベースとなるデータを用意します。
利用するのはwikipediaの日本語データです。

indexのmappingはシンプルに次のようにしました。
Wikipedia記事の内容はkuromoji analyerを適用したtextフィールドにindexします。
また、textのサブフィールドとして、bigramでtokenizeするtext.ngramフィールドも作成します。

{
  "settings": {
    "analysis": {
      "analyzer": {
        "bi-gram":{
          "tokenizer":"bi-gram"  
        }
      },
      "tokenizer": {
        "bi-gram":{
          "type":"ngram",
          "min_gram":2,
          "max_gram":2
        }
      }
    }
  },
  "mappings": {
    "properties": { 
    "text":{
      "type":"text",
      "analyzer":"kuromoji",
      "fields": {
        "ngram":{
          "type":"text",
          "analyzer":"bi-gram"
        }
      }
    }
    }
  }
}

また、今回はクエリ変更による影響度がテーマなので、変更前のクエリを事前に用意しておきます。

{
  "size":20,
  "query": {
     "match": {
        "text": "日本の電車"
     }
  }
}

影響度の測定

今回は影響度の測定のために、Ranking Evaluation APIを利用します。
Ranking Evaluation APIは、検索クエリのランキング結果に基づき、品質を測るためのものです。
概要については、昨年のアドベントカレンダーで記事にしていますのでご覧ください。
acro-engineer.hatenablog.com

本来、Ranking Evaluation APIは、正解となるランキングデータを事前に用意し、それに基づいてクエリを評価します。
本記事では、便宜上「変更前クエリの検索結果」を正解データと位置付け、クエリの変更がどの程度影響を及ぼすかを確認していきます。

ここからは、ElasticsearchへのリクエストはPythonを用いる想定で記述します。
※ 個人的にはKibanaのConsoleも好きですが、レスポンスを次のクエリに渡して実行する場合は辛いものがあります。

さて、変更前のクエリを実行してみます。
ここではIDの並び順のみ取得します。

# クエリを発行し、レスポンスから検索結果のIDリストのみを取得する(bodyは上述の変更前クエリ)
res = requests.post(ES_HOST + '/wikidata/_search', data=json.dumps(body), headers=headers).json()
org_result_set = [k['_id'] for k in res['hits']['hits']]
org_result_set

レスポンスは次の通り(size=20に設定しています)

['GMVlHm8BSg9IjoHR5Dek',
 '3tRtHm8BSg9IjoHROBB0',
 'SMpoHm8BSg9IjoHRXBsO',
 '9shnHm8BSg9IjoHRj4XA',
 '_MRlHm8BSg9IjoHRVgLP',
 'z8ZmHm8BSg9IjoHRrsBq',
 'BsVlHm8BSg9IjoHR-2i-',
 'GtBrHm8BSg9IjoHRhpPG',
 'D8hnHm8BSg9IjoHRuc3Z',
 'O9VtHm8BSg9IjoHRwSNt',
 'l8hnHm8BSg9IjoHRuctX',
 'HdBrHm8BSg9IjoHRe30U',
 '7tBrHm8BSg9IjoHRjpin',
 '4cpoHm8BSg9IjoHRpsTn',
 'esVmHm8BSg9IjoHRO8xl',
 '0MZmHm8BSg9IjoHRw_PP',
 'e9VtHm8BSg9IjoHRzTZ2',
 'JsdnHm8BSg9IjoHRAFPl',
 'HcdnHm8BSg9IjoHRAFGB',
 'kddvHm8BSg9IjoHRKJCY']


この結果を使って、Ranking Evaluationのリクエストを作成します。
まずはqueryを変更せずに実行してみます。

body = {
  "requests": [
    {
      "id": "1",
      "request": {
        "query": {
          "match": {
            "text": "日本の電車"
          }
        }
      },
      "ratings": []
    }
  ],
  "metric": {
    "dcg": {
      "k" : 20,
      "normalize": True
    }
  }
}

# 変更前クエリのレスポンスを元に、rating(正解データ)のリストを作成
for id in org_result_set:
  body['requests'][0]['ratings'].append({
      '_index':'wikidata',
      '_id':id,
      'rating':1
  })

requests.post(ES_HOST + '/wikidata/_rank_eval', data=json.dumps(body), headers=headers).json()

今回は比較のためのmetricとして、nDCGを利用することにします。
検索結果のランキングに全く差分がなければ、nDCGの値は1になります。
逆に言えば、 元の結果から大きく外れるほど0に近づくことになります。

それでは、クエリを変更してみましょう。
textフィールドへの検索をおこなうだけでなく、text.ngramも検索対象に入れてみます。

body = {
  "requests": [
    {
      "id": "1",
      "request": {
        "query": {
          "bool": {
            "should": [
              {
                "match": {
                  "text": "日本の電車"
                }
              },
              {
                "match": {
                  "text.ngram": "日本の電車"
                }
              }
            ]
          }
        }
      },
      "ratings": []
    }
  ],
  "metric": {
    "dcg": {
      "k" : 20,
      "normalize": True
    }
  }
}

# 変更前クエリのレスポンスを元に、rating(正解データ)のリストを作成
for id in org_result_set:
  body['requests'][0]['ratings'].append({
      '_index':'wikidata',
      '_id':id,
      'rating':1
  })

requests.post(ES_HOST + '/wikidata/_rank_eval', data=json.dumps(body), headers=headers).json()

レスポンス

{'metric_score': 0.1856246546622184,
 'details': {'1': {'metric_score': 0.1856246546622184,
   'unrated_docs': [{'_index': 'wikidata', '_id': 'n8tpHm8BSg9IjoHRJMSw'},
    {'_index': 'wikidata', '_id': '7dNtHm8BSg9IjoHRB67v'},
    {'_index': 'wikidata', '_id': 'ttNtHm8BSg9IjoHRB62I'},
    {'_index': 'wikidata', '_id': 'otJsHm8BSg9IjoHRUjhV'},
    {'_index': 'wikidata', '_id': 'kdNsHm8BSg9IjoHR-pG5'},
    {'_index': 'wikidata', '_id': '-s5qHm8BSg9IjoHRpc46'},
    {'_index': 'wikidata', '_id': 'yM5qHm8BSg9IjoHRrNzE'},
    {'_index': 'wikidata', '_id': 'KcpoHm8BSg9IjoHRSQgb'},
    {'_index': 'wikidata', '_id': 'pNZuHm8BSg9IjoHRlo2o'},
    {'_index': 'wikidata', '_id': 's9ZuHm8BSg9IjoHRl46t'},
    {'_index': 'wikidata', '_id': 'QMloHm8BSg9IjoHRFpFY'},
    {'_index': 'wikidata', '_id': '1s1pHm8BSg9IjoHRyyBV'},
    {'_index': 'wikidata', '_id': 'eM1qHm8BSg9IjoHRAIyh'},
    {'_index': 'wikidata', '_id': '5stpHm8BSg9IjoHRHraG'},
    {'_index': 'wikidata', '_id': 'FdBrHm8BSg9IjoHRhpPG'},
    {'_index': 'wikidata', '_id': 'adFrHm8BSg9IjoHR6VuY'},
    {'_index': 'wikidata', '_id': '9dFsHm8BSg9IjoHRAZiH'}],
   'hits': [{'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'n8tpHm8BSg9IjoHRJMSw',
      '_score': 29.578623},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'GtBrHm8BSg9IjoHRhpPG',
      '_score': 29.015862},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '7dNtHm8BSg9IjoHRB67v',
      '_score': 28.828218},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'ttNtHm8BSg9IjoHRB62I',
      '_score': 28.828218},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '7tBrHm8BSg9IjoHRjpin',
      '_score': 28.740217},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'otJsHm8BSg9IjoHRUjhV',
      '_score': 28.483252},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'kdNsHm8BSg9IjoHR-pG5',
      '_score': 28.478014},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '-s5qHm8BSg9IjoHRpc46',
      '_score': 28.416624},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'yM5qHm8BSg9IjoHRrNzE',
      '_score': 28.312298},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '9shnHm8BSg9IjoHRj4XA',
      '_score': 28.297285},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'KcpoHm8BSg9IjoHRSQgb',
      '_score': 28.2964},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'pNZuHm8BSg9IjoHRlo2o',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 's9ZuHm8BSg9IjoHRl46t',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'QMloHm8BSg9IjoHRFpFY',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '1s1pHm8BSg9IjoHRyyBV',
      '_score': 28.157154},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'eM1qHm8BSg9IjoHRAIyh',
      '_score': 28.04702},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '5stpHm8BSg9IjoHRHraG',
      '_score': 27.978518},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'FdBrHm8BSg9IjoHRhpPG',
      '_score': 27.97674},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'adFrHm8BSg9IjoHR6VuY',
      '_score': 27.943295},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '9dFsHm8BSg9IjoHRAZiH',
      '_score': 27.888338},
     'rating': None}],
   'metric_details': {'dcg': {'dcg': 1.3068473871238868,
     'ideal_dcg': 7.040268381923511,
     'normalized_dcg': 0.1856246546622184,
     'unrated_docs': 17}}}},
 'failures': {}}

nDCGの値が「0.1856246546622184」と、変更前の検索結果とかなりの差分があることがわかります。
nDCGの値が低くなったのは、検索ワード固有のものかもしれないので、リクエストを複数の検索ワードで発行し、平均を取るなどすると良いでしょう。

これによって、クエリ変更した際、検索結果にどの程度の影響が出るかを大まかに知ることができます。
「影響度がある程度大きい」かつ「オフラインで検索結果の改善が見られた」クエリを選んでABテストにかけるという判断基準のひとつとして有用かと思います。

まとめ

  • Ranking Evaluation APIを用いることで、クエリ変更時の影響度を予測することができた
  • 影響度を予測することで、ABテストにかけるかどうかの判断材料のひとつとして利用することができる


明日は @yukata_uno さんによる記事になります。お楽しみに!



Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

ユーザに最高の検索体験を提供したいエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com