Lucene 查詢原理及解析
前言
Lucene 是一個基於 Java 的全文資訊檢索工具包,目前主流的搜尋系統 Elasticsearch 和 solr 都是基於 lucene 的索引和搜尋能力進行。想要理解搜尋系統的實現原理,就需要深入 lucene 這一層,看看 lucene 是如何儲存需要檢索的資料,以及如何完成高效的資料檢索。
在資料庫中因為有索引的存在,也可以支援很多高效的查詢操作。不過對比 lucene,資料庫的查詢能力還是會弱很多,本文就將探索下 lucene 支援哪些查詢,並會重點選取幾類查詢分析 lucene 內部是如何實現的。為了方便大家理解,我們會先簡單介紹下 lucene 裡面的一些基本概念,然後展開 lucene 中的幾種資料儲存結構,理解了他們的儲存原理後就可以方便知道如何基於這些儲存結構來實現高效的搜尋。本文重點關注是 lucene 如何做到傳統資料庫較難做到的查詢,對於分詞,打分等功能不會展開介紹。
本文具體會分以下幾部分:
- 介紹 lucene 的資料模型,細節可以參閱 lucene 資料模型一文。
- 介紹 lucene 中如何儲存需要搜尋的 term。
- 介紹 lucene 的倒排鏈的如何儲存以及如何實現 docid 的快速查詢。
- 介紹 lucene 如何實現倒排鏈合併。
- 介紹 lucene 如何做範圍查詢和字首匹配。
- 介紹 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 =newNIOFSDirectory(Paths.get("/index")); IndexWriterConfig config =newIndexWriterConfig(); IndexWriter writer =newIndexWriter(index,config); // create a document Document doc =newDocument(); doc.add(newTextField("title","Lucene - IndexWriter", Field.Store.YES)); doc.add(newStringField("content"," 招人,求私信 ", Field.Store.YES)); // index the document writer.addDocument(doc); writer.commit();
先看下 Lucene 中如何使用 IndexWriter 來寫入資料,上面是一段精簡的呼叫示例程式碼,整個過程主要有三個步驟:
- 初始化:初始化 IndexWriter 必要的兩個元素是 Directory 和 IndexWriterConfig,Directory 是 Lucene 中資料持久層的抽象介面,通過這層介面可以實現很多不同型別的資料持久層,例如本地檔案系統、網路檔案系統、資料庫或者是分散式檔案系統。IndexWriterConfig 內提供了很多可配置的高階引數,提供給高階玩家進行效能調優和功能定製,它提供的幾個關鍵引數後面會細說。
- 構造文件:Lucene 中文件由 Document 表示,Document 由 Field 構成。Lucene 提供多種不同型別的 Field,其 FiledType 決定了它所支援的索引模式,當然也支援自定義 Field,具體方式可參考上一篇文章。
- 寫入文件:通過 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 來控制。整個多執行緒資料處理流程為:
- 多執行緒併發呼叫 IndexWriter 的寫介面,在 IndexWriter 內部具體請求會由 DocumentsWriter 來執行。DocumentsWriter 內部在處理請求之前,會先根據當前執行操作的 Thread 來分配 DocumentsWriterPerThread。
- 每個執行緒在其獨立的 DocumentsWriterPerThread 空間內部進行資料處理,包括分詞、相關性計算、索引構建等。
- 資料處理完畢後,在 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 介面,看下介面定義:
複製程式碼
longupdateDocument(finalIterable<?extendsIndexableField> doc,finalAnalyzer analyzer, finalTerm delTerm)throwsIOException, AbortingException
這個函式內的處理流程是:
- 根據 Thread 分配 DWPT
- 在 DWPT 內執行 delete
- 在 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 =newStandardAnalyzer(); // Store the index in memory: Directory directory =newRAMDirectory(); // To store an index on disk, use this instead: //Directory directory = FSDirectory.open("/tmp/testindex"); IndexWriterConfig config =newIndexWriterConfig(analyzer); IndexWriter iwriter =newIndexWriter(directory,config); Document doc =newDocument(); String text ="This is the text to be indexed."; doc.add(newField("fieldname",text, TextField.TYPE_STORED)); iwriter.addDocument(doc); iwriter.close(); // Now search the index: DirectoryReader ireader =DirectoryReader.open(directory); IndexSearcher isearcher =newIndexSearcher(ireader); // Parse a simple query that searches for "text": QueryParser parser =newQueryParser("fieldname",analyzer); Query query = parser.parse("text"); ScoreDoc[]hits = isearcher.search(query,1000).scoreDocs; //assertEquals(1, hits.length); // Iterate through the results: for (inti =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”。
- 插入“Alan”
- 插入“Alice”
這樣你就得到了一個有向無環圖,有這樣一個數據結構,就可以很快查詢某個人名是否存在。FST 在單 term 查詢上可能相比 hashmap 並沒有明顯優勢,甚至會慢一些。但是在範圍,字首搜尋以及壓縮率上都有明顯的優勢。
在通過 FST 定位到倒排鏈後,有一件事情需要做,就是倒排鏈的合併。因為查詢條件可能不止一個,例如上面我們想找 name=“alan” and age="18" 的列表。lucene 是如何實現倒排鏈的合併呢。這裡就需要看一下倒排鏈儲存的資料結構
SkipList
為了能夠快速查詢 docid,lucene 採用了 SkipList 這一資料結構。SkipList 有以下幾個特徵:
- 元素排序的,對應到我們的倒排鏈,lucene 是按照 docid 進行排序,從小到大。
- 跳躍有一個固定的間隔,這個是需要建立 SkipList 的時候指定好,例如下圖以間隔是 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 中會採用下列順序進行合併:
- 在 termA 開始遍歷,得到第一個元素 docId=1
- Set currentDocId=1
- 在 termB 中 search(currentDocId) = 1 (返回大於等於 currentDocId 的一個 doc),
- 因為 currentDocId ==1,繼續
- 如果 currentDocId 和返回的不相等,執行 2,然後繼續
- 到 termC 後依然符合,返回結果
- currentDocId = termC 的 nextItem
- 然後繼續步驟 3 依次迴圈。直到某個倒排鏈到末尾。
整個合併步驟我可以發現,如果某個鏈很短,會大幅減少比對次數,並且由於 SkipList 結構的存在,在某個倒排中定位某個 docid 的速度會比較快不需要一個個遍歷。可以很快的返回最終的結果。從倒排的定位,查詢,合併整個流程組成了 lucene 的查詢過程,和傳統資料庫的索引相比,lucene 合併過程中的優化減少了讀取資料的 IO,倒排合併的靈活性也解決了傳統索引較難支援多條件查詢的問題。
BKDTree
在 lucene 中如果想做範圍查詢,根據上面的 FST 模型可以看出來,需要遍歷 FST 找到包含這個 range 的一個點然後進入對應的倒排鏈,然後進行求並集操作。但是如果是數值型別,比如是浮點數,那麼潛在的 term 可能會非常多,這樣查詢起來效率會很低。所以為了支援高效的數值類或者多維度查詢,lucene 引入類 BKDTree。BKDTree 是基於 KDTree,對資料進行按照維度劃分建立一棵二叉樹確保樹兩邊節點數目平衡。在一維的場景下,KDTree 就會退化成一個二叉搜尋樹,在二叉搜尋樹中如果我們想查詢一個區間,logN 的複雜度就會訪問到葉子結點得到對應的倒排鏈。如下圖所示:
如果是多維,kdtree 的建立流程會發生一些變化。
比如我們以二維為例,建立過程如下:
- 確定切分維度,這裡維度的選取順序是資料在這個維度方法最大的維度優先。一個直接的理解就是,資料分散越開的維度,我們優先切分。
- 切分點的選這個維度最中間的點。
- 遞迴進行步驟 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 的主要有下面幾個目錄:
- analysis 模組主要負責詞法分析及語言處理而形成 Term。
- codecs 模組主要負責之前提到的一些資料結構的實現,和一些編碼壓縮演算法。包括 skiplist,docvalue 等。
- document 模組主要包括了 lucene 各類資料型別的定義實現。
- index 模組主要負責索引的建立,裡面有 IndexWriter。
- store 模組主要負責索引的讀寫。
- search 模組主要負責對索引的搜尋。
- geo 模組主要為 geo 查詢相關的類實現
- util 模組是 bkd,fst 等資料結構實現。
最後
本文介紹了 lucene 中的一些主要資料結構,以及如何利用這些資料結構實現高效的查詢。我們希望通過這些介紹可以加深理解倒排索引和傳統資料庫索引的區別,資料庫有時候也可以藉助於搜尋引擎實現更豐富的查詢語意。除此之外,做為一個搜尋庫,如何進行打分,query 語句如何進行 parse 這些我們沒有展開介紹,有興趣的同學可以深入 lucene 的原始碼進一步瞭解。
知乎原文連結: