Elasticsearchでコンテンツ検索のログを活用する

# まえがき

これは[Elasticsearch Advent Calendar](http://qiita.com/advent-calendar/2014/elasticsearch) 7日目の記事です。

今回はElasticsearchをコンテンツの検索として利用した際にElasticsearchで検索ログも収集・利用する方法についてです。

私はAdvent Calendar参加が今回初めてになります。
もし分かりにくいところがあったらツッコミお願いします。

# はじめに

Elasticsearchは主に「コンテンツ検索」と「ログ管理」の二通りの使われ方をしています。

「コンテンツ検索」は、記事やユーザのコメントなどの文章をESで検索する利用方法です。日本語の検索がメインになるので、正規化や形態素解析辞書、同義語辞書の管理などが課題になります。

「ログ管理」は、みんな大好きFluentdとKibanaなどを組み合わせてアクセスログを
グラフ化したり、エラーログを検索できるようにする活用方法です。

こちらは形態素解析すると期待通りにならない結果となるため、トークンフィルタは基本的にkeywordで一部whitespaceなどにすることが多いと思います。

ログ管理では、書き込み量が多いので書き込みの高速化であったり、集計をよく利用するため、検索時のヒープ量やCPU使用率などがよく課題になると思います。


今回は、「コンテンツ検索」でユーザが入力した検索ワードの「ログ管理」を行い、コンテンツ検索で必要な同義語辞書の管理をサポートする方法について書きます。

両方ともElasticsearchでできるので、一石二鳥ですｗ


# 検索部分を作る
まずはコンテンツ検索の機能がなくては始まりません。
今回は、Elasticsearchにはすでに調整されたスキーマにコンテンツが入っており、検索できる準備は整ってるものとします。

以下、コードは基本的にPythonです。{{string}}は適宜読み替えてください。

```py3
import json
import requests

# ElasticsearchのIP
ES_URL = "{{http://example.com:9200}}"

# ユーザからのクエリ
user_query = raw_input("input query: ")

# クエリをちょっと書き換え
query = {
    "simple_query_string" : {
        "query": user_query,
        "analyzer": "{{kuromoji_analyzer}}",
        "fields": ["{{title}}", "{{body}}"],
        "default_operator": "and"
    }
}

# ESにコンテンツ検索クエリを投げる
r = requests.post(ES_URL+'/{{index}}/{{type}}/_search', data=json.dumps(query))
res = r.json()

# ESの結果をユーザに返す
hits = map(lambda hit: hit['_source']['{{title}}'], res['hits']['hits'])
print(list(hits))  # ありがちなPython3対応...
```

コード実行はしていないですが、コンテンツ検索部分はこんな感じになると思います。

# ユーザの検索ログをESに入れる

上記だけでも検索の機能は利用できます。
ユーザの検索クエリは、外部のアクセス解析サービスなどを利用しても出来ますが、検索にかかった時間やヒット件数、検索語句同士の関連性などまで分析しようとすると難しくなってきます。

サーバサイドであれば、これらの値も簡単にログ収集できます。

```py3
# 大体上の続き。ウェブフレームワークなどでuser_ipなどが取れる前提
import pytz
from datetime import datetime
jst = pytz.timezone('Asia/Tokyo')

now = datetime.now(jst)
uid = {{request.get.uid}}
ua = {{request.header.user_agent}}

# 1日単位でユニーク検索数を計測する用
unique_key = uid+now.strftime('%Y%m%d')

q = {
    'took': res['took'],
    'total': res['hits']['total'],
    'user_ip': {{request.header.user_ip}},
    'user_agent': ua,
    'app_name': {{request.get.app_name}},
    'previous_user_query': {{session.pre_query}},
    'user_query': user_query,
    'user_query_length': len(user_query),
    'user_id': uid,
    'user_unique_key': unique_key,
    'query_json': json.dumps(query),
    '@timestamp':isoformat(now),
    # Pythonのdatetime.isoformatはmicrosecondsが0の場合省略されるなど、形式が変わってelasticsearchがパースに失敗することがあるので自前で用意する。
}
```


ログのスキーマ

```json
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "default": {"type" : "keyword"},
        }
      }
    }
  },
  "mappings": {
    "querylog": {
      "_all": {"enabled": false},

      "properties":{
        "took": {"type":"long"},
        "total": {"type":"long"},
        "previous_user_query": {"type":"string"},
        "user_query": {
          "type": "string",
          "analyzer": "whitespace",
          "fields": {
            "raw": {"type": "string", "index": "not_analyzed"}
          }
        },
        "user_query_length": {"type":"long"},
        "query_json": {"type":"string"},
        "@timestamp": {"type":"date", "format":"date_time"},
        "user_ip": {"type":"ip"},
        "user_serial_id": {"type":"string"},
        "user_unique_key": {"type":"string"},
        "user_agent": {"type":"string"},
        "app_name": {"type":"string", "analyzer": "whitespace", "null_value": "-"}
      }
    }
  }
}
```


# 集計する
Elasticsearchのアグリゲーションを利用すれば、1件もヒットしないキーワードや
検索にかかった時間の変化などを可視化できます。


以下の様なクエリが便利です。

## 検索時間
検索にかかった平均時間の変動を集計します。
Elasticsearchはパーセンタイルが利用できるので大変よいですね。

急に数字が増えていると何か問題が起こっている可能性があります。
定期的にチェックすると良いかと思います。


```json
{
    "size": 0,
    "query": {"filtered": {"filter": {"range": {"@timestamp": {"gt": "now-1w/d"}}}}},
    "aggs": {
        "by_date": {
            "date_histogram": {
                "field": "@timestamp",
                "pre_zone": "+09:00",
                "interval":"hour",
                "min_doc_count": 0,
                //"format": "yyyy/MM/dd"
            },
            "aggs": {"took_percentiles": {"percentiles": {"field": "took", "percents": [95, 99, 99.9]}}}
        }
    }
}
```


## ヒット数が0、少ない、多い
ユーザの期待した結果が得られていない可能性があるキーワードです。
短期間での集計はあまり意味が無いので、一ヶ月〜半年ほどの単位で集計します。

結果を参考にして、キーワードを同義語辞書に登録したり、変に形態素解析されていないか確認したりするのが良いでしょう。

```json
{
    "size": 0,
    "aggs": {
        // 1件もヒットがないキーワード（検索頻度順）
        "zero_hit": {
            "filter": {"range": {"total": {"lt": 1}}},
            "aggs": {
                "keyword": {
                    "terms": {
                        "field": "user_query.raw",
                        "size": 20,
                        "order": {"uniq": "desc"}
                    },
                    "aggs": {
                        "uniq": {"cardinality": {"field": "user_unique_key"}},
                        "avg_total": {"avg": {"field": "total"}}
                    }
                }
            }
        },
        // ヒット数が少ないキーワード（検索頻度順）
        "few_hit": {
            "filter":{ "range": {"total": {"lte": 3, "gte": 1}}},
            "aggs": {
                "keyword": {
                    "terms": {
                        "field": "user_query.raw",
                        "size": 20,
                        "order": {"uniq": "desc"}
                    },
                    "aggs": {
                        "uniq": {"cardinality": {"field": "user_unique_key"}},
                        "avg_total": {"avg": {"field": "total"}}
                    }
                }
            }
        },
        // ヒット数がやたら多かったキーワード（検索頻度順）
        "too_much_hit": {
            "filter":{ "range": {"total": {"gte": 10000}}},
            "aggs": {
                "keyword": {
                    "terms": {
                        "field": "user_query.raw",
                        "size": 20,
                        "order": {"avg_total": "desc"}
                    },
                    "aggs": {
                        "uniq": {"cardinality": {"field": "user_unique_key"}}
                        "avg_total": {"avg": {"field": "total"}}
                    }
                }
            }
        }
    }
}
```


## 検索に時間がかかったクエリ
スロークエリの一覧です。

ユーザがキーワードにワイルドカードを利用したり、キャッシュがうまく効いていなかったりすると遅くなりがちです。

```json
{
    "size": 0,
    "aggs": {
        // 検索に時間がかかったクエリ
        "slow_query_keyword": {
            "terms": {
                "field": "user_query.raw",
                "size": 20,
                "order": {
                    "avg_took": "desc"
                },
            },
            "aggs": {
                "avg_took": {"avg": {"field": "took"}}
            }
        }
    }
}
```


## 一日のランキング
前日の検索キーワードランキングです。

これを別のデータベースに毎日入れておくことで、その日の検索急上昇ワードを出すことも出来ます。Googleトレンドみたいなやつが自前で作れますね。

```json
{
    "size": 0,
    "query": {"filtered": {"filter": {"range": {"@timestamp": {"gt": "now-2d/d", "lte": "now-1d/d"}}}}},
    "aggs": {
        "keyword": {
            "terms": {
                "field": "user_query.raw",
                "size": 10000,
                "order": {"uniq": "desc"}
            },
            "aggs": {
                "uniq": {"cardinality": {"field": "user_unique_key"}}
            }
        }
    }
}
```

# 特定のキーワードで集計
その他、特定のキーワードでフィルタした上での集計も活用出来そうです。

## 次に検索されるキーワード
今の検索ワードの次に、どういうキーワードで検索されているか見ることできます。

検索のヒット数が少ないキーワードがどう検索されなおされているか見ることで、ユーザの期待している検索結果を想像するデータとして使えそうです。

```json
{
    "size": 0,
    "query": {"filtered": {"filter": {"and":[
        {"term" : { "previous_user_query" : keyword}},
        {"range": {"@timestamp": {"gte": "now-1y/M"}}}
    ]}}},
    "aggs": {
        "next_user_query": {"terms": {"field": "user_query.raw", "size": 100}},
    }
}
```

## 同時に検索される単語用
今の検索ワードと同時に使用されているキーワードです。

よく利用される組み合わせは、サジェストしてあげると利便性があがりそうです。

```json
{
    "size": 0,
    "query": {"filtered": {"filter": {"and": [
        {"term": {"user_query" : keyword}},
        {"range": {"@timestamp": {"gte": "now-1y/M"}}}
    ]}}},
    "aggs": {
        "together_user_query": {"terms": {"field": "user_query", "size": 101}},
    }
}
```

# まとめ
今回はElasticsearchをコンテンツの検索として利用した際にElasticsearchで検索ログも収集・利用する方法について書きました。

というか、Elasticsearchでのユーザー検索ログの活用クエリ一覧ですね。

どうすれば活用できるログが取れるか何度か試行した上で、私は今のところこういった内容を利用しています。

誰かの役に立つと幸いです。

次の記事は@yoshi0309さんです。