前言:目前自己在做使用Lucene.net和PanGu分詞實現全文檢索的工作,不過自己是把別人做好的項目進行遷移。因為項目整體要遷移到ASP.NET Core 2.0版本,而Lucene使用的版本是3.6.0 ,PanGu分詞也是對應Lucene3.6.0版本的。不過好在Lucene.net 已經有了Core 2.0版本(4.8.0 bate版),而PanGu分詞,目前有人正在做,貌似已經做完,只是還沒有測試~,Lucene升級的改變我都會加粗表示。
Lucene.net 4.8.0
https://github.com/apache/lucenenet
PanGu分詞
https://github.com/LonghronShen/Lucene.Net.Analysis.PanGu/tree/netcore2.0
不過現在我已經拋棄了PanGu分詞,取而代之的是JIEba分詞,
https://github.com/SilentCC/JIEba-netcore2.0
現在已經支持直接在nuget中下載,包名:Lucene.JIEba.net
https://www.nuget.org/packages/Lucene.JIEba.net/1.0.4
詳情可以參照上一篇。
這篇博文主要是想介紹Lucene的搜索過程在源碼中怎樣的。決定探究源碼的原因是因為我在使用Lucene的過程中遇到性能瓶頸的問題,根本不知道在搜索過程中哪里消耗的資源多,導致並發的時候服務器不堪重負。最后找到了原因,雖然和這篇博文沒什么大的關系,但還是想把自己學習的過程記錄下來。
一,搜索引擎的索引系統簡介
在介紹Lucene的search之前,有必要對搜索引擎的索引系統做一個簡單的了解。
索引通俗的說就是用來查找信息的信息,比如書的目錄也是索引,可以幫助我們快速的查找內容在哪一頁。那么在搜索引擎中我們需要儲存的是文檔和網頁內容,就像是書中的一個一個章節一樣。那么搜索引擎的索引其實就是查詢的關鍵詞,通過關鍵詞,搜索引擎幫助你快速查找到文檔在哪里。文檔的量是十分巨大的,然而關鍵詞在任何語言中都是固定的那么多,都是有限的。因此書本的目錄可以是很少的幾頁。那么如何去建這個索引呢?這就是索引系統簡歷的關鍵。
我們知道現在的全文檢索的索引系統大都是基於倒排索引的,倒排索引可以快速通過關鍵詞(索引)找到相應的文檔,Lucene的索引系統自然也是基於倒排索引。
1.正排索引
介紹倒排索引之前先介紹正排索引,因為正排索引是倒排索引創建的基礎,二者結合起來就很好理解搜索引擎的索引系統。全文檢索系統無法就是在大量的索引庫中尋找命中搜索關鍵詞的文檔。於是在任何一個索引系統中應該有這么兩個概念:關鍵詞(索引),文檔 (信息)。正排索引的儲存很簡單就是一個文檔到關鍵詞的映射,根據文檔id 可以映射到這篇文檔里面關鍵詞信息:
上面就是正排表,它表示DocId 為D1 的文檔 由三個詞組成 W1, W2 和W3 。W1 在文檔中出現了1次,起始位置為2。W2在文檔中出現了2次,起始位置分別為5 和6。
這樣可以通過文檔快速的找到文檔中的索引詞的信息。它是站在文檔的角度,以文檔編號為索引結構。
正排索引是無法滿足全文檢索的需要,於是在正排索引的基礎上創造了倒排索引。
2.倒排索引
倒排索引其實是以關鍵詞為索引結構,構造了從關鍵詞到文檔的一個映射。倒排索引由兩部分組成,第一部分是關鍵詞組成的字典,也就是索引結構。第二部分是文檔集合。
上圖就是一個倒排表,它表示的意思是:首先在第一部分(字典構成的索引)中,有個三個關鍵詞W1,W2,W3. 其中包含W1的文檔(nDocs)有3個,偏移位置(offset)為1 ,這個偏移位置就表示W1 映射在第二部分中的起始位置,所以可以看到,W1 命中了三篇文檔(1,2,3)在第一篇文檔中W1出現了2次,起始位置分別是1,2。以此類推第二篇和第三篇。 W2 命中了兩篇文檔(1 和 2),W3也是如此。
可以看到在倒排索引中,它是一個關鍵詞映射到文檔的集合。可以通過關鍵詞,快速查找該關鍵詞出現在哪里文檔,並且在該文檔中出現的次數和位置(這是建立在正排索引的基礎上)
實際上這樣一個簡單的倒排索引結構還是十分簡陋的,沒有考慮到記錄表中的何種文檔排序方式更有利於檢索,以及這樣一個倒排索引結構采用什么方式壓縮更省空間。這些都不去細究了。接下來看Lucene的索引系統。
3.Lucene的索引結構
在 Lucene.net(4.8.0) 學習問題記錄三: 索引的創建 IndexWriter 和索引速度的優化 中介紹了Lucene 索引結構的正向信息,所謂正向信息就是從文檔的角度出發儲存文檔的域,詞等信息:
- .fnm保存了此段包含了多少個域,每個域的名稱及索引方式。
- .fdx,.fdt保存了此段包含的所有文檔,每篇文檔包含了多少域,每個域保存了那些信息。
- .tvx,.tvd,.tvf保存了此段包含多少文檔,每篇文檔包含了多少域,每個域包含了多少詞,每個詞的字符串,位置等信息。
那么Lucene索引結構中的反向信息也就是我們所說的倒排索引:
- .tip .tim 就是上文中說的倒排索引中第一部分也即詞典索引。
- .doc 是倒排索引的第二部分(記錄表),儲存文檔和文檔中的詞頻信息。
Lucene的索引(這里就是指倒排索引第一部分也即詞典索引)用的是FST數據結構,Lucene的記錄表采用Frame of reference結構都不做細述。
- 在Lucene中.tip 儲存的叫做Term Directory 它列舉了每個Field ( 域 ) 的Terms ( 詞 ) 並且把它們儲存在Block(塊)中,每個Block有25-48個Term
- 而.tim 中儲存的叫做Term Index 它儲存了每個Field的 FST ,FST 中儲存的是Term(詞)的前綴, 對應.tip 中的Block。Block中的每個Term都是相同的前綴,這個前綴就儲存在FST中。可是每個Block最多存48個Term, 如果相同前綴的Term很多的話,Block會分出一個子Block,很顯然父Block的公共前綴是子Block公共前綴的前綴。
二,Lucene的搜索源碼分析
1.概覽
從索引文件上來說,Lucene的搜索過程:在IndexSearch 初始化的時候先就將.tip .tim文件的內容加載到內存中,在Search的過程中,會從.tip .tim文件中查找到關鍵詞(Terms),然后順着這些Terms 去.doc文件中查找命中的文檔,最后取出文檔ID。這只是很籠統的一個大概的過程。實際上Lucene在Search的過程中還有一個很重要同時也是很消耗時間的操作:評分。 接下來就看看Lucene的具體源碼是怎么實現的,在這個過程中只介紹重要的類和方法,因為整個搜索過程是很復雜的,並且在這個過程中可以看看Lucene的搜索操作時間都消耗在了哪里?。PS:我這里的Lucene都是指Lucene.Net版本。
2.實際操作
Lucene檢索的時序圖,大概如下所示,可以直觀的看下整個流程:
2.1 第一步 IndexSearch的初始化
我們都知道Lucene的搜索是通過IndexSearch來完成的。IndexSearch的初始化分為三步,前兩步是:
FSDriectory dir = FSDirectory.Open(storage.IndexDir);
IndexReader indexReader = DirectoryReader.Open(dir);
前面說到過Lucene需要加載詞典索引到內存中,這步操作就是在 DirectoryReader.Open()的函數中完成的。而完成加載的類叫做 BlockTreeTermsReader ,還有一個與之對應的類叫做BlockTreeTermsWriter 很顯然前者是從來讀取索引,后者是用來寫索引的,這兩個類是操作詞典索引的類。它們在Lucene.Net.Codecs包中
具體一點的加載方式:BlockTreeTermsReader 的內部類 FieldReader 它是前面的Term Directory 和Term Index的代碼實現,只貼出一部分。
public sealed class FieldReader : Terms { private readonly BlockTreeTermsReader outerInstance; internal readonly long numTerms; internal readonly FieldInfo fieldInfo; internal readonly long sumTotalTermFreq; internal readonly long sumDocFreq; internal readonly int docCount; internal readonly long indexStartFP; internal readonly long rootBlockFP; internal readonly BytesRef rootCode; internal readonly int longsSize; internal readonly FST<BytesRef> index; //private boolean DEBUG; ..... }
可以看到FST<BytesRef> index 對應.tim中的FST 。FST.cs在Lucene.Net.Util包中 。
每次初始化IndexSearch,都會將.tim 和.tip中的內容加載到內存中,這些操作都是很耗時的。所以這就是為什么用Lucene的人都說IndexSearch應該使用單例模式,或者把它緩存起來。
在初始化IndexSearch之后,便開始執行IndexSearch.Search 函數
public virtual TopDocs Search(Query query, Filter filter, int n) { Query q = WrapFilter(query, filter); Weight w = CreateNormalizedWeight(q); return Search(w, null, n); }
2.2 第二步 組合Query
將Query 和Filter 組合成過濾查詢FilteredQuery 就是上面代碼塊中的Query q = WrapFilter(query,filter);
IndexSearchr : WrapFilter
protected virtual Query WrapFilter(Query query, Filter filter) { Console.WriteLine("第二步:根據查詢query,和過濾條件filter 組合成過濾查詢FilteredQuery,執行函數WrapFilter"); return (filter == null) ? query : new FilteredQuery(query, filter); }
2.3 第三步 由第二步得到的Query 生成Weight
Weight 類簡單的概念:
- Weight 類是Search過程中很重要的類,它負責生成Scorer (一個命中Query的文檔集合的迭代器,文檔打分調用Similarity 類就是Lucene自己的TF/IDF打分機制) 。
- Weight類實際上是包裝Query 它先通過Query生成,之后IndexSearch所需要提供的Query便都由Weight提供。
- Weight 生成Scorer 是通過AtomicReaderContext (由IndexReaderContext而來)構造而得。所以搜索過程的AtomicReader(提供對索引進行讀取操作的類) 駐留在Scorer中。說白了Weight 生成Scorer的操作 便是 檢索的主要操作:是從索引中查找命中文檔的過程。
Lucene中生成Weight的源碼:
public virtual Weight CreateNormalizedWeight(Query query) {
query = Rewrite(query);//重寫查詢 Weight weight = query.CreateWeight(this);//生成Weight float v = weight.GetValueForNormalization(); float norm = Similarity.QueryNorm(v); if (float.IsInfinity(norm) || float.IsNaN(norm)) { norm = 1.0f; } weight.Normalize(norm, 1.0f); return weight; }
首先是重寫查詢
Lucene 將Query 重寫成一個個TermQuery組成的原始查詢 ,調用的是Query的Rewrite 方法,比如一個PrefixQuery 則會被重寫成由TermQuerys 組成的BooleanQuery 。所有繼承Query的 比如BooleanQuery ,PhraseQuery,CustomQuery都會覆寫這個方法以實現重寫Query。
public virtual Query Rewrite(IndexReader reader) { return this; }
然后計算查詢權重
計算查詢權重,實際上這么一個操作:在得到重寫查詢之后的原始查詢TermQuery ,先通過上文所說的 BlogTreeTermsReader 讀取詞典索引中符合TermQuery的Term ,然后通過Lucene自己TF/IDF 打分機制,算出Term的IDF值,以及QueryNorm的值(打分操作都是調用 Similarity 類),最后返回Weight。
計算Term IDF的源碼,它位於 TFIDFSimilarity : Similarity 中
public override sealed SimWeight ComputeWeight(float queryBoost, CollectionStatistics collectionStats, params TermStatistics[] termStats) { Explanation idf = termStats.Length == 1 ? IdfExplain(collectionStats, termStats[0]) : IdfExplain(collectionStats, termStats); return new IDFStats(collectionStats.Field, idf, queryBoost); }
IDFStats 是包裝Term IDF值的類,可以看到打分的過程還要考慮我們在應用層設置的Query的Boost .
上面只是計算一個文檔的分數的一小部分,實際上還是比較復雜的,我們可以簡單了解介紹Lucene 的TFIDFSimilarity 的打分機制
TFIDFSimilarity的簡單介紹:
TFIDFSimilarity 是Lucene中的評分類。這是官方文檔的介紹:https://lucene.apache.org/core/4_8_0/core/org/apache/lucene/search/similarities/TFIDFSimilarity.html
它並不僅僅是TFIDF那么簡單的算法。實際上它是很大部分搜索引擎都在使用的打分機制,叫做空間向量模型。
做過自然語言處理的人都知道,對於文本都需要它們處理成向量,這樣我們就可以利用數學,統計學中的知識對文本進行分析了。這些向量叫做文本向量。向量的維度是文檔中詞的個數,向量中的值是文檔中詞的權重。算余弦值
cosine-similarity(q,d) = |
|
通過這些文本向量,我們可以做一些很有意思的事情,比如計算兩個文本的文本向量的余弦值,就可以知道兩篇文本的相似程度。而搜索引擎就是利用了這樣的性質,將查詢關鍵詞和待查詢的文檔都轉成空間向量,計算二者的余弦值,這樣就可以知道哪些文檔和查詢關鍵詞十分相似了。這些相似的文檔得分就越高。這樣的打分方式高效而且准確。
在Lucene中空間向量的值其實就是TF/IDF的值。Lucene的計算空間余弦值經過變換已經變成這樣的形式
至於過程是怎么樣的,有興趣可以詳細閱讀上面的官方文檔。(一定要注意顏色,這個很重要)
PS: 在這里我要提醒一點,因為Lucene提供了自定義打分機制(CustomSocre),和給Query設置Boost ,最終的得分是score(q,d)*customScore 我就吃過自己設置的自定義打分機制和Boost不當的虧,導致排序結果是那些IDF值很低(也即無關緊要的詞,例如“我”,“在”,“找不到”...)的詞排名靠前,而明明有命中所有查詢詞的文檔卻排在后面。
可以猜到到這里Lucene只計算了 queryNorm(q) *idf(t in q) *t.getBoost() 值,最后的文檔的分數 還要再正真的Search過程中去完成剩余的部分。
2.4 第四步 生成TopSorceDocCollector
生成Weight 之后,Lucene執行的源碼如下:
protected virtual TopDocs Search(IList<AtomicReaderContext> leaves, Weight weight, ScoreDoc after, int nDocs) { // single thread int limit = reader.MaxDoc; if (limit == 0) { limit = 1; } nDocs = Math.Min(nDocs, limit); TopScoreDocCollector collector = TopScoreDocCollector.Create(nDocs, after, !weight.ScoresDocsOutOfOrder); Search(leaves, weight, collector); return collector.GetTopDocs(); }
TopSorceDocCollector 實際上一個文檔收集器,它是裝在查詢結果文檔的容器,collector.GetTopDocs() 得到就是大家都知道的TopDocs.
TopSorceDocCollector 生成函數
opScoreDocCollector collector = TopScoreDocCollector.Create(nDocs, after, !weight.ScoresDocsOutOfOrder);
2.5 第五步 由Weight 生成Scorer
Scorer 前面已經介紹過,它就是一個由TermQuery從索引庫中查詢出來的文檔集合的迭代器,可以說生成Scorer的過程就是查找文檔的過程。那么生成Scorer之后可以通過它的next 函數遍歷我們的結果文檔集合,對它們一一打分結合前面計算的queryWeight
先來看源碼:
protected virtual void Search(IList<AtomicReaderContext> leaves, Weight weight, ICollector collector) { // TODO: should we make this // threaded...? the Collector could be sync'd? // always use single thread: foreach (AtomicReaderContext ctx in leaves) // search each subreader { try { collector.SetNextReader(ctx); } catch (CollectionTerminatedException) { // there is no doc of interest in this reader context // continue with the following leaf continue; } BulkScorer scorer = weight.GetBulkScorer(ctx, !collector.AcceptsDocsOutOfOrder, ctx.AtomicReader.LiveDocs); if (scorer != null) { try { scorer.Score(collector); } catch (CollectionTerminatedException) { // collection was terminated prematurely // continue with the following leaf } } } }
通過Weight 生成scorer 的操作是:
BulkScorer scorer = weight.GetBulkScorer(ctx, !collector.AcceptsDocsOutOfOrder, ctx.AtomicReader.LiveDocs);
這應該是整個搜索過程中最耗時的操作。它是如果獲取Scorer的呢?上文說到Weight的一個作用是提供Search需要的Query, 其實生成Scorer的最終步驟是通過TermQuery(原始型查詢) 的GetScorer函數,GetScorer函數:
public override Scorer GetScorer(AtomicReaderContext context, IBits acceptDocs) { Debug.Assert(termStates.TopReaderContext == ReaderUtil.GetTopLevelContext(context), "The top-reader used to create Weight (" + termStates.TopReaderContext + ") is not the same as the current reader's top-reader (" + ReaderUtil.GetTopLevelContext(context)); TermsEnum termsEnum = GetTermsEnum(context); if (termsEnum == null) { return null; } DocsEnum docs = termsEnum.Docs(acceptDocs, null); Debug.Assert(docs != null); return new TermScorer(this, docs, similarity.GetSimScorer(stats, context)); }
在這個函數里,已經體現了Lucene是怎么根據查找文檔的,首先GetTermsEnum(context)函數 獲取 TermsEnum , TermsEnum 是用來獲取包含當前 Term 的 DocsEnum ,而DocsEnum 包含文檔docs 和詞頻term frequency .
於是查詢文檔的過程就清晰了:
對於當前的TermQuery ,查找符合TermQuery的文檔的步驟是 利用AtomicReader (通過AtomicReaderContext獲取) 生成TermsEnum (TermsEnum中的當前Term 就是TermQuery我們需要查詢的那個Term)
TermsEnum termsEnum = context.AtomicReader.GetTerms(outerInstance.term.Field).GetIterator(null);
再通過TermsEnum 獲取DocsEnum
DocsEnum docs = termsEnum.Docs(acceptDocs, null);
最后合成Scorer
return new TermScorer(this, docs, similarity.GetSimScorer(stats, context));
2.6 第六步 給每個搜出來的文檔打分並且添加到TopSorceDocCollector中
這一步直接體現在源碼中就是:
scorer.Score(collector);
當然不可能是這一行代碼就能完成的。它最終調用的Weight類的ScoreAll()函數.
internal static void ScoreAll(ICollector collector, Scorer scorer) { System.Console.WriteLine("Weight類,ScoreAll ,將Scorer中的doc傳給Collertor"); int doc; while ((doc = scorer.NextDoc()) != DocIdSetIterator.NO_MORE_DOCS) { collector.Collect(doc);//收集評分后的文檔 } }
然而正真打分的函數也不是ScoreAll函數,它是scorer.NextDoc()函數,
scorer執行NextDoc函數會調用 TFIDFSimScorer 類,它是TFIDFSimilarity的內部類,計算分數的函數為:
public override float Score(int doc, float freq) { System.Console.WriteLine("開始計算文檔的算分,根據TF/IDF方法"); float raw = outerInstance.Tf(freq) * weightValue; // compute tf(f)*weight return norms == null ? raw : raw * outerInstance.DecodeNormValue(norms.Get(doc)); // normalize for field }
這是Lucene評分公式中的部分得分,最終得分應該再乘以上文的查詢得分queryWeight再乘以自定義的得分CustomScore.
2.7 第七步 返回結果
沒什么好說的了。
三,結語
行文至此,終於將Lucene 的索引,搜索,打分機制說完了。實際上完整的過程不是一篇博文就能涵蓋的,源碼也遠遠不止我貼出來的那些。我只是大概了解這個過程,並且介紹了幾個關鍵的類:IndexSearcher,Weight , Scorer , Similarity, TopScoreDocCollector,AtomicReader 等。Lucene之所以是搜索引擎開源框架的不二選擇,是因為它的搜索效果和速度是真的不錯。如果你的程序搜索效果很差,那么一定是你沒有善用Lucene。
此外我想說一個問題,讀懂Lucene的源碼對於使用Lucene有沒有幫助呢?你不懂Lucene的內部機制和底層原理,照樣也可以用的很滑溜,還有Solr ElasticSearch 等現成的工具可以使用。其實讀懂源碼對你的知識和代碼認知能力提升不說,對於lucene,你可以在知道它內部原理的情況下自己修改它的源碼已適應你的程序,比如 1. 你完全可以將打分機制屏蔽,那么Lucene搜索的效率將成倍提高 2. 你也可以直接使用Lucene最底層的接口,比如AtomicReader 類,這個直接操作索引的類,從而達到更深層次的二次開發。這豈不是很酷炫?3. 可以直接修改lucene不合理的代碼。
最后說一句勉勵自己的話,其實寫博客是一個很好的方式,因為你抱着寫給別人看的態度,所以你要格外嚴謹,並且保證自己充分理解的情況下才能寫博客。這個過程已經足夠你對某個問題入木三分了。
最最后,我補充一下,我遇到的Lucene的性能問題,源於高亮。上述過程Lucene做的十分出色,而由於高亮的限制(實際上是自動摘要)搜索引擎的並發性能很低,而如何解決這個問題也是很值得深究的問題。