1. 程式人生 > 其它 >乾貨 | ElasticSearch相關性打分機制

乾貨 | ElasticSearch相關性打分機制

作者簡介

孫鹹偉,後端開發一枚,在攜程技術中心市場營銷研發部負責“攜程運動”專案的開發和維護。

攜程運動是攜程旗下新業務,主要給使用者提供羽毛球、游泳等運動專案的場館預定。最近我們在做場館搜尋的功能時,接觸到elasticsearch(簡稱es)搜尋引擎。

我們展示給使用者的運動場館,在匹配到使用者關鍵詞的情況下,還會綜合考慮多種因素,比如價格,庫存,評分,銷量,經緯度等。

如果單純按場館距離、價格排序時,排序過於絕對,比如有時會想讓庫存數量多的場館排名靠前,有時會想讓評分過低的排名靠後。有時在有多家價格相同的場館同時顯示的情況下,想讓距離使用者近的場館顯示在前面,這時就可以通過es強大的評分功能來實現。

本文將分享es是如何對文件打分的,以及在搜尋查詢時遇到的一些常用場景,希望給接觸搜尋的同學一些幫助。

一、Lucene的計分函式(Lucene’s Practical Scoring Function)

對於多術語查詢,Lucene採用布林模型(Boolean model)、詞頻/逆向文件頻率(TF/IDF)、以及向量空間模型(Vector Space Model),然後將他們合併到單個包中來收集匹配文件和分數計算。 只要一個文件與查詢匹配,Lucene就會為查詢計算分數,然後合併每個匹配術語的分數。這裡使用的分數計算公式叫做 實用計分函式(practical scoring function)。

score(q,d)  =  #1
            queryNorm(q)  #2
          · coord(q,d)    #3
          · ∑ (           #4
                tf(t in d)   #5
              · idf(t)²      #6
              · t.getBoost() #7
              · norm(t,d)    #8
            ) (t in q)    #9
  • #1 score(q, d) 是文件 d 與 查詢 q 的相關度分數
  • #2 queryNorm(q) 是查詢正則因子(query normalization factor)
  • #3 coord(q, d) 是協調因子(coordination factor)
  • #4 #9 查詢 q 中每個術語 t 對於文件 d 的權重和
  • #5 tf(t in d) 是術語 t 在文件 d 中的詞頻
  • #6 idf(t) 是術語 t 的逆向文件頻次
  • #7 t.getBoost() 是查詢中使用的 boost
  • #8 norm(t,d) 是欄位長度正則值,與索引時欄位級的boost的和(如果存在)
詞頻(Term frequency)

術語在文件中出現的頻度是多少?頻度越高,權重越大。一個5次提到同一術語的欄位比一個只有1次提到的更相關。詞頻的計算方式如下:

tf(t in d) = √frequency #1
  • #1 術語 t 在檔案 d 的詞頻(tf)是這個術語在文件中出現次數的平方根。

逆向文件頻率(Inverse document frequency)

術語在集合所有文件裡出現的頻次。頻次越高,權重越低。常用詞如 and 或 the 對於相關度貢獻非常低,因為他們在多數文件中都會出現,一些不常見術語如 elastic 或 lucene 可以幫助我們快速縮小範圍找到感興趣的文件。逆向文件頻率的計算公式如下:

idf(t) = 1 + log ( numDocs / (docFreq + 1)) #1
  • #1 術語t的逆向文件頻率(Inverse document frequency)是:索引中文件數量除以所有包含該術語文件數量後的對數值。

欄位長度正則值(Field-length norm)

欄位的長度是多少?欄位越短,欄位的權重越高。如果術語出現在類似標題 title 這樣的欄位,要比它出現在內容 body 這樣的欄位中的相關度更高。欄位長度的正則值公式如下:

norm(d) = 1 / √numTerms #1
  • #1 欄位長度正則值是欄位中術語數平方根的倒數。

查詢正則因子(Query Normalization Factor)

查詢正則因子(queryNorm)試圖將查詢正則化,這樣就能比較兩個不同查詢結果。儘管查詢正則值的目的是為了使查詢結果之間能夠相互比較,但是它並不十分有效,因為相關度分數_score 的目的是為了將當前查詢的結果進行排序,比較不同查詢結果的相關度分數沒有太大意義。

查詢協調(Query Coordination)

協調因子(coord)可以為那些查詢術語包含度高的文件提供“獎勵”,文件裡出現的查詢術語越多,它越有機會成為一個好的匹配結果。

二、查詢時權重提升(Query-Time Boosting)

在搜尋時使用權重提升引數讓一個查詢語句比其他語句更重要。查詢時的權重提升是我們可以用來影響相關度的主要工具,任意一種型別的查詢都能接受權重提升(boost)引數。將權重提升值設定為2,並不代表最終的分數會是原值的2倍;權重提升值會經過正則化和一些其他內部優化過程。儘管如此,它確實想要表明一個提升值為2的句子的重要性是提升值為1句子的2倍。

三、忽略TF/IDF(Ignoring TF/IDF)

有些時候我們不關心 TF/IDF,我們只想知道一個詞是否在某個欄位中出現過,不關心它在文件中出現是否頻繁。

constant_score 查詢

constant_score 查詢中,它可以包含一個查詢或一個過濾,為任意一個匹配的文件指定分數,忽略TF/IDF資訊。

function_score 查詢(function_score Query)

es進行全文搜尋時,搜尋結果預設會以文件的相關度進行排序,如果想要改變預設的排序規則,也可以通過sort指定一個或多個排序欄位。但是使用sort排序過於絕對,它會直接忽略掉文件本身的相關度。

在很多時候這樣做的效果並不好,這時候就需要對多個欄位進行綜合評估,得出一個最終的排序。這時就需要用到function_score 查詢(function_score query) ,它允許我們為每個與主查詢匹配的文件應用一個函式,以達到改變甚至完全替換原始分數的目的。 ElasticSearch預定義了一些函式:

  • weight 為每個文件應用一個簡單的而不被正則化的權重提升值:當 weight 為 2 時,最終結果為 2 * _score
  • field_value_factor 使用這個值來修改 _score,如將流行度或評分作為考慮因素。
  • random_score 為每個使用者都使用一個不同的隨機分數來對結果排序,但對某一具體使用者來說,看到的順序始終是一致的。
  • Decay functions — linear, exp, gauss 以某個欄位的值為標準,距離某個值越近得分越高。
  • script_score 如果需求超出以上範圍時,用自定義指令碼完全控制分數計算的邏輯。 它還有一個屬性boost_mode可以指定計算後的分數與原始的_score如何合併,有以下選項:
  • multiply 將分數與函式值相乘(預設)
  • sum 將分數與函式值相加
  • min 分數與函式值的較小值
  • max 分數與函式值的較大值
  • replace 函式值替代分數
field_value_factor

field_value_factor的目的是通過文件中某個欄位的值計算出一個分數,它有以下屬性:

  • field:指定欄位名
  • factor:對欄位值進行預處理,乘以指定的數值(預設為1)
  • modifier將欄位值進行加工,有以下的幾個選項:
    • none:不處理
    • log:計算對數
    • log1p:先將欄位值+1,再計算對數
    • log2p:先將欄位值+2,再計算對數
    • ln:計算自然對數
    • ln1p:先將欄位值+1,再計算自然對數
    • ln2p:先將欄位值+2,再計算自然對數
    • square:計算平方
    • sqrt:計算平方根
    • reciprocal:計算倒數

假設有一個場館索引,搜尋時希望在相關度排序的基礎上,評分(comment_score)更高的場館能排在靠前的位置,那麼這條查詢DSL可以是這樣的:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "游泳館"
        }      },
      "field_value_factor": {
        "field":    "comment_score",
        "modifier": "log1p",
        "factor":   0.1
      },
      "boost_mode": "sum"
    }  }}

這條查詢會將名稱中帶有游泳的場館檢索出來,然後對這些文件計算一個與評分(comment_score)相關的分數,並與之前相關度的分數相加,對應的公式為:

_score = _score + log(1 + 0.1 * comment_score)
隨機計分(random_score)

這個函式的使用相當簡單,只需要呼叫一下就可以返回一個0到1的分數。

它有一個非常有用的特性是可以通過seed屬性設定一個隨機種子,該函式保證在隨機種子相同時返回值也相同,這點使得它可以輕鬆地實現對於使用者的個性化推薦。

衰減函式(Decay functions)

衰減函式(Decay Function)提供了一個更為複雜的公式,它描述了這樣一種情況:對於一個欄位,它有一個理想的值,而欄位實際的值越偏離這個理想值(無論是增大還是減小),就越不符合期望。 有三種衰減函式——線性(linear)、指數(exp)和高斯(gauss)函式,它們可以運算元值、時間以及 經緯度地理座標點這樣的欄位。三個都能接受以下引數:

  • origin 代表中心點(central point)或欄位可能的最佳值,落在原點(origin)上的文件分數為滿分 1.0。
  • scale 代表衰減率,即一個文件從原點(origin)下落時,分數改變的速度。
  • decay 從原點(origin)衰減到 scale 所得到的分數,預設值為 0.5。
  • offset 以原點(origin)為中心點,為其設定一個非零的偏移量(offset)覆蓋一個範圍,而不只是原點(origin)這單個點。在此範圍內(-offset <= origin <= +offset)的所有值的分數都是 1.0。

這三個函式的唯一區別就是它們衰減曲線的形狀,用圖來說明會更為直觀 衰減函式曲線

如果我們想找一家游泳館:

  • 它的理想位置是公司附近
  • 如果離公司在5km以內,是我們可以接受的範圍,在這個範圍內我們不去考慮距離,而是更偏向於其他資訊
  • 當距離超過5km時,我們對這家場館的興趣就越來越低,直到超出某個範圍就再也不會考慮了

將上面提到的用DSL表示就是:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "游泳館"
        }      },
      "gauss": {
        "location": {
          "origin": { "lat": 31.227817, "lon": 121.358775 },
          "offset": "5km",
          "scale":  "10km"
           }         },
         "boost_mode": "sum"
    }  }}

我們希望租房的位置在(31.227817, 121.358775)座標附近,5km以內是滿意的距離,15km以內是可以接受的距離。

script_score

雖然強大的field_value_factor和衰減函式已經可以解決大部分問題,但是也可以看出它們還有一定的侷限性:

  1. 這兩種方式都只能針對一個欄位計算分值
  2. 這兩種方式應用的欄位型別有限,field_value_factor一般只用於數字型別,而衰減函式一般只用於數字、位置和時間型別

這時候就需要script_score了,它支援我們自己編寫一個指令碼執行,在該指令碼中我們可以拿到當前文件的所有欄位資訊,並且只需要將計算的分數作為返回值傳回Elasticsearch即可。

注:使用指令碼需要首先在配置檔案中開啟相關功能:

script.groovy.sandbox.enabled: true
script.inline: on
script.indexed: on
script.search: on
script.engine.groovy.inline.aggs: on

現在正值炎熱的夏天,游泳成為很多人喜愛的運動專案,在滿足使用者搜尋條件的情況下,我們想把游泳分類的場館排名提前。此時可以編寫Groovy指令碼(Elasticsearch的預設指令碼語言)來提高游泳相關場館的分數。

return doc['category'].value == '游泳' ? 1.5 : 1.0

接下來只要將這個指令碼配置到查詢語句:

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "運動"
        }      },
      "script_score": {
        "script": "return doc['category'].value == '游泳' ? 1.5 : 1.0"
      }    }  }}

當然還可以通過params屬性向指令碼傳值,讓推薦更靈活。

{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "運動"
        }      },
      "script_score": {
        "params": {
            "recommend_category": "游泳"
        },        "script": "return doc['category'].value == recommend_category ? 1.5 : 1.0"
      }    }  }}

scirpt_score 函式提供了巨大的靈活性,我們可以通過指令碼訪問文件裡的所有欄位、當前評分甚至詞頻、逆向文件頻率和欄位長度正則值這樣的資訊。

同時使用多個函式

上面的例子都只是呼叫某一個函式並與查詢得到的_score進行合併處理,而在實際應用中肯定會出現在多個點上計算分值併合並,雖然指令碼也許可以解決這個問題,但是應該沒人願意維護一個複雜的指令碼。

這時候通過多個函式將每個分值都計算出再合併才是更好的選擇。 在function_score中可以使用functions屬性指定多個函式。它是一個數組,所以原有函式不需要發生改動。同時還可以通過score_mode指定各個函式分值之間的合併處理,值跟最開始提到的boost_mode相同。

下面舉個例子介紹多個函式混用的場景。我們會向用戶推薦一些不錯的場館,特徵是:範圍要在當前位置的5km以內,有停車位很重要,場館的評分(1分到5分)越高越好,並且對不同使用者最好展示不同的結果以增加隨機性。

那麼它的查詢語句應該是這樣的:

{
  "query": {
    "function_score": {
      "filter": {
        "geo_distance": {
          "distance": "5km",
          "location": {
            "lat": $lat,
            "lon": $lng          }        }      },
      "functions": [
        {
          "filter": {
            "term": {
              "features": "停車位"
            }          },
          "weight": 2
        },
        {
            "field_value_factor": {
               "field": "comment_score",
               "factor": 1.5
             }        },
        {
          "random_score": {
            "seed": "$id"
          }        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }  }}

注:其中所有以$開頭的都是變數。 這樣一個場館的最高得分應該是2分(有停車位)+ 7.5分(評分5分 * 1.5)+ 1分(隨機評分)。

總結

本文主要介紹了 Lucene 是如何基於 TF/IDF 生成評分的,以及 function_score 的使用。實踐中,簡單的查詢組合就能提供很好的搜尋結果,但是為了獲得具有成效的搜尋結果,就必須反覆推敲修改前面介紹的這些除錯方法。

通常,經過對策略欄位應用權重提升,或通過對查詢語句結構的調整來強調某個句子的重要性這些方法,就足以獲得良好的結果。有時,如果 Lucene 基於詞的 TF/IDF 模型不再滿足評分需求(例如希望基於時間或距離來評分),則需要使用自定義指令碼,靈活應用各種需求。