es自定義score計算值和es返回值不同
背景
在搜尋個性化改造中,由於個性化打分耗時較長,所以不能對所有匹配的商品進行個性化打分排序,因此使用es rescore機制,第一次打分按相關性召回window size個商品,第二次對window size個商品進行個性化打分。
原先的排序邏輯為 A欄位、function A(自定義相關性打分)、B欄位、C欄位 使用sort機制進行排序,但是rescore是基於score機制,兩者只能取一種邏輯排序,因此將原先sort邏輯改造成打分外掛,將sort欄位整合至score中。
問題
自定義打分外掛返回的分數與es 返回結果中的score不一致,導致doc A打分結果比doc B大,但是doc A在doc B後面。
來看下面一個case:
query: -XPOST test_score/show/_search { "query": { "function_score": { "functions": [ { "script_score": { "script": "[doc].iid[0].value" } } ], "boost_mode": "replace" } } } 結果: { ... "hits": { "total": 2, "max_score": 40000020000, "hits": [ { "_index": "test_score", "_type": "show", "_id": "AXPghioobslXry4H06lE", "_score": 40000020000, "_source": { "iid": 40000019186.82314, "id": 1 } }, { "_index": "test_score", "_type": "show", "_id": "AXPghitBbslXry4H06lF", "_score": 40000020000, "_source": { "iid": 40000019413.37173, "id": 2 } } ] } }
score返回iid,但是在es結果中,_score都變成了40000020000,且iid為194的排在191後面,大概能猜測到是es的score有精度限制,但在哪一個過程中被限制了,是query階段、多分片資料排序階段還是最終返回的時候呢,限制的原因又是什麼呢?
帶著這個疑問,下面來看下es對score做了些什麼。
原始碼跟進
準備
自定義打分外掛打分是在Query階段,呼叫lucene的search介面,打分返回結果後的任意階段都有可能將score精度降低。
由於對es使用groovy指令碼邏輯不太熟悉,因此決定通過java編寫的打分外掛進行debug,隨後編寫了一個外掛,在啟動es時,InternalNode會載入PluginsService,外掛就是通過PluginService載入的,此處為了方便直接通過硬編碼方式將打分外掛進行載入(筆者的es版本為1.6,高版本載入方式各不相同,例如5.5版本可以通過啟動初始化Node類的時候增加載入外掛)。
debug過程
首先來確定打分外掛返回值,在return處返回的確實為4.000001918682314E10精確值,下面是打分外掛部分程式碼。
public static class SourceScoreScript extends AbstractDoubleSearchScript {
public SourceScoreScript(Map<String, Object> params) {
}
@Override
public double runAsDouble() {
DocLookup docLookup = this.doc();
ScriptDocValues.Doubles iid = (ScriptDocValues.Doubles) docLookup.get("iid");
return iid.getValue();
}
}
然後跟著呼叫棧慢慢往外走,到ScriptScoreFunction類呼叫外掛進行打分,返回的結果也是精確值。
public class ScriptScoreFunction extends ScoreFunction {
//es呼叫該方法,使用自定義的打分外掛進行打分
public double score(int docId, float subQueryScore) {
script.setNextDocId(docId);
scorer.docid = docId;
scorer.score = subQueryScore;
return script.runAsDouble();
}
}
繼續往外,一下就發現了有問題的地方,FunctionFactorScorer類的innerScore的返回值竟然是float,會不會就是這個轉換導致精度丟失呢?先來看下程式碼:
static class FunctionFactorScorer extends CustomBoostFactorScorer {
...
// 方法內部將自定義打分外掛的分值和es召回的評分做整合
@Override
public float innerScore() throws IOException {
//返回的分值是float
float score = scorer.score();
if (function == null) {
return subQueryBoost * score;
} else {
return scoreCombiner.combine(subQueryBoost, score,
function.score(scorer.docID(), score), maxBoost);
}
}
}
public enum CombineFunction {
...
//query中設定replace用打分外掛分數替代es的tf-idf分數
REPLACE {
@Override
//toFloat將double的funScore轉換成float
public float combine(double queryBoost, double queryScore, double funcScore, double maxBoost) {
return toFloat(queryBoost * Math.min(funcScore, maxBoost));
}
...
}
}
//toFloat強轉
public static float toFloat(double input) {
assert deviation(input) <= 0.001 : "input " + input + " out of float scope for function score deviation: " + deviation(input);
return (float) input;
}
測試4.000001918682314E10用float儲存,確實輸出的值為40000020000,到這裡其實就已經明白為什麼自定義score計算值與Es最終返回值不同,在query階段就已經被降精度了。
瞭解精度丟失的原因,那麼float最多能支援多少位的精度呢?查閱資料後,發現float的尾數位是23位,因此精度為2^23=8388608,最多能保證6-7位的精確度,因此使用es自定義打分需要注意score值最好不大於8388608這個值。
轉換的原因
為什麼es提供一個double的打分介面,卻又轉換成float返回呢?有沒有可能我修改FunctionFactorScorer的innerScore介面,保證精度不丟失呢?
再檢視方法棧,Lucene是通過collector收集器進行文件的召回,在collector呼叫collect()方法召回資料時,內部通過Score.score()方法,而該抽象方法就是float的,es為了適配collect方法,進行了一層轉換。
private static class OutOfOrderTopScoreDocCollector extends TopScoreDocCollector {
@Override
public void collect(int doc) throws IOException {
//通過此處進行打分
float score = scorer.score();
// This collector cannot handle NaN
assert !Float.isNaN(score);
totalHits++;
if (score < pqTop.score) {
// Doesn't compete w/ bottom entry in queue
return;
}
doc += docBase;
if (score == pqTop.score && doc > pqTop.doc) {
// Break tie in score by doc ID:
return;
}
pqTop.doc = doc;
pqTop.score = score;
pqTop = pq.updateTop();
}
}
public abstract class Scorer extends DocsEnum {
//float的抽象方法
/** Returns the score of the current document matching the query.
* Initially invalid, until {@link #nextDoc()} or {@link #advance(int)}
* is called the first time, or when called from within
* {@link Collector#collect}.
*/
public abstract float score() throws IOException;
}
總結
到這裡,上面的幾個疑惑基本解開了,在此小結一下:
- es的打分外掛返回的分數會被強轉成float型別,只能保證6-7位的精度。
- es進行強轉的原因主要是Lucene收集器的打分介面是返回float型別,檢視8.4版本Lucene該介面依然為float型別。
- 至於為什麼lucene要將該方法抽象成float返回值這個問題,翻閱資料後依舊未找到解釋,希望瞭解的人能解答我這個困惑。