Lucene 查詢原理及解析

前言

Lucene 是一個基於 Java 的全文信息檢索工具包,目前主流的搜索系統Elasticsearch和solr都是基於lucene的索引和搜索能力進行。想要理解搜索系統的實現原理,就需要深入lucene這一層,看看lucene是如何存儲需要檢索的數據,以及如何完成高效的數據檢索。

在數據庫中因爲有索引的存在,也可以支持很多高效的查詢操作。不過對比lucene,數據庫的查詢能力還是會弱很多,本文就將探索下lucene支持哪些查詢,並會重點選取幾類查詢分析lucene內部是如何實現的。爲了方便大家理解,我們會先簡單介紹下lucene裏面的一些基本概念,然後展開lucene中的幾種數據存儲結構,理解了他們的存儲原理後就可以方便知道如何基於這些存儲結構來實現高效的搜索。本文重點關注是lucene如何做到傳統數據庫較難做到的查詢,對於分詞,打分等功能不會展開介紹。

本文具體會分以下幾部分:

  1. 介紹lucene的數據模型,細節可以參閱lucene數據模型一文。
  2. 介紹lucene中如何存儲需要搜索的term。
  3. 介紹lucene的倒排鏈的如何存儲以及如何實現docid的快速查找。
  4. 介紹lucene如何實現倒排鏈合併。
  5. 介紹lucene如何做範圍查詢和前綴匹配。
  6. 介紹lucene如何優化數值類範圍查詢。

Lucene數據模型

Lucene中包含了四種基本數據類型,分別是:

Index:索引,由很多的Document組成。

Document:由很多的Field組成,是Index和Search的最小單位。

Field:由很多的Term組成,包括Field Name和Field Value。

Term:由很多的字節組成。一般將Text類型的Field Value分詞之後的每個最小單元叫做Term。

在lucene中,讀寫路徑是分離的。寫入的時候創建一個IndexWriter,而讀的時候會創建一個IndexSearcher。

IndexWriter

// initialization
Directory index = new NIOFSDirectory(Paths.get("/index"));
IndexWriterConfig config = new IndexWriterConfig();
IndexWriter writer = new IndexWriter(index, config);
// create a document
Document doc = new Document();
doc.add(new TextField("title", "Lucene - IndexWriter", Field.Store.YES));
doc.add(new StringField("content", "招人,求私信", Field.Store.YES));
// index the document
writer.addDocument(doc);
writer.commit();

先看下Lucene中如何使用IndexWriter來寫入數據,上面是一段精簡的調用示例代碼,整個過程主要有三個步驟:

  1. 初始化:初始化IndexWriter必要的兩個元素是Directory和IndexWriterConfig,Directory是Lucene中數據持久層的抽象接口,通過這層接口可以實現很多不同類型的數據持久層,例如本地文件系統、網絡文件系統、數據庫或者是分佈式文件系統。IndexWriterConfig內提供了很多可配置的高級參數,提供給高級玩家進行性能調優和功能定製,它提供的幾個關鍵參數後面會細說。
  2. 構造文檔:Lucene中文檔由Document表示,Document由Field構成。Lucene提供多種不同類型的Field,其FiledType決定了它所支持的索引模式,當然也支持自定義Field,具體方式可參考上一篇文章。
  3. 寫入文檔:通過IndexWriter的addDocument函數寫入文檔,寫入時同時根據FieldType創建不同的索引。文檔寫入完成後,還不可被搜索,最後需要調用IndexWriter的commit,在commit完後Lucene才保證文檔被持久化並且是searchable的。

以上就是Lucene的一個簡明的數據寫入流程,核心是IndexWriter,整個過程被抽象的非常簡潔明瞭。一個設計優良的庫的最大特點,就是可以讓普通玩家以非常小的代價學習和使用,同時又照顧高級玩家能夠提供可調節的性能參數和功能定製能力。

IndexWriterConfig

IndexWriterConfig內提供了一些供高級玩家做性能調優和功能定製的核心參數,我們列幾個主要的看下:

  • IndexDeletionPolicy:Lucene開放對commit point的管理,通過對commit point的管理可以實現例如snapshot等功能。Lucene默認配置的DeletionPolicy,只會保留最新的一個commit point。
  • Similarity:搜索的核心是相關性,Similarity是相關性算法的抽象接口,Lucene默認實現了TF-IDF和BM25算法。相關性計算在數據寫入和搜索時都會發生,數據寫入時的相關性計算稱爲Index-time boosting,計算Normalizaiton並寫入索引,搜索時的相關性計算稱爲query-time boosting。
  • MergePolicy:Lucene內部數據寫入會產生很多Segment,查詢時會對多個Segment查詢併合並結果。所以Segment的數量一定程度上會影響查詢的效率,所以需要對Segment進行合併,合併的過程就稱爲Merge,而何時觸發Merge由MergePolicy決定。
  • MergeScheduler:當MergePolicy觸發Merge後,執行Merge會由MergeScheduler來管理。Merge通常是比較耗CPU和IO的過程,MergeScheduler提供了對Merge過程定製管理的能力。
  • Codec:Codec可以說是Lucene中最核心的部分,定義了Lucene內部所有類型索引的Encoder和Decoder。Lucene在Config這一層將Codec配置化,主要目的是提供對不同版本數據的處理能力。對於Lucene用戶來說,這一層的定製需求通常較少,能玩Codec的通常都是頂級玩家了。
  • IndexerThreadPool:管理IndexWriter內部索引線程(DocumentsWriterPerThread)池,這也是Lucene內部定製資源管理的一部分。
  • FlushPolicy:FlushPolicy決定了In-memory buffer何時被flush,默認的實現會根據RAM大小和文檔個數來判斷Flush的時機,FlushPolicy會在每次文檔add/update/delete時調用判定。
  • MaxBufferedDoc:Lucene提供的默認FlushPolicy的實現FlushByRamOrCountsPolicy中允許DocumentsWriterPerThread使用的最大文檔數上限,超過則觸發Flush。
  • RAMBufferSizeMB:Lucene提供的默認FlushPolicy的實現FlushByRamOrCountsPolicy中允許DocumentsWriterPerThread使用的最大內存上限,超過則觸發flush。
  • RAMPerThreadHardLimitMB:除了FlushPolicy能決定Flush外,Lucene還會有一個指標強制限制DocumentsWriterPerThread佔用的內存大小,當超過閾值則強制flush。
  • Analyzer:即分詞器,這個通常是定製化最多的,特別是針對不同的語言。

核心操作

IndexWriter提供很簡單的幾種操作接口,這一章節會做一個簡單的功能和用途解釋,下一個章節會對其內部實現做一個詳細的剖析。IndexWrite的提供的核心API如下:

  • addDocument:比較純粹的一個API,就是向Lucene內新增一個文檔。Lucene內部沒有主鍵索引,所有新增文檔都會被認爲一個新的文檔,分配一個獨立的docId。
  • updateDocuments:更新文檔,但是和數據庫的更新不太一樣。數據庫的更新是查詢後更新,Lucene的更新是查詢後刪除再新增。流程是先delete by term,後add document。但是這個流程又和直接先調用delete後調用add效果不一樣,只有update能夠保證在Thread內部刪除和新增保證原子性,詳細流程在下一章節會細說。
  • deleteDocument:刪除文檔,支持兩種類型刪除,by term和by query。在IndexWriter內部這兩種刪除的流程不太一樣,在下一章節再細說。
  • flush:觸發強制flush,將所有Thread的In-memory buffer flush成segment文件,這個動作可以清理內存,強制對數據做持久化。
  • prepareCommit/commit/rollback:commit後數據纔可被搜索,commit是一個二階段操作,prepareCommit是二階段操作的第一個階段,也可以通過調用commit一步完成,rollback提供了回滾到last commit的操作。
  • maybeMerge/forceMerge:maybeMerge觸發一次MergePolicy的判定,而forceMerge則觸發一次強制merge。

數據路徑

上面幾個章節介紹了IndexWriter的基本流程、配置和核心接口,非常簡單和易理解。這一章節,我們將深入IndexWriter內部,來探索其內核實現。

如上是IndexWriter內部核心流程架構圖,接下來我們將以add/update/delete/commit這些主要操作來講解IndexWriter內部的數據路徑。

併發模型

IndexWriter提供的核心接口都是線程安全的,並且內部做了特殊的併發優化來優化多線程寫入的性能。IndexWriter內部爲每個線程都會單獨開闢一個空間來寫入,這塊空間由DocumentsWriterPerThread來控制。整個多線程數據處理流程爲:

  1. 多線程併發調用IndexWriter的寫接口,在IndexWriter內部具體請求會由DocumentsWriter來執行。DocumentsWriter內部在處理請求之前,會先根據當前執行操作的Thread來分配DocumentsWriterPerThread。
  2. 每個線程在其獨立的DocumentsWriterPerThread空間內部進行數據處理,包括分詞、相關性計算、索引構建等。
  3. 數據處理完畢後,在DocumentsWriter層面執行一些後續動作,例如觸發FlushPolicy的判定等。

引入了DocumentsWriterPerThread(後續簡稱爲DWPT)後,Lucene內部在處理數據時,整個處理步驟只需要對以上第一步和第三步進行加鎖,第二步完全不用加鎖,每個線程都在自己獨立的空間內處理數據。而通常來說,第一步和第三步都是非常輕量級的,而第二步是對計算和內存資源消耗最大的。所以這樣做之後,能夠將加鎖的時間大大縮短,提高併發的效率。每個DWPT內單獨包含一個In-memory buffer,這個buffer最終會flush成不同的獨立的segment文件。

這種方案下,對多線程併發寫入性能有很大的提升。特別是針對純新增文檔的場景,所有數據寫入都不會有衝突,所以非常適合這種空間隔離式的數據寫入方式。但對於刪除文檔的場景,一次刪除動作可能會涉及刪除不同線程空間內的數據,這裏Lucene也採取了一種特殊的交互方式來降低鎖的開銷,在剖析delete操作時會細說。

在搜索場景中,全量構建索引的階段,基本是純新增文檔式的寫入,而在後續增量索引階段(特別是數據源是數據庫時),會涉及大量的update和delete操作。從原理上來分析,一個最佳實踐是包含相同唯一主鍵Term的文檔分配相同的線程來處理,使數據更新發生在一個獨立線程空間內,避免跨線程。

add & update

add接口用於新增文檔,update接口用於更新文檔。但Lucene的update和數據庫的update不太一樣。數據庫的更新是查詢後更新,Lucene的更新是查詢後刪除再新增,不支持更新文檔內部分列。流程是先delete by term,後add document。

IndexWriter提供的add和update接口,都會映射到DocumentsWriter的udpate接口,看下接口定義:

long updateDocument(final Iterable<? extends IndexableField> doc, final Analyzer analyzer,
    final Term delTerm) throws IOException, AbortingException 

這個函數內的處理流程是:

  1. 根據Thread分配DWPT
  2. 在DWPT內執行delete
  3. 在DWPT內執行add

關於delete操作的細節在下一小結詳細說,add操作會直接將文檔寫入DWPT內的In-memory buffer。

delete

delete相對add和update來說,是完全不同的一個數據路徑。而且update和delete雖然內部都會執行數據刪除,但這兩者又是不同的數據路徑。文檔刪除不會直接影響In-memory buffer內的數據,而是會有另外的方式來達到刪除的目的。

在Delete路徑上關鍵的數據結構就是Deletion queue,在IndexWriter內部會有一個全局的Deletion Queue,稱爲Global Deletion Queue,而在每個DWPT內部,還會有一個獨立的Deletion Queue,稱爲Pending Updates。DWPT Pending Updates會與Global Deletion Queue進行雙向同步,因爲文檔刪除是全局範圍的,不應該只發生在DWPT範圍內。

Pending Updates內部會按發生順序記錄每個刪除動作,並且標記該刪除影響的文檔範圍,文檔影響範圍通過記錄當前已寫入的最大DocId(DocId Upto)來標記,即代表這個刪除動作只刪除小於等於該DocId的文檔。

update接口和delete接口都可以進行文檔刪除,但是有一些差異:

  • update只能進行by term的文檔刪除,而delete除了by term,還支持by query。
  • update的刪除會先作用於DWPT內部,後作用於Global,再由Global同步到其他DWPT。
  • delete的刪除會作用在Global級別,後異步同步到DWPT級別。

update和delete流程上的差異也決定了他們行爲上的一些差異,update的刪除操作會先發生在DWPT內部,並且是和add同時發生,所以能夠保證該DWPT內部的delete和add的原子性,即保證在add之前的所有符合條件的文檔一定被刪除。

DWPT Pending Updates裏的刪除操作什麼時候會真正作用於數據呢?在Lucene Segment內部,數據實際上並不會被真正刪除。Segment中有一個特殊的文件叫live docs,內部是一個位圖的數據結構,記錄了這個Segment內部哪些DocId是存活的,哪些DocId是被刪除的。所以刪除的過程就是構建live docs標記位圖的過程,數據實際上不會被真正刪除,只是在live docs裏會被標記刪除。Term刪除和Query刪除會在不同階段構建live docs,Term刪除要求先根據Term查詢出它關聯的所有doc,所以很明顯這個會發生在倒排索引構建時。而Query刪除要求執行一次完整的查詢後才能拿到其對應的docId,所以會發生在segment被flush完成後,基於flush後的索引文件構建IndexReader後執行搜索才能完成。

還有一點要注意的是,live docs隻影響倒排,所以在live docs裏被標記刪除的文檔沒有辦法通過倒排索引檢索出,但是還能夠通過doc id查詢到store fields。當然文檔數據最終是會被真正物理刪除,這個過程會發生在merge時。

flush

flush是將DWPT內In-memory buffer裏的數據持久化到文件的過程,flush會在每次新增文檔後由FlushPolicy判定自動觸發,也可以通過IndexWriter的flush接口手動觸發。

每個DWPT會flush成一個segment文件,flush完成後這個segment文件是不可被搜索的,只有在commit之後,所有commit之前flush的文件纔可被搜索。

commit

commit時會觸發數據的一次強制flush,commit完成後再此之前flush的數據纔可被搜索。commit動作會觸發生成一個commit point,commit point是一個文件。Commit point會由IndexDeletionPolicy管理,lucene默認配置的策略只會保留last commit point,當然lucene提供其他多種不同的策略供選擇。

merge

merge是對segment文件合併的動作,合併的好處是能夠提高查詢的效率以及回收一些被刪除的文檔。Merge會在segment文件flush時觸發MergePolicy來判定自動觸發,也可通過IndexWriter進行一次force merge。

IndexingChain

前面幾個章節主要介紹了IndexWriter內部各個關鍵操作的流程,本小節會介紹最核心的DWPT內部對文檔進行索引構建的流程。Lucene內部索引構建最關鍵的概念是IndexingChain,顧名思義,鏈式的索引構建。爲啥是鏈式的?這個和Lucene的整個索引體系結構有關係,Lucene提供了各種不同類型的索引類型,例如倒排、正排(列存)、StoreField、DocValues等。每個不同的索引類型對應不同的索引算法、數據結構以及文件存儲,有些是列級別的,有些是文檔級別的。所以一個文檔寫入後,需要被這麼多種不同索引處理,有些索引會共享memory-buffer,有些則是完全獨立的。基於這個架構,理論上Lucene是提供了擴展其他類型索引的可能性,頂級玩家也可以去嘗試。

在IndexWriter內部,indexing chain上索引構建順序是invert index、store fields、doc values和point values。有些索引類型處理文檔後會將索引內容直接寫入文件(主要是store field和term vector),而有些索引類型會先將文檔內容寫入memory buffer,最後在flush的時候再寫入文件。能直接寫入文件的索引,通常是文檔級的索引,索引構建可以文檔級的增量構建。而不能寫入文件的索引,例如倒排,則必須等Segment內所有文檔全部寫入完畢後,會先對Term進行一個全排序,之後才能構建索引,所以必須要有一個memory-buffer先緩存所有文檔。

前面提到,IndexWriterConfig支持配置Codec,Codec就是對應每種類型索引的Encoder和Decoder。在上圖可以看到,在Lucene 7.2.1版本中,主要有這麼幾種Codec:

  • BlockTreeTermsWriter:倒排索引對應的Codec,其中倒排表部分使用Lucene50PostingsWriter(Block方式寫入倒排鏈)和Lucene50SkipWriter(對Block的SkipList索引),詞典部分則是使用FST(針對倒排表Block級的詞典索引)。
  • CompressingTermVectorsWriter:對應Term vector索引的Writer,底層是壓縮Block格式。
  • CompressingStoredFieldsWriter:對應Store fields索引的Writer,底層是壓縮Block格式。
  • Lucene70DocValuesConsumer:對應Doc values索引的Writer。
  • Lucene60PointsWriter:對應Point values索引的Writer。

這一章節主要了解IndexingChain內部的文檔索引處理流程,核心是鏈式分階段的索引,並且不同類型索引支持Codec可配置。

總結

以上內容主要從一個全局視角來講解IndexWriter的配置、接口、併發模型、核心操作的數據路徑以及索引鏈。

下面是一個簡單的代碼示例,如何使用lucene的IndexWriter建索引以及如何使用indexSearch進行搜索查詢。

Analyzer analyzer = new StandardAnalyzer();
    // Store the index in memory:
    Directory directory = new RAMDirectory();
    // To store an index on disk, use this instead:
    //Directory directory = FSDirectory.open("/tmp/testindex");
    IndexWriterConfig config = new IndexWriterConfig(analyzer);
    IndexWriter iwriter = new IndexWriter(directory, config);
    Document doc = new Document();
    String text = "This is the text to be indexed.";
    doc.add(new Field("fieldname", text, TextField.TYPE_STORED));
    iwriter.addDocument(doc);
    iwriter.close();

    // Now search the index:
    DirectoryReader ireader = DirectoryReader.open(directory);
    IndexSearcher isearcher = new IndexSearcher(ireader);
    // Parse a simple query that searches for "text":
    QueryParser parser = new QueryParser("fieldname", analyzer);
    Query query = parser.parse("text");
    ScoreDoc[] hits = isearcher.search(query, 1000).scoreDocs;
    //assertEquals(1, hits.length);
    // Iterate through the results:
    for (int i = 0; i < hits.length; i++) {
         Document hitDoc = isearcher.doc(hits[i].doc);
         System.out.println(hitDoc.get("fieldname"));
    }
    ireader.close();
    directory.close();

從這個示例中可以看出,lucene的讀寫有各自的操作類。本文重點關注讀邏輯,在使用IndexSearcher類的時候,需要一個DirectoryReader和QueryParser,其中DirectoryReader需要對應寫入時候的Directory實現。QueryParser主要用來解析你的查詢語句,例如你想查 “A and B",lucene內部會有機制解析出是term A和term B的交集查詢。在具體執行Search的時候指定一個最大返回的文檔數目,因爲可能會有過多命中,我們可以限制單詞返回的最大文檔數,以及做分頁返回。

下面會詳細介紹一個索引查詢會經過幾步,每一步lucene分別做了哪些優化實現。

Lucene 查詢過程

在lucene中查詢是基於segment。每個segment可以看做是一個獨立的subindex,在建立索引的過程中,lucene會不斷的flush內存中的數據持久化形成新的segment。多個segment也會不斷的被merge成一個大的segment,在老的segment還有查詢在讀取的時候,不會被刪除,沒有被讀取且被merge的segement會被刪除。這個過程類似於LSM數據庫的merge過程。下面我們主要看在一個segment內部如何實現高效的查詢。

爲了方便大家理解,我們以人名字,年齡,學號爲例,如何實現查某個名字(有重名)的列表。

在lucene中爲了查詢name=XXX的這樣一個條件,會建立基於name的倒排鏈。以上面的數據爲例,倒排鏈如下:

姓名

如果我們還希望按照年齡查詢,例如想查年齡=18的列表,我們還可以建立另一個倒排鏈:

在這裏,Alice,Alan,18,這些都是term。所以倒排本質上就是基於term的反向列表,方便進行屬性查找。到這裏我們有個很自然的問題,如果term非常多,如何快速拿到這個倒排鏈呢?在lucene裏面就引入了term dictonary的概念,也就是term的字典。term字典裏我們可以按照term進行排序,那麼用一個二分查找就可以定爲這個term所在的地址。這樣的複雜度是logN,在term很多,內存放不下的時候,效率還是需要進一步提升。可以用一個hashmap,當有一個term進入,hash繼續查找倒排鏈。這裏hashmap的方式可以看做是term dictionary的一個index。 從lucene4開始,爲了方便實現rangequery或者前綴,後綴等複雜的查詢語句,lucene使用FST數據結構來存儲term字典,下面就詳細介紹下FST的存儲結構。

FST

我們就用Alice和Alan這兩個單詞爲例,來看下FST的構造過程。首先對所有的單詞做一下排序爲“Alice”,“Alan”。

  1. 插入“Alan”

  1. 插入“Alice”

這樣你就得到了一個有向無環圖,有這樣一個數據結構,就可以很快查找某個人名是否存在。FST在單term查詢上可能相比hashmap並沒有明顯優勢,甚至會慢一些。但是在範圍,前綴搜索以及壓縮率上都有明顯的優勢。

在通過FST定位到倒排鏈後,有一件事情需要做,就是倒排鏈的合併。因爲查詢條件可能不止一個,例如上面我們想找name=“alan” and age="18"的列表。lucene是如何實現倒排鏈的合併呢。這裏就需要看一下倒排鏈存儲的數據結構

SkipList

爲了能夠快速查找docid,lucene採用了SkipList這一數據結構。SkipList有以下幾個特徵:

  1. 元素排序的,對應到我們的倒排鏈,lucene是按照docid進行排序,從小到大。
  2. 跳躍有一個固定的間隔,這個是需要建立SkipList的時候指定好,例如下圖以間隔是3
  3. SkipList的層次,這個是指整個SkipList有幾層

有了這個SkipList以後比如我們要查找docid=12,原來可能需要一個個掃原始鏈表,1,2,3,5,7,8,10,12。有了SkipList以後先訪問第一層看到是然後大於12,進入第0層走到3,8,發現15大於12,然後進入原鏈表的8繼續向下經過10和12。

有了FST和SkipList的介紹以後,我們大體上可以畫一個下面的圖來說明lucene是如何實現整個倒排結構的:

有了這張圖,我們可以理解爲什麼基於lucene可以快速進行倒排鏈的查找和docid查找,下面就來看一下有了這些後如何進行倒排鏈合併返回最後的結果。

倒排合併

假如我們的查詢條件是name = “Alice”,那麼按照之前的介紹,首先在term字典中定位是否存在這個term,如果存在的話進入這個term的倒排鏈,並根據參數設定返回分頁返回結果即可。這類查詢,在數據庫中使用二級索引也是可以滿足,那lucene的優勢在哪呢。假如我們有多個條件,例如我們需要按名字或者年齡單獨查詢,也需要進行組合 name = “Alice” and age = "18"的查詢,那麼使用傳統二級索引方案,你可能需要建立兩張索引表,然後分別查詢結果後進行合併,這樣如果age = 18的結果過多的話,查詢合併會很耗時。那麼在lucene這兩個倒排鏈是怎麼合併呢。

假如我們有下面三個倒排鏈需要進行合併。

在lucene中會採用下列順序進行合併:

  1. 在termA開始遍歷,得到第一個元素docId=1
  2. Set currentDocId=1
  3. 在termB中 search(currentDocId) = 1 (返回大於等於currentDocId的一個doc),
    1. 因爲currentDocId ==1,繼續
    2. 如果currentDocId 和返回的不相等,執行2,然後繼續

 

  1. 到termC後依然符合,返回結果
  2. currentDocId = termC的nextItem
  3. 然後繼續步驟3 依次循環。直到某個倒排鏈到末尾。

整個合併步驟我可以發現,如果某個鏈很短,會大幅減少比對次數,並且由於SkipList結構的存在,在某個倒排中定位某個docid的速度會比較快不需要一個個遍歷。可以很快的返回最終的結果。從倒排的定位,查詢,合併整個流程組成了lucene的查詢過程,和傳統數據庫的索引相比,lucene合併過程中的優化減少了讀取數據的IO,倒排合併的靈活性也解決了傳統索引較難支持多條件查詢的問題。

BKDTree

在lucene中如果想做範圍查找,根據上面的FST模型可以看出來,需要遍歷FST找到包含這個range的一個點然後進入對應的倒排鏈,然後進行求並集操作。但是如果是數值類型,比如是浮點數,那麼潛在的term可能會非常多,這樣查詢起來效率會很低。所以爲了支持高效的數值類或者多維度查詢,lucene引入類BKDTree。BKDTree是基於KDTree,對數據進行按照維度劃分建立一棵二叉樹確保樹兩邊節點數目平衡。在一維的場景下,KDTree就會退化成一個二叉搜索樹,在二叉搜索樹中如果我們想查找一個區間,logN的複雜度就會訪問到葉子結點得到對應的倒排鏈。如下圖所示:

如果是多維,kdtree的建立流程會發生一些變化。

比如我們以二維爲例,建立過程如下:

  1. 確定切分維度,這裏維度的選取順序是數據在這個維度方法最大的維度優先。一個直接的理解就是,數據分散越開的維度,我們優先切分。
  2. 切分點的選這個維度最中間的點。
  3. 遞歸進行步驟1,2,我們可以設置一個閾值,點的數目少於多少後就不再切分,直到所有的點都切分好停止。

下圖是一個建立例子:

BKDTree是KDTree的變種,因爲可以看出來,KDTree如果有新的節點加入,或者節點修改起來,消耗還是比較大。類似於LSM的merge思路,BKD也是多個KDTREE,然後持續merge最終合併成一個。不過我們可以看到如果你某個term類型使用了BKDTree的索引類型,那麼在和普通倒排鏈merge的時候就沒那麼高效了所以這裏要做一個平衡,一種思路是把另一類term也作爲一個維度加入BKDTree索引中。

如何實現返回結果進行排序聚合

通過之前介紹可以看出lucene通過倒排的存儲模型實現term的搜索,那對於有時候我們需要拿到另一個屬性的值進行聚合,或者希望返回結果按照另一個屬性進行排序。在lucene4之前需要把結果全部拿到再讀取原文進行排序,這樣效率較低,還比較佔用內存,爲了加速lucene實現了fieldcache,把讀過的field放進內存中。這樣可以減少重複的IO,但是也會帶來新的問題,就是佔用較多內存。新版本的lucene中引入了DocValues,DocValues是一個基於docid的列式存儲。當我們拿到一系列的docid後,進行排序就可以使用這個列式存儲,結合一個堆排序進行。當然額外的列式存儲會佔用額外的空間,lucene在建索引的時候可以自行選擇是否需要DocValue存儲和哪些字段需要存儲。

Lucene的代碼目錄結構

介紹了lucene中幾個主要的數據結構和查找原理後,我們在來看下lucene的代碼結構,後續可以深入代碼理解細節。lucene的主要有下面幾個目錄:

  1. analysis模塊主要負責詞法分析及語言處理而形成Term。
  2. codecs模塊主要負責之前提到的一些數據結構的實現,和一些編碼壓縮算法。包括skiplist,docvalue等。
  3. document模塊主要包括了lucene各類數據類型的定義實現。
  4. index模塊主要負責索引的創建,裏面有IndexWriter。
  5. store模塊主要負責索引的讀寫。
  6. search模塊主要負責對索引的搜索。
  7. geo模塊主要爲geo查詢相關的類實現
  8. util模塊是bkd,fst等數據結構實現。

最後

本文介紹了lucene中的一些主要數據結構,以及如何利用這些數據結構實現高效的查找。我們希望通過這些介紹可以加深理解倒排索引和傳統數據庫索引的區別,數據庫有時候也可以藉助於搜索引擎實現更豐富的查詢語意。除此之外,做爲一個搜索庫,如何進行打分,query語句如何進行parse這些我們沒有展開介紹,有興趣的同學可以深入lucene的源碼進一步瞭解。

知乎原文鏈接:

https://zhuanlan.zhihu.com/p/35814539

https://zhuanlan.zhihu.com/p/35795070

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章