深入Lucene的索引文件
?
Lucene的索引里面存了些什么,如何存放的,也即Lucene的索引文件格式,是读懂Lucene源代码的一把钥匙。
当我们真正进入到Lucene源代码之中的时候,我们会发现:
?
本文详细解读了Apache Lucene - Index File Formats(http://lucene.apache.org/java/2_9_0/fileformats.html) 这篇文章。
?
一、基本概念
下图就是Lucene生成的索引的一个实例:
Lucene的索引结构是有层次结构的,主要分以下几个层次
?
?
Lucene的索引结构中,即保存了正向信息,也保存了反向信息。
所谓正向信息:
所谓反向信息:
?
在了解Lucene索引的详细结构之前,先看看Lucene索引中的基本数据类型。
?
?
二、基本类型
Lucene索引文件中,用一下基本类型来保存信息:
?
三、基本规则
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这篇文章,会发现很多符合这种规则的:
?
当然还有一些带?的但不属于此规则的:
?
为什么会存在以上两种情况,其实是可以理解的:
?
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)是如图的一种数据结构,有以下几个基本特征:
?
需要注意一点的是,在很多数据结构或算法书中都会有跳跃表的描述,原理都是大致相同的,但是定义稍有差别:
?
跳跃表比顺序查找,大大提高了查找速度,如查找元素72,原来要访问2,3,7,12,23,37,39,44,50,72总共10个元素,应用跳跃表后,只要首先访问第1层的50,发现72大于50,而第1层无下一个节点,然后访问第2层的94,发现94大于72,然后访问原链表的72,找到元素,共需要访问3个元素即可。
然而Lucene在具体实现上,与理论又有所不同,在具体的格式中,会详细说明。
?
?
Lucene总的来说是:
?
在Lucene in action中,Lucene 的构架和过程如下图,
说明Lucene是有索引和搜索的两个过程,包含索引创建,索引,搜索三个要点。
让我们更细一些看Lucene的各组件:
?
?
那么如何应用这些组件呢?
让我们再详细到对Lucene API 的调用实现索引和搜索过程。
?
以上便是Lucene API函数的简单调用。
然而当进入Lucene的源代码后,发现Lucene有很多包,关系错综复杂。
然而通过下图,我们不难发现,Lucene的各源码模块,都是对普通索引和搜索过程的一种实现。
此图是上一节介绍的全文检索的流程对应的Lucene实现的包结构。(参照http://www.lucene.com.cn/about.htm中文章《开放源代码的全文检索引擎Lucene》)
?
了解了Lucene的整个结构,我们便可以开始Lucene的源码之旅了。
?
?
四、具体格式
上面曾经交代过,Lucene保存了从Index到Segment到Document到Field一直到Term的正向信息,也包括了从Term到Document映射的反向信息,还有其他一些Lucene特有的信息。下面对这三种信息一一介绍。
4.1. 正向信息
Index –> Segments (segments.gen, segments_N) –> Field(fnm, fdx, fdt) –> Term (tvx, tvd, tvf)
上面的层次结构不是十分的准确,因为segments.gen和segments_N保存的是段(segment)的元数据信息(metadata),其实是每个Index一个的,而段的真正的数据信息,是保存在域(Field)和词(Term)中的。
4.1.1. 段的元数据信息(segments_N)
一个索引(Index)可以同时存在多个segments_N(至于如何存在多个segments_N,在描述完详细信息之后会举例说明),然而当我们要打开一个索引的时候,我们必须要选择一个来打开,那如何选择哪个segments_N呢?
Lucene采取以下过程:
?
IndexInput genInput = directory.openInput(IndexFileNames.SEGMENTS_GEN);//"segments.gen"
int version = genInput.readInt();//读出版本号
if (version == FORMAT_LOCKLESS) {//如果版本号正确
??? long gen0 = genInput.readLong();//读出第一个N
??? long gen1 = genInput.readLong();//读出第二个N
??? if (gen0 == gen1) {//如果两者相等则为genB
??????? genB = gen0;
??? }
}
其三,在上述得到的genA和genB中选择最大的那个作为当前的N,方才打开segments_N文件。其基本逻辑如下:?
if (genA > genB)
??? gen = genA;
else
??? gen = genB;
?
?
如下图是segments_N的具体格式:
?
//在DirectoryReader中有一下函数。
public boolean isCurrent() throws CorruptIndexException, IOException {
? return SegmentInfos.readCurrentVersion(directory) == segmentInfos.getVersion();
}
?
?
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
writer.setUseCompoundFile(false);
indexDocs(writer, docDir);//docDir中只有两篇文档
//文档一为:Students should be allowed to go out with their friends, but not allowed to drink beer.
//文档二为:My friend Jerry went to school to see his students but found them drunk which is not allowed.
writer.commit();//提交两篇文档,形成_0段。
writer.deleteDocuments(new Term("contents", "school"));//删除文档二
writer.commit();//提交删除,形成_0_1.del
indexDocs(writer, docDir);//再次索引两篇文档,Lucene不能判别文档与文档的不同,因而算两篇新的文档。
writer.commit();//提交两篇文档,形成_1段
writer.deleteDocuments(new Term("contents", "school"));//删除第二次添加的文档二
writer.close();//提交删除,形成_1_1.del
?
IndexWriter.applyDeletes()
-> DocumentsWriter.applyDeletes(SegmentInfos)
???? -> reader.deleteDocument(doc);
?
IndexWriter.commit()
-> IndexWriter.applyDeletes()
??? -> IndexWriter$ReaderPool.release(SegmentReader)
???????? -> SegmentReader(IndexReader).commit()
???????????? -> SegmentReader.doCommit(Map)
????????????????? -> SegmentInfo.advanceDelGen()
?????????????????????? -> if (delGen == NO) {
????????????????????????????? delGen = YES;
?????????????????????????? } else {
????????????????????????????? delGen++;
?????????????????????????? }
?
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
writer.setUseCompoundFile(false);
indexDocs(writer, docDir);//索引两篇文档,一篇包含"school",另一篇包含"beer"
writer.commit();//提交两篇文档到索引文件,形成段(Segment) "_0"
writer.deleteDocuments(new Term("contents", "school"));//删除包含"school"的文档,其实是删除了两篇文档中的一篇。
writer.commit();//提交删除到索引文件,形成"_0_1.del"
writer.deleteDocuments(new Term("contents", "beer"));//删除包含"beer"的文档,其实是删除了两篇文档中的另一篇。
writer.commit();//提交删除到索引文件,形成"_0_2.del"
indexDocs(writer, docDir);//索引两篇文档,和上次的文档相同,但是Lucene无法区分,认为是另外两篇文档。
writer.commit();//提交两篇文档到索引文件,形成段"_1"
writer.deleteDocuments(new Term("contents", "beer"));//删除包含"beer"的文档,其中段"_0"已经无可删除,段"_1"被删除一篇。
writer.close();//提交删除到索引文件,形成"_1_1.del"
形成的索引文件如下:
?
?
?
????? IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
????? writer.setUseCompoundFile(false);
?
????? indexDocs(writer, docDir);
????? writer.flush();
//flush生成segment "_0",并且flush函数中,flushDocStores设为false,也即下个段将同本段共享域和词向量信息,这时DocumentsWriter中的docStoreSegment= "_0"。
????? indexDocs(writer, docDir);
????? writer.commit();
//commit生成segment "_1",由于上次flushDocStores设为false,于是段"_1"的域以及词向量信息是保存在"_0"中的,在这个时刻,段"_1"并不生成自己的"_1.fdx"和"_1.fdt"。然而在commit函数中,flushDocStores设为true,也即下个段将单独使用新的段来存储域和词向量信息。然而这时,DocumentsWriter中的docStoreSegment= "_1",也即当段"_2"存储其域和词向量信息的时候,是存在"_1.fdx"和"_1.fdt"中的,而段"_1"的域和词向量信息却是存在"_0.fdt"和"_0.fdx"中的,这一点非常令人困惑。 如图writer.commit的时候,_1.fdt和_1.fdx并没有形成。
????? indexDocs(writer, docDir);
????? writer.flush();
//段"_2"形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_1.fdt和_1.fdx中的,这时候才产生了此二文件。
????? indexDocs(writer, docDir);
????? writer.flush();
//段"_3"形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_1.fdt和_1.fdx中的
????? indexDocs(writer, docDir);
????? writer.commit();
//段"_4"形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_1.fdt和_1.fdx中的。然而函数commit中flushDocStores设为true,也意味着下一个段将新创建一个段保存域和词向量信息,此时DocumentsWriter中docStoreSegment= "_4",也表明了虽然段"_4"的域和词向量信息保存在了段"_1"中,将来的域和词向量信息却要保存在段"_4"中。此时"_4.fdx"和"_4.fdt"尚未产生。???
????? indexDocs(writer, docDir);
????? writer.flush();
//段"_5"形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_4.fdt和_4.fdx中的,这时候才产生了此二文件。
????? indexDocs(writer, docDir);
????? writer.commit();
????? writer.close();
//段"_6"形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_4.fdt和_4.fdx中的
?
?
?
?
?
读取此文件格式参考SegmentInfos.read(Directory directory, String segmentFileName):
?
?
4.1.2. 域(Field)的元数据信息(.fnm)
一个段(Segment)包含多个域,每个域都有一些元数据信息,保存在.fnm文件中,.fnm文件的格式如下:
?
要了解域的元数据信息,还要了解以下几点:
?
?
?
public static final String ID_PAYLOAD_FIELD = "_ID";
public static final String ID_PAYLOAD_TERM = "_ID";
public static final Term ID_TERM = new Term(ID_PAYLOAD_TERM, ID_PAYLOAD_FIELD);
//声明一个特殊的TokenStream,它只生成一个词(Term),就是那个特殊的词,在特殊的域里面。
static class SinglePayloadTokenStream extends TokenStream {
??? private Token token;
??? private boolean returnToken = false;
??? SinglePayloadTokenStream(String idPayloadTerm) {
??????? char[] term = idPayloadTerm.toCharArray();
??????? token = new Token(term, 0, term.length, 0, term.length);
??? }
??? void setPayloadValue(byte[] value) {
??????? token.setPayload(new Payload(value));
??????? returnToken = true;
??? }
??? public Token next() throws IOException {
??????? if (returnToken) {
??????????? returnToken = false;
??????????? return token;
??????? } else {
??????????? return null;
??????? }
??? }
}
//对于每一篇文档,都让它包含这个特殊的词,在特殊的域里面
SinglePayloadTokenStream singlePayloadTokenStream = new SinglePayloadTokenStream(ID_PAYLOAD_TERM);
singlePayloadTokenStream.setPayloadValue(long2bytes(id));
doc.add(new Field(ID_PAYLOAD_FIELD, singlePayloadTokenStream));//每当得到一个Lucene的文档号时,通过以下的方式得到payload里面的文档号
long id = 0;
TermPositions tp = reader.termPositions(ID_PAYLOAD_TERM);
boolean ret = tp.skipTo(docID);
tp.nextPosition();
int payloadlength = tp.getPayloadLength();
byte[] payloadBuffer = new byte[payloadlength];
tp.getPayload(payloadBuffer, 0);
id = bytes2long(payloadBuffer);
tp.close();
?
?
?
FieldInfos.read(IndexInput, String)
?
?
4.1.3. 域(Field)的数据信息(.fdt,.fdx)
?
Document FieldsReader.doc(int n, FieldSelector fieldSelector)
?
4.1.3. 词向量(Term Vector)的数据信息(.tvx,.tvd,.tvf)
词向量信息是从索引(index)到文档(document)到域(field)到词(term)的正向信息,有了词向量信息,我们就可以得到一篇文档包含那些词的信息。
?
TermVectorsReader.get(int docNum, String field, TermVectorMapper)
四、具体格式
?
4.2. 反向信息
反向信息是索引文件的核心,也即反向索引。
反向索引包括两部分,左面是词典(Term Dictionary),右面是倒排表(Posting List)。
在Lucene中,这两部分是分文件存储的,词典是存储在tii,tis中的,倒排表又包括两部分,一部分是文档号及词频,保存在frq中,一部分是词的位置信息,保存在prx中。
?
4.2.1. 词典(tis)及词典索引(tii)信息
在词典中,所有的词是按照字典顺序排序的。
?
origEnum = new SegmentTermEnum(directory.openInput(segment + "." + IndexFileNames.TERMS_EXTENSION,readBufferSize), fieldInfos, false);//用于读取tis文件
?
SegmentTermEnum indexEnum = new SegmentTermEnum(directory.openInput(segment + "." + IndexFileNames.TERMS_INDEX_EXTENSION, readBufferSize), fieldInfos, true);//用于读取tii文件
?
4.2.2. 文档号及词频(frq)信息
文档号及词频文件里面保存的是倒排表,是以跳跃表形式存在的。
?
For example, the TermFreqs for a term which occurs once in document seven and three times in document eleven, with omitTf false, would be the following sequence of VInts:
15, 8, 3
If omitTf were true it would be this sequence of VInts instead:
7,4
首先我们看omitTf=false的情况,也即我们在索引中会存储一个文档中term出现的次数。
例子中说了,表示在文档7中出现1次,并且又在文档11中出现3次的文档用以下序列表示:15,8,3.
那这三个数字是怎么计算出来的呢?
首先,根据定义TermFreq --> DocDelta[, Freq?],一个TermFreq结构是由一个DocDelta后面或许跟着Freq组成,也即上面我们说的A+B?结构。
DocDelta自然是想存储包含此Term的文档的ID号了,Freq是在此文档中出现的次数。
所以根据例子,应该存储的完整信息为[DocID = 7, Freq = 1] [DocID = 11,? Freq = 3](见全文检索的基本原理章节)。
然而为了节省空间,Lucene对编号此类的数据都是用差值来表示的,也即上面说的规则2,Delta规则,于是文档ID就不能按完整信息存了,就应该存放如下:
[DocIDDelta = 7, Freq = 1][DocIDDelta = 4 (11-7), Freq = 3]
然而Lucene对于A+B?这种或然跟随的结果,有其特殊的存储方式,见规则3,即A+B?规则,如果DocDelta后面跟随的Freq为1,则用DocDelta最后一位置1表示。
如果DocDelta后面跟随的Freq大于1,则DocDelta得最后一位置0,然后后面跟随真正的值,从而对于第一个Term,由于Freq为1,于是放在DocDelta的最后一位表示,DocIDDelta = 7的二进制是000 0111,必须要左移一位,且最后一位置一,000 1111 = 15,对于第二个Term,由于Freq大于一,于是放在DocDelta的最后一位置零,DocIDDelta = 4的二进制是0000 0100,必须要左移一位,且最后一位置零,0000 1000 = 8,然后后面跟随真正的Freq = 3。
于是得到序列:[DocDleta = 15][DocDelta = 8, Freq = 3],也即序列,15,8,3。
如果omitTf=true,也即我们不在索引中存储一个文档中Term出现的次数,则只存DocID就可以了,因而不存在A+B?规则的应用。
[DocID = 7][DocID = 11],然后应用规则2,Delta规则,于是得到序列[DocDelta = 7][DocDelta = 4 (11 - 7)],也即序列,7,4.
?
Example: SkipInterval = 4, MaxSkipLevels = 2, DocFreq = 35. Then skip level 0 has 8 SkipData entries, containing the 3rd, 7th, 11th, 15th, 19th, 23rd, 27th, and 31st document numbers in TermFreqs. Skip level 1 has 2 SkipData entries, containing the 15th and 31st document numbers in TermFreqs.
按照描述,当SkipInterval为4,且有35篇文档的时候,Skip level = 0应该包括第3,第7,第11,第15,第19,第23,第27,第31篇文档,Skip level = 1应该包括第15,第31篇文档。
然而真正的实现中,跳跃表节点的时候,却向前偏移了,偏移的原因在于下面的代码:
?
从代码中,我们可以看出,当SkipInterval为4的时候,当docID = 0时,++df为1,1%4不为0,不是跳跃节点,当docID = 3时,++df=4,4%4为0,为跳跃节点,然而skipData里面保存的却是lastDocID为2。
所以真正的倒排表和跳跃表中保存一下的信息:
?
4.2.3. 词位置(prx)信息
词位置信息也是倒排表,也是以跳跃表形式存在的。
?
4.3. 其他信息
4.3.1. 标准化因子文件(nrm)
为什么会有标准化因子呢?从第一章中的描述,我们知道,在搜索过程中,搜索出的文档要按与查询语句的相关性排序,相关性大的打分(score)高,从而排在前面。相关性打分(score)使用向量空间模型(Vector Space Model),在计算相关性之前,要计算Term Weight,也即某Term相对于某Document的重要性。在计算Term Weight时,主要有两个影响因素,一个是此Term在此文档中出现的次数,一个是此Term的普通程度。显然此Term在此文档中出现的次数越多,此Term在此文档中越重要。
这种Term Weight的计算方法是最普通的,然而存在以下几个问题:
?
由于以上原因,Lucene在计算Term Weight时,都会乘上一个标准化因子(Normalization Factor),来减少上面三个问题的影响。
标准化因子(Normalization Factor)是会影响随后打分(score)的计算的,Lucene的打分计算一部分发生在索引过程中,一般是与查询语句无关的参数如标准化因子,大部分发生在搜索过程中,会在搜索过程的代码分析中详述。
标准化因子(Normalization Factor)在索引过程总的计算如下:
它包括三个参数:
?
从上面的公式,我们知道,一个词(Term)出现在不同的文档或不同的域中,标准化因子不同。比如有两个文档,每个文档有两个域,如果不考虑文档长度,就有四种排列组合,在重要文档的重要域中,在重要文档的非重要域中,在非重要文档的重要域中,在非重要文档的非重要域中,四种组合,每种有不同的标准化因子。
于是在Lucene中,标准化因子共保存了(文档数目乘以域数目)个,格式如下:
?
4.3.2. 删除文档文件(del)
?
?
五、总体结构
?
?
?
?
?
大家可以通过看源代码,相应的Reader和Writer来了解文件结构,将更为透彻。