深度解析 Lucene 輕量級全文索引實現(xiàn)原理
作者:vivo互聯(lián)網(wǎng)服務(wù)器團(tuán)隊-Qian Yulun
一、Lucene簡介
1.1 Lucene是什么?
Lucene是Apache基金會jakarta項目組的一個子項目;
Lucene是一個開放源碼的全文檢索引擎工具包,提供了完整的查詢引擎和索引引擎,部分語種文本分析引擎;
Lucene并不是一個完整的全文檢索引擎,僅提供了全文檢索引擎架構(gòu),但仍可以作為一個工具包結(jié)合各類插件為項目提供部分高性能的全文檢索功能;
現(xiàn)在常用的ElasticSearch、Solr等全文搜索引擎均是基于Lucene實現(xiàn)的。
1.2 Lucene的使用場景
適用于需要數(shù)據(jù)索引量不大的場景,當(dāng)索引量過大時需要使用ES、Solr等全文搜索服務(wù)器實現(xiàn)搜索功能。
1.3 通過本文你能了解到哪些內(nèi)容?
Lucene如此繁雜的索引如何生成并寫入,索引中的各個文件又在起著什么樣的作用?
Lucene全文索引如何進(jìn)行高效搜索?
Lucene如何優(yōu)化搜索結(jié)果,使用戶根據(jù)關(guān)鍵詞搜索到想要的內(nèi)容?
本文旨在分享Lucene搜索引擎的源碼閱讀和功能開發(fā)中的經(jīng)驗,Lucene采用7.3.1版本。
二、Lucene基礎(chǔ)工作流程
索引的生成分為兩個部分:
1. 創(chuàng)建階段:
添加文檔階段,通過IndexWriter調(diào)用addDocument方法生成正向索引文件;
文檔添加后,通過flush或merge操作生成倒排索引文件。
2. 搜索階段:
用戶通過查詢語句向Lucene發(fā)送查詢請求;
通過IndexSearch下的IndexReader讀取索引庫內(nèi)容,獲取文檔索引;
得到搜索結(jié)果后,基于搜索算法對結(jié)果進(jìn)行排序后返回。
索引創(chuàng)建及搜索流程如下圖所示:

三、Lucene索引構(gòu)成
3.1 正向索引
Lucene的基礎(chǔ)層次結(jié)構(gòu)由索引、段、文檔、域、詞五個部分組成。正向索引的生成即為基于Lucene的基礎(chǔ)層次結(jié)構(gòu)一級一級處理文檔并分解域存儲詞的過程。

索引文件層級關(guān)系如圖1所示:
索引:Lucene索引庫包含了搜索文本的所有內(nèi)容,可以通過文件或文件流的方式存儲在不同的數(shù)據(jù)庫或文件目錄下。
段:一個索引中包含多個段,段與段之間相互獨立。由于Lucene進(jìn)行關(guān)鍵詞檢索時需要加載索引段進(jìn)行下一步搜索,如果索引段較多會增加較大的I/O開銷,減慢檢索速度,因此寫入時會通過段合并策略對不同的段進(jìn)行合并。
文檔:Lucene會將文檔寫入段中,一個段中包含多個文檔。
域:一篇文檔會包含多種不同的字段,不同的字段保存在不同的域中。
詞:Lucene會通過分詞器將域中的字符串通過詞法分析和語言處理后拆分成詞,Lucene通過這些關(guān)鍵詞進(jìn)行全文檢索。
3.2 倒排索引
Lucene全文索引的核心是基于倒排索引實現(xiàn)的快速索引機(jī)制。
倒排索引原理如圖2所示,倒排索引簡單來說就是基于分析器將文本內(nèi)容進(jìn)行分詞后,記錄每個詞出現(xiàn)在哪篇文章中,從而通過用戶輸入的搜索詞查詢出包含該詞的文章。

問題:上述倒排索引使用時每次都需要將索引詞加載到內(nèi)存中,當(dāng)文章數(shù)量較多,篇幅較長時,索引詞可能會占用大量的存儲空間,加載到內(nèi)存后內(nèi)存損耗較大。
解決方案:從Lucene4開始,Lucene采用了FST來減少索引詞帶來的空間消耗。
FST(Finite StateTransducers),中文名有限狀態(tài)機(jī)轉(zhuǎn)換器。其主要特點在于以下四點:
查找詞的時間復(fù)雜度為O(len(str));
通過將前綴和后綴分開存儲的方式,減少了存放詞所需的空間;
加載時僅將前綴放入內(nèi)存索引,后綴詞在磁盤中進(jìn)行存放,減少了內(nèi)存索引使用空間的損耗;
FST結(jié)構(gòu)在對PrefixQuery、FuzzyQuery、RegexpQuery等查詢條件查詢時,查詢效率高。
具體存儲方式如圖3所示:

倒排索引相關(guān)文件包含.tip、.tim和.doc這三個文件,其中:
tip:用于保存倒排索引Term的前綴,來快速定位.tim文件中屬于這個Field的Term的位置,即上圖中的aab、abd、bdc。
tim:保存了不同前綴對應(yīng)的相應(yīng)的Term及相應(yīng)的倒排表信息,倒排表通過跳表實現(xiàn)快速查找,通過跳表能夠跳過一些元素的方式對多條件查詢交集、并集、差集之類的集合運算也提高了性能。
doc:包含了文檔號及詞頻信息,根據(jù)倒排表中的內(nèi)容返回該文件中保存的文本信息。
3.3 索引查詢及文檔搜索過程
Lucene利用倒排索引定位需要查詢的文檔號,通過文檔號搜索出文件后,再利用詞權(quán)重等信息對文檔排序后返回。
內(nèi)存加載tip文件,根據(jù)FST匹配到后綴詞塊在tim文件中的位置;
根據(jù)查詢到的后綴詞塊位置查詢到后綴及倒排表的相關(guān)信息;
根據(jù)tim中查詢到的倒排表信息從doc文件中定位出文檔號及詞頻信息,完成搜索;
文件定位完成后Lucene將去.fdx文件目錄索引及.fdt中根據(jù)正向索引查找出目標(biāo)文件。
文件格式如圖4所示:

上文主要講解Lucene的工作原理,下文將闡述Java中Lucene執(zhí)行索引、查詢等操作的相關(guān)代碼。
四、Lucene的增刪改操作
Lucene項目中文本的解析,存儲等操作均由IndexWriter類實現(xiàn),IndexWriter文件主要由Directory和IndexWriterConfig兩個類構(gòu)成,其中:
Directory:用于指定存放索引文件的目錄類型。既然要對文本內(nèi)容進(jìn)行搜索,自然需要先將這些文本內(nèi)容及索引信息寫入到目錄里。Directory是一個抽象類,針對索引的存儲允許有多種不同的實現(xiàn)。常見的存儲方式一般包括存儲有本地(FSDirectory),內(nèi)存(RAMDirectory)等。
IndexWriterConfig:用于指定IndexWriter在文件內(nèi)容寫入時的相關(guān)配置,包括OpenMode索引構(gòu)建模式、Similarity相關(guān)性算法等。
IndexWriter具體是如何操作索引的呢?讓我們來簡單分析一下IndexWriter索引操作的相關(guān)源碼。
4.1. 文檔的新增
a. Lucene會為每個文檔創(chuàng)建ThreadState對象,對象持有DocumentWriterPerThread來執(zhí)行文件的增刪改操作;
ThreadState getAndLock(Thread requestingThread, DocumentsWriter documentsWriter) {ThreadState threadState = null;synchronized (this) {if (freeList.isEmpty()) {// 如果不存在已創(chuàng)建的空閑ThreadState,則新創(chuàng)建一個return newThreadState();} else {// freeList后進(jìn)先出,僅使用有限的ThreadState操作索引threadState = freeList.remove(freeList.size()-1);// 優(yōu)先使用已經(jīng)初始化過DocumentWriterPerThread的ThreadState,并將其與當(dāng)前// ThreadState換位,將其移到隊尾優(yōu)先使用if (threadState.dwpt == null) {for(int i=0;i<freeList.size();i++) {ThreadState ts = freeList.get(i);if (ts.dwpt != null) {freeList.set(i, threadState);threadState = ts;break;}}}}}threadState.lock();return threadState;}
b. 索引文件的插入:DocumentWriterPerThread調(diào)用DefaultIndexChain下的processField來處理文檔中的每個域,processField方法是索引鏈的核心執(zhí)行邏輯。通過用戶對每個域設(shè)置的不同的FieldType進(jìn)行相應(yīng)的索引、分詞、存儲等操作。FieldType中比較重要的是indexOptions:
NONE:域信息不會寫入倒排表,索引階段無法通過該域名進(jìn)行搜索;
DOCS:文檔寫入倒排表,但由于不記錄詞頻信息,因此出現(xiàn)多次也僅當(dāng)一次處理;
DOCS_AND_FREQS:文檔和詞頻寫入倒排表;
DOCS_AND_FREQS_AND_POSITIONS:文檔、詞頻及位置寫入倒排表;
DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS:文檔、詞頻、位置及偏移寫入倒排表。
// 構(gòu)建倒排表if (fieldType.indexOptions() != IndexOptions.NONE) {fp = getOrAddField(fieldName, fieldType, true);boolean first = fp.fieldGen != fieldGen;// field具體的索引、分詞操作fp.invert(field, first);if (first) {fields[fieldCount++] = fp;fp.fieldGen = fieldGen;}} else {verifyUnIndexedFieldType(fieldName, fieldType);}// 存儲該field的storeFieldif (fieldType.stored()) {if (fp == null) {fp = getOrAddField(fieldName, fieldType, false);}if (fieldType.stored()) {String value = field.stringValue();if (value != null && value.length() > IndexWriter.MAX_STORED_STRING_LENGTH) {throw new IllegalArgumentException("stored field \"" + field.name() + "\" is too large (" + value.length() + " characters) to store");}try {storedFieldsConsumer.writeField(fp.fieldInfo, field);} catch (Throwable th) {throw AbortingException.wrap(th);}}}// 建立DocValue(通過文檔查詢文檔下包含了哪些詞)DocValuesType dvType = fieldType.docValuesType();if (dvType == null) {throw new NullPointerException("docValuesType must not be null (field: \"" + fieldName + "\")");}if (dvType != DocValuesType.NONE) {if (fp == null) {fp = getOrAddField(fieldName, fieldType, false);}indexDocValue(fp, dvType, field);}if (fieldType.pointDimensionCount() != 0) {if (fp == null) {fp = getOrAddField(fieldName, fieldType, false);}indexPoint(fp, field);}
c. 解析Field首先需要構(gòu)造TokenStream類,用于產(chǎn)生和轉(zhuǎn)換token流,TokenStream有兩個重要的派生類Tokenizer和TokenFilter,其中Tokenizer用于通過java.io.Reader類讀取字符,產(chǎn)生Token流,然后通過任意數(shù)量的TokenFilter來處理這些輸入的Token流,具體源碼如下:
// invert:對Field進(jìn)行分詞處理首先需要將Field轉(zhuǎn)化為TokenStreamtry (TokenStream stream = tokenStream = field.tokenStream(docState.analyzer, tokenStream))// TokenStream在不同分詞器下實現(xiàn)不同,根據(jù)不同分詞器返回相應(yīng)的TokenStreamif (tokenStream != null) {return tokenStream;} else if (readerValue() != null) {return analyzer.tokenStream(name(), readerValue());} else if (stringValue() != null) {return analyzer.tokenStream(name(), stringValue());}public final TokenStream tokenStream(final String fieldName, final Reader reader) {// 通過復(fù)用策略,如果TokenStreamComponents中已經(jīng)存在Component則復(fù)用。TokenStreamComponents components = reuseStrategy.getReusableComponents(this, fieldName);final Reader r = initReader(fieldName, reader);// 如果Component不存在,則根據(jù)分詞器創(chuàng)建對應(yīng)的Components。if (components == null) {components = createComponents(fieldName);reuseStrategy.setReusableComponents(this, fieldName, components);}// 將java.io.Reader輸入流傳入Component中。components.setReader(r);return components.getTokenStream();}
d. 根據(jù)IndexWriterConfig中配置的分詞器,通過策略模式返回分詞器對應(yīng)的分詞組件,針對不同的語言及不同的分詞需求,分詞組件存在很多不同的實現(xiàn)。
StopAnalyzer:停用詞分詞器,用于過濾詞匯中特定字符串或單詞。
StandardAnalyzer:標(biāo)準(zhǔn)分詞器,能夠根據(jù)數(shù)字、字母等進(jìn)行分詞,支持詞表過濾替代StopAnalyzer功能,支持中文簡單分詞。
CJKAnalyzer:能夠根據(jù)中文語言習(xí)慣對中文分詞提供了比較好的支持。
以StandardAnalyzer(標(biāo)準(zhǔn)分詞器)為例:
// 標(biāo)準(zhǔn)分詞器創(chuàng)建Component過程,涵蓋了標(biāo)準(zhǔn)分詞處理器、Term轉(zhuǎn)化小寫、常用詞過濾三個功能protected TokenStreamComponents createComponents(final String fieldName) {final StandardTokenizer src = new StandardTokenizer();src.setMaxTokenLength(maxTokenLength);TokenStream tok = new StandardFilter(src);tok = new LowerCaseFilter(tok);tok = new StopFilter(tok, stopwords);return new TokenStreamComponents(src, tok) {protected void setReader(final Reader reader) {src.setMaxTokenLength(StandardAnalyzer.this.maxTokenLength);super.setReader(reader);}};}
e. 在獲取TokenStream之后通過TokenStream中的incrementToken方法分析并獲取屬性,再通過TermsHashPerField下的add方法構(gòu)建倒排表,最終將Field的相關(guān)數(shù)據(jù)存儲到類型為FreqProxPostingsArray的freqProxPostingsArray中,以及TermVectorsPostingsArray的termVectorsPostingsArray中,構(gòu)成倒排表;
// 以LowerCaseFilter為例,通過其下的increamentToken將Token中的字符轉(zhuǎn)化為小寫public final boolean incrementToken() throws IOException {if (input.incrementToken()) {CharacterUtils.toLowerCase(termAtt.buffer(), 0, termAtt.length());return true;} elsereturn false;}
try (TokenStream stream = tokenStream = field.tokenStream(docState.analyzer, tokenStream)) {// reset TokenStreamstream.reset();invertState.setAttributeSource(stream);termsHashPerField.start(field, first);// 分析并獲取Token屬性while (stream.incrementToken()) {……try {// 構(gòu)建倒排表termsHashPerField.add();} catch (MaxBytesLengthExceededException e) {……} catch (Throwable th) {throw AbortingException.wrap(th);}}……}
4.2 文檔的刪除
a. Lucene下文檔的刪除,首先將要刪除的Term或Query添加到刪除隊列中;
synchronized long deleteTerms(final Term... terms) throws IOException {// TODO why is this synchronized?final DocumentsWriterDeleteQueue deleteQueue = this.deleteQueue;// 文檔刪除操作是將刪除的詞信息添加到刪除隊列中,根據(jù)flush策略進(jìn)行刪除long seqNo = deleteQueue.addDelete(terms);flushControl.doOnDelete();lastSeqNo = Math.max(lastSeqNo, seqNo);if (applyAllDeletes(deleteQueue)) {seqNo = -seqNo;}return seqNo;}
b. 根據(jù)Flush策略觸發(fā)刪除操作;
private boolean applyAllDeletes(DocumentsWriterDeleteQueue deleteQueue) throws IOException {// 判斷是否滿足刪除條件 --> onDeleteif (flushControl.getAndResetApplyAllDeletes()) {if (deleteQueue != null) {ticketQueue.addDeletes(deleteQueue);}// 指定執(zhí)行刪除操作的eventputEvent(ApplyDeletesEvent.INSTANCE); // apply deletes event forces a purgereturn true;}return false;}
public void onDelete(DocumentsWriterFlushControl control, ThreadState state) {// 判斷并設(shè)置是否滿足刪除條件if ((flushOnRAM() && control.getDeleteBytesUsed() > 1024*1024*indexWriterConfig.getRAMBufferSizeMB())) {control.setApplyAllDeletes();if (infoStream.isEnabled("FP")) {infoStream.message("FP", "force apply deletes bytesUsed=" + control.getDeleteBytesUsed() + " vs ramBufferMB=" + indexWriterConfig.getRAMBufferSizeMB());}}}
4.3 文檔的更新
文檔的更新就是一個先刪除后插入的過程,本文就不再做更多贅述。
4.4 索引Flush
文檔寫入到一定數(shù)量后,會由某一線程觸發(fā)IndexWriter的Flush操作,生成段并將內(nèi)存中的Document信息寫到硬盤上。Flush操作目前僅有一種策略:FlushByRamOrCountsPolicy。FlushByRamOrCountsPolicy主要基于兩種策略自動執(zhí)行Flush操作:
maxBufferedDocs:文檔收集到一定數(shù)量時觸發(fā)Flush操作。
ramBufferSizeMB:文檔內(nèi)容達(dá)到限定值時觸發(fā)Flush操作。
其中 activeBytes() 為dwpt收集的索引所占的內(nèi)存量,deleteByteUsed為刪除的索引量。
public void onInsert(DocumentsWriterFlushControl control, ThreadState state) {// 根據(jù)文檔數(shù)進(jìn)行Flushif (flushOnDocCount()&& state.dwpt.getNumDocsInRAM() >= indexWriterConfig.getMaxBufferedDocs()) {// Flush this state by num docscontrol.setFlushPending(state);// 根據(jù)內(nèi)存使用量進(jìn)行Flush} else if (flushOnRAM()) {// flush by RAMfinal long limit = (long) (indexWriterConfig.getRAMBufferSizeMB() * 1024.d * 1024.d);final long totalRam = control.activeBytes() + control.getDeleteBytesUsed();if (totalRam >= limit) {if (infoStream.isEnabled("FP")) {infoStream.message("FP", "trigger flush: activeBytes=" + control.activeBytes() + " deleteBytes=" + control.getDeleteBytesUsed() + " vs limit=" + limit);}markLargestWriterPending(control, state, totalRam);}}}
將內(nèi)存信息寫入索引庫。

索引的Flush分為主動Flush和自動Flush,根據(jù)策略觸發(fā)的Flush操作為自動Flush,主動Flush的執(zhí)行與自動Flush有較大區(qū)別,關(guān)于主動Flush本文暫不多做贅述。需要了解的話可以跳轉(zhuǎn)鏈接。
4.5 索引段Merge
索引Flush時每個dwpt會單獨生成一個segment,當(dāng)segment過多時進(jìn)行全文檢索可能會跨多個segment,產(chǎn)生多次加載的情況,因此需要對過多的segment進(jìn)行合并。
段合并的執(zhí)行通過MergeScheduler進(jìn)行管理。mergeScheduler也包含了多種管理策略,包括NoMergeScheduler、SerialMergeScheduler和ConcurrentMergeScheduler。
1) merge操作首先需要通過updatePendingMerges方法根據(jù)段的合并策略查詢需要合并的段。段合并策略分為很多種,本文僅介紹兩種Lucene默認(rèn)使用的段合并策略:TieredMergePolicy和LogMergePolicy。
TieredMergePolicy:先通過OneMerge打分機(jī)制對IndexWriter提供的段集進(jìn)行排序,然后在排序后的段集中選取部分(可能不連續(xù))段來生成一個待合并段集,即非相鄰的段文件(Non-adjacent Segment)。
LogMergePolicy:定長的合并方式,通過maxLevel、LEVEL_LOG_SPAN、levelBottom參數(shù)將連續(xù)的段分為不同的層級,再通過mergeFactor從每個層級中選取段進(jìn)行合并。
private synchronized boolean updatePendingMerges(MergePolicy mergePolicy, MergeTrigger trigger, int maxNumSegments)throws IOException {final MergePolicy.MergeSpecification spec;// 查詢需要合并的段if (maxNumSegments != UNBOUNDED_MAX_MERGE_SEGMENTS) {assert trigger == MergeTrigger.EXPLICIT || trigger == MergeTrigger.MERGE_FINISHED :"Expected EXPLICT or MERGE_FINISHED as trigger even with maxNumSegments set but was: " + trigger.name();spec = mergePolicy.findForcedMerges(segmentInfos, maxNumSegments, Collections.unmodifiableMap(segmentsToMerge), this);newMergesFound = spec != null;if (newMergesFound) {final int numMerges = spec.merges.size();for(int i=0;i<numMerges;i++) {final MergePolicy.OneMerge merge = spec.merges.get(i);merge.maxNumSegments = maxNumSegments;}}} else {spec = mergePolicy.findMerges(trigger, segmentInfos, this);}// 注冊所有需要合并的段newMergesFound = spec != null;if (newMergesFound) {final int numMerges = spec.merges.size();for(int i=0;i<numMerges;i++) {registerMerge(spec.merges.get(i));}}return newMergesFound;}
2)通過ConcurrentMergeScheduler類中的merge方法創(chuàng)建用戶合并的線程MergeThread并啟動。
public synchronized void merge(IndexWriter writer, MergeTrigger trigger, boolean newMergesFound) throws IOException {……while (true) {……// 取出注冊的后選段OneMerge merge = writer.getNextMerge();boolean success = false;try {// 構(gòu)建用于合并的線程MergeThreadfinal MergeThread newMergeThread = getMergeThread(writer, merge);mergeThreads.add(newMergeThread);updateIOThrottle(newMergeThread.merge, newMergeThread.rateLimiter);if (verbose()) {message(" launch new thread [" + newMergeThread.getName() + "]");}// 啟用線程newMergeThread.start();updateMergeThreads();success = true;} finally {if (!success) {writer.mergeFinish(merge);}}}}
3)通過doMerge方法執(zhí)行merge操作;
public void merge(MergePolicy.OneMerge merge) throws IOException {……try {// 用于處理merge前緩存任務(wù)及新段相關(guān)信息生成mergeInit(merge);// 執(zhí)行段之間的merge操作mergeMiddle(merge, mergePolicy);mergeSuccess(merge);success = true;} catch (Throwable t) {handleMergeException(t, merge);} finally {// merge完成后的收尾工作mergeFinish(merge)}……}
五、Lucene搜索功能實現(xiàn)
5.1 加載索引庫
Lucene想要執(zhí)行搜索首先需要將索引段加載到內(nèi)存中,由于加載索引庫的操作非常耗時,因此僅有當(dāng)索引庫產(chǎn)生變化時需要重新加載索引庫。

加載索引庫分為加載段信息和加載文檔信息兩個部分:
1)加載段信息:
通過segments.gen文件獲取段中最大的generation,獲取段整體信息;
讀取.si文件,構(gòu)造SegmentInfo對象,最后匯總得到SegmentInfos對象。
2)加載文檔信息:
讀取段信息,并從.fnm文件中獲取相應(yīng)的FieldInfo,構(gòu)造FieldInfos;
打開倒排表的相關(guān)文件和詞典文件;
讀取索引的統(tǒng)計信息和相關(guān)norms信息;
讀取文檔文件。

5.2 封裝
索引庫加載完成后需要IndexReader封裝進(jìn)IndexSearch,IndexSearch通過用戶構(gòu)造的Query語句和指定的Similarity文本相似度算法(默認(rèn)BM25)返回用戶需要的結(jié)果。通過IndexSearch.search方法實現(xiàn)搜索功能。
搜索:Query包含多種實現(xiàn),包括BooleanQuery、PhraseQuery、TermQuery、PrefixQuery等多種查詢方法,使用者可根據(jù)項目需求構(gòu)造查詢語句
排序:IndexSearch除了通過Similarity計算文檔相關(guān)性分值排序外,也提供了BoostQuery的方式讓用戶指定關(guān)鍵詞分值,定制排序。Similarity相關(guān)性算法也包含很多種不同的相關(guān)性分值計算實現(xiàn),此處暫不做贅述,讀者有需要可自行網(wǎng)上查閱。
六、總結(jié)
Lucene作為全文索引工具包,為中小型項目提供了強(qiáng)大的全文檢索功能支持,但Lucene在使用的過程中存在諸多問題:
由于Lucene需要將檢索的索引庫通過IndexReader讀取索引信息并加載到內(nèi)存中以實現(xiàn)其檢索能力,當(dāng)索引量過大時,會消耗服務(wù)部署機(jī)器的過多內(nèi)存。
搜索實現(xiàn)比較復(fù)雜,需要對每個Field的索引、分詞、存儲等信息一一設(shè)置,使用復(fù)雜。
Lucene不支持集群。
Lucene使用時存在諸多限制,使用起來也不那么方便,當(dāng)數(shù)據(jù)量增大時還是盡量選擇ElasticSearch等分布式搜索服務(wù)器作為搜索功能的實現(xiàn)方案。
