Elasticsearchを使った検索順序の調整方法を一歩一歩操作して理解する

# 概要
　Elasticsearchの検索結果の順序を調整する方法について書きました。紹介記事はいっぱいあったのですが、クエリがドン！と出てきて説明が書いてあると、どこがどう効いて並んでいるんだ。。。？というところが私には理解しづらかったので、一歩一歩条件を増やしていく感じでまとめたいと思います。この記事を書くに当たって、「Elasticsearchで簡単な検索とscoreを調整する方法[^1]」という記事を参考にしています。

# 動機
　最近、同僚がユーザ一覧について指摘してくれました。

　**上位が入力した情報の少ないユーザばかりになってます。**

　時間ないしとりあえず、更新時間の降順で並べておこう！としたまま放置していたわけですが、プロフィールなんてそんなに頻繁に更新しないでしょうし、別テーブルに保存されてソートに使っていた更新時間が変わらないものもあったりで、だいたい登録時間の降順と変わらない状態になってました。
　これはいかん、ということで、より多くの情報を登録した人から順に並べるにはどうしたら良いかを調べました。また、Elasticsearchで検索結果をスコアリングして並べられるという話を目にしたので、Elasticsearchを使った方法を検討してます。

# Elasticsearch環境構築

Dockerとdocker-composeを使って、elasticsearchとkibanaを起動します。kibanaは、Dev ToolsのConsoleってところでElasticsearchへのクエリが書けるのですが、それが便利なので入れました。

```yaml:docker-compose.yml
version: '3'
services:
  elasticsearch:
    image: elasticsearch:7.4.2
    ports:
      - 9200:9200
      - 9300:9300
    environment:
      discovery.type: single-node
  kibana:
    image: kibana:7.4.2
    ports:
      - 5601:5601
```

# とりあえずIndexとMappingを作る
今回は、ユーザがPRとスキル情報を登録できるとして考えます。

```:kibanaのコンソールに入力
# インデックス作成
PUT /users

# マッピング作成
PUT /users/_mapping
{
  "properties": {
    "pr": {
      "type": "text"
    },
    "skills": {
      "type": "nested",
      "properties": {
        "level": {
          "type": "long"
        },
        "name": {
          "type": "keyword"
        }
      }
    }
  }
}
```

# ドキュメントを登録
３パターンのユーザを登録します。idが3 > 2 > 1の順で情報量が多いとします。

```:kibanaのコンソールに入力
## prもskillも登録していない人
PUT /users/_create/1
{
  
}

## skillだけ登録した人
PUT /users/_create/2
{
  "skills": [
    {
      "name": "Rails",
      "level": 3
    },
    {  
      "name": "PHP",
      "level": 3
    },
    {
      "name": "Vue",
      "level": 3
    }
  ]  
}

## prもskillも登録した人
PUT /users/_create/3
{
  "pr": "aaa",
  "skills": [
    {
      "name": "Rails",
      "level": 1
    },
    {  
      "name": "PHP",
      "level": 1
    },
    {
      "name": "Vue",
      "level": 1
    }
  ]
}
```

# とりあえず検索

```:kibanaのコンソールに入力
GET /users/_search
```

見辛いですが、当然情報量とは全く関係ない順序で取得されます。

```json:response
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "users",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : { }
      },
      {
        "_index" : "users",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1.0,
        "_source" : {
          "skills" : [
            {
              "name" : "Rails",
              "level" : 3
            },
            {
              "name" : "PHP",
              "level" : 3
            },
            {
              "name" : "Vue",
              "level" : 3
            }
          ]
        }
      },
      {
        "_index" : "users",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "_source" : {
          "pr" : "aaa",
          "skills" : [
            {
              "name" : "Rails",
              "level" : 1
            },
            {
              "name" : "PHP",
              "level" : 1
            },
            {
              "name" : "Vue",
              "level" : 1
            }
          ]
        }
      }
    ]
  }
}
```

# skill登録した人を上位にする
　詳しくは[こちら](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html)に書かれていますが、function_scoreというものを使うと、検索結果のスコアを調整し、スコア順に結果を返してくれるみたいです。
　スコアの調整はfunctionsに書くようです。ここでは「`skills.name`が存在する事」という条件に対して、重みを2に設定しています。重みがスコアにどのように反映されるか（掛け算なのか、足し算なのか等）もパラメータ(`score_mode`)で指定できます（１つしかfilterがなければ掛け算も足し算も関係ないですが）。デフォルトは掛け算です。

```:kibanaのコンソールに入力
GET /users/_search
{
  "query": {
    "function_score": {
      "query": { "match_all": {} },
      "functions": [
        {
          "filter": {
            "nested": {
              "path": "skills",
              "query": {
                "exists": {
                  "field": "skills.name"
                }
              }
            }
          },
          "weight": 2
        }
      ]
    }
  }
}
```

　こちらが結果ですが、スキルが登録されたデータのスコアが2になり、上位に来ています。

```json:結果
"hits" : [
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "2",
    "_score" : 2.0,
    "_source" : {
      "skills" : [
        {
          "name" : "Rails",
          "level" : 3
        },
        {
          "name" : "PHP",
          "level" : 3
        },
        {
          "name" : "Vue",
          "level" : 3
        }
      ]
    }
  },
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "3",
    "_score" : 2.0,
    "_source" : {
      "pr" : "aaa",
      "skills" : [
        {
          "name" : "Rails",
          "level" : 1
        },
        {
          "name" : "PHP",
          "level" : 1
        },
        {
          "name" : "Vue",
          "level" : 1
        }
      ]
    }
  },
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "1",
    "_score" : 1.0,
    "_source" : { }
  }
]
```

# PRも登録した人はさらに上位に
　さらに条件を追加します。prが存在するか、というfilters加えることで、prが存在した場合のスコアをあげます。

```:kibanaのコンソールに入力
GET /users/_search
{
  "query": {
    "function_score": {
      "query": { "match_all": {} },
      "functions": [
        {
          "filter": {
            "nested": {
              "path": "skills",
              "query": {
                "exists": {
                  "field": "skills.name"
                }
              }
            }
          },
          "weight": 2
        },
        {
          "filter": {
            "exists": {
              "field": "pr"
            }
          },
          "weight": 2
        }
      ]
    }
  }
}
```

　PRを入力したユーザのスコアが4になり、トップに来ました。`score_mode`は掛け算なので、2 $\times$ 2 で 4 ですね。。。。あ、足し算でも変わらんな。。。 

```json:結果
"hits" : [
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "3",
    "_score" : 4.0,
    "_source" : {
      "pr" : "aaa",
      "skills" : [
        {
          "name" : "Rails",
          "level" : 1
        },
        {
          "name" : "PHP",
          "level" : 1
        },
        {
          "name" : "Vue",
          "level" : 1
        }
      ]
    }
  },
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "2",
    "_score" : 2.0,
    "_source" : {
      "skills" : [
        {
          "name" : "Rails",
          "level" : 3
        },
        {
          "name" : "PHP",
          "level" : 3
        },
        {
          "name" : "Vue",
          "level" : 3
        }
      ]
    }
  },
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "1",
    "_score" : 1.0,
    "_source" : { }
  }
]
```

# スキルの登録数が多い人を上位に
したかったのですが、これは方法がわかりませんでした。

# スキルで絞り込みつつ、PRを入力している人を上位に
　スコアを調整しつつ、絞り込みもできます。今までmatch_allとしていたquery部分に絞り込む条件を加えるだけです。

```:kibanaのコンソールに入力
GET /users/_search
{
  "query": {
    "function_score": {
      "query": {
        "nested": {
          "path": "skills",
          "query": {
            "match": {
              "skills.name": "Rails"
            }
          }
        }
      },
      "functions": [
        {
          "filter": {
            "nested": {
              "path": "skills",
              "query": {
                "exists": {
                  "field": "skills.name"
                }
              }
            }
          },
          "weight": 2
        },
        {
          "filter": {
            "exists": {
              "field": "pr"
            }
          },
          "weight": 2
        }
      ]
    }
  }
}
```

　ここでスコアがなんだかよくわからん数字になりました。実はスコアは、`queryで算出されたスコア`と`filtersで算出されたスコア` の組み合わせて計算されています。これは`boost_mode`で指定されていて、デフォルトは掛け算です。上記の問い合わせを、`query`部分だけで計算するとわかりますが、スコアが`1.0296195`になっています。この数字に絞り込み前のスコア（４と2）をかけると、この結果のスコアになります。

```json:結果
"hits" : [
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "3",
    "_score" : 4.118478,
    "_source" : {
      "pr" : "aaa",
      "skills" : [
        {
          "name" : "Rails",
          "level" : 1
        },
        {
          "name" : "PHP",
          "level" : 1
        },
        {
          "name" : "Vue",
          "level" : 1
        }
      ]
    }
  },
  {
    "_index" : "users",
    "_type" : "_doc",
    "_id" : "2",
    "_score" : 2.059239,
    "_source" : {
      "skills" : [
        {
          "name" : "Rails",
          "level" : 3
        },
        {
          "name" : "PHP",
          "level" : 3
        },
        {
          "name" : "Vue",
          "level" : 3
        }
      ]
    }
  }
]
```

# まとめ
　ここまでで、今まで通りフィルターしつつも、検索順位を調整することができました。紹介したものの他にも、フィールドの値によってスコアを変えるといったこともできるようです。
　上記説明の中では、特に理由もなくweightを2に設定したり、`score_mode`や`boost_mode`をデフォルトのまま使ったりしましたが、ここら辺の値を色々変更しつつ、順序を調整していけそうです。
　あとはシステムに組み込むに当たって、この複雑なクエリをどうシンプルなUIから叩かせるかですが、なかなかしんどそうですね。。。

　今後、全文検索も加えたり、どういう構成でインフラを構築したらいいか検討していきたいです。

[^1]: [Elasticsearchで簡単な検索とscoreを調整する方法](https://techblog.recochoku.jp/5810)