Help us understand the problem. What is going on with this article?

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

概要

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

動機

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

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

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

Elasticsearch環境構築

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

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

ドキュメントを登録

3パターンのユーザを登録します。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

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

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登録した人を上位にする

 詳しくはこちらに書かれていますが、function_scoreというものを使うと、検索結果のスコアを調整し、スコア順に結果を返してくれるみたいです。
 スコアの調整はfunctionsに書くようです。ここでは「skills.nameが存在する事」という条件に対して、重みを2に設定しています。重みがスコアにどのように反映されるか(掛け算なのか、足し算なのか等)もパラメータ(score_mode)で指定できます(1つしかfilterがなければ掛け算も足し算も関係ないですが)。デフォルトは掛け算です。

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

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

結果
"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 ですね。。。。あ、足し算でも変わらんな。。。

結果
"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になっています。この数字に絞り込み前のスコア(4と2)をかけると、この結果のスコアになります。

結果
"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_modeboost_modeをデフォルトのまま使ったりしましたが、ここら辺の値を色々変更しつつ、順序を調整していけそうです。
 あとはシステムに組み込むに当たって、この複雑なクエリをどうシンプルなUIから叩かせるかですが、なかなかしんどそうですね。。。

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした