Lucene的索引裏面存了些什麼,如何存放的,也即Lucene的索引文件格式,是讀懂Lucene源代碼的一把鑰匙。
當我們真正進入到Lucene源代碼之中的時候,我們會發現:
- Lucene的索引過程,就是按照全文檢索的基本過程,將倒排表寫成此文件格式的過程。
- Lucene的搜索過程,就是按照此文件格式將索引進去的信息讀出來,然後計算每篇文檔打分(score)的過程。
本文詳細解讀了Apache Lucene - Index File Formats(http://lucene.apache.org/java/2_9_0/fileformats.html) 這篇文章。
一、基本概念
下圖就是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索引中的基本數據類型。
二、基本類型
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。
三、基本規則
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在具體實現上,與理論又有所不同,在具體的格式中,會詳細說明。