[转]Lucene的索引文件格式

原文链接Lucene学习总结之三:Lucene的索引文件格式(1)Lucene的索引文件格式(2)Lucene的索引文件格式(3),这里做一些摘要并收藏。

Lucene的索引里面存了些什么,如何存放的,也即Lucene的索引文件格式,是读懂Lucene源代码的一把钥匙。

当我们真正进入到Lucene源码之中的时候,我们会发现:

  • Lucene的索引过程,就是按照全文检索的基本过程,将倒排表写成此文件格式的过程。
  • Lucene的搜索过程,就是按照此文件格式将索引进去的信息读出来,然后计算每篇文档打分(score)的过程。

本文详细解读了Apache Lucene - Index File Formats(http://lucene.apache.org/java/2_9_0/fileformats.html)这篇文章。

摘录本文时,Apache Lucene的最新版本为7.3.0,对应的Index File Formats(http://lucene.apache.org/core/7_3_0/core/org/apache/lucene/codecs/lucene70/package-summary.html)

一、基本概念

下图就是Lucene生成的索引的一个实例:

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保存了倒排表中每个词在包含此词的文档中的位置。

Apache Lucene 2.9.0 - Index File Formats中对文件的名称和扩展名的总结:

Name Extension Brief Description
Segments File segments.gen, segments_N Stores information about segments
Lock File write.lock The Write lock prevents multiple IndexWriters from writing to the same file.
Compound File .cfs An optional "virtual" file consisting of all the other index files for systems that frequently run out of file handles.
Fields .fnm Stores information about the fields
Field Index .fdx Contains pointers to field data
Field Data .fdt The stored fields for documents
Term Infos .tis Part of the term dictionary, stores term info
Term Info Index .tii The index into the Term Infos file
Frequencies .frq Contains the list of docs which contain each term along with frequency
Positions .prx Stores position information about where a term occurs in the index
Norms .nrm Encodes length and boost factors for docs and fields
Term Vector Index .tvx Stores offset into the document data file
Term Vector Documents .tvd Contains information about each document that has term vectors
Term Vector Fields .tvf The field level info about term vectors
Deleted Documents .del Info about what files are deleted

Apache Lucene 7.3.0 - Index File Formats中对文件的名称和扩展名的总结:

Name Extension Brief Description
Segments File segments_N Stores information about a commit point
Lock File write.lock The Write lock prevents multiple IndexWriters from writing to the same file.
Segment Info .si Stores metadata about a segment
Compound File .cfs, .cfe An optional "virtual" file consisting of all the other index files for systems that frequently run out of file handles.
Fields .fnm Stores information about the fields
Field Index .fdx Contains pointers to field data
Field Data .fdt The stored fields for documents
Term Dictionary .tim The term dictionary, stores term info
Term Index .tip The index into the Term Dictionary
Frequencies .doc Contains the list of docs which contain each term along with frequency
Positions .pos Stores position information about where a term occurs in the index
Payloads .pay Stores additional per-position metadata information such as character offsets and user payloads
Norms .nvd, .nvm Encodes length and boost factors for docs and fields
Per-Document Values .dvd, .dvm Encodes additional scoring factors or other per-document information.
Term Vector Index .tvx Stores offset into the document data file
Term Vector Data .tvd Contains term vector data.
Live Documents .liv Info about what documents are live
Point values .dii, .dim Holds indexed points, if any

在了解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)000 0010,第二个Byte表示第8位,并且最高位置0来表示后面没有其他Byte了,所以(0)000 0001。
Value First byte Second byte Third byte
0 0
1 1
2 10
...
127 1111111
128 10000000 1
129 10000001 1
130 10000010 1
...
16383 11111111 1111111
16384 10000000 10000000 1
16385 10000001 10000000 1
  • 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。
(一个Chars 1个Byte,共15个;一个VInt一个Byte,4个字符串长度,3个offset,共7个;总计22个)

大大缩小了存储空间,尤其是在按字典顺序排序的情况下,前缀重合率大大提高。

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和TermVertor.WITH_OFFSETS)

为什么会存在以上两种情况,其实是可以理解的:

  • 对于符合或然跟随规则的,是因为对于每一个A,B是否存在都不相同,当这种情况大量存在的时候,从一个Byte到一个bit如此8倍的空间节约还是很值得的。
  • 对于不符合或然跟随规则的,是因为某个值的是否存在的配置对于整个域(Field)甚至整个索引都是有效的,而非每次的情况都不相同,因而可以统一存放一个标志。

文章中对如下格式的描述令人困惑:

Positions --> <PositionDelta,Payload?> Freq
Payload --> <PayloadLength?,PayloadData>

PositionDelta和Payload是否使用或然跟随规则呢?如何标识PayloadLength是否存在呢?

其实PostionDelta和Payload并不符合或然跟随规则,Payload是否存在,是由.fnm文件中对于每个域的配置中有关Payload的配置决定的(FieldOption.STORES_PAYLOADS)。

当Payload不存在时,PayloadDelta本身不遵循或然跟随规则。

当Payload存在时,格式应该变成如下:Positions --> <PositionDelta,PayloadLength?,PayloadData> Freq
从而PositionDelta和PayloadLength一起适用或然跟随规则。

4. 跳跃表规则(Skip list)

为了提高查找性能,Lucene在很多地方采取了跳跃表数据结构。

跳跃表(Skip List)是如图的一种数据结构,有以下几个基本特征:

  • 元素是按照顺序排列的,在Lucene中,或是按字典数序排列,或是按从小到大顺序排列。
  • 跳跃时有间隔的(Inverval),也即每次跳跃的元素数,间隔时事先配置好的,如图跳跃表的间隔为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,而第一层无下一个节点,然后访问第2层的94,发现94大于72,然后访问原链表的72,找到元素,共需访问3个元素即可。

然而Lucene在具体实现上,与理论又有所不同,在具体的格式中,会有详细说明。

四、具体格式

上面曾交代过,Lucene保存了从Index到Segment到Document到Feild一直到Term的正向信息,也包括了从Term到Document映射的反向信息,还有后其他一些Lucene特有的信息。下面对这三种信息一一介绍。

4.1 正向信息

Index -> Segments(segments.gen,segments_N) -> Field(fnm,fdx,fdt) -> Term(tvx,tvd,tvf)

4.1.1 段的元数据信息(segments_N)

一个索引(Index)可以同时存在多个segments_N(至于如何存在多个segments_N,在描述完详细信息之后会举例说明),然而当我们要打开一个索引的时候,我们必须要选择一个来打开,那么如何选择打开哪个segments_N呢?

Lucene采取以下过程:

  • 其一,在所有的segments_N中选择N最大的一个。基本逻辑参照SegmentInfos.getCurrentSegmentGeneration(File[] files),其基本思路就是在所有以segments开头,并且不是segments.gen的文件中,选择N最大的一个作为genA。
  • 其二,打开segments.gen,其中保存了当前的N值。其格式如下,读出版本号(Version),然后再独处两个N,如果两者相等,则作为genB。
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的具体格式:

segments_N的具体格式
  • Format:

    • 索引文件格式的版本号。
    • 由于Lucene是在不断开发过程中的,因而不同版本的Lucene,其索引文件格式也不尽相同,于是规定一个版本号。
    • Lucene 2.1此值时-3,Lucene 2.9时,此值是-9。
    • 在Lucene 7.3的源码中标注,Lucene 5.3+时,此值是6;Lucene 7.0时,此值是7;Lucene 7.2时,此值是8。
    • 当用某个版本号的IndexReader读取另一个版本号生成的索引的时候,会因为此值不同而报错。
  • Version:

    • 索引的版本号,记录了IndexWriter将修改提交到索引文件中的次数。
    • 其初始值大多数情况下从索引文件里读出,仅仅在索引开始创建的时候,被赋予当前的时间,以取得一个唯一值。
    • 其值改变在IndexWriter.commit -> IndexWriter.startCommit -> SegmentInfos.prepareCommit -> SegmentInfos.write -> writLong(++Version)
    • 其初始值之所以最初取一个时间,是因为我们并不关心IndexWriter将修改提交到索引的具体次数,而更关心到底哪个是最新的。IndexReader中常比较自己的version和索引文件中的version是否相同来判断此IndexReader被打开后,还有没有被IndexWriter更新。
// 在DirectoryReader中有一个函数

public boolean isCurrent() throws CorruptIndexException, IOException { 
    return SegmentInfos.readCurrentVersion(directory) == segmentInfos.getVersion(); 
}
  • NameCount:
    • 是下一个新段(Segment)的段名。
    • 所有属于同一个段的索引文件都以段作为文件名,一般为_0.xxx,_0.yyy,_1.xxx,_1.yyy,... 。
    • 新生成的段的段名一般为原有最大段名加1.
    • 如相同的索引,NameCount读出来是2,说明新的段为_2.xxx,_2.yyy。
Index下的文件
  • SegCount

    • 段(Segment)的个数。
    • 如上图,此值为2。
  • SegCount个段的元数据信息:

    • 此段中包含的文档数
    • 然而此文档数是包括了已删除,又没有optimize的文档的。因为在optimize之前,Lucene的段中包含了所有被索引的文档,而被删除的文档是保存在.del文件中的,在搜索的过程中,实现从段中读到了被删除的文档,然后再用.del中的标识,将这边文档过滤掉。
    • 如下的代码形成了上图的索引,可以看出索引了两篇文档形成了_0段,然后又删除了其中一篇,形成了_0_1.del,又索引了两篇文档形成_1段,然后又删除了其中一篇,形成_1_1.del。因此在两个段中,此值都是2。
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
    • DelGen
      • .del文件的版本号
      • Lucene中,在optimize之前,删除的文档是保存在.del中的。
      • 在Lucene 2.9中,文档删除有以下几种方式:
        • IndexReader.deleteDocument(int docID) 是用IndexReader按文档号删除。
        • IndexReader.deleteDocuments(Term term)是用IndexReader删除包含此词(Term)的文档。
        • IndexWriter.deleteDocuments(Term term)是用IndexWriter删除包含此词(Term)的文档。
        • IndexWriter.deleteDocuments(Term[] terms)是用IndexWriter删除包含这些词(Term)的文档。
        • IndexWriter.deleteDocuments(Query query)是用IndexWriter删除能满足此查询(Query)的文档
        • IndexWriter.deleteDocuments(Query[] queries)是用IndexWriter删除能满足这些查询(Query)的文档
        • 原来的版本中Lucene的删除一直是有IndexReader来完成的,在Lucene 2.9中虽可以用IndexWriter来删除,但其实真正的实现是在IndexWriter中,保存了readerpool,当IndexWriter向索引文件提交删除的时候,仍然是从readerpool中得到相应的IndexReader,并用IndexReader来进行删除的。下面的代码可以说明:

IndexWriter.applyDeletes()

-> DocumentsWriter.applyDeletes(SegmentInfos)

     -> reader.deleteDocument(doc);
        • DelGen是每当IndexWriter向索引文件中提交删除操作的时候,加1,并生成新的.del文件。
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"

形成的索引文件如下:

    • DocStoreOffset
    • DocStoreSegment
    • DocDtoreIsCompoundFile
      • 对于域(Stored Field)和词向量(Term Vector)的存储可以有不同的方式,即可以每隔段(Segment)单独存储自己的域和词向量信息,也可以多个段共享域和词向量,把他们存储到一个段中去。
      • 如果DocStoreOffset为-1,则此段单独存储自己的域和词向量,从存储文件上来看,如果此段段名为XXX,则此段有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx文件。DocStoreSegment和DocStoreIsCompoundFile在此处不被保存。
      • 如果DocStoreOffset不为-1,则DocStoreSegment保存了共享的段的名字,比如为YYY,DocStoreOffset则为此段的域及词向量信息在共享段中的偏移量。则此段没有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx文件,而是将信息放在共享段的YYY.fdt,YYY.fdx,YYY.tvf,YYY.tvd,YYY.tvx文件中。
      • DocumentsWriter中有两个成员变量:String segment是当前索引信息存放的段,String docStoreSegment是域和词向量信息存储的段。两者可以相同也可以不同,觉得了域和词向量信息是存储在本段中,还是和其他的段共享。
      • IndexWriter.flush(boolean triggerMerge,boolean flushDocStores,boolean flushDeletes)中第二个参数flushDocStores会影响到是否单独或是共享存储。其实最终影响的是DocumentsWriter.closeDocStore()。每当flushDocStores为false时,closeDocStore不被调用,说明下次添加到索引文件中的域和词向量信息时同此次共享一个段的。直到flushDocStores为true的时候,closeDocStore被调用,从而下次添加到索引文件中的域和词向量信息将被保存在一个新的段中,不同此次共享一个段(在这里需要指出的是Lucene的一个很奇怪的实现,虽然下次域和词向量信息是被保存到新的段中,然而段名确实这次被确定了的,在initSegmentName中当docStoreSegment == null时,被指为当前段的segment,而非下一个新段的segment,docStoreSegment = segment,于是会出现下面例子的现象)。
      • 好在共享域和词向量存储并不是经常被使用到,实现也或有缺陷,暂且解释到此。
      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并没有形成。
段"_1"形成
      indexDocs(writer, docDir); 
      writer.flush();

//段"_2"形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_1.fdt和_1.fdx中的,这时候才产生了此二文件。
段"_2"形成
      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"尚未产生。
段"_4"形成
      indexDocs(writer, docDir); 
      writer.flush();

//段"_5"形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_4.fdt和_4.fdx中的,这时候才产生了此二文件。
段"_5"形成
      indexDocs(writer, docDir); 
      writer.commit(); 
      writer.close();

//段"_6"形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_4.fdt和_4.fdx中的
段"_6"形成
    • HasSingleNormFile

      • 在搜索的过程中,标准化因子(Normalization Factor)会影响文档最后的评分。
      • 不同的文档重要性不同,不同的域重要性也不同。因而每个文档的每个域都可以有自己的标准化因子
      • 如果HasSingleNormFile为1,则所有的标准化因子都是存在.nrm文件中的。
      • 如果HasSingleNormFile不是1,则每个域都有自己的标准化因子文件.fN
      • NumField

        • 域的数量
      • NormGen

        • 如果每个域有自己的标准化因子文件,则此数组描述了每个标准化因子文件的版本号,也即.fN的N
        • IsCompoundFile
          • 是否保存为复合文件,也即把同一段中的文件按照一定格式,保存在一个文件当中,这样可以减少每次打开文件的个数。
          • 是否为复合文件,由接口IndexWriter.setUseCompoundFile(boolean)设定。
          • 非复合文件同复合文件的对比如下图:
非复合文件 复合文件
    • DeletionCount
      • 记录了此段中删除的文档数目。
    • HasProx
      • 如果至少有一个段omitTF为false,也即词频(term frequency)需要被保存,则HasProx为1,否则为0。
    • Diagnostic
      • 调试信息。
  • User map data
    • 保存了用户从字符串到字符串的映射Map
  • CheckSum
    • 此文件segment_N的校验和
读取此文件格式参考SegmentInfos.read(Directory directory, String segmentFileName):

int format = input.readInt();
version = input.readLong(); // read version
counter = input.readInt(); // read counter
for (int i = input.readInt(); i > 0; i--) // read segmentInfos
    add(new SegmentInfo(directory, format, input));
        name = input.readString();
        docCount = input.readInt();
        delGen = input.readLong();
        docStoreOffset = input.readInt();
        docStoreSegment = input.readString();
        docStoreIsCompoundFile = (1 == input.readByte());
        hasSingleNormFile = (1 == input.readByte());
        int numNormGen = input.readInt();
        normGen = new long[numNormGen];
        for(int j=0;j
        normGen[j] = input.readLong();
    isCompoundFile = input.readByte();
    delCount = input.readInt();
    hasProx = input.readByte() == 1;
    diagnostics = input.readStringStringMap();
    userData = input.readStringStringMap();
final long checksumNow = input.getChecksum();
final long checksumThen = input.readLong();
4.1.2 域(Field)的元数据信息(.fnm)

一个段(Segment)包含多个域,每个域都有一些元数据信息,保存在.fnm文件中,.fnm文件的格式如下:

.fnm文件格式
  • FNMVersion
    • 是fnm文件的版本号,对于Lucene 2.9为-2
  • FieldCount
    • 域的数目
  • 一个数组的域(Field)
    • FieldName:域名,如"title","modified","content"等。
    • FieldBits:一系列标志位,表明此域的索引方式
      • 最低位: 1表示此域被索引,0则不被索引。所谓被索引,也即放到倒排表中去。
        • 仅仅被索引的域才能被搜索到。
        • Field.Index.NO则表示不被索引。
        • Field.Index.ANALYZED则表示不但被索引,而且被分词,比如索引"hello world"后,无论是搜索"hello",还是搜索"world"都能够被搜到。
        • Field.Index.NOT_ANALYZED表示虽然被索引,但是不 分词,比如索引"hello world"后,仅当搜"hello world"时,能够搜到,搜"hello"和搜"world"都搜不到。
        • 一个域除了能被索引,还能够被存储,仅仅被存储的域时搜索不到的,但是能通过文档号查到,多用于不想被搜索到,但是在通过其他域能够搜索到的情况下,能够随着文档号返回给用户的域。
        • Field.Store.Yes则表示存储此域,Field.Store.NO则表示不存储此域。
      • 倒数第二位:1表示保存词向量,0为不保存词向量。
        • Field.TermVector.YES表示保存词向量
        • Field.TermVector.NO表示不保存词向量
      • 倒数第三位:1表示在词向量中保存位置信息。
        • Field.TermVector.WITH_POSITIONS
      • 倒数第四位:1表示在词向量中保存偏移量信息。
        • Field.TermVector.WITH_OFFSETS
      • 倒数第五位:1表示不保存标准化因子
        • Field.Index.ANALYZED_NO_NORMS
        • Field.Index.NOT_ANALYZED_NO_NORMS
      • 倒数第六位:是否保存payload

要了解域的元数据信息,还要了解以下几点:

  • 位置(Position)和偏移量(Offset)的区别
    • 位置(Position)是基于词(Term)的,偏移量是基于字母或汉字的。
Position and offset
  • 索引域(Indexed)和存储域(Stored)的区别

    • 一个域为什么会被存储(Store)而不被索引(Index)呢?在一个文档的所有信息中,有这样一部分信息,可能不想被索引从而可以搜索到,但是当这个文档由于其他的信息被搜索到时,可以同其他信息一同返回。
    • 举个例子,读研究生时,您好不容易写了一篇论文交给您的导师,您的导师却要他做第一作者而您做第二作者,然而您导师不想别人在论文系统中搜索您的名字时找到这篇论文,于是在论文系统中,把第二作者这个Field的Indexed设为false,这样别人搜索您的民资,永远不知道您写过这篇论文,只有在别人搜索您导师的名字从而找到您的文章时,在一个角落表述着第二作者时您。
  • payload的使用

    • 我们知道,索引是以倒排表形式存储的,对于每一个词,都保存了包含这个词的一个链表,当然为了加快查询速度,此链表多用跳跃表进行存储。
    • Payload信息就是存储在倒排表中的,同文档号一起存放,多用于存储与每篇文档相关的一些信息。当然这部分信息也可以存存储域里(store Field),两者从功能上基本是一样的,然而当要存储的信息很多的时候,存放在倒排表里,利用跳跃表,有利于大大提高搜索速度。
    • Payload的存储方式如下图:
Payload的存储方式
    • Payload主要有以下几种用法:
      • 存储每个文件都有的信息:比如有的时候,我们想给每个文档赋一个我们自己的文档号,而不是用Lucene自己的文档号,于是我们可以声明一个特殊的域(Field)"_ID"和特殊的词(Term)"_ID",使得每篇文档都包含词"_ID",于是词"_ID"的倒排表里面对于每篇文档又有一个项,每一项都有一个payload,于是我们可以在payload里边保存我们自己的文档号。每当我们得到一个Lucene的文档号的时候,就能从跳跃表中查找到我们自己的文档号。
//声明一个特殊的域和特殊的词 
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();
      • 影响词的评分
        • 在Similarity抽象类中有函数public float scorePayload(byte[] payload,int offset,int length) 可以根据payload的值影响评分。
  • 读取域元数据信息的代码如下:
FieldInfos.read(IndexInput, String)

int firstInt = input.readVInt();
size = input.readVInt();
for (int i = 0; i < size; i++)
String name = input.readString();
byte bits = input.readByte();
boolean isIndexed = (bits & IS_INDEXED) != 0;
boolean storeTermVector = (bits & STORE_TERMVECTOR) != 0;
boolean storePositionsWithTermVector = (bits & STORE_POSITIONS_WITH_TERMVECTOR) != 0;
boolean storeOffsetWithTermVector = (bits & STORE_OFFSET_WITH_TERMVECTOR) != 0;
boolean omitNorms = (bits & OMIT_NORMS) != 0;
boolean storePayloads = (bits & STORE_PAYLOADS) != 0;
boolean omitTermFreqAndPositions = (bits & OMIT_TERM_FREQ_AND_POSITIONS) != 0;
4.1.3 域(Field)的数据信息(.fdt,.fdx)
Field
  • 域数据文件(.fdt):

    • 真正保存存储域(store field)信息的文件是.fdt文件
    • 在一个段(Segment)中总共有segment size篇文档,所以.fdt文件中共有segment size个项,在每一项保存一篇文档的域的信息。
    • 对于每一篇文档,一开始是一个fieldcount,也即此文档包含的域的数目,接下来是fieldcount个项,每一项保存一个域的信息。
    • 对于每一个域,fieldnum是域号,接着是一个8位的byte,最低一位表示此域是否分词(tokenized),倒数第二位表示此域是保存字符串数据还是二进制数据,倒数第三位表示此域是否被压缩,再接下来就是存储域的值,比如new Field("title","lucene in action",Field.Store.Yes,...),则此处存放的就是"lucene in action"这个字符串。
  • 域索引文件(fdx)

    • 由域数据文件格式我们知道,每篇文档包含的域的个数,每个存储域的值都是不一样的,因而域数据文件中segment size这篇文档,每篇文档占用的大小也是不一样的,那么如何在.fdt中辨别每一篇文档的起始地址和终止地址呢?如何能够更快的找到第n篇文档的存储域的信息呢?就是要借助域索引文件。
    • 域索引文件也总共有segment size个项,每篇文档都有一个项,每一项都是一个long,大小固定,每一项都是对应的文件在.fdt文件中的起始地址的偏移量,这样如果我们想找到第n篇文档的存储域信息,只要在.fdx中找到第n项,然后按照取出的long作为偏移量,就可以在.fdt文件中找到对应的存储域信息。
  • 读取域数据信息的代码如下:

Document FieldsReader.doc(int n, FieldSelector fieldSelector);

long position = indexStream.readLong();//indexStream points to ".fdx"
fieldsStream.seek(position);//fieldsStream points to "fdt"
int numFields = fieldsStream.readVInt();
for (int i = 0; i < numFields; i++){
    int fieldNumber = fieldsStream.readVInt();
    byte bits = fieldsStream.readByte();
    boolean compressed = (bits & FieldsWriter.FIELD_IS_COMPRESSED) != 0;
    boolean tokenize = (bits & FieldsWriter.FIELD_IS_TOKENIZED) != 0;
    boolean binary = (bits & FieldsWriter.FIELD_IS_BINARY) != 0;
    if (binary){
    int toRead = fieldsStream.readVInt();
        final byte[] b = new byte[toRead];
        fieldsStream.readBytes(b, 0, b.length);
        if (compressed)
        int toRead = fieldsStream.readVInt();
        final byte[] b = new byte[toRead];
        fieldsStream.readBytes(b, 0, b.length);
        uncompress(b);
    }
    else{
        fieldsStream.readString();
    }
}
    
4.1.4 词向量(Term Vector)的数据信息(.tvx,.tvd,.tvf)
Term Vector

词向量信息是从索引 (index)到文档(document)到域(field)到(term)的正向信息,有了词向量信息,我们就可以得到一篇文档包含了哪些词的信息。

  • 词向量索引文件(.tvx):

    • 一个段(segment)包含N篇文档,此文档就有N项,每一项代表一篇文档。
    • 每一项包含两部分信息:第一部分是词向量文档文件(.tvd)中此文档的偏移量,第二部分是词向量域文件(.tvf)中此文档第一个域的偏移量。
  • 词向量文档文件(.tvd):

    • 一个段(segment)包含N篇文档,此文件就有N项,每一项包含了此文档的所有的域的信息。
    • 每一项首先是此文档包含的域的个数NumFields,然后是一个NumFields大小的数组,数组的每一项是域号。然后是一个(NumFields - 1)大小的数组,由前面我们知道,每篇文档的第一个域在.tvf中的偏移量在tvx文件中保存,而其他的(NumFields - 1)个在.tvf的偏移量就是第一个域的偏移量加上这(NumFields - 1)个组数组的每一项的值。
  • 词向量域文件(.tvf)

    • 此文件包含了此段中的所有的域,并不对文档做区分,到底第几个域到第几个域是属于哪篇文档,是由.tvx中的第一个域的偏移量以及.tvd中的(NumFields - 1)个域的偏移量来决定的。
    • 对于每一个域,首先是词域包含的词的个数NumTerms,然后是一个8位的byte,最后一位是指定是否保存位置信息,倒数第二位是指定是否保存偏移量信息。然后是NumTerms个像的数组,每一项代表一个词(Term),对于每一个词,由词的文本TermText,词频TermFreq(也即此词在文档中出现的次数),词的位置信息,词的偏移量信息。
  • 读取词向量数据信息的代码如下:

TermVectorsReader.get(int docNum, String field, TermVectorMapper)

int fieldNumber = fieldInfos.fieldNumber(field);//通过field名字得到field号

seekTvx(docNum);//在tvx文件中按docNum文档号找到相应文档的项

long tvdPosition = tvx.readLong();//找到tvd文件中相应文档的偏移量

tvd.seek(tvdPosition);//在tvd文件中按偏移量找到相应文档的项

int fieldCount = tvd.readVInt();//此文档包含的域的个数。

for (int i = 0; i < fieldCount; i++) //按域号查找域
    number = tvd.readVInt();
    if (number == fieldNumber)
        found = i;

position = tvx.readLong();//在tvx中读出此文档的第一个域在tvf中的偏移量

for (int i = 1; i <= found; i++)
    position += tvd.readVLong();//加上所要找的域在tvf中的偏移量

tvf.seek(position);

int numTerms = tvf.readVInt();

byte bits = tvf.readByte();

storePositions = (bits & STORE_POSITIONS_WITH_TERMVECTOR) != 0;

storeOffsets = (bits & STORE_OFFSET_WITH_TERMVECTOR) != 0;

for (int i = 0; i < numTerms; i++)
    
    start = tvf.readVInt();
    deltaLength = tvf.readVInt();
    totalLength = start + deltaLength;
    tvf.readBytes(byteBuffer, start, deltaLength);
    term = new String(byteBuffer, 0, totalLength, "UTF-8");
    
    if (storePositions)
        
        positions = new int[freq];
        int prevPosition = 0;
        
        for (int j = 0; j < freq; j++)
            positions[j] = prevPosition + tvf.readVInt();
            prevPosition = positions[j];
            
    if (storeOffsets)
        
        offsets = new TermVectorOffsetInfo[freq];
        int prevOffset = 0;
        for (int j = 0; j < freq; j++)
        int startOffset = prevOffset + tvf.readVInt();
        int endOffset = startOffset + tvf.readVInt();
        offsets[j] = new TermVectorOffsetInfo(startOffset, endOffset);
        prevOffset = endOffset;
4.2 反向信息

反向信息是索引文件的核心,也即反向索引。

反向索引包括两部分,左面是词典(Term Dictionary),右边是倒排表(Posting List)。

在Lucene中,这两部分是分文件存储的,词典是存储在.tii,.tis中的,倒排表又包括两部分,一部分是文档号及词频,保存在.frq中,一部分是词的位置信息,保存在.prx中。

  • Term Dictionary (.tii,.tis)
    • -> Frequencies(.frq)
    • -> Positions(.prx)
4.2.1 词典(.tis)及词典索引 (.tii)信息
Term Dictionary

在词典中,所有的词都是按照字典顺序排序的。

  • 词典文件(.tis)

    • TermCount:词典中包含的总词数
    • IndexInterval:为了加快对词的查找速度,也应用类似跳跃表的查询结构,假设IndexInterval为4,则在词典索引文件(.tii)中保存第4个,第8个,第12个词,这样可以加快在词典文件中查找词的速度。
    • SkipInterval:倒排表无论是文档号及词频,还是位置信息,都是以跳跃表的结构存在的,SkipInterval是跳跃的步数。
    • MaxSkipLevels:跳跃表是多层的,这个值指的是跳跃表的最大层数。
    • TermCount个项的数组,每一项代表一个词,对于每一个词,以前缀后缀规则存放词的文本信息(PrefixLength + Suffix),词属于的域的域号(FieldNum),有多少篇文档包含此词(DocFreq),此词的倒排表在.frq,.prx中的偏移量(FreqDelta,ProxDelta),此词的倒排表的跳跃表在.frq中的偏移量(SkipDelta),这里之所以用Delta,是应用差值规则。
  • 词典索引文件(.tii)

    • 词典索引文件是为了加快对词典文件中词的查找速度,保存每隔IndexInterval个词。
    • 词典索引文件是会被全部加载到内存中去的。
    • IndexTermCount = TermCount / IndexInterval: 词典索引文件中包含的词数,
    • IndexInterval同词典文件中的IndexInterval。
    • SkipInterval同词典文件中的SkipInterval。
    • MaxSkipLevels同词典文件中的MaxSkipLevels。
    • IndexTermCount个项的数组,每一项代表一个词,每一项包括两部分,第一部分是词本身(TermInfo),第二部分是在词典文件中的偏移量(IndexDelta)。假设IndexInterval为4,词数组中保存第4个,第8个,第12个词……
  • 读取词典及词典索引文件的代码如下:

origEnum = new SegmentTermEnum(directory.openInput(segment + "." + IndexFileNames.TERMS_EXTENSION,readBufferSize), fieldInfos, false);//用于读取tis文件

    int firstInt = input.readInt();
    size = input.readLong();
    indexInterval = input.readInt();
    skipInterval = input.readInt();
    maxSkipLevels = input.readInt();

SegmentTermEnum indexEnum = new SegmentTermEnum(directory.openInput(segment + "." + IndexFileNames.TERMS_INDEX_EXTENSION, readBufferSize), fieldInfos, true);//用于读取tii文件

    indexTerms = new Term[indexSize];
    indexInfos = new TermInfo[indexSize];
    indexPointers = new long[indexSize];
    for (int i = 0; indexEnum.next(); i++)
        indexTerms[i] = indexEnum.term();
        indexInfos[i] = indexEnum.termInfo();
        indexPointers[i] = indexEnum.indexPointer;
4.2.2 文档号及词频(.frq)信息
文档号及词频

文档号及词频文件里面保存的是倒排表,是以跳跃表形式存在的。

  • 此文件包含TermCount个项,每一个词都有一项,因为每一个词都有自己的倒排表。
  • 对于每一个词的倒排表都包括两部分,一部分是倒排表本身,也即一个数组的文档号及词频,另一部分是跳跃表,为了更快的访问和定位倒排表中文档号及词频的位置。
  • 对于文档号和词频的存储应用的是差值规则和或然跟随规则,Lucene的文档本身又以下几句话,比较难理解,在此解释一下:

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.

  • 对于跳跃表的存储又以下几点需要解释一下:
    • 跳跃表可根据倒排表本身的长度(DocFreq)和跳跃幅度(SkipInterval)而分不同的层次,层次数为NumSkipLevels = Min(MaxSkipLevels, floor(log(DocFreq/log(SkipInterval))))。
    • 除了最低层之外,其他层都有SkipLevelLength来表示此层的二进制长度(而非节点的个数),方便读取某一层的跳跃表到缓存里面。
    • 高层在前,低层在后,当读完所有的高层后,剩下的就是最低一层,因而最后一层不需要SkipLevelLength。这也是为什么Lucene文档中的格式描述为NumSkipLevels-1,SkipLevel,也即低NumSkipLevels-1层又SkipLevelLength,最后一层只有SkipLevel,没有SkipLevelLength。
    • 除最低层以外,其他层都有SkipChildLevelPointer来指向下一层相应的节点。
    • 每一个跳跃节点包含以下信息:文档号,payload的长度,文档号对应的倒排表中的节点在.frq中的偏移量,文档号对应的倒排表中的节点在.prx中的偏移量。
    • 虽然Lucene的文档中有以下的描述,然而实验的结果却不是完全准确的:

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篇文档。

然而真正的实现中,跳跃表节点的时候,却向前偏移了,偏移的原因在于下面的代码:

FormatPostingsDocsWriter.addDoc(int docID, int termDocFreq)
   final int delta = docID - lastDocID;
   if ((++df % skipInterval) == 0)
       skipListWriter.setSkipData(lastDocID, storePayloads, posWriter.lastPayloadLength);
       skipListWriter.bufferSkip(df);

从代码中,我们可以看出,当SkipInterval为4的时候,当docID = 0时,++df为1,1%4不为0,不是跳跃节点,当docID = 3时,++df=4,4%4为0,为跳跃节点,然而skipData里面保存的却是lastDocID为2。

所以真正的倒排表和跳跃表中保存以下的信息:

倒排表与跳跃表
4.2.3 词位置(prx)信息
词位置信息

词位置信息也是倒排表,也是以跳跃表形式存在的。

  • 此文件包含TermCount个选项,每一个词都有一个项,因为每一个词都有自己的词位置倒排表。
  • 对于每个词都有一个DocFreq大小的数组,每项代表一篇文档,记录此文档中此词出现的位置。这个文档数组也是和.frq文件中的跳跃表有关系的,从上面我们知道,在.frq的跳跃表节点中有ProxSkip,当SkipInterval为3的时候,.frq的跳跃节点指向.prx文件中此数组的第1,第4,第7,第10,第13,第16篇文档。
  • 对于每一篇文档,可能包含一个词多次,因而有一个Freq大小的数组,每一项代表此词在此文档中出现一次,则有一个位置信息。
  • 每一个位置信息包含:PositionDelta(采用差值规则),还可以保存payload,应用或然跟随规则。
4.3 其他信息
4.3.1 标准化因子文件(nrm)

为什么会有标准化因子呢?从第一章中的描述,我们知道,在搜索的过程中,搜索出的文档要按与查询语句的相关性排序,相关性大的打分(score)高,从而排在前面。相关性打分(score)使用向量空间模型(Vector Space Model),在计算相关性之前,要计算Term Weight,也即某Term相对于莫Document的重要性。在计算Term Weight时,主要有两个影响因素,一个是此Term在此文档中出现的次数,一个是此Term的普通程度。显然此Term在文档中出现的次数越多,此Term在此文档中越重要。

这种Term Weight的计算方法时最普通的,然而存在以下几个问题:

  • 不同的文档重要性不同。有的文档重要些,有的文档相对不重要,比如做软件的,在索引书记的时候,我想让计算机方面的书更容易搜索到,而文学方面的书籍搜索时排名靠后。
  • 不同的域重要性不同。有的域重要一些,如关键字,如标题;有的域不重要一些, 如附件等。同样的一个词(Term),出现在关键词中应该比出现在附件中打分要高。
  • 根据词(Term)在文档中出现的绝对次数来决定此词对文档的重要性,有不合理的地方。比如长的文档词在文档中出现的次数相对较多,这样短的文档比较吃亏。比如一个词在一本砖头书中出现了10次,在另一篇不足100字的文章中出现了9次,就说明砖头书应该排在前面吗?不应该,显然此词在不足100字的文章中能出现9次,可见其对此文章的重要性。

由于上述原因,Lucene在计算Term Weight时,都会乘上一个保准话因子(Normalization Factor),来减少上面三个问题的影响。

标准化因子(Normalization Factor)是会影响随后打分(score)的计算的,Lucene的打分计算一部分发生在索引过程中,一般是与查询语句无关的参数如标准化因子,大部分发生在搜索过程中,会在搜索过程的代码分析中详述。

标准化因子(Normalization Factor)在索引过程总的计算如下:

计算标准化因子

它包含三个参数:

  • Document boost:此值越大,说明此文档越重要。
  • Field boost:此域越大,说明此域越重要。
  • lengthNorm(field) = (1.0 / Math.sqrt(numTerms)):一个域中包含的Term总数越多,也即文档越长,此值越小,文档越短,此值越大。

从上面的公式,我们知道,一个词(Term)出现在不同的文档或不同的域中,标准化因子不同。比如有两个文档,每个文档有两个域,如果不考虑文档长度,就有四种排列组合,在重要文档的重要域中,在重要文档的非重要域中,在非重要文档的重要域中,在非重要文档的非重要域中,四种组合,每种有不同的标准化因子。

于是在Lucene中,标准化因子共保存了(文档数目乘以域数目)个,格式如下:

标准化因子
  • 标准化因子文件(Normalization Factor File: nrm):
    • NormsHeader:字符串“NRM”外加Version,依Lucene的版本的不同而不同。
    • 接着是一个数组,大小为NumFields,每个Field一项,每一项为一个Norms。
    • Norms也是一个数组,大小为SegSize,即此段中文档的数量,每一项为一个Byte,表示一个浮点数,其中02为尾数,38为指数。
4.3.2 删除文档文件(.del)
删除文档文件
  • 被删除文档文件(Deleted Document File: .del)
    • Format:在此文件中,Bits和DGaps只能保存其中之一,-1表示保存DGaps,非负值表示保存Bits。
    • ByteCount:此段中有多少文档,就有多少个bit被保存,但是以byte形式计数,也即Bits的大小应该是byte的倍数。
    • BitCount:Bits中有多少位被至1,表示此文档已经被删除。
    • Bits:一个数组的byte,大小为ByteCount,应用时被认为是byte*8个bit。
    • DGaps:如果删除的文档数量很小,则Bits大部分位为0,很浪费空间。DGaps采用以下的方式来保存稀疏数组:比如第十,十二,三十二个文档被删除,于是第十,十二,三十二位设为1,DGaps也是以byte为单位的,仅保存不为0的byte,如第1个byte,第4个byte,第1个byte十进制为20,第4个byte十进制为1。于是保存成DGaps,第1个byte,位置1用不定长正整数保存,值为20用二进制保存,第2个byte,位置4用不定长正整数保存,用差值为3,值为1用二进制保存,二进制数据不用差值表示。

五、总体结构

总体结构
  • 图示为Lucene索引文件的整体结构:
    • 属于整个索引(Index)的segment.gen,segment_N,其保存的是段(segment)的元数据信息,然后分多个segment保存数据信息,同一个segment有相同的前缀文件名。
    • 对于每一个段,包含域信息,词信息,以及其他信息(标准化因子,删除文档)
    • 域信息也包括域的元数据信息,在fnm中,域的数据信息,在fdx,fdt中。
    • 词信息是反向信息,包括词典(tis, tii),文档号及词频倒排表(frq),词位置倒排表(prx)。

大家可以通过看源代码,相应的Reader和Writer来了解文件结构,将更为透彻。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容