【Elasticsearch】使用 function score 在查詢時評分


https://unsplash.com/photos/_DyUcJalGAc

在查詢時,ElasticSearch 會對查詢到的資料進行評分。分數越高,代表與當下的查詢條件越相符。在 ES 能夠以評分分數進行排序的情況下,我們可達到類似商業需求中「推薦」或是「依相關度排序」的效果。

本文將解說如何自行設計分數的計算方式,包含透過欄位值、條件、權重與衰減函數。

一、function score 的概念與構成

我們能夠在包含 should(OR 邏輯)或 match(關鍵字查詢)的查詢,觀察到越相符的 document,其分數會越高的現象。但這些分數是由 ES 算出來的,我們無法掌握其多寡。透過「function score」的功能,我們可讓 document 本身的欄位值納入評分標準,進一步決定怎樣的 document 應該更高分。

分數的計算方式,會在 request body 中定義好,所以是即時計算。以下是一個使用 function score 的 request body 結構。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [],
            "score_mode": "sum",
            "boost_mode": "replace",
            "max_boost": 5
        }
    }
}

讀者可以看到,在第一層的 query 中包含了一個 function_score 物件,而在裡面又包含另一個 query。內層的 query 才是撰寫查詢條件的地方,在本文,筆者一律使用「match_all」(無條件),以取得所有 document,觀察它們的分數。

在 function_score 物件中還包含其他參數,下面將進行介紹。

(一)functions

我們會使用「函數」來計算分數,其定義會存放在 functions 陣列中。至於有哪些函數可用,後面小節會陸續介紹。筆者將函數計算的結果稱呼為「函數分數」,它會參與最後 document 分數的計算。

(二)score_mode

一個 document 可能被一至多個函數計算,得到多個函數分數。透過 score_mode 參數,能決定它們要如何運算,以產生最終的函數分數。可使用的參數值如下。

  • multiply:相乘(預設)
  • sum:相加
  • avg:平均
  • max:取最大值
  • min:取最小值
  • first:取第一個函數的分數

假設某 document 的函數分數有 1、2、5。則在選用 sum 時,最終函數分數為 8。以下是 request body 的示意內容。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [
                {
                    // 算出 1
                },
                {
                    // 算出 2
                },
                {
                    // 算出 5
                }
            ],
            "score_mode": "sum"
        }
    }
}

(三)boost_mode

ES 原先可能會給予查詢到的 document 一個分數,筆者稱之為「查詢分數」。在使用 function score 後,我們可透過 boost_mode 參數,決定最終函數分數要如何與查詢分數運算,以做為 document 的分數。可使用的參數值如下。

  • multiply:相乘(預設)
  • sum:相加
  • max:取最大值
  • min:取最小值
  • replace:直接將最終函數分數做為 document 分數

假設某 document 的查詢分數為 1.5,最終函數分數為 6。在選用 sum 時,document 分數為 7.5。

(四)max_boost

這個參數的目的在於限制最終函數分數的最大值,可用來避免某些 document 的分數太高,導致在按照分數排序後,經常「霸佔」最前面的位置。在商業需求上,能讓分數也蠻高的其他 document 有往前曝光的機會。

假設某 document 的查詢分數為 1.5,最終函數分數為 5,max_boost 為 4。在 boost_mode 選用 multiply 時,document 分數為 6。


二、範例資料介紹

由於筆者會利用 document 的各種欄位進行計分,因此將範例資料展示如下。

[
{
"id": "103",
"name": "Vincent Zheng",
"departments": ["資訊管理", "財務金融"],
"courses": [
{ "name": "計算機概論", "point": 3 },
{ "name": "程式設計", "point": 4 },
{ "name": "投資學", "point": 3 },
{ "name": "會計學", "point": 0 }
],
"grade": 2,
"conductScore": 86,
"job": {
"name": "班長",
"primary": true
},
"introduction": "I have a blog used to record what I learn in my career. All of them are about information technology and programming.",
"englishIssuedDate": "2021-01-01"
},
{
"id": "101",
"name": "Dora Pan",
"departments": ["財務金融"],
"courses": [
{ "name": "財金概論", "point": 3 },
{ "name": "保險學", "point": 3 },
{ "name": "投資學", "point": 3 }
],
"grade": 4,
"conductScore": 74,
"job": {
"name": "衛生股長",
"primary": null
},
"introduction": "Wealth ignores those who ignore it. So I apply knowledge about accounting in my life.",
"englishIssuedDate": "2021-04-01"
},
{
"id": "104",
"name": "Winnie Kuo",
"departments": ["企業管理"],
"courses": [
{ "name": "會計學", "point": 3 },
{ "name": "商業概論", "point": 1 }
],
"grade": 1,
"conductScore": 71,
"job": {
"name": "班長",
"primary": false
},
"introduction": "To lead a team in company in career, learn to lead students in university first.",
"englishIssuedDate": "2021-12-01"
},
{
"id": "102",
"name": "Mario Lu",
"departments": ["會計"],
"courses": [
{ "name": "會計學", "point": 5 },
{ "name": "審計學", "point": 3 },
{ "name": "企業資源規劃", "point": 3 }
],
"grade": 3,
"conductScore": 83,
"job": {
"name": "康樂股長"
},
"introduction": "Accounting work can be done by technology. So I start to learn programming on internet.",
"englishIssuedDate": "2022-05-01"
}
]
view raw students.json hosted with ❤ by GitHub

這些是描述學生資料的 document,以下為欄位說明。

欄位 說明 欄位 說明
id 編號 conductScore 操行成績
name 姓名 job 職務
departments 修習科系 job.name 職務名稱
courses 修習課程 job.isPrimary 是否主要(正副),若空值則代表主要
courses.name 課程名稱 introduction 自我介紹
courses.point 學分數 englishIssuedDate 英文檢定通過日
grade 年級 bloodType 血型
phoneNumbers 電話號碼 - -

其中 englishIssuedDate 雖然是字串,但 ES 能辨認出它是日期格式,因此會改用「date」類型來儲存。這部份能夠從 index 的「mapping」去確認。

Mapping 是一份資料,用來定義 ES 要如何儲存欄位值。透過如下的 API,我們可看到各欄位的 mapping。

GET /student/_mapping

得到的 response 之部份內容如下:

ElasticSearch 的 mapping

三、field_value_factor 函數

從這一節開始,讓我們認識 function score 的函數。本節要介紹的是 field_value_factor,它能夠將 document 的一個欄位值,傳入數學函數計算而得到分數。

以下範例是根據年級來計分,計算方式為 grade 欄位值 ×2。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [
                {
                    "field_value_factor": {
                        "field": "grade",
                        "modifier": "none",
                        "factor": 2,
                        "missing": 1
                    }
                }
            ],
            "score_mode": "sum",
            "boost_mode": "replace"
        }
    }
}

此例的分數結果為:Dora 8 分、Vincent 4 分、Mario 6 分、Winnie 2 分。

modifier 是傳入要使用的數學函數,可使用的值如下。

  • none:不另外使用數學函數,直接使用欄位值,此為預設值
  • square:平方
  • sqrt:正平方根
  • reciprocal:倒數
  • log:log₁₀(欄位值)
  • log1p:log₁₀(欄位值 + 1)
  • log2p:log₁₀(欄位值 + 2)
  • ln:ln(欄位值)
  • ln1p:ln(欄位值 + 1)
  • ln2p:ln(欄位值 + 2)

log 與 ln(即 logₑ)屬於數學上的對數函數,其數學定義筆者不多做解釋。不過它們的函數圖形較平緩,意即算出來的分數,變化不會太劇烈。下圖是 log 與 ln 的函數圖形。

對數(log)與自然對數(ln)的函數圖形

而 factor 是用來先將欄位值乘上某個指定的值,再傳入數學函數計算,可做為調節分數之用。最後是 missing,它是在欄位值不存在時,用來替代的預設值。


四、使用 weight 給予權重

被查詢到的 document 能夠從函數得到函數分數。而使用 weight 符號則可進一步給予這些函數「權重」,調整它們函數對 document 分數的影響力。又或者可以達到「符合條件才給予分數」的目的。

以下範例是學生每升一個年級得 0.5 分;就讀「資訊管理」系者得 3 分;有修習「會計學」課程者得 2 分。加總即為 document 分數。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [
                {
                    "field_value_factor": {
                        "field": "grade",
                        "factor": 1,
                        "modifier": "none"
                    },
                    "weight": 0.5
                },
                {
                    "filter": {
                        "term": {
                            "departments.keyword": "資訊管理"
                        }
                    },
                    "weight": 3
                },
                {
                    "filter": {
                        "term": {
                            "courses.name.keyword": "會計學"
                        }
                    },
                    "weight": 2
                }
            ],
            "score_mode": "sum",
            "boost_mode": "replace"
        }
    }
}

此例的分數結果為:Vincent 6 分、Mario 3.5 分、Winnie 2.5 分、Dora 2 分。

第一個 function 使用了上一節提到的 field_value_factor,但意義稍微不同。此處是在 field_value_factor 計算完畢後,再乘上 weight 的值,代表給予計算結果一個權重。

至於第二、三個 function 並未使用真正的數學函數,而是使用了「filter」符號,它是用來規定只有符合條件的 document 才能被計分。而所傳入的條件,與 ES 的查詢寫法相同。包括 term、range,甚至更複雜的 bool 都能使用。

若讀者想將 filter 與 field_value_factor 用在一起,也是可以的,如下:

{
    "filter": {
        "range": {
            "grade": {
                "lte": 4
            }
        }
    },
    "field_value_factor": {
        "field": "grade",
        "factor": 1,
        "modifier": "none"
    },
    "weight": 0.5
}

五、衰減函數(decay function)

(一)定義

衰減函數是一個特別的函數。它的特色之一是算出來的分數介於 0 到 1 之間。特色之二是當 document 的欄位值與我們指定的值越靠近,其分數就越高。下面以生活情境來舉幾個例子。

  • 根據日期:小明通過某技能檢定的日期若越接近現在,他就越適合擔任訓練課程的小老師。
  • 根據數值:商品價格若越接近使用者輸入的預算,則購物網站應該盡量推薦。即使價格超出預算一點點,可能也是很適合推薦的商品。
  • 根據地理位置:店家距離使用者輸入的地點越近,人們前往的方便性就越高,更適合推薦。

接下來將介紹函數圖形,讀者可能要配合數學上「變化率」或「切線斜率」的概念,才較容易理解。

(二)函數圖形與參數

ES 的衰減函數包含「linear」(線性)、「exp」(指數)與「guass」(高斯)三種。以下是函數圖形的範例,x 軸為欄位值,y 軸為分數,讀者可一邊參照。

Elastic Search 衰減函數圖形
https://www.elastic.co/guide/en/elasticsearch/guide/current/decay-functions.html

我們可透過四個參數對衰減函數做調整。

  • origin:x 的最佳值。當 document 的欄位值等於 origin,分數為 1。
  • offset:用來擴大最佳值的範圍,讓 x 落在 origin ± offset 時,分數均為 1。預設值為 0。
  • scale、decay:共同控制分數的變化率。當 x 遠離到 origin + offset + scale 或 origin - offset - scale 時,分數會下降至 decay。decay 預設值為 0.5。

在上面的範例圖形中,origin 為 40;offset 為 5;scale 為 5;decay 為 0.5。也就是說,x 落在 35 到 45 時,分數為 1。x 遠離到 30 或 50 時,分數為 0.5。

讓我們觀察這三種衰減函數的分數(y 值)變化率。

  • linear:圖形在不同的 x 範圍中都各自是直線。意即 y 變化率一致,沒有遞增或遞減的情況。
  • exp:當 x 從 origin ± offset 遠離,y 變化率是從劇烈到緩和。
  • gauss:當 x 從 origin ± offset 遠離,y 變化率從 0 逐漸變劇烈。直到 y 小於 decay 後,變化率又開始緩和。意即座標(origin + offset + scale, decay)與(origin - offset - scale, decay)是「反曲點」。

這邊跟讀者分享一個工具,它能夠繪製出衰減函數的圖形。但它是以日期做為單位,在觀察上不太方便就是了。
https://codepen.io/xyu/full/MyQYjN/

(三)使用數值欄位計分

接下來讓我們實際對範例資料進行計分。以下範例是將 gauss 衰減函數用於學生的操性成績欄位。在設計上,我們規定 95 到 100 分為最優。而 80 分者,document 分數為 0.5。更低分者,分數變化率開始緩和(開始不敏感)。講口語一點,就是當欄位值低到一個程度,那些 document 都一樣差的概念。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [
                {
                    "gauss": {
                        "conductScore": {
                            "origin": 100,
                            "offset": 5,
                            "scale": 15,
                            "decay": 0.5
                        }
                    }
                }
            ]
        }
    }
}

此例的分數結果為:


VincentMarioDoraWinnie
conductScore (x)86837471
_score (y)0.7790.6410.2570.169

照理來說,當 x < 80 後,y 的變化率就會緩和,但到了 Dora 的 74 時,y 卻已經掉到 0.257,感覺似乎下降很快!這是因為 y 在 x = 80 時的變化率是最劇烈的,在 x 從 80 遠離到 74 的期間,y 的變化率雖然在下降中,但依然很大。

(四)使用日期欄位計分

在範例資料中的 englishIssuedDate 欄位,值看似是字串,但實際上是以 date 類型儲存的。若讀者在其他 index 想以數值(毫秒數)來儲存日期,可使用以下 API,在新增 document 前先設定 mapping,使欄位以 date 類型儲存。

PUT /{indexName}/_mapping
{
    "properties": {
        "{fieldName}": {
            "type": "date"
        }
    }
}

以下範例是將 gauss 衰減函數用於學生的檢定通過日欄位。在設計上,我們規定距今 90 天內通過者為最優。而距今 360 天就通過者,document 分數為 0.5。更早之前就通過者,分數變化率開始緩和。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [
                {
                    "gauss": {
                        "englishIssuedDate": {
                            "origin": "now",
                            "offset": "90d",
                            "scale": "270d",
                            "decay": 0.5
                        }
                    }
                }
            ]
        }
    }
}

若讀者想要在 origin 參數給予時間的毫秒數也是可以的。假設筆者的現在日期是 2022-07-24,則此例的分數結果為:


MarioWinnieDoraVincent
englishIssuedDate2022-05-012021-12-012021-04-012021-01-01
_score10.8170.2360.112

為何筆者強調要在 mapping 將欄位的儲存類型設定為 date 呢?在上面的 request body 中,我們使用了「now」關鍵字代表現在,而「d」代表時間單位。這種做法只有在儲存類型為 date 時才能使用。以下是衰減函數支援的時間單位。

  • d:天
  • h:小時
  • m:分
  • s:秒
  • ms:毫秒
  • micros:微秒
  • nanos:奈秒

上一篇:【ElasticSearch 8】使用 DSL 撰寫查詢條件

下一篇:【ElasticSearch 8】導入到 Spring Boot 並實作測試 CRUD

延伸閱讀:【ElasticSearch 8】使用 Java API Client 實作 function score

留言