Solr In Action 筆記(2) 之評分機制(相似性計算)
1 簡述
我們對搜索引擎進行查詢時候,很少會有人進行翻頁操作。這就要求我們對索引的內容提取具有高度的匹配性,這就搜索引擎文檔的相似性計算,如何准確的選出最符合查詢條件的文檔。
《這就是搜索引擎》里面對相似性計算進行了簡單的介紹。
內容的相似性計算由搜索引擎的檢索模型建模,它是搜索引擎的理論基礎,為量化相關性提供了一種數學模型,否則沒法計算。當然檢索模型理論研究存在理想化的隱含假設,即假設用戶需求已經通過查詢非常清晰明確地表達出來了,所以檢索模型的任務不牽扯到對用戶需求建模,但實際上這個和實際相差較遠,即使相同的查詢詞,不同用戶的需求目的可能差異很大,而檢索模型對此無能為力。幾種常見的檢索模型有:
- 布爾模型:數學基礎是集合論,文檔和用戶查詢由其包含的單詞集合來表示,兩者的相似性則通過布爾代數運算來進行判定;缺點是其結果輸出是二元的(相關和不相關),無法得出多大程度相關的結果,也就無法排序,同時讓用戶以布爾表達式進行搜索要求過高;
- 向量空間模型:把文檔看成是由t維特征組成的一個向量,特征一般采用單詞,每個特征會根據一定依據計算其權重,這t維帶有權重的特征共同構成了一個文檔,以此來表示文檔的主題內容。計算文檔的相似性可以采用Cosine計算定義,實際上是求文檔在t維空間中查詢詞向量和文檔向量的夾角,越小越相似;對於特征權重,可以采用Tf*IDF框架,Tf是詞頻,IDF是逆文檔頻率因子指的是同一個單詞在文檔集合范圍的出現次數,這個是一種全局因子,其考慮的不是文檔本身的特征,而是特征單詞之間的相對重要性,特征詞出現在其中的文檔數目越多,IDF值越低,這個詞區分不同文檔的能力就越差,這個框架一般把Weight=Tf*IDF作為權重計算公式,當然向量空間的缺點是其是個經驗模型,是靠直覺和經驗不斷摸索和完善的,缺乏一個明確的理論來引導其改進方向,例如求Tf和IDF值的時候為了懲罰長文檔,都需要加入經驗值;
- 概率模型:是目前效果最好的模型之一,okapi BM25這一經典概率模型計算公式已經在商業搜索引擎的網頁排序中廣泛使用。概率檢索模型是從概率排序原理推導出來的,其基本思想是:給定一個用戶查詢,如果搜索系統能夠在搜索結果排序時按照文檔和用戶需求的相關性由高到底排序,那么這個搜索系統的准確性是最優的。在文檔集合的基礎上盡可能准確地對這種相關性進行估計就是其核心。
- 語言模型:1998年首次提出,其他的檢索模型的思考路徑是從查詢到文檔,即給定用戶查詢,如何找出相關的文檔,該模型的思路正好想法,是由文檔到查詢這個方向,即為每個文檔建立不同的語言模型,判斷由文檔生成用戶查詢的可能性有多大,然后按照這種生成概率由高到低排序,作為搜索結果。語言模型代表了單詞或者單詞序列在文檔中的分布情況;
- 機器學習排序算法:隨着搜索引擎的發展,對於某個網頁進行排序需要考慮的因素越來越多,這是無法根據人工經驗完成的,這時候用機器學習就是非常合適的,例如Google目前的網頁排序公式考慮了200多種因子。機器學習需要的數據源在搜索引擎中較好滿足,例如用戶的搜索點擊記錄。其分成人工標注訓練、文檔特征抽取、學習分類函數以及在實際搜索系統中采用機器學習模型等4個步驟組成。人工標注訓練可由用戶點擊記錄來模擬人為對文檔相關打分的機制。
2 向量空間模型
簡述中介紹了好多種相似性計算方法,Solr采用了最基本的向量空間模型,本節就主要介紹下向量空間模型。其他的向量空間模型以后有時間進行學習吧。
Solr的索引文件中有.tvx,.tvd,tvf存儲了term vector的信息,首先我們學習如何利用term vector來反映相似性程度。
用v(d1)表示了term d1的term向量,向量空間模型中,兩個term的相似程度是計算向量空間坐標系中兩個term向量的夾角,如果夾角越小,說明相似程度越大,而角度的計算可以使用余弦定理計算。
給定一個查詢以及一個文檔,如何計算他們的相似值呢,請看以下公式,它使用了以下概念:term frequency (tf), inverse document frequency (idf), term boosts (t.getBoost), field normalization (norm), coordination factor (coord), and query normalization (queryNorm).
- t = term, d = document, q = query , f = field
- tf(t in d ) 表示該term 在 這個文檔里出現的頻率(即出現了幾次)。
- idf(t) 表示 出現該term的文檔個數。
- t.getBoost() 查詢語句中每個詞的權重,可以在查詢中設定某個詞更加重要。 norm(t,d) 標准化因子d.getBoost() • lengthNorm(f) • f.getBoost() ,它包括三個參數:
- Document boost:此值越大,說明此文檔越重要。
- Field boost:此域越大,說明此域越重要。
- lengthNorm(field) = (1.0 / Math.sqrt(numTerms)):一個域中包含的Term總數越多,也即文檔越長,此值越小,文檔越短,此值越大。
- coord(q,d):一次搜索可能包含多個搜索詞,而一篇文檔中也可能包含多個搜索詞,此項表示,當一篇文檔中包含的搜索詞越多,則此文檔則打分越高 ,numTermsInDocumentFromQuery / numTermsInQuery
- queryNorm(q):計算每個查詢條目的方差和,此值並不影響排序,而僅僅使得不同的query之間的分數可以比較。
3 評分機制
- tf, 表示term匹配文章的程度,如果在一篇文章中該term出現了次數越多,說明該term對該文章的重要性越大,因而更加匹配。相反的出現越少說明該term越不匹配文章。但是這里需要注意,出現次數與重要性並不是成正比的,比如term A出現10次,term B出現1次,對於該文章的重要性term A並不是term B的10倍,所以這里tf的值進行平方根計算。
tf(t in d) = numTermOccurrencesInDocument 1/2
- idf, 表示包含該文章的個數,與tf不同,idf 越大表明該term越不重要。比如this很多文章都包含,但是它對於匹配文章幫助不大。這也如我們程序員所學的技術,對於程序員本身來說,這項技術掌握越深越好(掌握越深說明花時間看的越多,tf越大),找工作時越有競爭力。然而對於所有程序員來說,這項技術懂得的人越少越好(懂得的人少df小),找工作越有競爭力。人的價值在於不可替代性就是這個道理。
idf(t) = 1 + log (numDocs / (docFreq +1))
- t.getBoost,boost是人為給term提升權重的過程,我們可以在Index和Query中分別加入term boost,但是由於Query過程比較靈活,所以這里介紹給Query boost。term boost 不僅可以對Pharse進行,也可以對單個term進行,在查詢的時候用^后面加數字表示:
- title:(solr in action)^2.5 對solr in action 這個pharse設置boost
- title:(solr in action) 默認的boost時1.0
- title:(solr^2in^.01action^1.5)^3OR"solrinaction"^2.5
- norm(t,d) 即field norm,它包含Document boost,Field boost,lengthNorm。相比於t.getBoost()可以在查詢的時候進行動態的設置,norm里面的f.getBoost()和d.getBoost()只能建索引過程中設置,如果需要對這兩個boost進行修改,那么只能重建索引。他們的值是存儲在.nrm文件中。
norm(t,d) = d.getBoost() • lengthNorm(f) • f.getBoost()
-
- d.getBoost() document的boost,對document設置boost是通過對每一個field設置boost實現的。
- f .getBoost() field的boost,這里需要提以下,Solr是支持多值域方式建索引的,即同一個field多個value,如以下代碼。當一個文檔里出現同名的多值域時候,倒排索引和項向量都會在邏輯上將這些域的詞匯單元附加進去。當對多值域進行存儲的時候,它們在文檔中的存儲順序是分離的,因此當你在搜索期間對文檔進行檢索時,你會發現多個Field實例。如下圖例子所示,當查詢author:Lucene時候出現兩個author域,這就是所謂的多值域現象。
-
1 Document doc = new Document(); 2 for (String author : authors){ 3 doc.add(new Field("author",author,Field.Store.YES,Field.Index.ANALYZED)); 4 }
1 //首先對多值域建立索引 2 Directory dir = FSDirectory.open(new File("/Users/rcf/workspace/java/solr/Lucene")); 3 IndexWriterConfig indexWriterConfig = new IndexWriterConfig(Version.LUCENE_48,new WhitespaceAnalyzer(Version.LUCENE_48)); 4 @SuppressWarnings("resource") 5 IndexWriter writer = new IndexWriter(dir,indexWriterConfig); 6 Document doc = new Document(); 7 doc.add(new Field("author","lucene",Field.Store.YES,Field.Index.ANALYZED)); 8 doc.add(new Field("author","solr",Field.Store.YES,Field.Index.ANALYZED)); 9 doc.add(new Field("text","helloworld",Field.Store.YES,Field.Index.ANALYZED)); 10 writer.addDocument(doc); 11 writer.commit(); 12 //對多值域進行查詢 13 IndexReader reader = IndexReader.open(dir); 14 IndexSearcher search = new IndexSearcher(reader); 15 Query query = new TermQuery(new Term("author","lucene")); 16 TopDocs docs = search.search(query, 1); 17 Document doc = search.doc(docs.scoreDocs[0].doc); 18 for(IndexableField field : doc.getFields()){ 19 System.out.println(field.name()+":"+field.stringValue()); 20 } 21 System.out.print(docs.totalHits); 22 //運行結果 23 author:lucene 24 author:solr 25 text:helloworld 26 2
當對多值域設置boost的時候,那么該field的boost最后怎么算呢?即為每一個值域的boost相乘。比如title這個field,第一次boost是3.0,第二次1,第三次0.5,那么結果就是3*1*0.5.
-
Boost: (3) · (1) · (0.5) = 1.5
-
-
- lengthNorm, Norm的長度是field中term的個數的平方根的倒數,field的term的個數被定義為field的長度。field長度越大,Norm Field越小,說明term越不重要,反之越重要,這很好理解,在10個詞的title中出現北京一次和在有200個詞的正文中出現北京2次,哪個field更加匹配,當然是title。
- 最后再說明下,document boost,field boost 以及lengthNorm在存儲為索引是以byte形式的,編解碼過程中會使得數值損失,該損失對相似值計算的影響微乎其微。
- queryNorm, 計算每個查詢條目的方差和,此值並不影響排序,而僅僅使得不同的query之間的分數可以比較。也就說,對於同一詞查詢,他對所有的document的影響是一樣的,所以不影響查詢的結果,它主要是為了區分不同query了。
queryNorm(q) = 1 / (sumOfSquaredWeights )
sumOfSquaredWeights = q.getBoost()2 • ∑ ( idf(t) • t.getBoost() )2
coord(q,d),表示文檔中符合查詢的term的個數,如果在文檔中查詢的term個數越多,那么這個文檔的score就會更高。
numTermsInDocumentFromQuery / numTermsInQuery
比如Query:AccountantAND("SanFrancisco"OR"NewYork"OR"Paris")
文檔A包含了上面的3個term,那么coord就是3/4,如果包含了1個,則coord就是4/4
4 源碼
上面介紹了相似值計算的公式,那么現在就來查看Solr實現的代碼,這部分實現是在DefaultSimilarity類中。
1 @Override 2 public float coord(int overlap, int maxOverlap) { 3 return overlap / (float)maxOverlap; 4 } 5 6 @Override 7 public float queryNorm(float sumOfSquaredWeights) { 8 return (float)(1.0 / Math.sqrt(sumOfSquaredWeights)); 9 } 10 11 @Override 12 public float lengthNorm(FieldInvertState state) { 13 final int numTerms; 14 if (discountOverlaps) 15 numTerms = state.getLength() - state.getNumOverlap(); 16 else 17 numTerms = state.getLength(); 18 return state.getBoost() * ((float) (1.0 / Math.sqrt(numTerms))); 19 } 20 21 @Override 22 public float tf(float freq) { 23 return (float)Math.sqrt(freq); 24 } 25 26 @Override 27 public float idf(long docFreq, long numDocs) { 28 return (float)(Math.log(numDocs/(double)(docFreq+1)) + 1.0); 29 }
Solr計算score(q,d)的過程如下:
1:調用IndexSearcher.createNormalizedWeight()計算queryNorm();
1 public Weight createNormalizedWeight(Query query) throws IOException { 2 query = rewrite(query); 3 Weight weight = query.createWeight(this); 4 float v = weight.getValueForNormalization(); 5 float norm = getSimilarity().queryNorm(v); 6 if (Float.isInfinite(norm) || Float.isNaN(norm)) { 7 norm = 1.0f; 8 } 9 weight.normalize(norm, 1.0f); 10 return weight; 11 }
具體實現步驟如下:
-
Weight weight = query.createWeight(this);
-
- 創建BooleanWeight->new TermWeight()->this.stats = similarity.computeWeight)->this.weight = idf * t.getBoost()
-
1 public IDFStats(String field, Explanation idf, float queryBoost) { 2 // TODO: Validate? 3 this.field = field; 4 this.idf = idf; 5 this.queryBoost = queryBoost; 6 this.queryWeight = idf.getValue() * queryBoost; // compute query weight 7 }
-
計算sumOfSquaredWeights
-
s = weights.get(i).getValueForNormalization()計算( idf(t) • t.getBoost() )2 如以下代碼所示,queryWeight在上一部中計算出
-
1 public float getValueForNormalization() { 2 // TODO: (sorta LUCENE-1907) make non-static class and expose this squaring via a nice method to subclasses? 3 return queryWeight * queryWeight; // sum of squared weights 4 }
-
- BooleanWeight->getValueForNormalization->sum = (q.getBoost)2 *∑(this.weight)2 = (q.getBoost)2 *∑(idf * t.getBoost())2
-
-
1 public float getValueForNormalization() throws IOException { 2 float sum = 0.0f; 3 for (int i = 0 ; i < weights.size(); i++) { 4 // call sumOfSquaredWeights for all clauses in case of side effects 5 float s = weights.get(i).getValueForNormalization(); // sum sub weights 6 if (!clauses.get(i).isProhibited()) { 7 // only add to sum for non-prohibited clauses 8 sum += s; 9 } 10 } 11 12 sum *= getBoost() * getBoost(); // boost each sub-weight 13 14 return sum ; 15 }
-
-
計算完整的querynorm() = 1 / Math.sqrt(sumOfSquaredWeights));
-
1 public float queryNorm(float sumOfSquaredWeights) { 2 return (float)(1.0 / Math.sqrt(sumOfSquaredWeights)); 3 }
-
weight.normalize(norm, 1.0f) 計算norm()
-
topLevelBoost *= getBoost();
- 計算value = idf()*queryWeight*queryNorm=idf()2*t.getBoost()*queryNorm(queryWeight在前面已計算出)
-
1 public void normalize(float queryNorm, float topLevelBoost) { 2 this.queryNorm = queryNorm * topLevelBoost; 3 queryWeight *= this.queryNorm; // normalize query weight 4 value = queryWeight * idf.getValue(); // idf for document 5 }
2:調用IndexSearch.weight.bulkScorer()計算coord(q,d),並獲取每一個term的docFreq,並將docFreq按td從小到大排序。
1 if (optional.size() == 0 && prohibited.size() == 0) { 2 float coord = disableCoord ? 1.0f : coord(required.size(), maxCoord); 3 return new ConjunctionScorer(this, required.toArray(new Scorer[required.size()]), coord); 4 }
3:score.score()進行評分計算,獲取相似值,並放入優先級隊列中獲取評分最高的doc id。
- weightValue= value =idf()2*t.getBoost()*queryNorm
- sore = ∑(tf()*weightValue)*cood 計算出最終的相似值
- 這里貌似沒有用到lengthNorm,
1 public float score(int doc, float freq) { 2 final float raw = tf(freq) * weightValue; // compute tf(f)*weight 3 4 return norms == null ? raw : raw * decodeNormValue(norms.get(doc)); // normalize for field 5 }
1 public float score() throws IOException { 2 // TODO: sum into a double and cast to float if we ever send required clauses to BS1 3 float sum = 0.0f; 4 for (DocsAndFreqs docs : docsAndFreqs) { 5 sum += docs.scorer.score(); 6 } 7 return sum * coord; 8 }
1 public void collect(int doc) throws IOException { 2 float score = scorer.score(); 3 4 // This collector cannot handle these scores: 5 assert score != Float.NEGATIVE_INFINITY; 6 assert !Float.isNaN(score); 7 8 totalHits++; 9 if (score <= pqTop.score) { 10 // Since docs are returned in-order (i.e., increasing doc Id), a document 11 // with equal score to pqTop.score cannot compete since HitQueue favors 12 // documents with lower doc Ids. Therefore reject those docs too. 13 return; 14 } 15 pqTop.doc = doc + docBase; 16 pqTop.score = score; 17 pqTop = pq.updateTop(); 18 }
5 公式推導
關於公式的推導覺先的《Lucene學習總結之六:Lucene打分公式的數學推導》可以查看這部分內容。
我們把文檔看作一系列詞(Term),每一個詞(Term)都有一個權重(Term weight),不同的詞(Term)根據自己在文檔中的權重來影響文檔相關性的打分計算。
於是我們把所有此文檔中詞(term)的權重(term weight) 看作一個向量。
Document = {term1, term2, …… ,term N}
Document Vector = {weight1, weight2, …… ,weight N}
同樣我們把查詢語句看作一個簡單的文檔,也用向量來表示。
Query = {term1, term 2, …… , term N}
Query Vector = {weight1, weight2, …… , weight N}
我們把所有搜索出的文檔向量及查詢向量放到一個N維空間中,每個詞(term)是一維。
我們認為兩個向量之間的夾角越小,相關性越大。
所以我們計算夾角的余弦值作為相關性的打分,夾角越小,余弦值越大,打分越高,相關性越大。
余弦公式如下:
下面我們假設:
查詢向量為Vq = <w(t1, q), w(t2, q), ……, w(tn, q)>
文檔向量為Vd = <w(t1, d), w(t2, d), ……, w(tn, d)>
向量空間維數為n,是查詢語句和文檔的並集的長度,當某個Term不在查詢語句中出現的時候,w(t, q)為零,當某個Term不在文檔中出現的時候,w(t, d)為零。
w代表weight,計算公式一般為tf*idf。
我們首先計算余弦公式的分子部分,也即兩個向量的點積:
Vq*Vd = w(t1, q)*w(t1, d) + w(t2, q)*w(t2, d) + …… + w(tn ,q)*w(tn, d)
把w的公式代入,則為
Vq*Vd = tf(t1, q)*idf(t1, q)*tf(t1, d)*idf(t1, d) + tf(t2, q)*idf(t2, q)*tf(t2, d)*idf(t2, d) + …… + tf(tn ,q)*idf(tn, q)*tf(tn, d)*idf(tn, d)
在這里有三點需要指出:
- 由於是點積,則此處的t1, t2, ……, tn只有查詢語句和文檔的並集有非零值,只在查詢語句出現的或只在文檔中出現的Term的項的值為零。
- 在查詢的時候,很少有人會在查詢語句中輸入同樣的詞,因而可以假設tf(t, q)都為1
- idf是指Term在多少篇文檔中出現過,其中也包括查詢語句這篇小文檔,因而idf(t, q)和idf(t, d)其實是一樣的,是索引中的文檔總數加一,當索引中的文檔總數足夠大的時候,查詢語句這篇小文檔可以忽略,因而可以假設idf(t, q) = idf(t, d) = idf(t)
基於上述三點,點積公式為:
Vq*Vd = tf(t1, d) * idf(t1) * idf(t1) + tf(t2, d) * idf(t2) * idf(t2) + …… + tf(tn, d) * idf(tn) * idf(tn)
所以余弦公式變為:
下面要推導的就是查詢語句的長度了。
由上面的討論,查詢語句中tf都為1,idf都忽略查詢語句這篇小文檔,得到如下公式
所以余弦公式變為:
下面推導的就是文檔的長度了,本來文檔長度的公式應該如下:
這里需要討論的是,為什么在打分過程中,需要除以文檔的長度呢?
因為在索引中,不同的文檔長度不一樣,很顯然,對於任意一個term,在長的文檔中的tf要大的多,因而分數也越高,這樣對小的文檔不公平,舉一個極端的例子,在一篇1000萬個詞的鴻篇巨著中,"lucene"這個詞出現了11次,而在一篇12個詞的短小文檔中,"lucene"這個詞出現了10次,如果不考慮長度在內,當然鴻篇巨著應該分數更高,然而顯然這篇小文檔才是真正關注"lucene"的。
然而如果按照標准的余弦計算公式,完全消除文檔長度的影響,則又對長文檔不公平(畢竟它是包含了更多的信息),偏向於首先返回短小的文檔的,這樣在實際應用中使得搜索結果很難看。
所以在Lucene中,Similarity的lengthNorm接口是開放出來,用戶可以根據自己應用的需要,改寫lengthNorm的計算公式。比如我想做一個經濟學論文的搜索系統,經過一定時間的調研,發現大多數的經濟學論文的長度在8000到10000詞,因而lengthNorm的公式應該是一個倒拋物線型的,8000到 10000詞的論文分數最高,更短或更長的分數都應該偏低,方能夠返回給用戶最好的數據。
在默認狀況下,Lucene采用DefaultSimilarity,認為在計算文檔的向量長度的時候,每個Term的權重就不再考慮在內了,而是全部為一。
而從Term的定義我們可以知道,Term是包含域信息的,也即title:hello和content:hello是不同的Term,也即一個Term只可能在文檔中的一個域中出現。
所以文檔長度的公式為:
代入余弦公式:
再加上各種boost和coord,則可得出Lucene的打分計算公式。
6. 總結
前面學習了Solr的評分機制,雖然對理論的推導以及公式有了一些了解,但是在Solr具體實現上我卻產生了不少疑惑:
1. BooleanQuery查詢,為什么沒有用到LengthNorm。
2. BooleanQuery 多條件查詢時候,Not And Or 對文檔進行打分時候是否具有影響。
3. PharseQuery查詢時候,打分又是怎么進行的。
4. 怎么樣對這個進行打分進行定制。
這些都是接下來需要去理解的。