第一節 全文檢索系統與Lucene簡介
一、什么是全文檢索與全文檢索系統?
全文檢索是指計算機索引程序通過掃描文章中的每一個詞,對每一個詞建立一個索引,指明該詞在文章中出現的次數和位置,當用戶查詢時,檢索程序就根據事先建立的索引進行查找,並將查找的結果反饋給用戶的檢索方式。這個過程類似於通過字典中的檢索字表查字的過程。
全文檢索的方法主要分為按字檢索和按詞檢索兩種。按字檢索是指對於文章中的每一個字都建立索引,檢索時將詞分解為字的組合。對於各種不同的語言而言,字有不同的含義,比如英文中字與詞實際上是合一的,而中文中字與詞有很大分別。按詞檢索指對文章中的詞,即語義單位建立索引,檢索時按詞檢索,並且可以處理同義項等。英文等西方文字由於按照空白切分詞,因此實現上與按字處理類似,添加同義處理也很容易。中文等東方文字則需要切分字詞,以達到按詞索引的目的,關於這方面的問題,是當前全文檢索技術尤其是中文全文檢索技術中的難點,在此不做詳述。
全文檢索系統是按照全文檢索理論建立起來的用於提供全文檢索服務的軟件系統。一般來說,全文檢索需要具備建立索引和提供查詢的基本功能,此外現代的全文檢索系統還需要具有方便的用戶接口、面向WWW[1]的開發接口、二次應用開發接口等等。功能上,全文檢索系統核心具有建立索引、處理查詢返回結果集、增加索引、優化索引結構等等功能,外圍則由各種不同應用具有的功能組成。結構上,全文檢索系統核心具有索引引擎、查詢引擎、文本分析引擎、對外接口等等,加上各種外圍應用系統等等共同構成了全文檢索系統。圖1.1展示了上述全文檢索系統的結構與功能。
在上圖中,我們看到:全文檢索系統中最為關鍵的部分是全文檢索引擎,各種應用程序都需要建立在這個引擎之上。一個全文檢索應用的優異程度,根本上由全文檢索引擎來決定。因此提升全文檢索引擎的效率即是我們提升全文檢索應用的根本。另一個方面,一個優異的全文檢索引擎,在做到效率優化的同時,還需要具有開放的體系結構,以方便程序員對整個系統進行優化改造,或者是添加原有系統沒有的功能。比如在當今多語言處理的環境下,有時需要給全文檢索系統添加處理某種語言或者文本格式的功能,比如在英文系統中添加中文處理功能,在純文本系統中添加XML[2]或者HTML[3]格式的文本處理功能,系統的開放性和擴充性就十分的重要。
二、 什么是Lucene?
Lucene是apache軟件基金會[4] jakarta項目組的一個子項目,是一個開放源代碼[5]的全文檢索引擎工具包,即它不是一個完整的全文檢索引擎,而是一個全文檢索引擎的架構,提供了完整的查詢引擎和索引引擎,部分文本分析引擎(英文與德文兩種西方語言)。Lucene的目的是為軟件開發人員提供一個簡單易用的工具包,以方便的在目標系統中實現全文檢索的功能,或者是以此為基礎建立起完整的全文檢索引擎。
Lucene的原作者是Doug Cutting,他是一位資深全文索引/檢索專家,曾經是V-Twin搜索引擎[6]的主要開發者,后在Excite[7]擔任高級系統架構設計師,目前從事於一些Internet底層架構的研究。早先發布在作者自己的http://www.lucene.com/,后來發布在SourceForge[8],2001年年底成為apache軟件基金會jakarta的一個子項目:http://jakarta.apache.org/lucene/。
三、Lucene的應用、特點及優勢
作為一個開放源代碼項目,Lucene從問世之后,引發了開放源代碼社群的巨大反響,程序員們不僅使用它構建具體的全文檢索應用,而且將之集成到各種系統軟件中去,以及構建Web應用,甚至某些商業軟件也采用了Lucene作為其內部全文檢索子系統的核心。apache軟件基金會的網站使用了Lucene作為全文檢索的引擎,IBM的開源軟件eclipse[9]的2.1版本中也采用了Lucene作為幫助子系統的全文索引引擎,相應的IBM的商業軟件Web Sphere[10]中也采用了Lucene。Lucene以其開放源代碼的特性、優異的索引結構、良好的系統架構獲得了越來越多的應用。
Lucene作為一個全文檢索引擎,其具有如下突出的優點:
(1)索引文件格式獨立於應用平台。Lucene定義了一套以8位字節為基礎的索引文件格式,使得兼容系統或者不同平台的應用能夠共享建立的索引文件。
(2)在傳統全文檢索引擎的倒排索引的基礎上,實現了分塊索引,能夠針對新的文件建立小文件索引,提升索引速度。然后通過與原有索引的合並,達到優化的目的。
(3)優秀的面向對象的系統架構,使得對於Lucene擴展的學習難度降低,方便擴充新功能。
(4)設計了獨立於語言和文件格式的文本分析接口,索引器通過接受Token流完成索引文件的創立,用戶擴展新的語言和文件格式,只需要實現文本分析的接口。
(5)已經默認實現了一套強大的查詢引擎,用戶無需自己編寫代碼即使系統可獲得強大的查詢能力,Lucene的查詢實現中默認實現了布爾操作、模糊查詢(Fuzzy Search[11])、分組查詢等等。
面對已經存在的商業全文檢索引擎,Lucene也具有相當的優勢。首先,它的開發源代碼發行方式(遵守Apache Software License[12]),在此基礎上程序員不僅僅可以充分的利用Lucene所提供的強大功能,而且可以深入細致的學習到全文檢索引擎制作技術和面相對象編程的實踐,進而在此基礎上根據應用的實際情況編寫出更好的更適合當前應用的全文檢索引擎。在這一點上,商業軟件的靈活性遠遠不及Lucene。其次,Lucene秉承了開放源代碼一貫的架構優良的優勢,設計了一個合理而極具擴充能力的面向對象架構,程序員可以在Lucene的基礎上擴充各種功能,比如擴充中文處理能力,從文本擴充到HTML、PDF[13]等等文本格式的處理,編寫這些擴展的功能不僅僅不復雜,而且由於Lucene恰當合理的對系統設備做了程序上的抽象,擴展的功能也能輕易的達到跨平台的能力。最后,轉移到apache軟件基金會后,借助於apache軟件基金會的網絡平台,程序員可以方便的和開發者、其它程序員交流,促成資源的共享,甚至直接獲得已經編寫完備的擴充功能。最后,雖然Lucene使用Java語言寫成,但是開放源代碼社區的程序員正在不懈的將之使用各種傳統語言實現(例如.net framework[14]),在遵守Lucene索引文件格式的基礎上,使得Lucene能夠運行在各種各樣的平台上,系統管理員可以根據當前的平台適合的語言來合理的選擇。
四、本文的重點問題與cLucene項目
作為中國人民大學信息學院99級本科生的一個畢業設計項目,我們對Lucene進行了深入的研究,包括系統的結構,索引文件結構,各個部分的實現等等。並且我們啟動了cLucene項目,做為一個Lucene的C++語言的重新實現,以期望帶來更快的速度和更加廣泛的應用范圍。我們先分析了系統結構,文件結構,然后在研究各個部分的具體實現的同時開始進行的cLucene實現。限於時間的限制,到本文完成為止,cLucene項目並沒有完成,對於Lucene的具體實現部分也僅僅完成到了索引引擎部分。
接下來的部分,本文將對Lucene的系統結構、文件結構、索引引擎部分做一個徹底的分析。以期望提供對Lucene全文檢索引擎的系統架構和部分程序實現的清晰的了解。cLucene項目則作為一個開放源代碼的項目,繼續進行的開發。
有關cLucene項目的一些信息:
# 開發語言:ISO C++[15],STLport 4.5.3[16],OpenTop 1.1[17]
# 目標平台:Win32,POSIX
# 授權協議:GNU General Public License (GPL)[18]
第二節 Lucene系統結構分析
一、系統結構組織
Lucene作為一個優秀的全文檢索引擎,其系統結構具有強烈的面向對象特征。首先是定義了一個與平台無關的索引文件格式,其次通過抽象將系統的核心組成部分設計為抽象類,具體的平台實現部分設計為抽象類的實現,此外與具體平台相關的部分比如文件存儲也封裝為類,經過層層的面向對象式的處理,最終達成了一個低耦合高效率,容易二次開發的檢索引擎系統。
以下將討論Lucene系統的結構組織,並給出系統結構與源碼組織圖:
從圖中我們清楚的看到,Lucene的系統由基礎結構封裝、索引核心、對外接口三大部分組成。其中直接操作索引文件的索引核心又是系統的重點。Lucene的將所有源碼分為了7個模塊(在java語言中以包即package來表示),各個模塊所屬的系統部分也如上圖所示。需要說明的是org.apache.lucene.queryPaser是做為org.apache.lucene.search的語法解析器存在,不被系統之外實際調用,因此這里沒有當作對外接口看待,而是將之獨立出來。
從面象對象的觀點來考察,Lucene應用了最基本的一條程序設計准則:引入額外的抽象層以降低耦合性。首先,引入對索引文件的操作org.apache.lucene.store的封裝,然后將索引部分的實現建立在(org.apache.lucene.index)其之上,完成對索引核心的抽象。在索引核心的基礎上開始設計對外的接口org.apache.lucene.search與org.apache.lucene.analysis。在每一個局部細節上,比如某些常用的數據結構與算法上,Lucene也充分的應用了這一條准則。在高度的面向對象理論的支撐下,使得Lucene的實現容易理解,易於擴展。
Lucene在系統結構上的另一個特點表現為其引入了傳統的客戶端服務器結構以外的的應用結構。Lucene可以作為一個運行庫被包含進入應用本身中去,而不是做為一個單獨的索引服務器存在。這自然和Lucene開放源代碼的特征分不開,但是也體現了Lucene在編寫上的本來意圖:提供一個全文索引引擎的架構,而不是實現。
第三節 Lucene索引文件格式分析
一、 Lucene源碼實現分析的說明
通過以上對Lucene系統結構的分析,我們已經大致的清楚了Lucene系統的組成,以及在Lucene系統之上的開發步驟。接下來,我們試圖來分析Lucene項目(采用Lucene 1.2版本)的源碼實現,考察其實現的細節。這不僅僅是我們嘗試用C++語言重新實現Lucene的必須工作,也是進一步做Lucene開發工作的必要准備。因此,這一部分所涉及到的內容,對於Lucene上的應用開發也是有價值的,尤其是本部分所做的文件格式分析。
由於本文建立在我們的畢設項目之上,且同時我們需要實現cLucene項目,因此很遺憾的我們並沒有完全的完成Lucene的所有源碼實現的分析工作。接下來的部分,我們將涉及的部分為Lucene文件格式分析,Lucene中的存儲抽象模塊分析,以及Lucene中的索引構建邏輯模塊分析。這一部分,我們主要涉及到的是文件格式分析與存儲抽象模塊分析。
二、Lucene索引文件格式
1.基本概念
下圖就是Lucene生成的索引的一個實例:
Lucene的索引結構是有層次結構的,主要分以下幾個層次:
- 索引(Index):
- 在Lucene中一個索引是放在一個文件夾中的。
- 如上圖,同一文件夾中的所有的文件構成一個Lucene索引。
- 段(Segment):
- 一個索引可以包含多個段,段與段之間是獨立的,添加新文檔可以生成新的段,不同的段可以合並。
- 如上圖,具有相同前綴文件的屬同一個段,圖中共兩個段 "_0" 和 "_1"。
- segments.gen和segments_5是段的元數據文件,也即它們保存了段的屬性信息。
- 文檔(Document):
- 文檔是我們建索引的基本單位,不同的文檔是保存在不同的段中的,一個段可以包含多篇文檔。
- 新添加的文檔是單獨保存在一個新生成的段中,隨着段的合並,不同的文檔合並到同一個段中。
- 域(Field):
- 一篇文檔包含不同類型的信息,可以分開索引,比如標題,時間,正文,作者等,都可以保存在不同的域里。
- 不同域的索引方式可以不同,在真正解析域的存儲的時候,我們會詳細解讀。
- 詞(Term):
- 詞是索引的最小單位,是經過詞法分析和語言處理后的字符串。
Lucene的索引結構中,即保存了正向信息,也保存了反向信息。
所謂正向信息:
- 按層次保存了從索引,一直到詞的包含關系:索引(Index) –> 段(segment) –> 文檔(Document) –> 域(Field) –> 詞(Term)
- 也即此索引包含了那些段,每個段包含了那些文檔,每個文檔包含了那些域,每個域包含了那些詞。
- 既然是層次結構,則每個層次都保存了本層次的信息以及下一層次的元信息,也即屬性信息,比如一本介紹中國地理的書,應該首先介紹中國地理的概況,以及中國包含多少個省,每個省介紹本省的基本概況及包含多少個市,每個市介紹本市的基本概況及包含多少個縣,每個縣具體介紹每個縣的具體情況。
- 如上圖,包含正向信息的文件有:
- segments_N保存了此索引包含多少個段,每個段包含多少篇文檔。
- XXX.fnm保存了此段包含了多少個域,每個域的名稱及索引方式。
- XXX.fdx,XXX.fdt保存了此段包含的所有文檔,每篇文檔包含了多少域,每個域保存了那些信息。
- XXX.tvx,XXX.tvd,XXX.tvf保存了此段包含多少文檔,每篇文檔包含了多少域,每個域包含了多少詞,每個詞的字符串,位置等信息。
所謂反向信息:
- 保存了詞典到倒排表的映射:詞(Term) –> 文檔(Document)
- 如上圖,包含反向信息的文件有:
- XXX.tis,XXX.tii保存了詞典(Term Dictionary),也即此段包含的所有的詞按字典順序的排序。
- XXX.frq保存了倒排表,也即包含每個詞的文檔ID列表。
- XXX.prx保存了倒排表中每個詞在包含此詞的文檔中的位置。
在了解Lucene索引的詳細結構之前,先看看Lucene索引中的基本數據類型。
2.基本數據類型
Lucene索引文件中,用以下基本類型來保存信息:
- Byte:是最基本的類型,長8位(bit)。
- UInt32:由4個Byte組成。
- UInt64:由8個Byte組成。
- VInt:
- 變長的整數類型,它可能包含多個Byte,對於每個Byte的8位,其中后7位表示數值,最高1位表示是否還有另一個Byte,0表示沒有,1表示有。
- 越前面的Byte表示數值的低位,越后面的Byte表示數值的高位。
- 例如130化為二進制為 1000, 0010,總共需要8位,一個Byte表示不了,因而需要兩個Byte來表示,第一個Byte表示后7位,並且在最高位置1來表示后面還有一個Byte,所以為(1) 0000010,第二個Byte表示第8位,並且最高位置0來表示后面沒有其他的Byte了,所以為(0) 0000001。
- Chars:是UTF-8編碼的一系列Byte。
- String:一個字符串首先是一個VInt來表示此字符串包含的字符的個數,接着便是UTF-8編碼的字符序列Chars。
3.基本規則
Lucene為了使的信息的存儲占用的空間更小,訪問速度更快,采取了一些特殊的技巧,然而在看Lucene文件格式的時候,這些技巧卻容易使我們感到困惑,所以有必要把這些特殊的技巧規則提取出來介紹一下。
在下不才,胡亂給這些規則起了一些名字,是為了方便后面應用這些規則的時候能夠簡單,不妥之處請大家諒解。
1)前綴后綴規則(Prefix+Suffix)
Lucene在反向索引中,要保存詞典(Term Dictionary)的信息,所有的詞(Term)在詞典中是按照字典順序進行排列的,然而詞典中包含了文檔中的幾乎所有的詞,並且有的詞還是非常的長的,這樣索引文件會非常的大,所謂前綴后綴規則,即當某個詞和前一個詞有共同的前綴的時候,后面的詞僅僅保存前綴在詞中的偏移(offset),以及除前綴以外的字符串(稱為后綴)。
比如要存儲如下詞:term,termagancy,termagant,terminal,
如果按照正常方式來存儲,需要的空間如下:
[VInt = 4] [t][e][r][m],[VInt = 10][t][e][r][m][a][g][a][n][c][y],[VInt = 9][t][e][r][m][a][g][a][n][t],[VInt = 8][t][e][r][m][i][n][a][l]
共需要35個Byte.
如果應用前綴后綴規則,需要的空間如下:
[VInt = 4] [t][e][r][m],[VInt = 4 (offset)][VInt = 6][a][g][a][n][c][y],[VInt = 8 (offset)][VInt = 1][t],[VInt = 4(offset)][VInt = 4][i][n][a][l]
共需要22個Byte。
大大縮小了存儲空間,尤其是在按字典順序排序的情況下,前綴的重合率大大提高。
2)差值規則(Delta)
在Lucene的反向索引中,需要保存很多整型數字的信息,比如文檔ID號,比如詞(Term)在文檔中的位置等等。
由上面介紹,我們知道,整型數字是以VInt的格式存儲的。隨着數值的增大,每個數字占用的Byte的個數也逐漸的增多。所謂差值規則(Delta)就是先后保存兩個整數的時候,后面的整數僅僅保存和前面整數的差即可。
比如要存儲如下整數:16386,16387,16388,16389
如果按照正常方式來存儲,需要的空間如下:
[(1) 000, 0010][(1) 000, 0000][(0) 000, 0001],[(1) 000, 0011][(1) 000, 0000][(0) 000, 0001],[(1) 000, 0100][(1) 000, 0000][(0) 000, 0001],[(1) 000, 0101][(1) 000, 0000][(0) 000, 0001]
供需12個Byte。
如果應用差值規則來存儲,需要的空間如下:
[(1) 000, 0010][(1) 000, 0000][(0) 000, 0001],[(0) 000, 0001],[(0) 000, 0001],[(0) 000, 0001]
共需6個Byte。
大大縮小了存儲空間,而且無論是文檔ID,還是詞在文檔中的位置,都是按從小到大的順序,逐漸增大的。
3)或然跟隨規則(A, B?)
Lucene的索引結構中存在這樣的情況,某個值A后面可能存在某個值B,也可能不存在,需要一個標志來表示后面是否跟隨着B。
一般的情況下,在A后面放置一個Byte,為0則后面不存在B,為1則后面存在B,或者0則后面存在B,1則后面不存在B。
但這樣要浪費一個Byte的空間,其實一個Bit就可以了。
在Lucene中,采取以下的方式:A的值左移一位,空出最后一位,作為標志位,來表示后面是否跟隨B,所以在這種情況下,A/2是真正的A原來的值。
如果去讀Apache Lucene - Index File Formats這篇文章,會發現很多符合這種規則的:
- .frq文件中的DocDelta[, Freq?],DocSkip,PayloadLength?
- .prx文件中的PositionDelta,Payload? (但不完全是,如下表分析)
當然還有一些帶?的但不屬於此規則的:
- .frq文件中的SkipChildLevelPointer?,是多層跳躍表中,指向下一層表的指針,當然如果是最后一層,此值就不存在,也不需要標志。
- .tvf文件中的Positions?, Offsets?。
- 在此類情況下,帶?的值是否存在,並不取決於前面的值的最后一位。
- 而是取決於Lucene的某項配置,當然這些配置也是保存在Lucene索引文件中的。
- 如Position和Offset是否存儲,取決於.fnm文件中對於每個域的配置(TermVector.WITH_POSITIONS和TermVector.WITH_OFFSETS)
為什么會存在以上兩種情況,其實是可以理解的:
- 對於符合或然跟隨規則的,是因為對於每一個A,B是否存在都不相同,當這種情況大量存在的時候,從一個Byte到一個Bit如此8倍的空間節約還是很值得的。
- 對於不符合或然跟隨規則的,是因為某個值的是否存在的配置對於整個域(Field)甚至整個索引都是有效的,而非每次的情況都不相同,因而可以統一存放一個標志。
文章中對如下格式的描述令人困惑: Positions --> <PositionDelta,Payload?> Freq Payload --> <PayloadLength?,PayloadData> PositionDelta和Payload是否適用或然跟隨規則呢?如何標識PayloadLength是否存在呢? 其實PositionDelta和Payload並不符合或然跟隨規則,Payload是否存在,是由.fnm文件中對於每個域的配置中有關Payload的配置決定的(FieldOption.STORES_PAYLOADS) 。 當Payload不存在時,PayloadDelta本身不遵從或然跟隨原則。 當Payload存在時,格式應該變成如下:Positions --> <PositionDelta,PayloadLength?,PayloadData> Freq 從而PositionDelta和PayloadLength一起適用或然跟隨規則。 |
4)跳躍表規則(Skip list)
為了提高查找的性能,Lucene在很多地方采取的跳躍表的數據結構。
跳躍表(Skip List)是如圖的一種數據結構,有以下幾個基本特征:
- 元素是按順序排列的,在Lucene中,或是按字典順序排列,或是按從小到大順序排列。
- 跳躍是有間隔的(Interval),也即每次跳躍的元素數,間隔是事先配置好的,如圖跳躍表的間隔為3。
- 跳躍表是由層次的(level),每一層的每隔指定間隔的元素構成上一層,如圖跳躍表共有2層。
需要注意一點的是,在很多數據結構或算法書中都會有跳躍表的描述,原理都是大致相同的,但是定義稍有差別:
- 對間隔(Interval)的定義: 如圖中,有的認為間隔為2,即兩個上層元素之間的元素數,不包括兩個上層元素;有的認為是3,即兩個上層元素之間的差,包括后面上層元素,不包括前面的上層元素;有的認為是4,即除兩個上層元素之間的元素外,既包括前面,也包括后面的上層元素。Lucene是采取的第二種定義。
- 對層次(Level)的定義:如圖中,有的認為應該包括原鏈表層,並從1開始計數,則總層次為3,為1,2,3層;有的認為應該包括原鏈表層,並從0計數,為0,1,2層;有的認為不應該包括原鏈表層,且從1開始計數,則為1,2層;有的認為不應該包括鏈表層,且從0開始計數,則為0,1層。Lucene采取的是最后一種定義。
跳躍表比順序查找,大大提高了查找速度,如查找元素72,原來要訪問2,3,7,12,23,37,39,44,50,72總共10個元素,應用跳躍表后,只要首先訪問第1層的50,發現72大於50,而第1層無下一個節點,然后訪問第2層的94,發現94大於72,然后訪問原鏈表的72,找到元素,共需要訪問3個元素即可。
然而Lucene在具體實現上,與理論又有所不同,在具體的格式中,會詳細說明。
4.具體格式
上面曾經交代過,Lucene保存了從Index到Segment到Document到Field一直到Term的正向信息,也包括了從Term到Document映射的反向信息,還有其他一些Lucene特有的信息。
在這里不進行細細呈列,建議參考forfuture1978的《Lucene學習總結之三:Lucene的索引文件格式(2)》一文,本人覺得他(她)針對Lucene的理解和分析是相當深入的。
三、一些公用的基礎類
分析完索引文件格式,我們接下來應該着手對存儲抽象也就是org.apache.lucenestore中的源碼做一些分析。我們先不着急分析這部分,而是分析圖2.1中基礎結構封裝那一部分,因為這是整個系統的基石,然后我們在下一部分再來分析存儲抽象。
基礎結構封裝,或者基礎類,由org.apache.lucene.util和org.apache.lucene.document兩個包組成,前者定義了一些常量和優化過的常用的數據結構和算法,后者則是對於文檔(document)和域(field)概念的一個類定義。以下我們用列表的方式來分析這些封裝類,指出其要點。
類包org.apache.lucene.util:
Arrays
一個關於數組的排序方法的靜態類,提供了優化的基於快排序的排序方法sort
BitVector
C/C++語言中位域的java實現品,但是加入了序列化能力
Constants
常量靜態類,定義了一些常量
PriorityQueue
一個優先隊列的抽象類,用於后面實現各種具體的優先隊列,提供常數時間內的最小元素訪問能力,內部實現機制是哈析表和堆排序算法
基礎類包org.apache.lucene.document:
Document
是文檔概念的一個實現類,每個文檔包含了一個域表(fieldList),並提供了一些實用的方法,比如多種添加域的方法、返回域表的迭代器的方法
Field
是域概念的一個實現類,每個域包含了一個域名和一個值,以及一些相關的屬性
DateField
提供了一些輔助方法的靜態類,這些方法將java中Date和Time數據類型和String相互轉化
四、 存儲抽象
有了上面的知識,我們接下來來分析存儲抽象部分,也就是org.apache.lucene.store包。存儲抽象是唯一能夠直接對索引文件存取的包,因此其主要目的是抽象出和平台文件系統無關的存儲抽象,提供諸如目錄服務(增、刪文件)、輸入流和輸出流。在分析其實現之前,首先我們看一下UML[22]圖。
圖 3.3 存儲抽象實現UML圖(一)
圖 3.4 存儲抽象實現UML圖(二)
圖3.2到3.4展示了整個org.apache.lucene.store中主要的繼承體系。共有三個抽象類定義:Directory、InputStream和OutputStrem,構成了一個完整的基於抽象文件系統的存取體系結構,在此基礎上,實作出了兩個實現品:(FSDirectory,FSInputStream,FSOutputStream)和(RAMDirectory,RAMInputStream和RAMOutputStream)。前者是以實際的文件系統做為基礎實現的,后者則是建立在內存中的虛擬文件系統。前者主要用來永久的保存索引文件,后者的作用則在於索引操作時是在內存中建立小的索引,然后一次性的輸出合並到文件中去,這一點我們在后面的索引邏輯部分能夠看到。此外,還定以了org.apache.lucene.store.lock和org.apache.lucene.store.with兩個輔助內部實現的類用在實現Directory方法的makeLock的時候,以在鎖定索引讀寫之前來讓客戶程序做一些准備工作。
(FSDirectory,FSInputStream,FSOutputStream)的內部實現依托於java語言中的io類庫,只是簡單的做了一個外部邏輯的包裝。這當然要歸功於java語言所提供的跨平台特性,同時也帶了一些隱患:文件存取的效率提升需要依耐於文件類庫的優化。如果需要繼續優化文件存取的效率,應該還提供一個文件與目錄的抽象,以根據各種文件系統或者文件類型來提供一個優化的機會。當然,這是應用開發者所不需要關系的問題。
(RAMDirectory,RAMInputStream和RAMOutputStream)的內部實現就比較直接了,直接采用了虛擬的文件RAMFile類(定義於文件RAMDirectory.java中)來表示文件,目錄則看作一個String與RAMFile對應的關聯數組。RAMFile中采用數組來表示文件的存儲空間。在此的基礎上,完成各項操作的實現,就形成了基於內存的虛擬文件系統。因為在實際使用時,並不會牽涉到很大字節數量的文件,因此這種設計是簡單直接的,也是高效率的。
這部分的實現在理清楚繼承體系后,相當的簡單。因此接下來的部分,我們可以通過直接閱讀源代碼解決。接下來我們看看這個部分的源代碼如何在實際中使用的。
一般來說,我們使用的是抽象類提供的接口而不是實際的實現類本身。在實現類中一般都含有幾個靜態函數,比如createFile,它能夠返回一個OutputStream接口,或者openFile,它能夠返回一個InputStream接口,利用這些接口之中的方法,比如writeString,writeByte等等,我們就能夠在抽象的層次上處理Lucene定義的數據類型的讀寫。簡單的說,Lucene中存儲抽象這部分設計時采用了工廠模式(Factory parttern)[23]。我們利用靜態類的方法也就是工廠來創建對象,返回接口,通過接口來執行操作。
第四節 Lucene索引構建邏輯模塊分析
一、 緒論
這一個部分,我們將分析Lucene中的索引構建邏輯模塊。它與前面介紹的存儲抽象一起構成了Lucene的索引核心部分。無論是對外接口中的查詢,還是分析各種文本以進一步生成索引,都需要直接調用這部分來獲得對索引文件的訪問能力,因此,這部分在系統中至關重要。構建一個高效的、易使用的索引構建邏輯,即是Lucene在這一部分需要達到的目的。
從面向對象的經典思考方式出發來看,我們只需要使用繼承體系來表達圖3.1中的各個概念,就可以通過這個繼承體系來控制索引文件的結構,然后設計合適的永久化方法,以及接受分析token流的操作,即可將索引構建邏輯完成。原理上就是這樣的簡單。由於兩個關鍵的概念document和field都已經在org.apache.lucene.document中當作基礎類定義過了,因此實際上Lucene在這部分需要完善的概念結構還有segment和term。在此基礎上繼續編寫各個邏輯結構的永久化方法,然后提供一個進入的接口方法,即是宣告完成了這個過程。其中永久化的部分,Lucene使用了另外實現一個代理類的方式來實現,即對於某個類X,存在XWriter類和XReader類來負責寫出和讀入的功能;用作永久化功能的類是被永久化的類的友元。
在接下來的分析過程中,我們按照這樣一個思路,以UML圖和對象體系的描述來敘述這部分的設計和實現,然后通過內部的數據流理清楚調用時序。
二、對象體系與UML圖
1. 項(Term)
這部分主要是分析針對項(Term)這個概念所做的設計,包括概念所實際涉及的類、永久化類。首先,我們從圖3.2和閱讀參考文獻3知道,項(Term)所表示的是一個字符串,它擁有域、頻數和位置信息等等屬性。因此,Lucene中設計了兩個類來表示這個概念,如下圖:
圖 4.1 UML圖(-)
上圖中,有意的突出了類Term和TermInfo中的數據成員,因為它反映了對於項(Term)這個概念的具體表示。同時上圖中也同時列出了用於永久化項(Term)的代理類TermInfosWriter和TermInfosReader,它們完成永久化的功能,需要注意的是,TermInfosReader內部使用了數組indexTerms和indexInfos來存儲一系列項;而TermInfosWriter則是一個類似於鏈表的結構,通過一個other指向下一個TermInfosWriter,每一個TermInfosWriter只負責本身那個lastTerm和lastTi的永久化工作。這是一個設計上的技巧,通過批量讀取(或者稱為緩沖的方式)來獲得讀入時候的效率優化;而通過一個鏈表式的、各負其責的方式,來獲得寫出時候的設計簡化。
項(term)這部分的設計中,還有一些重要的接口和類,我們先介紹如下,同樣我們也先展示UML圖
圖 4.2 UML圖(二)
圖4.2中,我們看到三個類:TermEnum、TermDocs與TermPositions,第一個是抽象類,后兩個都是接口。TermEnum的設計主要用在后面Segment和Document等等的實現中,以提供枚舉其中每一個項(Term)的能力。TermDocs是一個接口,用來繼承以提供返回<document, frequency>值對的能力,通過這個接口就可以獲得某個項(Term)在某個文檔中出現的頻數。TermPositions則是在TermDocs上的擴展,將項(Term)在文檔中的位置信息也表示出來。TermDocs(TermPositions)接口的使用方式類似於java中的Enumration接口,即通過next方法跳轉,通過doc,freq等方法獲得當前的屬性值。
2. 域(Field)
由於Field的基本概念在org.apache.lucene.document中已經做了定義,因此在這部分主要是針對項文件(.fnm文件、.fdx文件、.fdt文件)所需要的信息再來設計一些類。
圖 4.3 UML圖(三)
圖 4.3中展示的,就是表示與域(Field)所關聯的屬性信息的類。其中isIndexed表示的這個域的值是否被索引過,即值是否被分詞然后索引;另外兩個屬性所表示的意思則很明顯:一個是域的名字,一個是域的編號。
接下來我們來看關於域表和存取邏輯的UML圖。
圖 4.4 UML圖(四)
FieldInfos即為域表的概念表示,內部采用了冗余的方式以獲取在通過域的編號訪問或者通過域的名字來訪問時候的高效率。FieldsReader與FieldsWriter則分別是寫出和讀入的代理類。在功能和實現上,這兩個類都比較簡單。至於FieldInfos中采用的冗余方式,則是基於域的數目相對比較少而做出的一種折衷處理。
3. 文檔(document)
文檔(document)同樣也是在org.apache.lucene.document中定義過的結構。由於對於這部分比較重要,我們也來看看其UML圖。
圖 4.5 UML圖(五)
在圖4.5中我們看到,Document的設計基本上沿用了鏈表的處理方法。左邊的Document類作為一個數據外包類,用來提供對於內部結構DocumentFieldList的增加刪除訪問操作等等。DocumentFieldList才是實際上的數據存儲單位,它用了鏈表的處理方法,直接指向一個當前的Field對象和下一個DocumentFieldList對象,這個與前面的類似。為了能夠逐個訪問鏈表中的節點,還設計了DocumentFieldEnumeration枚舉類。
圖 4.6 UML圖(六)
實際上定義於org.apache.lucene.index中的有關於Document的就是永久化的代理類。在圖4.6中給出了其UML圖。需要說明的是為什么沒有出現讀入的方法:這個方法已經隱含在圖4.5中Document類中的add方法中了,結合圖2.4中的程序代碼段,我們就能夠清楚的理解這種設計。
4. 段(segment)
段(Segment)這一部分設計的比較特殊,在實現簡單的對象結構之上,還特意的設計了用於段之間合並的類。接下來,我們仍然采取對照UML分析的方式逐個敘述。接下來我們看Lucene中如何表示段這個概念。
圖 4.7 UML圖(七)
Lucene定義了一個類SegmentInfo用來表示每一個段(Segment)的信息,包括名字(name)、含有的文檔的數目(docCount)和段所位於的目錄的位置(dir)。根據索引文件中的段的意義,有了這三點,就能唯一確定一個段了。SegmentInfos這個類則是用來表示一個段的鏈表(從標准的java.util.Vector繼承而來),實際上,也就是索引(index)的意思了。需要注意的是,這里並沒有在SegmentInfo中安插一個文檔(document)的鏈表。這樣做的原因牽涉到Lucene內部對於文檔(相當於一個被索引文件)的處理;Lucene內部采用了賦予文檔編號,給域賦值的方式來處理文檔,即加入的文檔順次編號,以后用文檔號表示文檔,而路徑信息,文件名字等等在以后索引查找需要的屬性,都作為域存儲下來;因此SegmentInfo中並沒有另外存儲一個文檔(document)的鏈表,對於這些的寫出和讀入,則交給了永久化的代理類來做。
圖 4.8 UML圖(八)
圖4.8給出了負責段(segment)的讀入操作的代理類,而負責段(segment)的寫出操作也同樣沒有定義,這些操作都直接實現在了類IndexWriter類中(后面會詳細分析)。段的操作同樣采用了之前的數組或者說是緩沖的處理方式,相關的細節也不在這里詳細敘述了。
然后,針對前面項(term)那部分定義的幾個接口,段(segment)這部分也需要做相應的接口實現,因為提供直接遍歷訪問段中的各個項的能力對於檢索來說,無疑是十分重要的。即這部分的設計,實際上都是在為了檢索在服務。
圖 4.9 UML圖(九)
圖 4.10 UML圖(十)
圖4.9和圖4.10分別展示了前面項(term)那里定義的接口是如何在這里通過繼承實現的。Lucene在處理這部分的時候,也是分成兩部分(Segment與Segments開頭的類)來實現,而且很合理的運用了數組的技法,以及注意了繼承重用。但是細化到局部,終歸是比較簡單的按照語義來獲得結果而已了,因此關於更多的也就不多做分析了,我們完全可以通過閱讀源代碼來解決。
接下來所介紹的,就是在Lucene的設計過程中比較特殊的一個部分:段合並類(SegmentMerger)。這首先需要介紹Lucene中的建立索引時的段合並策略。
Lucene為了兼顧建立索引時的效率和讀取索引查找的速度,引入了分小段建立索引的方式,即每一次批量建立索引時,先在內存中的虛擬文件系統中為每一個文檔單獨建立一個段,然后在輸出的時候將這些段合並之后輸出成為索引文件,這時僅僅存在一個段。多次建立的索引后,如果想優化索引文件,也可采取合並段的方法,將索引中的段合並成為一個段。我們來看一下在IndexWriter類中相應的方法的實現,來了解一下這中建立索引的實現。
對於上面的代碼,我們不做過多注釋了,結合源碼中的注解應該很容易理解。在最后那個mergeSegments函數中,將用到幾個重要的類結構,它們記錄了合並時候的一些重要信息,完成合並時候的工作。接下來,我們來看這幾個類的UML圖。
圖 4.12 UML圖(十一)
從圖4.12中,我們看到Lucene設計一個類SegmentMergeInfo用來保存每一個被合並的段的信息,也保存能夠訪問其內部的接口句柄,也就是說合並時的操作使用這個類作為對被合並的段的操作代理。類SegmentMergeQueue則設計為org.apache.lucene.util.PriorityQueue的子類,做為SegmentMergeInfo的容器類,而且附帶能夠自動排序。SegmentMerger是主要進行操作的類,里面各個方法環環相扣,分別完成合並各個數據項的問題。
5. IndexReader類與IndexWirter類
最后剩下的,就是整個索引邏輯部分的使用接口類了。外界通過這兩個類以及文檔(document)類的構造函數調用之,比如圖2.4中的代碼示例所示。下面我們來看一下這部分最后兩個類的UML圖。
圖 4.13 UML圖(十二)
IndexWriter的設計與IndexReader的設計很不相同,前者是一個實現類,而后者是一個抽象類,帶有沒有實現的接口。IndexWriter的主要作用就是接收新加入的文檔(document),然后在內部為之生成相應的小段,最后再合並並向索引文件中輸出,圖4.11中已經給出了一些實現的代碼。由於Lucene在面向對象上封裝的努力,通過各個構造函數就已經完成了對於各個概念的構造過程,剩下部分的代碼主要是依據各個數組或者是鏈表中的信息,逐個逐個的將信息寫出到相應的文件中去了。IndexReader部分則只是做了接口設計,沒有具體的實現,這個和本部分所完成的主要功能有關:索引構建邏輯。設計這個抽象類的目的是,預先完成一些函數,為以后的檢索(search)部分的各種形式的IndexReader鋪平道路,也是利用了在同一個包內可以方便訪問其它類的保護變量這個java語言的限制。
結束語
到此,在索引構建邏輯部分出現的類我們就分析完畢了,需要說明主要是做的一個宏觀上的組成結構上的分析,並指出一些實現上的要點。具體的實現,由於Lucene的開放源碼而顯得並不是非常的重要,因為Lucene在做到良好的面相對象設計之后,實際帶來的是局部復雜性的減小,因此某一些單獨的函數或者實現就比較容易編寫,也容易讓人閱讀。