圖文示例詳解Lucene數(shù)據(jù)模型查詢原理
前言
Lucene 是一個(gè)基于 Java 的全文信息檢索工具包,目前主流的搜索系統(tǒng)Elasticsearch和solr都是基于lucene的索引和搜索能力進(jìn)行。想要理解搜索系統(tǒng)的實(shí)現(xiàn)原理,就需要深入lucene這一層,看看lucene是如何存儲需要檢索的數(shù)據(jù),以及如何完成高效的數(shù)據(jù)檢索。
在數(shù)據(jù)庫中因?yàn)橛兴饕拇嬖?,也可以支持很多高效的查詢操作。不過對比lucene,數(shù)據(jù)庫的查詢能力還是會弱很多,本文就將探索下lucene支持哪些查詢,并會重點(diǎn)選取幾類查詢分析lucene內(nèi)部是如何實(shí)現(xiàn)的。為了方便大家理解,我們會先簡單介紹下lucene里面的一些基本概念,然后展開lucene中的幾種數(shù)據(jù)存儲結(jié)構(gòu),理解了他們的存儲原理后就可以方便知道如何基于這些存儲結(jié)構(gòu)來實(shí)現(xiàn)高效的搜索。本文重點(diǎn)關(guān)注是lucene如何做到傳統(tǒng)數(shù)據(jù)庫較難做到的查詢,對于分詞,打分等功能不會展開介紹。
本文具體會分以下幾部分:
- 介紹lucene的數(shù)據(jù)模型,細(xì)節(jié)可以參閱lucene數(shù)據(jù)模型一文。
- 介紹lucene中如何存儲需要搜索的term。
- 介紹lucene的倒排鏈的如何存儲以及如何實(shí)現(xiàn)docid的快速查找。
- 介紹lucene如何實(shí)現(xiàn)倒排鏈合并。
- 介紹lucene如何做范圍查詢和前綴匹配。
- 介紹lucene如何優(yōu)化數(shù)值類范圍查詢。
Lucene數(shù)據(jù)模型
Lucene中包含了四種基本數(shù)據(jù)類型,分別是:
- Index:索引,由很多的Document組成。
- Document:由很多的Field組成,是Index和Search的最小單位。
- Field:由很多的Term組成,包括Field Name和Field Value。
- Term:由很多的字節(jié)組成。一般將Text類型的Field Value分詞之后的每個(gè)最小單元叫做Term。
在lucene中,讀寫路徑是分離的。寫入的時(shí)候創(chuàng)建一個(gè)IndexWriter,而讀的時(shí)候會創(chuàng)建一個(gè)IndexSearcher,
下面是一個(gè)簡單的代碼示例,如何使用lucene的IndexWriter建索引以及如何使用indexSearch進(jìn)行搜索查詢。
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();
從這個(gè)示例中可以看出,lucene的讀寫有各自的操作類。本文重點(diǎn)關(guān)注讀邏輯,在使用IndexSearcher類的時(shí)候,需要一個(gè)DirectoryReader和QueryParser,其中DirectoryReader需要對應(yīng)寫入時(shí)候的Directory實(shí)現(xiàn)。QueryParser主要用來解析你的查詢語句,例如你想查 “A and B",lucene內(nèi)部會有機(jī)制解析出是term A和term B的交集查詢。在具體執(zhí)行Search的時(shí)候指定一個(gè)最大返回的文檔數(shù)目,因?yàn)榭赡軙羞^多命中,我們可以限制單詞返回的最大文檔數(shù),以及做分頁返回。
下面會詳細(xì)介紹一個(gè)索引查詢會經(jīng)過幾步,每一步lucene分別做了哪些優(yōu)化實(shí)現(xiàn)。
Lucene 查詢過程
在lucene中查詢是基于segment。每個(gè)segment可以看做是一個(gè)獨(dú)立的subindex,在建立索引的過程中,lucene會不斷的flush內(nèi)存中的數(shù)據(jù)持久化形成新的segment。多個(gè)segment也會不斷的被merge成一個(gè)大的segment,在老的segment還有查詢在讀取的時(shí)候,不會被刪除,沒有被讀取且被merge的segement會被刪除。這個(gè)過程類似于LSM數(shù)據(jù)庫的merge過程。下面我們主要看在一個(gè)segment內(nèi)部如何實(shí)現(xiàn)高效的查詢。為了方便大家理解,我們以人名字,年齡,學(xué)號為例,如何實(shí)現(xiàn)查某個(gè)名字(有重名)的列表。
在lucene中為了查詢name=XXX的這樣一個(gè)條件,會建立基于name的倒排鏈。以上面的數(shù)據(jù)為例,倒排鏈如下:
姓名
Alice | [1,2,3]
---- | --- |
Alan | [4,5]
如果我們還希望按照年齡查詢,例如想查年齡=18的列表,我們還可以建立另一個(gè)倒排鏈:
18 | [1,5]
---| --- |
20 | [2]
21 | [3,4]
在這里,Alice,Alan,18,這些都是term。所以倒排本質(zhì)上就是基于term的反向列表,方便進(jìn)行屬性查找。到這里我們有個(gè)很自然的問題,如果term非常多,如何快速拿到這個(gè)倒排鏈呢?在lucene里面就引入了term dictonary的概念,也就是term的字典。term字典里我們可以按照term進(jìn)行排序,那么用一個(gè)二分查找就可以定為這個(gè)term所在的地址。這樣的復(fù)雜度是logN,在term很多,內(nèi)存放不下的時(shí)候,效率還是需要進(jìn)一步提升??梢杂靡粋€(gè)hashmap,當(dāng)有一個(gè)term進(jìn)入,hash繼續(xù)查找倒排鏈。這里hashmap的方式可以看做是term dictionary的一個(gè)index。 從lucene4開始,為了方便實(shí)現(xiàn)rangequery或者前綴,后綴等復(fù)雜的查詢語句,lucene使用FST數(shù)據(jù)結(jié)構(gòu)來存儲term字典,下面就詳細(xì)介紹下FST的存儲結(jié)構(gòu)。
FST我們就用Alice和Alan這兩個(gè)單詞為例,來看下FST的構(gòu)造過程。首先對所有的單詞做一下排序?yàn)?ldquo;Alice”,“Alan”。
- 插入“Alan”
- 插入“Alice”
這樣你就得到了一個(gè)有向無環(huán)圖,有這樣一個(gè)數(shù)據(jù)結(jié)構(gòu),就可以很快查找某個(gè)人名是否存在。FST在單term查詢上可能相比hashmap并沒有明顯優(yōu)勢,甚至?xí)恍?。但是在范圍,前綴搜索以及壓縮率上都有明顯的優(yōu)勢。
在通過FST定位到倒排鏈后,有一件事情需要做,就是倒排鏈的合并。因?yàn)椴樵儣l件可能不止一個(gè),例如上面我們想找name="alan" and age="18"的列表。lucene是如何實(shí)現(xiàn)倒排鏈的合并呢。這里就需要看一下倒排鏈存儲的數(shù)據(jù)結(jié)構(gòu)
SkipList為了能夠快速查找docid,lucene采用了SkipList這一數(shù)據(jù)結(jié)構(gòu)。SkipList有以下幾個(gè)特征:
- 元素排序的,對應(yīng)到我們的倒排鏈,lucene是按照docid進(jìn)行排序,從小到大。
- 跳躍有一個(gè)固定的間隔,這個(gè)是需要建立SkipList的時(shí)候指定好,例如下圖以間隔是3
- SkipList的層次,這個(gè)是指整個(gè)SkipList有幾層
有了這個(gè)SkipList以后比如我們要查找docid=12,原來可能需要一個(gè)個(gè)掃原始鏈表,1,2,3,5,7,8,10,12。有了SkipList以后先訪問第一層看到是然后大于12,進(jìn)入第0層走到3,8,發(fā)現(xiàn)15大于12,然后進(jìn)入原鏈表的8繼續(xù)向下經(jīng)過10和12。
有了FST和SkipList的介紹以后,我們大體上可以畫一個(gè)下面的圖來說明lucene是如何實(shí)現(xiàn)整個(gè)倒排結(jié)構(gòu)的:
有了這張圖,我們可以理解為什么基于lucene可以快速進(jìn)行倒排鏈的查找和docid查找,下面就來看一下有了這些后如何進(jìn)行倒排鏈合并返回最后的結(jié)果。
倒排合并假如我們的查詢條件是name = “Alice”,那么按照之前的介紹,首先在term字典中定位是否存在這個(gè)term,如果存在的話進(jìn)入這個(gè)term的倒排鏈,并根據(jù)參數(shù)設(shè)定返回分頁返回結(jié)果即可。這類查詢,在數(shù)據(jù)庫中使用二級索引也是可以滿足,那lucene的優(yōu)勢在哪呢。假如我們有多個(gè)條件,例如我們需要按名字或者年齡單獨(dú)查詢,也需要進(jìn)行組合 name = "Alice" and age = "18"的查詢,那么使用傳統(tǒng)二級索引方案,你可能需要建立兩張索引表,然后分別查詢結(jié)果后進(jìn)行合并,這樣如果age = 18的結(jié)果過多的話,查詢合并會很耗時(shí)。那么在lucene這兩個(gè)倒排鏈?zhǔn)窃趺春喜⒛亍?br />假如我們有下面三個(gè)倒排鏈需要進(jìn)行合并。
在lucene中會采用下列順序進(jìn)行合并:
- 在termA開始遍歷,得到第一個(gè)元素docId=1
- Set currentDocId=1
- 在termB中 search(currentDocId) = 1 (返回大于等于currentDocId的一個(gè)doc),
因?yàn)閏urrentDocId ==1,繼續(xù)
如果currentDocId 和返回的不相等,執(zhí)行2,然后繼續(xù)
- 到termC后依然符合,返回結(jié)果
- currentDocId = termC的nextItem
- 然后繼續(xù)步驟3 依次循環(huán)。直到某個(gè)倒排鏈到末尾。
整個(gè)合并步驟我可以發(fā)現(xiàn),如果某個(gè)鏈很短,會大幅減少比對次數(shù),并且由于SkipList結(jié)構(gòu)的存在,在某個(gè)倒排中定位某個(gè)docid的速度會比較快不需要一個(gè)個(gè)遍歷??梢院芸斓姆祷刈罱K的結(jié)果。從倒排的定位,查詢,合并整個(gè)流程組成了lucene的查詢過程,和傳統(tǒng)數(shù)據(jù)庫的索引相比,lucene合并過程中的優(yōu)化減少了讀取數(shù)據(jù)的IO,倒排合并的靈活性也解決了傳統(tǒng)索引較難支持多條件查詢的問題。
BKDTree在lucene中如果想做范圍查找,根據(jù)上面的FST模型可以看出來,需要遍歷FST找到包含這個(gè)range的一個(gè)點(diǎn)然后進(jìn)入對應(yīng)的倒排鏈,然后進(jìn)行求并集操作。但是如果是數(shù)值類型,比如是浮點(diǎn)數(shù),那么潛在的term可能會非常多,這樣查詢起來效率會很低。所以為了支持高效的數(shù)值類或者多維度查詢,lucene引入類BKDTree。BKDTree是基于KDTree,對數(shù)據(jù)進(jìn)行按照維度劃分建立一棵二叉樹確保樹兩邊節(jié)點(diǎn)數(shù)目平衡。在一維的場景下,KDTree就會退化成一個(gè)二叉搜索樹,在二叉搜索樹中如果我們想查找一個(gè)區(qū)間,logN的復(fù)雜度就會訪問到葉子結(jié)點(diǎn)得到對應(yīng)的倒排鏈。如下圖所示:
如果是多維,kdtree的建立流程會發(fā)生一些變化。
比如我們以二維為例,建立過程如下:
- 確定切分維度,這里維度的選取順序是數(shù)據(jù)在這個(gè)維度方法最大的維度優(yōu)先。一個(gè)直接的理解就是,數(shù)據(jù)分散越開的維度,我們優(yōu)先切分。
- 切分點(diǎn)的選這個(gè)維度最中間的點(diǎn)。
- 遞歸進(jìn)行步驟1,2,我們可以設(shè)置一個(gè)閾值,點(diǎn)的數(shù)目少于多少后就不再切分,直到所有的點(diǎn)都切分好停止。
下圖是一個(gè)建立例子:
BKDTree是KDTree的變種,因?yàn)榭梢钥闯鰜?,KDTree如果有新的節(jié)點(diǎn)加入,或者節(jié)點(diǎn)修改起來,消耗還是比較大。類似于LSM的merge思路,BKD也是多個(gè)KDTREE,然后持續(xù)merge最終合并成一個(gè)。不過我們可以看到如果你某個(gè)term類型使用了BKDTree的索引類型,那么在和普通倒排鏈merge的時(shí)候就沒那么高效了所以這里要做一個(gè)平衡,一種思路是把另一類term也作為一個(gè)維度加入BKDTree索引中。
如何實(shí)現(xiàn)返回結(jié)果進(jìn)行排序聚合
通過之前介紹可以看出lucene通過倒排的存儲模型實(shí)現(xiàn)term的搜索,那對于有時(shí)候我們需要拿到另一個(gè)屬性的值進(jìn)行聚合,或者希望返回結(jié)果按照另一個(gè)屬性進(jìn)行排序。在lucene4之前需要把結(jié)果全部拿到再讀取原文進(jìn)行排序,這樣效率較低,還比較占用內(nèi)存,為了加速lucene實(shí)現(xiàn)了fieldcache,把讀過的field放進(jìn)內(nèi)存中。這樣可以減少重復(fù)的IO,但是也會帶來新的問題,就是占用較多內(nèi)存。新版本的lucene中引入了DocValues,DocValues是一個(gè)基于docid的列式存儲。當(dāng)我們拿到一系列的docid后,進(jìn)行排序就可以使用這個(gè)列式存儲,結(jié)合一個(gè)堆排序進(jìn)行。當(dāng)然額外的列式存儲會占用額外的空間,lucene在建索引的時(shí)候可以自行選擇是否需要DocValue存儲和哪些字段需要存儲。
Lucene的代碼目錄結(jié)構(gòu)
介紹了lucene中幾個(gè)主要的數(shù)據(jù)結(jié)構(gòu)和查找原理后,我們在來看下lucene的代碼結(jié)構(gòu),后續(xù)可以深入代碼理解細(xì)節(jié)。lucene的主要有下面幾個(gè)目錄:
- analysis模塊主要負(fù)責(zé)詞法分析及語言處理而形成Term。
- codecs模塊主要負(fù)責(zé)之前提到的一些數(shù)據(jù)結(jié)構(gòu)的實(shí)現(xiàn),和一些編碼壓縮算法。包括skiplist,docvalue等。
- document模塊主要包括了lucene各類數(shù)據(jù)類型的定義實(shí)現(xiàn)。
- index模塊主要負(fù)責(zé)索引的創(chuàng)建,里面有IndexWriter。
- store模塊主要負(fù)責(zé)索引的讀寫。
- search模塊主要負(fù)責(zé)對索引的搜索。
- geo模塊主要為geo查詢相關(guān)的類實(shí)現(xiàn)
- util模塊是bkd,fst等數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)。
最后
本文介紹了lucene中的一些主要數(shù)據(jù)結(jié)構(gòu),以及如何利用這些數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)高效的查找。我們希望通過這些介紹可以加深理解倒排索引和傳統(tǒng)數(shù)據(jù)庫索引的區(qū)別,數(shù)據(jù)庫有時(shí)候也可以借助于搜索引擎實(shí)現(xiàn)更豐富的查詢語意。除此之外,做為一個(gè)搜索庫,如何進(jìn)行打分,query語句如何進(jìn)行parse這些我們沒有展開介紹,有興趣的同學(xué)可以深入lucene的源碼進(jìn)一步了解。
以上就是圖文示例詳解Lucene數(shù)據(jù)模型查詢原理的詳細(xì)內(nèi)容,更多關(guān)于Lucene數(shù)據(jù)模型查詢原理的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
RabbitMQ 3.9.7 鏡像模式集群與Springboot 2.5.5 整合
今天我們來聊聊 RabbitMQ 3.9.7 鏡像模式集群與Springboot 2.5.5 整合,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),需要的朋友參考下吧2021-10-10Mybatis-Plus自動生成代碼的實(shí)現(xiàn)示例
在工作中,程序員很多時(shí)候都是在寫類似的代碼,可以使用自動生成代碼,本文主要介紹了Mybatis-Plus自動生成代碼的實(shí)現(xiàn)示例,具有一定的參考價(jià)值,感興趣的可以了解一下2023-11-11SpringBoot 過濾器, 攔截器, 監(jiān)聽器的具體使用
本文主要介紹了SpringBoot 過濾器, 攔截器, 監(jiān)聽器的具體使用,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-05-05java并發(fā)編程中實(shí)現(xiàn)可見性的四種可行方案解析
這篇文章主要介紹了java并發(fā)編程中實(shí)現(xiàn)可見性的四種可行方案解析,使用關(guān)鍵字volatile和使用鎖(如synchronized關(guān)鍵字或者java.util.concurrent包中的鎖)來確保對共享變量的修改在多線程環(huán)境中能夠正確地被其他線程所觀察到,需要的朋友可以參考下2023-08-08java實(shí)現(xiàn)將數(shù)字轉(zhuǎn)換成人民幣大寫
前面給大家介紹過使用javascript,php,c#,python等語言實(shí)現(xiàn)人民幣大寫格式化,這篇文章主要介紹了java實(shí)現(xiàn)將數(shù)字轉(zhuǎn)換成人民幣大寫的代碼,非常的簡單實(shí)用,分享給大家,需要的朋友可以參考下2015-04-04SpringCloud Zuul服務(wù)功能與使用方法解析
這篇文章主要介紹了SpringCloud Zuul服務(wù)功能與使用方法解析,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-05-05詳解使用Mybatis-plus + velocity模板生成自定義的代碼
這篇文章主要介紹了詳解使用Mybatis-plus + velocity模板生成自定義的代碼,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-03-03