我們將介紹使用function_score的基礎知識,並介紹一些function core技術非常有用和有效的用例。
介紹
評分的概念是任何搜索引擎(包括Elasticsearch)的核心。評分可以粗略地定義為:找到符合一組標准的數據並按相關性順序將其返回。相關性通常是通過類似TF-IDF的算法來實現的,該算法試圖找出文本上與提交的查詢最相似的文檔。盡管TF-IDF及其表親(例如BM25)非常棒,但有時必須通過其他算法或通過其他評分啟發式方法來解決相關性問題。在這里,Elasticsearch的script_score和function_score功能變得非常有用。本文將介紹這些工具的用法。
文本相似性不是最重要因素的一個域示例是地理搜索。如果正在尋找在給定點附近的好咖啡店,則按與查詢在文本上的相似程度對咖啡店進行排名對用戶而言不是很有用,但按地理位置在附近的排名對他們。
另一個示例可能是視頻共享站點上的視頻,其中搜索結果可能應該考慮視頻的相對受歡迎程度。如果某個流行歌星上傳了具有給定標題的視頻,從而獲得了數百萬的觀看次數,那么該視頻可能應該比具有相似文字相關性的不受歡迎的視頻更勝一籌。
在使用Elasticsearch進行全文搜索的時候,默認是使用BM25計算的_score字段進行降序排序的。當我們需要用其他字段進行降序或者升序排序的時候,可以使用sort字段,傳入我們想要的排序字段和方式。 當簡單的使用幾個字段升降序排列組合無法滿足我們的需求的時候,我們就需要自定義排序的特性,Elasticsearch提供了function_score的DSL來自定義打分,這樣就可以根據自定義的_score來進行排序。
在實際的使用中,我們必須注意的是:soft_score和function_score是耗資源的。您只需要計算一組經過過濾的文檔的分數。
下面我們來用一個例子來具體說明如何來通過script_core和function_core來定制我們的分數。
准備數據
我們首先來下載我們的測試數據:
git clone https://github.com/liu-xiao-guo/best_games_json_data
然后我們通過Kibana把這個數據來導入到我們的Elasticsearch中:
在導入的過程中,我們選擇Time field為year,並且指定相應的日期格式:
我們指定我們的索引名字為best_games:
我們可以查看一下一個樣本的文檔就像是下面的格式一樣:
"_source" : {
"global_sales" : 82.53,
"year" : 2006,
"image_url" : "https://upload.wikimedia.org/wikipedia/en/thumb/e/e0/Wii_Sports_Europe.jpg/220px-Wii_Sports_Europe.jpg",
"platform" : "Wii",
"@timestamp" : "2006-01-01T00:00:00.000+08:00",
"user_score" : 8,
"critic_score" : 76,
"name" : "Wii Sports",
"genre" : "Sports",
"publisher" : "Nintendo",
"developer" : "Nintendo",
"id" : "wii-sports-wii-2006"
}
在上面我們可以看出來這個文檔里有兩個很重要的字段:critic_score及user_score。一個是表示這個游戲的難度,另外一個表示游戲的受歡迎的程度。
正常查詢
首先我們來看看如果不使用任何的分數定制,那么情況是怎么樣的。
GET best_games/_search
{
"_source": [
"name",
"critic_score",
"user_score"
],
"query": {
"match": {
"name": "Final Fantasy"
}
}
}
在上面的查詢中,為了說明問題的方便,在返回的結果中,我們只返回name, critic_score和user_score字段。我們在name字段里含有“Final Fantasy”的所有游戲,那么顯示顯示的結果是:
"hits" : [
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "2qccJ28BCSSrjaXdSOnC",
"_score" : 8.138414,
"_source" : {
"user_score" : 9,
"critic_score" : 92,
"name" : "Final Fantasy VII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6KccJ28BCSSrjaXdSOnC",
"_score" : 8.138414,
"_source" : {
"user_score" : 8,
"critic_score" : 92,
"name" : "Final Fantasy X"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6qccJ28BCSSrjaXdSOnC",
"_score" : 8.138414,
"_source" : {
"user_score" : 8,
"critic_score" : 90,
"name" : "Final Fantasy VIII"
}
},
...
從上面的結果中,我們可以看出來Final Fantasy VII是最匹配的結果。它的分數是最高的。
Soft_score 查詢
加入我們我們是游戲的運營商,那么我們也許我們自己想要的排名的方法。比如,雖然所有的結果都很匹配,但是我們也許不只單單是匹配Final Fantasy,而且我們想把user_score和critic_score加進來(雖然你可以使用其中的一個)。我們想這樣來算我們的分數。
最終score = score*(user_score*10 + critic_score)/2/100
也就是我們把user_score乘以10,從而變成100分制。它和critic_score加起來,然后除以2,並除以100,這樣就得出來最后的分數的加權系數。這個加權系數再乘以先前在上一步得出來的分數才是最終的分數值。經過這樣的改造后,我們發現我們的分數其實不光是全文搜索的相關性,同時它也緊緊地關聯了我們的用戶體驗和游戲的難道系數。
那么我們如何使用這個呢?
參照Elastics的官方文檔soft_score,我們現在做如下的搜索:
GET best_games/_search
{
"_source": [
"name",
"critic_score",
"user_score"
],
"query": {
"script_score": {
"query": {
"match": {
"name": "Final Fantasy"
}
},
"script": {
"source": "_score * (doc['user_score'].value*10+doc['critic_score'].value)/2/100"
}
}
}
}
在上面的查詢中,我們可以看到我們使用了新的公式:
"script": {
"source": "_score * (doc['user_score'].value*10+doc['critic_score'].value)/2/100"
}
那么我查詢后的結果為:
"hits" : [
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "2qccJ28BCSSrjaXdSOnC",
"_score" : 7.405957,
"_source" : {
"user_score" : 9,
"critic_score" : 92,
"name" : "Final Fantasy VII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "K6ccJ28BCSSrjaXdSOrC",
"_score" : 7.0804205,
"_source" : {
"user_score" : 8,
"critic_score" : 94,
"name" : "Final Fantasy IX"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6KccJ28BCSSrjaXdSOnC",
"_score" : 6.9990363,
"_source" : {
"user_score" : 8,
"critic_score" : 92,
"name" : "Final Fantasy X"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6qccJ28BCSSrjaXdSOnC",
"_score" : 6.917652,
"_source" : {
"user_score" : 8,
"critic_score" : 90,
"name" : "Final Fantasy VIII"
}
},
...
我們從上面可以看出來最終的分數_score是完全不一樣的值。我們同時也看出來盡管第一名的Final Fantasy VII沒有發生變化,但是第二名的位置由Final Fantasy X變為Final Fantasy IX了。
針對script的運算,有一些預定義好的函數可以供我們調用,它們可以幫我們加速我們的計算。
- Saturation
- Sigmoid
- Random score function
- Decay functions for numeric fields
- Decay functions for geo fields
- Decay functions for date fields
- Functions for vector fields
我們可以參考Elastic的官方文檔來幫我們更深入地了解。
Function score 查詢
function_score允許您修改查詢檢索的文檔分數。 例如,如果分數函數在計算上很昂貴,並且足以在過濾后的文檔集上計算分數,則此功能很有用。
要使用function_score,用戶必須定義一個查詢和一個或多個函數,這些函數為查詢返回的每個文檔計算一個新分數。
function_score可以只與一個函數一起使用,比如:
GET /_search
{
"query": {
"function_score": {
"query": {
"match_all": {}
},
"boost": "5",
"random_score": {},
"boost_mode": "multiply"
}
}
}
這里它把所有的文檔的分數由5和一個由random_score (返回0到1之間的值)相乘而得到。那么這個分數就是一個從0到5之間的一個數值:
"hits" : [
{
"_index" : "chicago_employees",
"_type" : "_doc",
"_id" : "Hrz0_W4BDM8YqwyDD06A",
"_score" : 4.9999876,
"_source" : {
"Name" : "ADKINS, WILLIAM J",
"Job Titles" : "SUPERVISING FIRE COMMUNICATIONS OPERATOR",
"Department" : "OEMC",
"Full or Part-Time" : "F",
"Salary or Hourly" : "Salary",
"Annual Salary" : 121472.04
}
},
{
"_index" : "kibana_sample_data_logs",
"_type" : "_doc",
"_id" : "eXNIHm8BjrINWI3xYF0J",
"_score" : 4.9999495,
"_source" : {
"agent" : "Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24",
"bytes" : 6630,
"clientip" : "77.5.51.49",
"extension" : "",
"geo" : {
"srcdest" : "CN:ID",
...
盡管這個分數沒有多大實際的意思,但是它可以讓我們每次進入一個網頁看到不同的文檔,而不是嚴格按照固定的匹配而得到的固定的結果。
我們也可以配合soft_score一起來使用function_score:
GET best_games/_search
{
"_source": [
"name",
"critic_score",
"user_score"
],
"query": {
"function_score": {
"query": {
"match": {
"name": "Final Fantasy"
}
},
"script_score": {
"script": "_score * (doc['user_score'].value*10+doc['critic_score'].value)/2/100"
}
}
}
}
那么顯示的結果是:
"hits" : [
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "2qccJ28BCSSrjaXdSOnC",
"_score" : 60.272747,
"_source" : {
"user_score" : 9,
"critic_score" : 92,
"name" : "Final Fantasy VII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "K6ccJ28BCSSrjaXdSOrC",
"_score" : 57.623398,
"_source" : {
"user_score" : 8,
"critic_score" : 94,
"name" : "Final Fantasy IX"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6KccJ28BCSSrjaXdSOnC",
"_score" : 56.96106,
"_source" : {
"user_score" : 8,
"critic_score" : 92,
"name" : "Final Fantasy X"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6qccJ28BCSSrjaXdSOnC",
"_score" : 56.29872,
"_source" : {
"user_score" : 8,
"critic_score" : 90,
"name" : "Final Fantasy VIII"
}
},
...
細心的讀者可能看出來了。我們的分數和之前的那個soft_score結果是不一樣的,但是我們搜索的結果的排序是一樣的。
在上面的script的寫法中,我們使用了硬編碼,也就是把10硬寫入到script中了。假如有一種情況,我將來想修改這個值為20或其它的值,重新看看查詢的結果。由於script的改變,需要重新進行編譯,這樣的效率並不高。一種較好的辦法是如下的寫法:
GET best_games/_search
{
"_source": [
"name",
"critic_score",
"user_score"
],
"query": {
"script_score": {
"query": {
"match": {
"name": "Final Fantasy"
}
},
"script": {
"params":{
"multiplier": 10
},
"source": "_score * (doc['user_score'].value*params.multiplier+doc['critic_score'].value)/2/100"
}
}
}
}
腳本編譯被緩存以加快執行速度。 如果腳本具有需要考慮的參數,則最好重用相同的腳本並為其提供參數。
boost_mode
boost_mode是用來定義最新計算出來的分數如何和查詢的分數來相結合的。
mulitply 查詢分數和功能分數相乘(默認)
replace 僅使用功能分數,查詢分數將被忽略
sum 查詢分數和功能分數相加
avg 平均值
max 查詢分數和功能分數的最大值
min 查詢分數和功能分數的最小值
field_value_factor
field_value_factor函數使您可以使用文檔中的字段來影響得分。 與使用script_score函數類似,但是它避免了腳本編寫的開銷。 如果用於多值字段,則在計算中僅使用該字段的第一個值。
例如,假設您有一個用數字likes字段索引的文檔,並希望通過該字段影響文檔的得分,那么這樣做的示例如下所示:
GET /_search
{
"query": {
"function_score": {
"field_value_factor": {
"field": "likes",
"factor": 1.2,
"modifier": "sqrt",
"missing": 1
}
}
}
}
上面的function_score將根據field_value_factore按照如下的方式來計算分數:
sqrt(1.2 * doc['likes'].value)
field_value_factor函數有許多選項:
field 要從文檔中提取的字段。
factor 字段值乘以的可選因子,默認為1。
modifier 應用於字段值的修飾符可以是以下之一:none,log,log1p,log2p,ln,ln1p,ln2p,平方,sqrt或reciprocal。 默認為無。
missing 如果文檔沒有該字段,則使用該值。 就像從文檔中讀取一樣,修飾符和因數仍然適用於它。
針對我們的例子,我們也可以使用如下的方法來重新計算分數:
GET best_games/_search
{
"_source": [
"name",
"critic_score",
"user_score"
],
"query": {
"function_score": {
"query": {
"match": {
"name": "Final Fantasy"
}
},
"field_value_factor": {
"field": "user_score",
"factor": 1.2,
"modifier": "none",
"missing": 1
}
}
}
}
在上面的例子里,我們使用user_score字段,並把這個字段的factor設置為1.2。這樣加大這個字段的重要性。重新進行搜索:
"hits" : [
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "2qccJ28BCSSrjaXdSOnC",
"_score" : 87.89488,
"_source" : {
"user_score" : 9,
"critic_score" : 92,
"name" : "Final Fantasy VII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6KccJ28BCSSrjaXdSOnC",
"_score" : 78.128784,
"_source" : {
"user_score" : 8,
"critic_score" : 92,
"name" : "Final Fantasy X"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6qccJ28BCSSrjaXdSOnC",
"_score" : 78.128784,
"_source" : {
"user_score" : 8,
"critic_score" : 90,
"name" : "Final Fantasy VIII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "K6ccJ28BCSSrjaXdSOrC",
"_score" : 78.128784,
"_source" : {
"user_score" : 8,
"critic_score" : 94,
"name" : "Final Fantasy IX"
}
},
...
我們可以看出來我們的分數又有些變化。而且排序也有變化。
functions
上面的例子中,每一個doc都會乘以相同的系數,有時候我們需要對不同的doc采用不同的權重。這時,使用functions是一種不錯的選擇。幾個function可以組合。 在這種情況下,可以選擇僅在文檔與給定的過濾查詢匹配時才應用該function:
GET /_search
{
"query": {
"function_score": {
"query": {
"match_all": {}
},
"boost": "5",
"functions": [
{
"filter": {
"match": {
"test": "bar"
}
},
"random_score": {},
"weight": 23
},
{
"filter": {
"match": {
"test": "cat"
}
},
"weight": 42
}
],
"max_boost": 42,
"score_mode": "max",
"boost_mode": "multiply",
"min_score": 42
}
}
}
上面的boost為5,也即所有的文檔的加權都是5。我們同時也看到幾個定義的functions。它們是針對相應的匹配的文檔分別進行加權的。如果匹配了,就可以乘以相應的加權。
針對我們的例子,我們也可以做如下的實驗。
GET best_games/_search
{
"query": {
"function_score": {
"query": {
"match": {
"name": "Final Fantasy"
}
},
"boost": "1",
"functions": [
{
"filter": {
"match": {
"name": " XIII"
}
},
"weight": 10000000
}
],
"boost_mode": "multiply"
}
}
}
我們想把name含有XIII的所有游戲都加一個權。這樣它可以排到最前面。我們給它的加權值很大:10000000。
搜索后的結果是:
"hits" : [
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "KqccJ28BCSSrjaXdSOrC",
"_score" : 8.1384144E7,
"_source" : {
"global_sales" : 5.33,
"year" : 2009,
"image_url" : "https://www.wired.com/images_blogs/gamelife/2009/09/ffxiii-01.jpg",
"platform" : "PS3",
"@timestamp" : "2009-01-01T00:00:00.000+08:00",
"user_score" : 7,
"critic_score" : 83,
"name" : "Final Fantasy XIII",
"genre" : "Role-Playing",
"publisher" : "Square Enix",
"developer" : "Square Enix",
"id" : "final-fantasy-xiii-ps3-2009"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "OKccJ28BCSSrjaXdSOvC",
"_score" : 7.2601472E7,
"_source" : {
"global_sales" : 2.63,
"year" : 2011,
"image_url" : "https://i.ytimg.com/vi/tSJH_vhaYUk/maxresdefault.jpg",
"platform" : "PS3",
"@timestamp" : "2011-01-01T00:00:00.000+08:00",
"user_score" : 6,
"critic_score" : 79,
"name" : "Final Fantasy XIII-2",
"genre" : "Role-Playing",
"publisher" : "Square Enix",
"developer" : "Square Enix",
"id" : "final-fantasy-xiii-2-ps3-2011"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "2qccJ28BCSSrjaXdSOnC",
"_score" : 8.138414,
"_source" : {
"global_sales" : 9.72,
"year" : 1997,
"image_url" : "https://r.hswstatic.com/w_907/gif/finalfantasyvii-MAIN.jpg",
"platform" : "PS",
"@timestamp" : "1997-01-01T00:00:00.000+08:00",
"user_score" : 9,
"critic_score" : 92,
"name" : "Final Fantasy VII",
"genre" : "Role-Playing",
"publisher" : "Sony Computer Entertainment",
"developer" : "SquareSoft",
"id" : "final-fantasy-vii-ps-1997"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6KccJ28BCSSrjaXdSOnC",
"_score" : 8.138414,
"_source" : {
"global_sales" : 8.05,
"year" : 2001,
"image_url" : "https://www.mobygames.com/images/promo/l/192477-final-fantasy-x-screenshot.jpg",
"platform" : "PS2",
"@timestamp" : "2001-01-01T00:00:00.000+08:00",
"user_score" : 8,
"critic_score" : 92,
"name" : "Final Fantasy X",
"genre" : "Role-Playing",
"publisher" : "Sony Computer Entertainment",
"developer" : "SquareSoft",
"id" : "final-fantasy-x-ps2-2001"
}
},
...
我們可以看出來,在這一次的搜索中Final Fantasy XIII的排名變成第一了。
Elasticsearch中的衰變函數
在Elasticsearch中,常見的Decay function (衰變函數)有一下的幾種:
Function評分技術不僅可以修改默認的Elasticsearch評分算法,還可以用於完全替代它。 一個很好的例子是“trending”搜索,顯示主題中正在迅速流行的項目。
這樣的分數不能基於簡單的指標(例如“喜歡”或“觀看次數”),而必須根據當前時間不斷調整。 與在24小時內獲得10000次觀看的視頻相比,在1小時內獲得1000次觀看的視頻通常被認為“更熱”。 Elasticsearch附帶了幾個衰減函數,這些函數使解決此類問題變得輕而易舉。
我們現在以gauss來為例展示如何使用這個衰變函數的。曲線的形狀可以通過orgin,scale,offset和decay來控制。 這三個變量是控制曲線形狀的主要工具。 可以將origin和scale參數視為您的最小值和最大值,它定義了將在其中定義曲線的邊界框。 如果我們希望趨勢視頻列表涵蓋一整天,則最好將原點定義為當前時間戳,比例尺定義為24小時。 offset可用於在開始時將曲線完全平坦,例如將其設置為1h,可消除最近視頻的所有懲罰,也即最近1個小時里的所有視頻不受影響 。最后,衰減選項會根據文檔的位置更改文檔降級的嚴重程度。 默認的衰減值是0.5,較大的值會使曲線更陡峭,其效果也更明顯。
我們還是拿我們的best_games來為例:
GET best_games/_search
{
"_source": [
"name",
"critic_score",
"user_score"
],
"query": {
"function_score": {
"query": {
"match": {
"name": "Final Fantasy"
}
},
"functions": [
{
"gauss": {
"@timestamp": {
"origin": "2016-01-01T00:00:00",
"scale": "365d",
"offset": "0h",
"decay": 0.1
}
}
}
],
"boost_mode": "multiply"
}
}
}
上面的查詢是基於2016-010-01這一天開始,在365天之內的文檔不收衰減,那么超過這個時間的所有文檔,衰減的加權值為0.1。也就是說1年開外的所有文檔對我的意義並不是太多。
重新運行我們的查詢,結果顯示:
"hits" : [
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "OKccJ28BCSSrjaXdSOvC",
"_score" : 6.6742494E-25,
"_source" : {
"user_score" : 6,
"critic_score" : 79,
"name" : "Final Fantasy XIII-2"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "2qccJ28BCSSrjaXdSOnC",
"_score" : 0.0,
"_source" : {
"user_score" : 9,
"critic_score" : 92,
"name" : "Final Fantasy VII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6KccJ28BCSSrjaXdSOnC",
"_score" : 0.0,
"_source" : {
"user_score" : 8,
"critic_score" : 92,
"name" : "Final Fantasy X"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "6qccJ28BCSSrjaXdSOnC",
"_score" : 0.0,
"_source" : {
"user_score" : 8,
"critic_score" : 90,
"name" : "Final Fantasy VIII"
}
},
{
"_index" : "best_games",
"_type" : "_doc",
"_id" : "FqccJ28BCSSrjaXdSOrC",
"_score" : 0.0,
"_source" : {
"user_score" : 7,
"critic_score" : 92,
"name" : "Final Fantasy XII"
}
},
...
這次的搜索結果顯示Final Fantasy XIII-2是得分最高的文檔。
參考:
【1】https://www.elastic.co/blog/found-function-scoring
【2】https://medium.com/horrible-hacks/customizing-scores-in-elasticsearch-for-product-recommendations-9e0d02ce1dbd
【3】https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-field-value-factor
【4】https://juejin.im/post/5df8f465518825123751c089
【5】https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html