原創:史上對BM25模型最全面最深刻的解讀以及lucene排序深入講解


  垂直搜索結果的優化包括對搜索結果的控制和排序優化兩方面,其中排序又是重中之重。本文將全面深入探討垂直搜索的排序模型的演化過程,最后推導出BM25模型的排序。然后將演示如何修改lucene的排序源代碼,下一篇將深入解讀目前比較火熱的機器學習排序在垂直搜索中的應用。本文的結構如下:

一、VSM模型簡單介紹;

二、lucene默認的評分公式介紹;

三、概率語言模型中的二元獨立模型BIM介紹;

四、BM25介紹;

五、lucene中的edismax解析器介紹以及評分公式源代碼介紹;

六、修改排序源代碼;

七、機器學習排序:①為什么需要機器學習排序②機器學習排序相關算法介紹③關於ListNet算法的英語原版學術論文的解讀④機器學習排序實施思路

  寫這篇文章,花費了很大的精力,一部分是對原有的經驗和技術的總結,另一方面又必然會涉及到改進和新技術的探索。任何開源框架都不是最完美的,之前探索了對lucene內部boolean查詢AND邏輯實現的算法的改進,作為一個優秀的開源框架,lucene有很多閃光的地方值得借鑒,比如優先級隊列的設計。lucene在搜索排序方面的設計思想是最傑出的一個閃光點,因此有必要從理論層面進行全面探討,然后看看lucene是如何進行簡化的。因為在工程實際應用中,尤其是設計一款優秀的開源框架或者是企業級的應用級軟件,一定會在准確度和時間復雜度上折中處理。比如,BM25排序lucene就進行了簡化。目前,在業內,機器學習排序的關於listwise方法很火熱,早在2007年,微軟研究院就研究出了listnet方法,用神經網絡構造luce概率模型,運用交叉熵構造損失函數,采用SGD作為優化方法。但是,截止到2014之前,貌似Google也沒有采用機器學習排序方法。也就是說,每個公司,只有探索出適合自己的算法,才是最優化的。脫離了應用場景,算法就變得毫無意義了,即使在理論上是最優的,實際情況有可能是很拙劣的。就技術創新而言,70%以上來源於對原有技術的整合,但是整合不等同於抄襲或者簡單的拼湊。比如listnet機器學習排序方法,在原有算法(pairwise)上提出改進。任何算法都不是憑空產生的,數學模型絕大部分來源於觀察,總結歸納,演繹推理,遷移,改進,這種思維方法的培養,遠遠勝於知識量的積累,就像程序員想提升編程水平,僅僅靠代碼量的堆積,是拙劣的,不可行的。本文在提出修改lucene底層排序源代碼之前,也是認真研讀了很多經典著作,並且詳細分析了lucene源代碼的實現,結合具體的業務需求,經過多次測試和改進才有所進展。冰凍三尺,非一日之寒,在程序員的道路上,只有多讀經典,多實踐,多思考,敢於提出自己的想法,本着大膽假設,小心求證的原則,不斷試錯和探索,才會取得一點兒成就。

  信息論在信息檢索中發揮了重要的作用,之前有人問我,如果在搜索框中輸入蘋果,你如何判斷用戶想要的是蘋果手機還是蘋果電腦,還是蘋果水果本身?我相信,在搜索領域,應該不止一個人會提出這樣的問題。很遺憾的是,如果你把它當作研究方向,我只能說你應該重修《信息論》這門課程。牛頓在年輕時曾經對永動機非常瘋狂,后來認識到了是個偽命題,及時收手了。犯錯誤並不可怕,可怕的是認識不到這是個錯誤方向。按照信息論,信息檢索的本質是不斷減少信息不確定的過程,也就是減少信息熵的過程。蘋果的信息熵很大,也就是不確定特別大,信息檢索的目標是減少這個不確定性,方法是增加特征信息。在進入搜索系統之前,可以增加一些分類信息,然后在排序過程中,可以考慮增加一些有用的因素,比如pagerank,point等等。這些手段都是為了一個共同的目標:減少信息的不確定性。如果方向搞錯了,即使搞出一個算法來,效果也不會太好,不會具有普適性。在第三代搜索系統的研發中,目前百度已經走在了前列,度秘機器人v3.0版本跟之前相比,有了很大的提升。以之前提出的問題為例,如果單獨對度秘說出蘋果,她很難知道用戶的需求,但是如果你對她說:"我想要蘋果"和"我想吃蘋果",這下度秘就知道了用戶的准確需求了。很顯然,吃蘋果中的蘋果是水果,如果什么特征信息都沒有,再智能的機器人也無法判斷。也許有人會說,我可以在進入搜索系統之前,分詞之后,挖掘用戶(id)的歷史記錄,如果之前買水果的幾率比較大,就判斷為水果。這種方法毫無意義,無異於猜謎。第三代智能化的搜索,主要體現在個性化,能夠理解部分人的意圖和情感,個性化的推薦,人機智能問答等等。RNN(遞歸神經網絡)將發揮重要作用,包括機器翻譯。。。其中,消除歧義分詞,語義分析是重中之重。比如吃蘋果,分詞結果是吃/蘋果,蘋果的語義標注有很多,例如水果,手機,電腦,logo等等。基於CRF和viterbi算法,可以預測出這句話中的蘋果語義是水果,這樣在搜索時,就可以構造出搜索詞:蘋果水果的分類,降低了不確定性。中文分詞是nlp的基礎,而信息檢索又離不開NLP。在目前國內的聊天機器人中,度秘是最優秀的,小黃雞等還差的很遠。在學習RNN等深度學習技術之前,一定要把基礎性的知識學好,不可好高騖遠。下面進入到第一部分:

 第一部分:VSM

VSM簡稱向量空間模型,主要用於計算文檔的相似度。計算文檔相似度時,需要提取重要特征。特征提取一般用最通用常規的方法:TF-IDF算法。這個方法非常簡單但是卻非常實用。給你一篇文章,用中文分詞工具(目前最好的是opennlp社區中的開源源碼包HanLP)對文檔進行切分,處理成詞向量(去除停詞后的結果),然后計算TF-IDF,按降序排列,排在前幾位的就是重要特征。這里不論述TF-IDF,因為太簡單了。那么對於一個查詢q來說,經過分詞處理后形成查詢向量T[t1,t2……],給每個t賦予權重值,假設總共查詢到n個文檔,把每個文檔處理成向量(按t處理),計算每個t在各自文檔中的TF-IDF。然后分別計算與T向量的余弦相似度,得出的分數按降序排列。

 

 

VSM的本質是:計算查詢和文檔內容的相似度。沒有考慮到相關性。因為用戶輸入一個查詢,最想得到的是相關度大的文檔,而不只是這個文檔中出現了查詢詞。因為某篇文檔出現了查詢詞,也不一定是相關性的,所以需要引入概率模型。后面要講的BIM還有BM25本質是:計算查詢和用戶需求的相似度。所以BM25會有很好的表現。而lucen底層默認的評分擴展了VSM。下面進入第二部分,lucent的默認評分公式:

二、lucene默認的評分公式介紹

Lucene 評分體系/機制(lucene scoring)是 Lucene 出名的一核心部分。它對用戶來說隱藏了很多復雜的細節,致使用戶可以簡單地使用 lucene。但個人覺得:如果要根據自己的應用調節評分(或結構排序),十分有必須深入了解 lucene 的評分機制。

Lucene scoring 組合使用了 信息檢索的向量空間模型 和 布爾模型 。

首先來看下 lucene 的評分公式(在 Similarity 類里的說明)

score(q,d)   =   coord(q,d) ·  queryNorm(q) · ( tf(t in d) ·  idf(t)2 ·  t.getBoost() ·  norm(t,d) )
 
t in q
 

其中:

    1. tf(t in d) 關聯到項頻率,項頻率是指 項 t 在 文檔 d 中出現的次數 frequency。默認的實現是:
      tf(t in d) = frequency½
    2. idf(t) 關聯到反轉文檔頻率,文檔頻率指出現 項 t 的文檔數 docFreq。docFreq 越少 idf 就越高(物以稀為貴),但在同一個查詢下些值是相同的。默認實現:
      idf(t) = 1 + log (
      numDocs
      –––––––––
      docFreq+1
      )
    3. 關於idf(t)應該這樣認識:一個詞語在文檔集合中出現了n次,文檔集合總數為N。idf(t)來源於信息論。那么每篇文檔出現這個詞語的概率為:n/N,所以這篇文檔出現這個詞語的信息量為:-log(n/N)。這個和信息熵有些類似(-P(x)logP(x)),在數據挖掘的過濾法進行特征選擇時,需要用到互信息,其實是計算信息增益,還有決策樹。把-log(n/N)變換一下,log(N/n),為了避免0的出現,進行平滑處理,就是上面的公式(就像朴素貝葉斯需要拉普拉斯平滑處理一樣)。
    4. coord(q,d) 評分因子,是基於文檔中出現查詢項的個數。越多的查詢項在一個文檔中,說明些文檔的匹配程序越高。默認是出現查詢項的百分比。
    5. queryNorm(q)查詢的標准查詢,使不同查詢之間可以比較。此因子不影響文檔的排序,因為所有有文檔 都會使用此因子。默認值:
      queryNorm(q)   =   queryNorm(sumOfSquaredWeights) =
      1
      ––––––––––––––
      sumOfSquaredWeights½

      每個查詢項權重的平分方和(sumOfSquaredWeights)由 Weight 類完成。例如 BooleanQuery 地計算:

      sumOfSquaredWeights =   q.getBoost() 2 · ( idf(t) ·  t.getBoost() ) 2
       
      t in q  
    6. t.getBoost()查詢時期的 項 t 加權(如:java^1.2),或者由程序使用 setBoost()。
    7. norm(t,d)壓縮幾個索引期間的加權和長度因子:
      • Document boost - 文檔加權,在索引之前使用 doc.setBoost()
      • Field boost - 字段加權,也在索引之前調用 field.setBoost()
      • lengthNorm(field) - 由字段內的 Token 的個數來計算此值,字段越短,評分越高,在做索引的時候由 Similarity.lengthNorm 計算。
      以上所有因子相乘得出 norm 值,如果文檔中有相同的字段,它們的加權也會相乘:
      norm(t,d)   =   doc.getBoost() ·  lengthNorm(field) · f.getBoost()
       
      field f in d named as t
       

      索引的時候,把 norm 值壓縮(encode)成一個 byte 保存在索引中。搜索的時候再把索引中 norm 值解壓(decode)成一個 float 值,這個 encode/decode 由 Similarity 提供。官方說:這個過程由於精度問題,以至不是可逆的,如:decode(encode(0.89)) = 0.75。

 

總體來說,這個評分公式仍然是基於查詢與文檔內容的相似度計算分數。而且,lengthNorm(field) = 1/sqrt(numTerms),即文檔的索引列越長,分值越低。這個顯然是不合理的,需要改進。而且這個評分公式僅僅考慮了查詢詞在文檔向量中的TF,並沒有考慮在T(查詢向量)中的TF,而且,如果一篇文檔越長,它的TF一般會越高,會成一定的正相關性。這對於短文檔來說計算TF是不公平的。在用這個公式打分的時候,需要對文檔向量歸一化處理,其中的lengthNorm如何處理是個問題。舉個例子,在用球拍打羽毛球的時候,球拍會有一個最佳擊球和回球的區域,被成為"甜區"。在處理文檔向量的長度時候,我們同樣可以規定一個"甜區",比如min/max,超過這個范圍的,lengthNorm設置為1。基於以上缺點,需要改進排序模型,讓查詢和用戶的需求更加相關,所以提出了概率模型,下面進入第三部分:

三、概率語言模型中的二元獨立模型BIM介紹

概率檢索模型是從概率排序原理推導出來的,所以理解這一原理對於理解概率檢索模型非常重要。概率排序模型的思想是:給定一個查詢,返回的文檔能夠按照查詢和用戶需求的相關性得分高低排序。這是一種對用戶需求相關性建模的方法。按照如下思路進行思考:首先,我們可以對查詢后得到的文檔進行分類:相關文檔和非相關文檔。這個可以按照朴素貝葉斯的生成學習模型進行考慮。如果這個文檔屬於相關性的概率大於非相關性的,那么它就是相關性文檔,反之屬於非相關性文檔。所以,引入概率模型:P(R|D)是一個文檔相關性的概率,P(NR|D)是一個文檔非相關性的概率。如果P(R|D) > P(NR|D),說明它與查詢相關,是用戶想要的。按照這個思路繼續,怎樣才能計算這個概率呢?如果你熟悉朴素貝葉斯的話,就容易了。P(R|D) = P(D|R)P(R)/P(D),P(NR|D) = P(D|NR)P(NR)/P(D)。用概率模型計算相關性的目的就是判斷一個文檔是否P(R|D) > P(NR|D),即P(D|R)P(R)/P(D) > P(D|NR)P(NR)/P(D) <=> P(D|R)P(R) > P(D|NR)P(NR) <=> P(D|R)/P(D|NR) > P(NR)/P(R)。對於搜索來說,並不需要真的進行分類,只需計算P(D|R)/P(D|NR)然后按降序排列即可。於是引入二元獨立模型(Binary Independent Model) 假設=>

①二元假設:在對文檔向量進行數據建模時,假設特征的值屬於Bernoulli分布,其值為0或者1(朴素貝葉斯就適用於特整值和分類值都屬於Bernoulli分布的情況,而loggistic Regression適用於分類值為Bernoulli分布)。在文本處理領域,就是這個特征在文檔中出現或者不出現,不考慮詞頻。

②詞匯獨立性假設:假設構成每個特征的詞是相互獨立的,不存在關聯性。在機器學習領域里,進行聯合似然估計或者條件似然估計時,都是假設數據遵循iid分布。事實上,詞匯獨立假設是非常不合理的。比如"喬布斯"和"ipad"和"蘋果"是存在關聯的。

有了上面的假設,就可以計算概率了。比如,有一篇文檔D,查詢向量由5個Term組成,在D中的分布情況如下:[1,0,1,01]。那么,P(D|R) = P1*(1-P2)*P3*(1-P4)*P5。Pi為特征在D中出現的概率,第二個和第四個詞匯沒有出現,所以用(1-P2)和(1-P4)。這是文檔屬於相關性的概率,生成模型還需要計算非相關性的概率情況。用Si表示特征在非相關性文檔中出現的概率,那么P(D|NR)=S1*(1-S2)*S3*(1-S4)*S5。=>

,這個公式中第一項代表在D中出現的各個特征概率乘積,第二項表示沒有在D中出現的概率乘積。進一步變換得到:

這個公式里,第一部分是文檔里出現的特征概率乘積,第二項是所有特征的概率乘積,是從全局計算得出。對於特定的文檔,第二項對排序沒有影響,計算結果都是一樣的,所以去掉。於是,得出最終結果:。為了計算方便,對這個公式取對數:。進一步求解這個公式:。其中,N表示文檔集合總數,R表示相關文檔總數,那么N-R就是非相關文檔數目,ni表示包含特征di的文檔數目,在這其中屬於相關文檔的數目是ri。於是,。當出現一個查詢q和返回文檔時,只需計算出現的特征的概率乘積,和朴素貝葉斯的predict原理是一樣的。這個公式,在特定情況下可以轉化為IDF模型。上述公式就是BM25模型的基礎。下面來講述第四部分。

四、BM25模型

BIM模型基於二元獨立假設推導出,只考慮特征是否出現,不考慮TF因素。那么,如果在這個基礎之上再考慮Tf因素的話,會更加完美,於是,有人提出了BM25模型。加入了詞匯再查詢向量中的權值以及在文檔中的權值還有一系列經驗因子。公式如下:

 

第一項就是BIM模型推導出的公式,因為在搜索的時候,我們不知道哪些是相關的哪些不是相關的,所以把ri和R設置為0,於是,第一項退化成了

就是IDF!非常神奇!,fi是特征在文檔D中的TF權值,qfi是特征在查詢向量中的TF權值。一般情況下,k1=1.2,b=0.75,k2=200.當查詢向量比較短的時候,qfi通常取值為1。分析來看,當K1=0時,第二項不起作用,也就是不考慮特征在文檔中的TF權值,當k2=0時,第三項也失效。從中可以看出,k1和k2值是對特征在文檔或者查詢向量中TF權值的懲罰因子。綜合來看,BM25考慮了4個因素:IDF因子,文檔長度因子,文檔詞頻因子和查詢詞頻因子。lucene內部的BM25要比上面公式的簡單一些,個人認為並不是很好。其實lucene內部有很多的算法並不是最優的,有待提升!有了以上4個部分,相信大部分人會對lucene的評分公式有了很深入的了解,下面進入源代碼解讀和修改階段,主要是為了能夠滿足根據時間業務場景自定義排序。進入第五部分:

五、edismax解析器介紹:

之所以介紹這個查詢解析器,是因為特殊的業務場景需要。lucene的源碼包中,兩大核心包,org.apache.lucene.index和org.apache.lucene.search。其中第一個包會調用store、util和document子包,第二個會和queryParser和analysis、message子包交互。在查詢中,最重要的就是queryParser。當用戶輸入查詢字符串后,調用lucene的查詢服務,要調用QueryParser類,第一步是調用analyzer(分詞)形成查詢向量T[t1,t2……tn],這一步是詞法分析,接下來是句法分析,形成查詢語法,即先形成QueryNode--->QueryTree  .t1和t2之間是邏輯與的關系,用Boolean查詢。這樣lucene就能理解查詢語法了。為了加深理解,先看一段代碼:

package com.txq.lucene.queryParser;

import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
import org.apache.lucene.analysis.tokenattributes.TypeAttribute;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.util.Version;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.index.Term;
/**
 * 自定義一個查詢解析器,BooleanQuery
 * @author XueQiang Tong
 *
 */
public class BlankAndQueryParser extends QueryParser {    
    // analyzer = new IKAnalyzer(false);
    public BlankAndQueryParser(Version matchVersion, String field, Analyzer analyzer) {
        super(matchVersion, field, analyzer);        
    }
    
    protected Query getFieldQuery(String field,String queryText,int slop) throws ParseException{
        try {
            TokenStream ts = this.getAnalyzer().tokenStream(field, new StringReader(queryText));
            OffsetAttribute offset = (OffsetAttribute) ts.addAttribute(OffsetAttribute.class);
            CharTermAttribute term = (CharTermAttribute) ts.addAttribute(CharTermAttribute.class);
            TypeAttribute type = (TypeAttribute) ts.addAttribute(TypeAttribute.class);
            ts.reset();
            ArrayList<CharTermAttribute> v = new ArrayList<CharTermAttribute>();
            while (ts.incrementToken()) {
//                System.out.println(offset.startOffset() + " - "
//                        + offset.endOffset() + " : " + term.toString() + " | "
//                        + type.type());
                if(term.toString() == null){
                    break;
                }
                v.add(term);                
            }
            ts.end();
            ts.close();
            if(v.size() == 0){
                return null;
            } else if (v.size() == 1){
                return new TermQuery(new Term(field,v.get(0).toString()));
            } else {
                PhraseQuery q = new PhraseQuery();
                BooleanQuery b = new BooleanQuery();
                q.setBoost(2048.0f);
                b.setBoost(0.001f);
                for(int i = 0;i < v.size();i++){
                    CharTermAttribute  t = v.get(i);
                    //q.add(new Term(field,t.toString()));
                    TermQuery tmp = new TermQuery(new Term(field,t.toString()));
                    tmp.setBoost(0.01f);
                    b.add(tmp, Occur.MUST);
                }
                return b;
            }
            
        } catch (IOException e) {            
            e.printStackTrace();
        }
        return null;        
    }
    
    protected Query getFieldQuery(String field,String queryText) throws ParseException{
        return getFieldQuery(field,queryText,0);        
    }
}
上面這段代碼,展示了QueryParser的基本步驟,需要分詞器,我用的是去年自己寫的基於逆向最大匹配算法的分詞器(由IK分詞改造而來)。從上面能夠基本了解BooleanQuery的工作原理了。去年寫的兩個數組取交集的算法,就是布爾查詢為AND的邏輯問題抽象。短語查詢精確度最高,在倒排索引項中存儲有詞元的位置信息,就是提供短語查詢功能支持的。

現在回歸到第五部分的內容,現在有一個排序業務場景:有一個電商平台,交易量非常火,點擊量比較大,需要自定義一個更加合理的符合自己公司的排序需求。先提出如下需求:要求按照入住商家的時間,商家是否為VIP以及商品的點擊率(point)綜合考慮三者因素得出最后的評分。為了完成這樣一項排序任務,先梳理以下思路:這個排序要求,按照lucene現有的score公式肯定滿足不了,這是屬於用戶在外部自定義的排序規則,與底層的排序規則不相干。能夠滿足這樣需求的只有solr的edismax解析器。所以按照如下思路:先了解一下edismax怎么使用(比如可以在外部定義linear函數實現規則),然后還需要解析器的內部原理(看源代碼),看看它與底層的score有何種關系(不可能沒有關系,所以需要深入研讀源代碼,看看有沒有必要修改lucene底層的score源代碼)。按照上面的思路開展工作,查看源代碼后發現,最終得分是外部傳遞的評分函數與底層score的乘積,dismax解析器是相加。如果用dismax解析器的話,相加不能突出上述規則的作用,所以最好用edismax解析器。從理論上分析,如果底層使用VSM模型或者是BM25模型的話,score打分會對業務排序規則產生影響,比如有的商家是VIP,點擊率很高,但是底層的score可能很低,這樣一相乘的話,最后得分就不准確了,所以需要把底層的score寫死,改為1,消除影響。所以,按照這個規則,評分函數,點擊率高的排在前面(在point前設置比較高的權重)是比較合理的。

為了驗證以上想法的正確性,可以先定義評分函數,不修改底層的score,看看排序效果,排序是混亂的 。所以,根據上面的分析,需要從基本的lucene底層的score打分源代碼開始研究,然后edismax源代碼。在修改lucene的score源代碼的時候,最好不要用jd-gui反編譯工具,最開始用的時候,得到的代碼只有部分是正確的。用maven構建項目時,直接下載以來的包,包括lucene-core-4.9.0-sources.jar,修改源碼包,然后重新編譯打包,替換掉原來的包。這是一項繁瑣的工程,包括后面的博客中介紹的機器學習排序,構建文檔數據特征時,需要獲取BM25信息,同樣需要lucene源代碼,構建訓練系統和預測系統。

lucene評分流程:以BooleanQuery為例,可以參看上面寫的QueryParser,BooleanQuery需要用到TermQuery,那么這個打分就由它完成。TermQuery繼承了Query,所以需要實現createWeight方法,得到的是Weight的子類TermWeight。TermWeight需要實現scorer方法得到Scorer,然后調用Scorer的score方法。先看一下TermQuery的createWeight:

public Weight createWeight(IndexSearcher searcher)
        throws IOException
{
        IndexReaderContext context = searcher.getTopReaderContext();
        TermContext termState;
        if(perReaderTermState == null || perReaderTermState.topReaderContext != context)
            termState = TermContext.build(context, term);
        else
            termState = perReaderTermState;
        if(docFreq != -1)
            termState.setDocFreq(docFreq);
        return new TermWeight(searcher, termState);
}

查詢文檔由IndexSearcher完成,然后得到TermWeight類。再看看TermWeight的scorer:

public Scorer scorer(AtomicReaderContext context, Bits acceptDocs)
            throws IOException
 {
            if(!$assertionsDisabled && termStates.topReaderContext != ReaderUtil.getTopLevelContext(context))
                throw new AssertionError((new StringBuilder()).append("The top-reader used to create Weight (").append(termStates.topReaderContext).append(") is not the same as the current reader's top-reader (").append(ReaderUtil.getTopLevelContext(context)).toString());
            TermsEnum termsEnum = getTermsEnum(context);
            if(termsEnum == null)
                return null;
            DocsEnum docs = termsEnum.docs(acceptDocs, null);
            if(!$assertionsDisabled && docs == null)
                throw new AssertionError();
            else
                return new TermScorer(this, docs, similarity.simScorer(stats, context));
}

 得到了TermScorer類。調用這個對象的score方法(調用了Similarity)

public float score()
        throws IOException
 {
        if(!$assertionsDisabled && docID() == 2147483647)
            throw new AssertionError();
        else
            return docScorer.score(docsEnum.docID(), docsEnum.freq());
 }

 private final org.apache.lucene.search.similarities.Similarity.SimScorer docScorer;//這是Similarity的內部抽象類

 docScorer.score方法由很多實現者,這里用BM25Similarity extends Similarity,主要實現SimScorer的explain方法,這是最終打分的函數,通過Explain對象獲取到得分。

public abstract class Similarity
{
    public static abstract class SimWeight
    {

        public abstract float getValueForNormalization();

        public abstract void normalize(float f, float f1);

        public SimWeight()
        {
        }
    }

    public static abstract class SimScorer
    {

        public abstract float score(int i, float f);

        public abstract float computeSlopFactor(int i);

        public abstract float computePayloadFactor(int i, int j, int k, BytesRef bytesref);

        public Explanation explain(int doc, Explanation freq)
        {
            Explanation result = new Explanation(score(doc, freq.getValue()), (new StringBuilder()).append("score(doc=").append(doc).append(",freq=").append(freq.getValue()).append("), with freq of:").toString());
            result.addDetail(freq);
            return result;
        }

        public SimScorer()
        {
        }
    }

 看一看BM25Similarity:

package org.apache.lucene.search.similarities;

import java.io.IOException;
import org.apache.lucene.index.*;
import org.apache.lucene.search.*;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.SmallFloat;

public class BM25Similarity extends Similarity
{
    private static class BM25Stats extends Similarity.SimWeight
    {

        public float getValueForNormalization()
        {
            float queryWeight = idf.getValue() * queryBoost;
            return queryWeight * queryWeight;
        }

        public void normalize(float queryNorm, float topLevelBoost)
        {
            this.topLevelBoost = topLevelBoost;
            weight = idf.getValue() * queryBoost * topLevelBoost;
        }

        private final Explanation idf;
        private final float avgdl;
        private final float queryBoost;
        private float topLevelBoost;
        private float weight;
        private final String field;
        private final float cache[];

        BM25Stats(String field, Explanation idf, float queryBoost, float avgdl, float cache[])
        {
            this.field = field;
            this.idf = idf;
            this.queryBoost = queryBoost;
            this.avgdl = avgdl;
            this.cache = cache;
        }
    }

    private class BM25DocScorer extends Similarity.SimScorer
    {

        public float score(int doc, float freq)
        {
            float norm = norms != null ? cache[(byte)(int)norms.get(doc) & 255] : k1;
            return (weightValue * freq) / (freq + norm);
        }

        public Explanation explain(int doc, Explanation freq)
        {
            return explainScore(doc, freq, stats, norms);//這是最終打分函數
        }

        public float computeSlopFactor(int distance)
        {
            return sloppyFreq(distance);
        }

        public float computePayloadFactor(int doc, int start, int end, BytesRef payload)
        {
            return scorePayload(doc, start, end, payload);
        }

        private final BM25Stats stats;
        private final float weightValue;
        private final NumericDocValues norms;
        private final float cache[];
        final BM25Similarity this$0;

        BM25DocScorer(BM25Stats stats, NumericDocValues norms)
            throws IOException
        {
            this$0 = BM25Similarity.this;
            super();
            this.stats = stats;
            weightValue = stats.weight * (k1 + 1.0F);
            cache = stats.cache;
            this.norms = norms;
        }
    }


    public BM25Similarity(float k1, float b)
    {
        discountOverlaps = true;
        this.k1 = k1;
        this.b = b;
    }

    public BM25Similarity()
    {
        discountOverlaps = true;
        k1 = 1.2F;
        b = 0.75F;
    }

    protected float idf(long docFreq, long numDocs)
    {
        return (float)Math.log(1.0D + ((double)(numDocs - docFreq) + 0.5D) / ((double)docFreq + 0.5D));
    }

    protected float sloppyFreq(int distance)
    {
        return 1.0F / (float)(distance + 1);
    }

    protected float scorePayload(int doc, int start, int end, BytesRef bytesref)
    {
        return 1.0F;
    }

    protected float avgFieldLength(CollectionStatistics collectionStats)
    {
        long sumTotalTermFreq = collectionStats.sumTotalTermFreq();
        if(sumTotalTermFreq <= 0L)
            return 1.0F;
        else
            return (float)((double)sumTotalTermFreq / (double)collectionStats.maxDoc());
    }

    protected byte encodeNormValue(float boost, int fieldLength)
    {
        return SmallFloat.floatToByte315(boost / (float)Math.sqrt(fieldLength));
    }

    protected float decodeNormValue(byte b)
    {
        return NORM_TABLE[b & 255];
    }

    public void setDiscountOverlaps(boolean v)
    {
        discountOverlaps = v;
    }

    public boolean getDiscountOverlaps()
    {
        return discountOverlaps;
    }

    public final long computeNorm(FieldInvertState state)
    {
        int numTerms = discountOverlaps ? state.getLength() - state.getNumOverlap() : state.getLength();
        return (long)encodeNormValue(state.getBoost(), numTerms);
    }

    public Explanation idfExplain(CollectionStatistics collectionStats, TermStatistics termStats)
    {
        long df = termStats.docFreq();
        long max = collectionStats.maxDoc();
        float idf = idf(df, max);
        return new Explanation(idf, (new StringBuilder()).append("idf(docFreq=").append(df).append(", maxDocs=").append(max).append(")").toString());
    }

    public Explanation idfExplain(CollectionStatistics collectionStats, TermStatistics termStats[])
    {
        long max = collectionStats.maxDoc();
        float idf = 0.0F;
        Explanation exp = new Explanation();
        exp.setDescription("idf(), sum of:");
        TermStatistics arr$[] = termStats;
        int len$ = arr$.length;
        for(int i$ = 0; i$ < len$; i$++)
        {
            TermStatistics stat = arr$[i$];
            long df = stat.docFreq();
            float termIdf = idf(df, max);
            exp.addDetail(new Explanation(termIdf, (new StringBuilder()).append("idf(docFreq=").append(df).append(", maxDocs=").append(max).append(")").toString()));
            idf += termIdf;
        }

        exp.setValue(idf);
        return exp;
    }

    public final transient Similarity.SimWeight computeWeight(float queryBoost, CollectionStatistics collectionStats, TermStatistics termStats[])
    {
        Explanation idf = termStats.length != 1 ? idfExplain(collectionStats, termStats) : idfExplain(collectionStats, termStats[0]);
        float avgdl = avgFieldLength(collectionStats);
        float cache[] = new float[256];
        for(int i = 0; i < cache.length; i++)
            cache[i] = k1 * ((1.0F - b) + (b * decodeNormValue((byte)i)) / avgdl);

        return new BM25Stats(collectionStats.field(), idf, queryBoost, avgdl, cache);
    }

    public final Similarity.SimScorer simScorer(Similarity.SimWeight stats, AtomicReaderContext context)
        throws IOException
    {
        BM25Stats bm25stats = (BM25Stats)stats;
        return new BM25DocScorer(bm25stats, context.reader().getNormValues(bm25stats.field));
    }

    private Explanation explainScore(int doc, Explanation freq, BM25Stats stats, NumericDocValues norms)
    {
        Explanation result = new Explanation();
        result.setDescription((new StringBuilder()).append("score(doc=").append(doc).append(",freq=").append(freq).append("), product of:").toString());
        Explanation boostExpl = new Explanation(stats.queryBoost * stats.topLevelBoost, "boost");
        if(boostExpl.getValue() != 1.0F)
            result.addDetail(boostExpl);
        result.addDetail(stats.idf);
        Explanation tfNormExpl = new Explanation();
        tfNormExpl.setDescription("tfNorm, computed from:");
        tfNormExpl.addDetail(freq);
        tfNormExpl.addDetail(new Explanation(k1, "parameter k1"));
        if(norms == null)
        {
            tfNormExpl.addDetail(new Explanation(0.0F, "parameter b (norms omitted for field)"));
            tfNormExpl.setValue((freq.getValue() * (k1 + 1.0F)) / (freq.getValue() + k1));
        } else
        {
            float doclen = decodeNormValue((byte)(int)norms.get(doc));
            tfNormExpl.addDetail(new Explanation(b, "parameter b"));
            tfNormExpl.addDetail(new Explanation(stats.avgdl, "avgFieldLength"));
            tfNormExpl.addDetail(new Explanation(doclen, "fieldLength"));
            tfNormExpl.setValue((freq.getValue() * (k1 + 1.0F)) / (freq.getValue() + k1 * ((1.0F - b) + (b * doclen) / stats.avgdl)));
        }
        result.addDetail(tfNormExpl);
        result.setValue(boostExpl.getValue() * stats.idf.getValue() * tfNormExpl.getValue());
        return result;
    }

    public String toString()
    {
        return (new StringBuilder()).append("BM25(k1=").append(k1).append(",b=").append(b).append(")").toString();
    }

    public float getK1()
    {
        return k1;
    }

    public float getB()
    {
        return b;
    }

    private final float k1;
    private final float b;
    protected boolean discountOverlaps;
    private static final float NORM_TABLE[];

    static
    {
        NORM_TABLE = new float[256];
        for(int i = 0; i < 256; i++)
        {
            float f = SmallFloat.byte315ToFloat((byte)i);
            NORM_TABLE[i] = 1.0F / (f * f);
        }

    }

}

從上面的代碼可以看出,要修改的話,就修改explainScore方法,把影響因素全部改為1就行了,因為獲取評分是通過Explain對象。

以上就是lucene底層的評分流程:BooleanQuery----->TermQuery----->createWeight----->TermWeight.scorer()------>TermScorer----TermScorer.score()------->(Similarity內部抽象類)SimScorer.explain()------->BM25Similarity的explainScore方法。最后看一下edismax解析器的源代碼:


六、為機器學習排序做准備:

下面進一步深入思考,加入由如下示例的查詢:

 String[] fields = {"name","content"};  
        QueryParser queryParser = new MultiFieldQueryParser(matchVersion, fields,analyzer);  
        Query query = queryParser.parse(queryString);  
        BooleanQuery bq = new BooleanQuery();
        bq.add(query, Occur.MUST);
        IndexSearcher indexSearcher = new IndexSearcher((IndexReader)DirectoryReader.open(FSDirectory.open(new File("/Users/ChinaMWorld/Desktop/index/"))));  
        Filter filter = null;  
        //查詢前10000條記錄  
        TopDocs topDocs = indexSearcher.search(bq,filter,10000);

現在我要求不用lucene以及solr的所有的評分,用機器學習排序,先構建訓練系統,然后預測,最后排序。問題的關鍵是在lucene返回排序的文檔之前截取結果(ScoreDocs),截取到的這些文檔具有BM25信息,但是還沒有排序,我們把它先截取下來,然后構建文檔向量,開始數據建模(訓練時需要樣本的評分,可以在點擊圖中轉化,把點擊率轉化為評分),然后進入機器學習系統訓練評分函數。訓練時可以這樣,獲取一段時間內用戶的搜索圖和點擊圖,得到文檔及對應的評分。然后再模擬用戶的搜索詞,獲取到相同的文檔,這個時候用我們改造過的代碼,截取到未排序前的ScoreDoc。然后開始數據建模,訓練。當用戶再搜索時,仍然截取到上述文檔,把這些文檔轉到predict系統中,最后加入到自定義的PriorityQueue(區別於JDK的)排序,得到最終結果。有了思路后,就開始實施,在實施的過程中其實是有一定難度的。先從最外部的代碼一步步抽絲剝繭,找到答案。上面的示例代碼是有問題的,在正式的生產環境中,IndexSearch()構造器中一定要傳遞CompletionService,滿足多線程的要求。從indexSearcher.search(bq,filter,10000)開始進入源代碼內部,我們的任務是找到未排序前的代碼,截取下來進行改造。------>

public TopDocs search(Query query, Filter filter, int n)throws IOException    
{
    return search(createNormalizedWeight(wrapFilter(query, filter)), ((ScoreDoc) (null)), n);
}//這個query是我們制定的BooleanQuery,createNormalizedWeight方法產生Weight的子類BooleanWeight,里面的評分方法需要的Similarity由我們在配置文件中指定:<similarity class="org.apache.lucene.search.similarities.BM25Similarity"/>,運行原理跟前面的一樣了,都是Bm25Similarity評分。接着繼續看serach方法:

protected TopDocs search(Weight weight, ScoreDoc after, int nDocs)
        throws IOException
    {
        int limit = reader.maxDoc();
        if(limit == 0)
            limit = 1;
        if(after != null && after.doc >= limit)
            throw new IllegalArgumentException((new StringBuilder()).append("after.doc exceeds the number of documents in the reader: after.doc=").append(after.doc).append(" limit=").append(limit).toString());
        nDocs = Math.min(nDocs, limit);
        if(executor == null)
            return search(leafContexts, weight, after, nDocs);-------------①
        HitQueue hq = new HitQueue(nDocs, false);
        Lock lock = new ReentrantLock();
        ExecutionHelper runner = new ExecutionHelper(executor);------------②
        for(int i = 0; i < leafSlices.length; i++)
            runner.submit(new SearcherCallableNoSort(lock, this, leafSlices[i], weight, after, nDocs, hq));---------------------③

        int totalHits = 0;
        float maxScore = (-1.0F / 0.0F);
        Iterator i$ = runner.iterator();
        do
        {
            if(!i$.hasNext())
                break;
            TopDocs topDocs = (TopDocs)i$.next();
            if(topDocs.totalHits != 0)
            {
                totalHits += topDocs.totalHits;
                maxScore = Math.max(maxScore, topDocs.getMaxScore());
            }
        } while(true);
        ScoreDoc scoreDocs[] = new ScoreDoc[hq.size()];
      
        for(int i = hq.size() - 1; i >= 0; i--)
            scoreDocs[i] = (ScoreDoc)hq.pop();//排序--------------④

        return new TopDocs(totalHits, scoreDocs, maxScore);
    }   

標出了4個序號:第①出直接越過去了,第②處的ExecutionHelper對象封裝了CompletionService,如果對jdk1.7及以后版本的多線程,還有lunece內部的PriorityQueue的設計思想以及CAS,ReentrantLock這些都不了解的話,自己補一補。把代碼貼出來:

private static final class ExecutionHelper
        implements Iterator, Iterable
    {

        public boolean hasNext()
        {
            return numTasks > 0;
        }

        public void submit(Callable task)
        {
            service.submit(task);
            numTasks++;
        }

        public Object next()
        {
            if(!hasNext())
                throw new NoSuchElementException("next() is called but hasNext() returned false");
            Object obj;
            try
            {
                obj = service.take().get();
            }
            catch(InterruptedException e)
            {
                throw new ThreadInterruptedException(e);
            }
            catch(ExecutionException e)
            {
                throw new RuntimeException(e);
            }
            numTasks--;
            return obj;
            Exception exception;
            exception;
            numTasks--;
            throw exception;
        }

        public void remove()
        {
            throw new UnsupportedOperationException();
        }

        public Iterator iterator()
        {
            return this;
        }

        private final CompletionService service;
        private int numTasks;

        ExecutionHelper(Executor executor)
        {
            service = new ExecutorCompletionService(executor);
        }
    }

第③處是我們要改造的地方,是submit()方法里面的東東:new SearcherCallableNoSort(lock, this, leafSlices[i], weight, after, nDocs, hq),從名字上就能看出來,是非排序的查詢結果集。submit方法調用的是Callabel,所以看一下SearcherCallableNoSort的call()方法---------------->

private static final class SearcherCallableNoSort
        implements Callable
    {

        public TopDocs call()
            throws IOException
        {
            TopDocs docs;
            ScoreDoc scoreDocs[];
            docs = searcher.search(Arrays.asList(slice.leaves), weight, after, nDocs);//這里就不往下追蹤了,跟前面講的BM25Similarity排序是一樣的
            scoreDocs = docs.scoreDocs;
            lock.lock();
            int j = 0;
            do
            {
                if(j >= scoreDocs.length)
                    break;
                ScoreDoc scoreDoc = scoreDocs[j];
                //問題的關鍵在這里,得到scoreDoc后,由於已經獲取了BM25參數,接下來把ScoreDoc處理成向量,開始
                //數據建模,然后進入機器學習訓練系統,學習評分函數,后面的代碼可以用在predict后獲得每個文檔的
                //分數,然后加入到優先級隊列中排序
                if(scoreDoc == hq.insertWithOverflow(scoreDoc))
                    break;
                j++;
            } while(true);
            lock.unlock();
            break MISSING_BLOCK_LABEL_106;
            Exception exception;
            exception;
            lock.unlock();
            throw exception;
            return docs;
        }

        public volatile Object call()
            throws Exception
        {
            return call();
        }

        private final Lock lock;
        private final IndexSearcher searcher;
        private final Weight weight;
        private final ScoreDoc after;
        private final int nDocs;
        private final HitQueue hq;
        private final LeafSlice slice;

        public SearcherCallableNoSort(Lock lock, IndexSearcher searcher, LeafSlice slice, Weight weight, ScoreDoc after, int nDocs, HitQueue hq)
        {
            this.lock = lock;
            this.searcher = searcher;
            this.weight = weight;
            this.after = after;
            this.nDocs = nDocs;
            this.hq = hq;
            this.slice = slice;
        }
    }

需要把代碼改造成訓練系統和predict系統兩個版本。到此為止,這篇博客算是寫完了,還差edismax解析器的源代碼解讀,單獨寫一篇文章吧,下一篇博客將開始研究ListNet算法(機器學習排序的一種)……

另外,關於RNN的理解和應用,可以參考一些一線專家從實踐中總結出來的心得,在結合一下理論效果會比較好。在微信公眾號中搜索深度學習大講堂 公眾號,中科視拓的公眾號,很不錯的,包括tensorflow的源碼解析,很系統。學習這些技術,沒有捷徑,必須多實踐,多動手,多編程,多思考。上帝是公平的,付出多了,會有回報的。

佟氏出品,必屬精品!堅持獨立思考,大膽假設,小心求證,技術進步永無止境………………………………

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM