Lucene.net(4.8.0) 學習問題記錄六:Lucene 的索引系統和搜索過程分析

前言:目前本身在作使用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升級的改變我都會加粗表示。html

Lucene.net 4.8.0   git

https://github.com/apache/lucenenetgithub

PanGu分詞算法

https://github.com/LonghronShen/Lucene.Net.Analysis.PanGu/tree/netcore2.0apache

不過如今我已經拋棄了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 類簡單的概念:

  1. Weight  類是Search過程當中很重要的類,它負責生成Scorer (一個命中Query的文檔集合的迭代器,文檔打分調用Similarity 類就是Lucene本身的TF/IDF打分機制) 。
  2. Weight類其實是包裝Query 它先經過Query生成,以後IndexSearch所須要提供的Query便都由Weight提供。
  3. 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)   =  
V(q) · V(d)
–––––––––
|V(q)| |V(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作的十分出色,而因爲高亮的限制(其實是自動摘要)搜索引擎的併發性能很低,而如何解決這個問題也是很值得深究的問題。

相關文章
相關標籤/搜索