標籤:

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) = #1n queryNorm(q) #2n · coord(q,d) #3n · ∑ ( #4n tf(t in d) #5n · idf(t)2 #6n · t.getBoost() #7n · norm(t,d) #8n ) (t in q) #9n

  • #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 #1n

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

逆向文檔頻率(Inverse document frequency)

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

idf(t) = 1 + log ( numDocs / (docFreq + 1)) #1n

  • #1 術語t的逆向文檔頻率(Inverse document frequency)是:索引中文檔數量除以所有包含該術語文檔數量後的對數值。

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

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

norm(d) = 1 / √numTerms #1n

  • #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預定義了一些函數:

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

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可以是這樣的:

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

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

_score = _score + log(1 + 0.1 * comment_score)n

隨機計分(random_score)

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

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

衰減函數(Decay functions)

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

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

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

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

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

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

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

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

script_score

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

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

這時候就需要script_score了,它支持我們自己編寫一個腳本運行,在該腳本中我們可以拿到當前文檔的所有欄位信息,並且只需要將計算的分數作為返回值傳回Elasticsearch即可。

註:使用腳本需要首先在配置文件中打開相關功能:

script.groovy.sandbox.enabled: truenscript.inline: onnscript.indexed: onnscript.search: onnscript.engine.groovy.inline.aggs: onn

現在正值炎熱的夏天,游泳成為很多人喜愛的運動項目,在滿足用戶搜索條件的情況下,我們想把游泳分類的場館排名提前。此時可以編寫Groovy腳本(Elasticsearch的默認腳本語言)來提高游泳相關場館的分數。

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

接下來只要將這個腳本配置到查詢語句:

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

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

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

scirpt_score 函數提供了巨大的靈活性,我們可以通過腳本訪問文檔里的所有欄位、當前評分甚至詞頻、逆向文檔頻率和欄位長度正則值這樣的信息。

同時使用多個函數

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

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

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

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

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

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

總結

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

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

【作者簡介】孫咸偉,後端開發一枚,在攜程技術中心市場營銷研發部負責「攜程運動」項目的開發和維護。

沒看夠?更多來自攜程技術人的一手乾貨,歡迎搜索關注「攜程技術中心」微信公號哦~


推薦閱讀:

世界偏見地圖:上大學去美國,買炒鍋找中國
把類pinterest的人工圖片分類標籤,如堆糖、花瓣的圖片分類,投到機器學習訓練,能夠自動化的準確的產生「萌」「清新」「森女系「這種情感化的分類嗎?搜索引擎的圖片搜索是不是也可以做這種訓練,然後改進搜索結果呢?
微軟Bing:全球化浪潮下的搜索選擇
基於百科語料優化搜狗圖片搜索的方法實踐
[Paper Reading] 基於文檔主題結構的關鍵詞抽取方法研究

TAG:搜索引擎 |