Elasticsearch BM25相關度演算法超詳細解釋

夜色微光發表於2021-08-23

Photo by Pixabay from Pexels

前言:日常在使用Elasticsearch的搜尋業務中多少會出現幾次 “為什麼這個Doc分數要比那個要稍微低一點?”、“為什麼幾分鐘之前還是正確的結果現在確變了?”之類的疑問。

抱著深入探究的學習態度還是決定要把相關度評分演算法摸透,本文內容基於目前的7.14版本,儘量以通俗易懂的話語詳細解釋這些概念。

1. Elasticsearch中的相關性計算

在正式進入演算法解析階段之前,先一步一步的補足相關的概念知識,這會幫助我們更好的學習和理解。

1.1 什麼是相關性評分(relevance score)?

相關性評分(relevance score)是衡量每個文件與輸入查詢匹配的程度。預設情況下,Elasticsearch根據相關性評分對匹配的搜尋結果進行排序。

相關性評分是一個正浮點數,在Search API的score後設資料欄位中返回。score越高,說明文件越相關。。。

一個簡單的示例,首先通過bulk API 或者你熟悉的方式向索引裡寫入一些資料,這裡以書名為例。

POST _bulk
{ "index" : { "_index" : "book_info", "_id" : "1" } }
{ "book_name" : "《大學》" }
{ "index" : { "_index" : "book_info", "_id" : "2" } }
{ "book_name" : "《中庸》" }
{ "index" : { "_index" : "book_info", "_id" : "3" } }
{ "book_name" : "《論語》" }
{ "index" : { "_index" : "book_info", "_id" : "4" } }
{ "book_name" : "《孟子》" }
{ "index" : { "_index" : "book_info", "_id" : "5" } }
{ "book_name" : "《道德經》" }
{ "index" : { "_index" : "book_info", "_id" : "6" } }
{ "book_name" : "《詩經》" }
{ "index" : { "_index" : "book_info", "_id" : "7" } }
{ "book_name" : "《春秋》" }

然後執行一個最簡單的查詢,搜尋索引內書名匹配“詩經”這兩個字的書籍資訊

GET /book_info/_search
{
  "query": {
    "match": { "book_name": "詩經" }
  }
}

//得到以下響應
{
  "took" : 2,
........
    "max_score" : 2.916673,
    "hits" : [
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "6",
        "_score" : 2.916673,
        "_source" : {
          "book_name" : "《詩經》"
        }
      },
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 0.99958265,
        "_source" : {
          "book_name" : "《道德經》"
        }
      }
    ]
  }
}

可以看到結果如同預期的,《詩經》這本書的相關性評分為 2.916673 ,而第二本書因為只匹配了一個“經”字,所以得分較低。現在我們的目的就是徹底吃透這兩個分數是如何計算的,以應對實際使用時的各種問題。

1.2 相關度評分是如何計算的?

Elasticsearch是基於 Lucene 之上的搜尋引擎,這部分深入的內容以及概念留到後面補充,否則只會出現片面的描述或者陷入遞迴式的探究中。現在,先讓我們囫圇吞棗的理解眼前的事物。

Elasticsearch中的相關性評分計算可以參考Elasticsearch文件相似模組的描述,傳送門:Elasticsearch | Index Modules Similarity

在不做任何配置,預設的情況下我們可以使用以下三種相似度評分演算法:

  • BM25:Okapi BM 25演算法。在Elasticsearch和Lucene中預設使用的演算法。
  • classic: 在7.0.0中標記為過時。基於TF/IDF 演算法,以前在Elasticsearch和Lucene中的預設值。
  • boolean:一個簡單的布林相似度演算法,當不需要全文排序時可以使用,並且分數應該只基於查詢項是否匹配。布林相似度給查詢一個簡單的分數,等價於設定的Query Boost。

通過以上描述我們可以瞭解到,Elasticsearch中預設的評分演算法是BM25演算法,且其他兩個選項一個被標記過時,一個不適用於全文檢索排序。現在實際嘗試一下上面提到的三種演算法,由於classic演算法已經被標記過時,這裡直接在Mapping中使用classic會直接丟擲異常並提示我們可以使用指令碼自定義實現原本的classic演算法

{
        "type" : "illegal_argument_exception",
        "reason" : "The [classic] similarity may not be used anymore. Please use the [BM25] similarity or build a custom [scripted] similarity instead."
}

按照文件中給出的示例編寫索引Mapping:

//刪除之前建立的索引
DELETE book_info

//建立自定義的索引並制定欄位型別、相關度評分演算法
PUT book_info
{
  "mappings": {
    "properties": {
      //預設欄位依舊採用BM25,並且對該欄位賦值時自動複製到下面兩個欄位
      "book_name": {
        "type": "text",
        "similarity": "BM25",
        "copy_to": [
          "book_name_classic",
          "book_name_boolean"
        ]
      },
      //這個欄位使用classic相關度演算法
      "book_name_classic": {
        "type": "text",
        "similarity": "my_classic"
      },
      //這個欄位使用boolean相關度演算法
      "book_name_boolean": {
        "type": "text",
        "similarity": "boolean"
      }
    }
  },
  "settings": {
    "number_of_shards": 1,
    "similarity": {
      "my_classic": {
        "type": "scripted",
        "script": {
          "source": "double tf = Math.sqrt(doc.freq); double idf = Math.log((field.docCount+1.0)/(term.docFreq+1.0)) + 1.0; double norm = 1/Math.sqrt(doc.length); return query.boost * tf * idf * norm;"
        }
      }
    }
  }
}

之後用與上面相同的Bulk請求填充一下資料,即可觀察相關性演算法配置的結果,在這裡不對結果進行解析。

GET /book_info/_search
{
  "query": {
    "match": {
      "book_name": {
        "query":  "詩經"
      }
    }
  }
}
GET /book_info/_search
{
  "query": {
    "match": {
      "book_name_classic": {
        "query":  "詩經"
      }
    }
  }
}
GET /book_info/_search
{
  "query": {
    "match": {
      "book_name_boolean": {
        "query":  "詩經"
      }
    }
  }
}

2. BM 25 演算法

通過第一章的描述,我們知道了現在在Elasticsearch中的相關性評分預設採用BM25相似度演算法,下面正式進入演算法的學習階段。

BM25全稱Okapi BM25。Okapi 是使用它的第一個系統的名稱,即Okapi資訊檢索系統,BM則是best matching的縮寫。

BM25是基於TF-IDF演算法並做了改進,基於概率模型的文件檢索演算法,目前BM25及其較新的變體(例如BM25F)代表了文件檢索中使用的最先進的TF/IDF類檢索功能。

現在,拋開中文分詞器、同義詞、停詞等一切可能的干擾項,我們就使用最基本的Standard分詞器,準備一點英文文件資料:

PUT _bulk
{ "index" : { "_index" : "people", "_id" : "1" } }
{ "title": "Shane" }
{ "index" : { "_index" : "people", "_id" : "2" } }
{ "title": "Shane C" }
{ "index" : { "_index" : "people", "_id" : "3" } }
{ "title": "Shane Connelly" }
{ "index" : { "_index" : "people", "_id" : "4" } }
{ "title": "Shane P Connelly" }

下面是BM25演算法的標準公式,表示給定一個查詢Q,包含關鍵字 q{1},...,q{n},文件D的BM 25分數計算公式為

現在詳細解釋等式每個部分的含義

2.1 搜尋項 qi及詞頻TF

\(q_i\) : 查詢項,例如我搜尋“Shane”,只有一個查詢項,所以 \(q_0\) 是“Shane”。

如果我用英語搜尋“Shane Connelly”,Elasticsearch將看到空格並將其標記為兩個Term:

  • \(q_0\) :Shane
  • \(q_1\): Connelly

\(f(q_i,D)\) : D 是文件, Term Frequency (TF)是指Term在文件中出現的頻率,即詞頻。Term在文件中出現的權重與頻率成正比。用最通俗易懂的話來說就是查詢的詞語在文件中出現了多少次。

這很有直覺意義,例如我正在搜尋Elasticsearch,一篇文章內提到了一次可能只是簡單的引用,如果文章中出現了很多次Elasticsearch那就更有可能與我們的搜尋內容相關。

2.2 逆文件頻率 IDF

\(IDF(q_i)\): 查詢項的逆文件頻率,衡量這個詞提供了多少資訊,也就是說它在所有文件中是常見的還是罕見的。這

其中的含義是如果一個搜尋詞是非常罕見的詞語(例如專業術語)且在某個文件中匹配了,則分數會提高;反之對於非常常見的匹配詞降低分數。

TF-IDF中原本的逆文件頻率公式是這樣的:

\[IDF(q_i,D) = log{N \over |{d\in D}:t\in d|} \]

其中,變數\(N\)可以看做索引中的文件總數,\(n(q_i)\) 代表索引中包含查詢項\(q_i\)的文件數量。

而BM25演算法中對這個公式進行了一些改造,如下:

\[IDF(q_i,D) = ln(1 + {N-n(q_i)+0.5 \over n(q_i)+0.5}) \]

以這一章開頭寫入的索引資料為例,假如我們在當前索引people 中搜尋文字“Shane”,“Shane”這個詞語出現在4個文件中,將這個數值代入到上面的公式則:

  • \(n(q_i)\) : 索引文章中包含查詢項Shane 的文件數量 = 4
  • N:索引中的文件總數 = 4

\[ln(1+ {(4-4+0.5) \over 4+0.5} ) = ln(1+{0.5 \over 4.5}) = 0.105360515657826 \]

我們在Kibana中請求Elasticsearch的 explain 端點來驗證分析一下

GET /people/_search

//Response, 驗證一下我們的文件內容全部包含 Shane 
{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "people",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : { "title" : "Shane" }},
      {
        "_index" : "people",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1.0,
        "_source" : { "title" : "Shane C" }},
      {
        "_index" : "people",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "_source" : { "title" : "Shane Connelly" }},
      {
        "_index" : "people",
        "_type" : "_doc",
        "_id" : "4",
        "_score" : 1.0,
        "_source" : { "title" : "Shane P Connelly" }}
    ]
  }
}

//然後呼叫explain
POST people/_explain/1
{
  "query":{
    "match":{
      "title":"Shane"
    }
  }
}
//得到以下結果
{
  "_index" : "people",
  "_type" : "_doc",
  "_id" : "1",
  "matched" : true,
  "explanation" : {
    "value" : 0.13245323,
    "description" : "weight(title:shane in 0) [PerFieldSimilarity], result of:",
    "details" : [
      {
        "value" : 0.13245323,
        "description" : "score(freq=1.0), computed as boost * idf * tf from:",
        "details" : [
          //省去boost,重點看下面的idf
          {
            "value" : 0.105360515,
            "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
            "details" : [
              {
                "value" : 4,
                "description" : "n, number of documents containing term",
                "details" : [ ]
              },
              {
                "value" : 4,
                "description" : "N, total number of documents with field",
                "details" : [ ]
              }
            ]
          }
          //省去tf,重點看上面的idf
        ]
      }
    ]
  }
}

注意看73行計算的結果,和我們上面在公式裡計算的結果是相同的。

2.3 欄位長度與平均長度部分

現在把視角移到公式分母的右下角部分。其中\(|D|\)代表文件的長度,\(avgdl\) 代表平均欄位長度(avgFieldLen)。

如果文件比平均值長,分母就會變大(降低分數),如果文件比平均值短,分母就會變小(提高分數)。注意,Elasticsearch中欄位長度的實現是基於Term數量的(而不是字元長度之類的)。

考慮這個問題的方法是,文件中的詞語越多(至少是與查詢不匹配的術語),文件的得分就越低。同樣,這也很直觀:如果一份300頁長的文件只提到過我的名字一次,那麼它與我的關係可能不如一條只提到過我一次的文章段落。

2.4 可調節變數 b 和 k1

這是中BM25演算法中可調節的兩個引數,在使用Elasticsearch的過程中也可以作為一些特殊搜尋場景的調優點。

  • k1 : 控制非線性詞頻率歸一化(飽和),Elasticsearch中預設值為1.2。用人能看懂的話說就是詞語在文件中出現的次數對於得分的重要性。例如說我覺得在某些場景,一個搜尋詞在文件中出現越多則越接近我希望搜尋的內容,就可以將這個引數調大一點。

  • b :控制文件長度對於分數的懲罰力度。變數b處於分母上,它乘以剛剛討論過的欄位長度的比值,Elasticsearch中的b 預設值為0.75。 如果b較大,則文件長度相對於平均長度的影響更大。 可以想象如果將b設定為0,那麼長度比率的影響將完全無效,文件的長度將與分數無關。

另外Elasticsearch中還有一個引數 discount_overlaps 確定計算標準時是否忽略重疊標記(位置增量為0的標記,這種情況一般是同義詞)。 預設情況下為true,這意味著在計算規範時不計算重疊標記。

k1值越高/越低,說明BM25“tf()”的曲線斜率發生變化。 這就改變了“詞語出現的額外次數會增加額外分數”的方式。 k1的一種解釋是,對於平均長度的文件,詞頻的值為所考慮的詞語的最大分數的一半。 tf()≤k1時,tf()對評分的影響曲線增長迅速,tf() > k1時,影響曲線增長越來越慢。

使用k1,我們可以控制以下問題的答案:

“ 在文件中新增第二個‘shane’比第一個‘shane’,再或者,第三個‘shane’與第二個‘shane’相比,對分數的貢獻應該多多少?”

這句話有點拗口,但確實表達了正確的含義。換個問題,我在Google上搜尋‘Elasticsearch’ ,出現的結果文章列表中匹配‘Elasticsearch’關鍵字的數量是非常重要的嗎?

較高的k1意味著每一項的分數在該項的更多例項中可以相對更高地繼續上升。 k1的值為0意味著除了IDF(qi)之外的所有東西都將被抵消 。

現在,我們在準備好的示例資料中驗證一下上面兩個引數的說明是否正確,先看看在不更改索引的預設情況下分數計算情況:

POST people/_explain/1
{
  "query":{
    "match":{
      "title":"Shane"
    }
  }
}

//Response ,省去了Boost、idf等其他無關部分
{
    "value" : 0.5714286,
    "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
    "details" : [
        {
            "value" : 1.0,
            "description" : "freq, occurrences of term within document",
            "details" : [ ]
        },
        {
            "value" : 1.2,
            "description" : "k1, term saturation parameter",
            "details" : [ ]
        },
        {
            "value" : 0.75,
            "description" : "b, length normalization parameter",
            "details" : [ ]
        },
        {
            "value" : 1.0,
            "description" : "dl, length of field",
            "details" : [ ]
        },
        {
            "value" : 2.0,
            "description" : "avgdl, average length of field",
            "details" : [ ]
        }
    ]
}

上面的程式碼塊中是詞頻部分的計算結果,此時是預設的索引設定,k1 = 1.2, b=0.75 。在這種預設設定下文件的長度懲罰、詞頻相關較為均衡。例如現在加入一條混淆資料:

PUT people/_doc/5
{
  "title": "Shane Shane P" 
}

如果我們搜尋關鍵字“Shane”則會發現這條資料雖然因為有兩個單詞匹配,但是因為文件長度而分數略低於完全匹配的1號文件:

GET /people/_search
{
  "query":{
    "match":{
      "title":"Shane"
    }
  }
}
//Response
{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 0.112004004,
    "hits" : [
      {
        "_index" : "people",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.112004004,
        "_source" : {
          "title" : "Shane"
        }
      },
      {
        "_index" : "people",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 0.108539954,
        "_source" : {
          "title" : "Shane Shane P"
        }
      }
.......
    ]
  }
}

假如我們希望能在文件中儘量匹配更多的搜尋詞,即使文件長度稍微長一點也沒關係,則可以嘗試著將 k1 增大, 同時降低 變數 b :

PUT /people2
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "similarity": "my_bm25"
      }
    }
  },
  "settings": {
    "number_of_shards": 1,
    "index": {
      "similarity": {
        "my_bm25": {
          "type": "BM25",
          "b": 0.5,
          "k1": 1.5
        }
      }
    }
  }
}
 
PUT _bulk
{ "index" : { "_index" : "people2", "_id" : "1" } }
{ "title": "Shane" }
{ "index" : { "_index" : "people2", "_id" : "2" } }
{ "title": "Shane C" }
{ "index" : { "_index" : "people2", "_id" : "3" } }
{ "title": "Shane Connelly" }
{ "index" : { "_index" : "people2", "_id" : "4" } }
{ "title": "Shane P Connelly" }
{ "index" : { "_index" : "people2", "_id" : "5" } }
{ "title": "Shane Shane P" }

GET /people2/_search
{
  "query": {
    "match": {
      "title": {
        "query": "Shane"
      }
    }
  }
}

現在,搜尋的最佳結果將是我們期望的5號文件:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 0.11531627,
    "hits" : [
      {
        "_index" : "people2",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 0.11531627,
        "_source" : {
          "title" : "Shane Shane P"
        }
      },
      {
        "_index" : "people2",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.10403533,
        "_source" : {
          "title" : "Shane"
        }
      }
      ......
    ]
  }
}

3. 為什麼要學習BM25

當我們在實際應用中,不能快速正確的找到文件時,首先要做的事情通常不是調整演算法的引數b和k1。預設值b = 0.75和k1 = 1.2在大多數情境中都可以很好地工作。更加值得嘗試的或許是調整業務邏輯對查詢語句的應用,找出問題的規律並在應用端解決,例如:

  • 在bool查詢中為精確短語匹配之類的事情增加或新增常量分數

  • 利用同義詞來匹配使用者可能感興趣的其他單詞

  • 新增模糊、列印、語音匹配、詞幹提取和其他文字/分析元件,以幫助解決拼寫錯誤、語言差異等問題。

  • 新增或使用 Function Score 來衰減較舊文件或地理位置上離終端使用者較遠的文件的評分

如果業務上已經沒有任何改動餘地,走完了該走的路再來思考一下調整引數是否會帶來一些好的改變。

基於以上這些考慮,為什麼要理解BM25演算法呢,我覺得更多是出於個人的追求和探索。

可以是為了在應用搜尋時理解其內部到底發生了什麼,如果結果的順序不理想,是查詢語句的問題還是索引文件的問題?

可以是為了觸類旁通,可能在實際應用中還有其他的相似度演算法,此時在有預備知識的情況下可以對比參考,學習更多內容。

。。。

4. 如何調節評分演算法

首先對於BM25演算法,所有資料/查詢,都不存在“最佳”b和k1值。確定更改b和k1引數的使用者可以通過計算每個增量逐步的尋找最佳點。Elasticsearch中的Rank Eval API 和Explain API都可以很好的幫助評估引數改變帶來的影響。

在試驗b和k1時,應該首先考慮它們的邊界。從一些歷史的經驗中可以得到一些指導:

  • b 必須在0和1之間。許多實驗以0.1左右的增量測試值,大多數實驗似乎表明最佳b的範圍在0.3-0.9 (Lipani, Lupu, Hanbury, Aizawa (2015);Taylor, Zaragoza, Craswell, Robertson, Burges (2006);Trotman, Puurula, Burgess (2014);等等)。

  • k1 通常在0到3的範圍內進行實驗。許多實驗集中在0.1到0.2的增量上,大多數實驗似乎表明最佳的k1在0.5-2.0範圍內。

對於k1可以嘗試著回答,“對於很長的文件我們什麼時候認為一項可能是飽和的?”。比如書籍,很可能在一部作品中多次出現很多不同的術語,即使這些術語與整個作品並不是高度相關。例如,“眼睛”或“眼睛”在一本小說中可以出現數百次,即使“眼睛”不是這本書的主要主題之一。然而,一本書提到“眼睛”一千次,可能與眼睛有更多的關係。在這種情況下,你可能不希望項很快飽和,所以有人建議,當文字更長更多樣化時,k1通常應該趨向於更大的數字。對於相反的情況,建議將k1設定在較低的一邊。如果一篇短篇新聞沒有與眼睛高度相關的主題,那麼它就不太可能出現幾十到幾百次的“眼睛”。

對於b可以嘗試著回答,“我們什麼時候認為文件可能很長,什麼時候這會影響到它與術語的相關性?”。高度具體的文件,如工程規範或專利是冗長的,以更具體的主題。它們的長度不太可能對相關性有害,b可能更適合更低。另一方面,涉及幾個不同的主題廣泛的方式——新聞文章(政治的文章可能涉及經濟學、國際事務和某些公司),使用者評論,等等。(通常是通過選擇一個更大的受益b這樣無關緊要的話題使用者的搜尋,包括垃圾郵件等,都受到處罰。

這些都是一般的起點,但最終應該測試設定的所有引數。這也展示了相關性是如何與相同索引中的類似文件緊密結合在一起的。

除此之外,Elasticsearch中還是很多其他演算法可供選擇,還可以通過指令碼實現自己的評分演算法。說起來,Elasticsearch為什麼將BM25作為預設評分演算法?k1 和 b 的預設值為什麼是 1.2 和 0.75呢?

簡短的答案是:工業實踐與實驗的結果,在演算法或選擇k1或b值方面似乎沒有任何銀彈,但在大多數情況下,k1 = 1.2和b = 0.75的BM25工作的非常好。

5. Elasticsearch中Shard對於評分的影響

雖然通過上面的知識我們瞭解的BM25的評分機制和引數選擇,但索引內的文件、查詢、引數並不是所有影響相關性分數的因素,索引分片也對分數有一些影響,這方面內容還是瞭解一下為好,否則出現問題的時候會一頭霧水。

上面給出的示例都是在一個分片內的搜尋,現在我們改變一下索引:

PUT book_info
{
  "settings": {
    "number_of_shards": 2,
    "number_of_routing_shards":2,
    "number_of_replicas": 0
  }
}

PUT book_info/_doc/1?routing=0
{ "book_name" : "《詩經·風》" }
PUT book_info/_doc/2?routing=0
{ "book_name" : "《詩經·雅》" }
PUT book_info/_doc/3?routing=1
{ "book_name" : "《詩經·頌》" }
PUT book_info/_doc/4?routing=0
{ "book_name" : "《道德經》" }
PUT book_info/_doc/5?routing=1
{ "book_name" : "《易經》" }

GET /book_info/_search
{
  "query": {
    "match": {
      "book_name": "詩經·頌"
    }
  }
}

在這種情況下,你會發現搜尋《詩經·頌》時,詩經風、雅的文件得分是相同的(0.603535)

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 1.4499812,
    "hits" : [
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.4499812,
        "_routing" : "1",
        "_source" : {
          "book_name" : "《詩經·頌》"
        }
      },
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.603535,
        "_routing" : "0",
        "_source" : {
          "book_name" : "《詩經·風》"
        }
      },
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.603535,
        "_routing" : "0",
        "_source" : {
          "book_name" : "《詩經·雅》"
        }
      }
    ]
  }
}

而反過來搜尋詩經風、雅的時候,另外兩本詩經的得分卻不相同:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 1.5843642,
    "hits" : [
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.5843642,
        "_routing" : "0",
        "_source" : {
          "book_name" : "《詩經·風》"
        }
      },
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.80925685,
        "_routing" : "1",
        "_source" : {
          "book_name" : "《詩經·頌》"
        }
      },
      {
        "_index" : "book_info",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.603535,
        "_routing" : "0",
        "_source" : {
          "book_name" : "《詩經·雅》"
        }
      }
    ]
  }
}

這是因為在Elasticsearch中按每個分片計算分數,而不是按整個索引計算分數。回憶一下上面BM25演算法中的詞頻和逆文件頻率部分的計算過程中,我們需要用到 索引文章中包含查詢項的文件數量索引中的文件總數,這些都是在分片內計算的。

現在用explain來驗證一下這個說法:

//在不同的分片中分別檢視分數計算情況
POST book_info/_explain/1?routing=0
{
  "query": {
    "match": { "book_name": "詩經·風" }
  }
}
//只看IDF部分的影響,注意這裡的包含查詢項的文件數量小n是2,文件總數大N是3
{
    "value" : 0.47000363,
    "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
    "details" : [
        {
            "value" : 2,
            "description" : "n, number of documents containing term",
            "details" : [ ]
        },
        {
            "value" : 3,
            "description" : "N, total number of documents with field",
            "details" : [ ]
        }
    ]
}

POST book_info/_explain/3?routing=1
{
  "query": {
    "match": { "book_name": "詩經·頌" }
  }
}
//而在分片1中,包含查詢項的文件數量小n是1,文件總數大N是2
{
    "value" : 0.6931472,
    "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
    "details" : [
        {
            "value" : 1,
            "description" : "n, number of documents containing term",
            "details" : [ ]
        },
        {
            "value" : 2,
            "description" : "N, total number of documents with field",
            "details" : [ ]
        }
    ]
},

在這種例子中,“詩經”這個詞在不同的索引分片內出現的頻率是不同的,分數計算也當然不同。

如果開始在索引中載入幾個文件,就問“為什麼文件A的分數比文件B高/低”,有時答案是碎片與文件的比率相對較高,從而使分數在不同的碎片之間傾斜。有幾種方法可以在各個碎片之間獲得更一致的分數:

  • 載入到索引中的文件越多,分片的統計資料就會變得越規範化。有了足夠多的文件,詞頻統計資料的細微差異不足以影響到每個分片中的評分細節。

  • 可以使用更低的碎片計數來減少術語頻率的統計偏差。

  • 嘗試在請求中新增search_type=dfs_query_then_fetch引數,該請求首先收集分散式詞頻(DFS =分散式詞頻搜尋),然後使用它們計算分數。這種情況下返回的分數就像是索引只有一個shard一樣。這個選項使用從執行搜尋的所有分片收集的資訊,全域性計算分散式項頻率。雖然提高了評分的準確性,但它增加了對每個分片的往返搜尋時間,可能導致更慢的搜尋請求。


參考連結


相關文章