Lucene 是一個基於 Java 的全文信息檢索工具包,目前主流的搜索系統Elasticsearch和solr都是基於lucene的索引和搜索能力進行。想要理解搜索系統的實現原理,就須要深刻lucene這一層,看看lucene是如何存儲須要檢索的數據,以及如何完成高效的數據檢索。算法
在數據庫中由於有索引的存在,也能夠支持不少高效的查詢操做。不過對比lucene,數據庫的查詢能力仍是會弱不少,本文就將探索下lucene支持哪些查詢,並會重點選取幾類查詢分析lucene內部是如何實現的。爲了方便你們理解,咱們會先簡單介紹下lucene裏面的一些基本概念,而後展開lucene中的幾種數據存儲結構,理解了他們的存儲原理後就能夠方便知道如何基於這些存儲結構來實現高效的搜索。本文重點關注是lucene如何作到傳統數據庫較難作到的查詢,對於分詞,打分等功能不會展開介紹。數據庫
本文具體會分如下幾部分:數據結構
Lucene中包含了四種基本數據類型,分別是:工具
在lucene中,讀寫路徑是分離的。寫入的時候建立一個IndexWriter,而讀的時候會建立一個IndexSearcher,
下面是一個簡單的代碼示例,如何使用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的時候指定一個最大返回的文檔數目,由於可能會有過多命中,咱們能夠限制單詞返回的最大文檔數,以及作分頁返回。this
下面會詳細介紹一個索引查詢會通過幾步,每一步lucene分別作了哪些優化實現。搜索引擎
在lucene中查詢是基於segment。每一個segment能夠看作是一個獨立的subindex,在創建索引的過程當中,lucene會不斷的flush內存中的數據持久化造成新的segment。多個segment也會不斷的被merge成一個大的segment,在老的segment還有查詢在讀取的時候,不會被刪除,沒有被讀取且被merge的segement會被刪除。這個過程相似於LSM數據庫的merge過程。下面咱們主要看在一個segment內部如何實現高效的查詢。爲了方便你們理解,咱們以人名字,年齡,學號爲例,如何實現查某個名字(有重名)的列表。編碼
在lucene中爲了查詢name=XXX的這樣一個條件,會創建基於name的倒排鏈。以上面的數據爲例,倒排鏈以下:
姓名spa
Alice | [1,2,3]
---- | --- |
Alan | [4,5]
若是咱們還但願按照年齡查詢,例如想查年齡=18的列表,咱們還能夠創建另外一個倒排鏈:3d
18 | [1,5]
---| --- |
20 | [2]
21 | [3,4]
在這裏,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」。
這樣你就獲得了一個有向無環圖,有這樣一個數據結構,就能夠很快查找某我的名是否存在。FST在單term查詢上可能相比hashmap並無明顯優點,甚至會慢一些。可是在範圍,前綴搜索以及壓縮率上都有明顯的優點。
在經過FST定位到倒排鏈後,有一件事情須要作,就是倒排鏈的合併。由於查詢條件可能不止一個,例如上面咱們想找name="alan" and age="18"的列表。lucene是如何實現倒排鏈的合併呢。這裏就須要看一下倒排鏈存儲的數據結構
SkipList
爲了可以快速查找docid,lucene採用了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中會採用下列順序進行合併:
由於currentDocId ==1,繼續
若是currentDocId 和返回的不相等,執行2,而後繼續
整個合併步驟我能夠發現,若是某個鏈很短,會大幅減小比對次數,而且因爲SkipList結構的存在,在某個倒排中定位某個docid的速度會比較快不須要一個個遍歷。能夠很快的返回最終的結果。從倒排的定位,查詢,合併整個流程組成了lucene的查詢過程,和傳統數據庫的索引相比,lucene合併過程當中的優化減小了讀取數據的IO,倒排合併的靈活性也解決了傳統索引較難支持多條件查詢的問題。
BKDTree
在lucene中若是想作範圍查找,根據上面的FST模型能夠看出來,須要遍歷FST找到包含這個range的一個點而後進入對應的倒排鏈,而後進行求並集操做。可是若是是數值類型,好比是浮點數,那麼潛在的term可能會很是多,這樣查詢起來效率會很低。因此爲了支持高效的數值類或者多維度查詢,lucene引入類BKDTree。BKDTree是基於KDTree,對數據進行按照維度劃分創建一棵二叉樹確保樹兩邊節點數目平衡。在一維的場景下,KDTree就會退化成一個二叉搜索樹,在二叉搜索樹中若是咱們想查找一個區間,logN的複雜度就會訪問到葉子結點獲得對應的倒排鏈。以下圖所示:
若是是多維,kdtree的創建流程會發生一些變化。
好比咱們以二維爲例,創建過程以下:
下圖是一個創建例子:
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中的一些主要數據結構,以及如何利用這些數據結構實現高效的查找。咱們但願經過這些介紹能夠加深理解倒排索引和傳統數據庫索引的區別,數據庫有時候也能夠藉助於搜索引擎實現更豐富的查詢語意。除此以外,作爲一個搜索庫,如何進行打分,query語句如何進行parse這些咱們沒有展開介紹,有興趣的同窗能夠深刻lucene的源碼進一步瞭解。
詳情請閱讀原文