概要
Elasticsearchの検索結果の順序を調整する方法について書きました。紹介記事はいっぱいあったのですが、クエリがドン!と出てきて説明が書いてあると、どこがどう効いて並んでいるんだ。。。?というところが私には理解しづらかったので、一歩一歩条件を増やしていく感じでまとめたいと思います。この記事を書くに当たって、「Elasticsearchで簡単な検索とscoreを調整する方法1」という記事を参考にしています。
動機
最近、同僚がユーザ一覧について指摘してくれました。
上位が入力した情報の少ないユーザばかりになってます。
時間ないしとりあえず、更新時間の降順で並べておこう!としたまま放置していたわけですが、プロフィールなんてそんなに頻繁に更新しないでしょうし、別テーブルに保存されてソートに使っていた更新時間が変わらないものもあったりで、だいたい登録時間の降順と変わらない状態になってました。
これはいかん、ということで、より多くの情報を登録した人から順に並べるにはどうしたら良いかを調べました。また、Elasticsearchで検索結果をスコアリングして並べられるという話を目にしたので、Elasticsearchを使った方法を検討してます。
Elasticsearch環境構築
Dockerとdocker-composeを使って、elasticsearchとkibanaを起動します。kibanaは、Dev ToolsのConsoleってところでElasticsearchへのクエリが書けるのですが、それが便利なので入れました。
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とスキル情報を登録できるとして考えます。
# インデックス作成
PUT /users
# マッピング作成
PUT /users/_mapping
{
"properties": {
"pr": {
"type": "text"
},
"skills": {
"type": "nested",
"properties": {
"level": {
"type": "long"
},
"name": {
"type": "keyword"
}
}
}
}
}
ドキュメントを登録
3パターンのユーザを登録します。idが3 > 2 > 1の順で情報量が多いとします。
## 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
}
]
}
とりあえず検索
GET /users/_search
見辛いですが、当然情報量とは全く関係ない順序で取得されます。
{
"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がなければ掛け算も足し算も関係ないですが)。デフォルトは掛け算です。
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が存在した場合のスコアをあげます。
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部分に絞り込む条件を加えるだけです。
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_mode
やboost_mode
をデフォルトのまま使ったりしましたが、ここら辺の値を色々変更しつつ、順序を調整していけそうです。
あとはシステムに組み込むに当たって、この複雑なクエリをどうシンプルなUIから叩かせるかですが、なかなかしんどそうですね。。。
今後、全文検索も加えたり、どういう構成でインフラを構築したらいいか検討していきたいです。