lucene索引结构分析

Lucene是一个优秀的开源全文搜索项目,很多项目的搜索模块都是使用Lucene。
例如大名鼎鼎的eclipse的帮助系统就是使用的Luence作为起做索引的内核。
Lucene良好的体系结构使得其API接口非常方便易用,使得非自然语言处理的专业人员可以不用关心内部的索引结构,也可以很快的搭建起一个搜索引擎。但是对于高级用户和专业人员,了解其背后使用的索引结构也是必不可少的。

在研究生阶段,我自己也弄了一个索引结构,好奇心促使我将Luence的索引结构和自己的索引结构进行了一番比较。

要比较,首先就要了解;在Lucene的官方主页上有其文档结构的详细文档,下面是我翻译的一部分:

定义:

Lucene中最基本的概念有:index, document, term.

Index包含一个document序列(document的有序集合)。

1 一个document是一个field序列(field的有序集合)

2 一个field是一个term的命名序列。

3  一个term是一个字符串。

    在两个不同field中的同一个字符串被认为是不同的term。因此terms是用一个字符串对来表示,第一个字符串的名字是field,第二个字符串的名字是text.

倒排索引

    索引存储关于terms的统计信息以便于基于term的搜索更加有效。Lucene的索引属于被称为倒排索引的索引家族。这是由于针对每个term,他可以列出包含该term的所有文档。这是对自然文档关系的一种倒置,在自然文档关系中,文档包含term。

Fields的类型

    在Lucene中,Field分为两种。一种Field是可以被存储的域,在这种情况下,Field中的所有文字都被逐字保存在索引文件中,而不是以倒排的方式保存。另一种是以倒排的方式把文字存储在索引文件中,这种域被称为是倒排的域(Field)。一个域既可以是存储的又是倒排的。

    域中的文字可以先被序列化(tokenized)成Terms,然后再索引。域中的文字也可以直接被当成一个Term而被索引。大多数域都被tokenized过,但是有些时候针对某些域直接索引也是很有用的。

Segments(段)

        Lucene索引可以由多个子索引构成,这个子索引便叫做段。每个段都是一个完全独立的索引,可以被独立查询。索引是从以下两个方面进化而来:

1 通过增加新的文档创建新的段。

2 合并现存的段。

查询可以包含多个段,也可以包含多个索引,每个索引也可以由多个段构成。

Document Numbers(文档号码)

在内部,Lucene通过整数文档号码来引用文档。第一篇加入索引的文档被称为文档0,接下来的每个文档得到比前一个文档增一的号码。

注意文档的号码可以改变,因此当把这些号码存储到Lucene以外的系统时必须格外小心。特别的,号码可能在以下情况下发生变化:

1 存储在每个段中的号码只是在该段中唯一,在更大的环境下使用时必须转换。标准的技术是对每个段分配一个值段。当把来自段中的文档号码转换成外部值时,段的基文档号必须被加上。当把外部值转换成段相关的值时,通过文档号所在的范围来确定段,然后减去断的基文档号。例如两个包含五篇文档的段被合并时,第一个段拥有基号码0,第二个拥有基号码5。第二个段的文档三具有外部值八。

2 当文档被删除时,号码中会产成缝隙。这些缝隙最终会被删除当索引通过合并演化时。被当段合并的时候,删除的文档会被丢弃。因此一个刚合并的段在文档号码上是不会有缝隙的。

概述

每个段索引维护以下内容:

域名(Field Name): 包含在该段中使用的所有域的名字的集合。

存储的域值:  对于每个文档,他包含一个属性-值对,属性是域的名字。这些被用来存储关于文档的附加信息,例如文档的标题,URL,存取数据库的标记等等。每次查询的结果都将返回存取域的集合,存储域有文档号码所标记。

Term字典:字典包含了所有文档中所有索引域中使用的Term。字典还记录了包含该Term的文档的号码,以及指向该Term频率和proximity数据的指针。

Term Frequency 数据: 对于字典中的每个Term,包含该Term的文档的号码以及在该文档中出现的频率。

Term Proximity 数据: 对于字典中的每个Term, Term在每个文档中出现的位置。

Normalization factors: 对于每个文档中的每个域,存储着一个值,该值用来标志该域的重要性(在计算score的时候)。

Term Vectors: 对于每篇文档中的每个域,Term向量被存储。Term向量包括Term的文本以及Term频率。

Deleted document: 一个可选的文件用来指示那篇文档被删除了。

文件命名:

属于同一个段的所有文件具有相同的名字,不同的后缀。不同的后缀对应于下面描述的不同的文件格式。通常情况下,一个索引的所有段都位于同一个目录下,尽管这不是必须的。

基本类型:

Byte

    最基本的初等数据类型是八位字节。文件是以字节序列的方式存取。所有其他的数据类型都被定义成字节序列,因此文件格式是字节顺序无关的。

   UInt32

       32位无符号整型被写成四个字节,高位先存储。

  UInt64

64位无符号整型被写成八个字节,高位先存储。

VInt

正整数的边长格式,每个字节的最高位表示是否还有更多的字节需要读取。后七位被当作高位追加到结果整数值中。因此,从0到127的值可以保存在一个字节中,从128到16383被存储在两个字节中。

Chars

Lucen使用Java的”modified UTF-8 encoding来存储Unicode字符串。

String

Lucene用长度,字符数据来表示String

String->VInt,Chars.

在lucene中按照所属关系可以将文件分成两大类,第一类是每个索引相关,也就是说文件是属于某一个索引的。

另一类是段相关的,也就是说文件是属于一个段的。

索引相关的文件主要是记录给索引包含多少个段,每个段的名字是什么,有多大等等统计信息。

索引相关文件还包括一个锁文件(lock file)用来控制对索引的并发存取。

索引文件还包括一个可删除文档列表文件用来记录所有没有被索引使用的文件名。

在Lucene1.4中还引入了一个复合文件。

复合文件为属于该索引的所有段相关文件提供一个容器,所有段相关的文件都可以放在给复合文件中。

众所周知,全文索引的瓶颈在于文件IO的次数,我
推测在Lucene1.4中之所以把所有的段相关文件放到一个作为容器的复合文件中就是为了减少文件IO次数,提高查询速度。

在段相关文件中,按照用途有可以分成三类,第一类文件是用来记录域(Field)相关信息的文件。

第二类是用来记录”词典(Term Dictionary)"的相关文件;

他们记录了索引中所有的单词,单词在那些文档中出现,出现了多少次,具体在文档中的什么位置等等信息。

第三类是用来记录单词权重和统计信息的文件,主要是为计算文档向量服务。

例如针对某一具体的查询对所有匹配的文档进行排序的时候,我们可以对某些关键词赋予较高的权重,从而将最相关的文档排在最前面。

用户使用Google感觉其查询结果较其他搜索引擎更准确一些,就是由于它在对匹配项进行排序的时候有他的独特算法PageRank.

PageRank根据每张网页的入度以及指向该网页的网页的“权威度”计算一个PageRank值,(以前的Google工具条上有这个值,不知道为什么现在没有了)

然后根据PageRank值来排序。

这方面也是现在Lucene研究最热的地方。

扯远了,就全文检索本身而言,最核心的结构是第二类文件,下图是对这类文件的图解。(我费了一个小时画这幅图啊) 
 

 
下面是对上图的一些说明,从图中可以看到三个红线筐,每个筐代表一个文件。

左边筐的文件后缀是.tis,其中各个字段的名字都能够很清楚的说明其具体意义,需要对IndexInternal和SkipInternal解释一下,

由于索引中的词一般都比较多,对图中最左边的矩形筐中的TermInfo有进行的索引,索引存储在另外一个后缀为.tii的文件中。

.tii和.tis中的结构和基本相同,只是在.tis中的TermInfo在.tii中换成TermIndex,(TermIndx->),

其中IndexDelta指向tis中相应的termInfo项。

在tii中不是每个Term都有对应的TermIndex,而是每隔IndexInternal个Term项后才在该文件中存储一项TermIndx。

这样可以加速对Term的查找速度。

同样的技术也运用在对Document的查找上。

从图中可以看出,对每一个Term,在 .frq文件中有一项(TermFreqs 和SkipData)对应于出现该Term所有文档(一个TermFreq对于一偏文档),

用来记录文档号码和在该文档(DocDelta)中出现的频率(Freq)
(在lucene中记录DocDelta时不是真的记录的文档号码,而是和前一个文档号码的差值,采用这种技术可以减小索引文件大小,
另外在记录频率时也不是直接记录频率,也是采用了一个小技巧用来压缩索引大小)。

SkipData是用来加速对文档的定位,其中每一项SkipDatum记录了每隔SkipInternal篇文档出现该Term的文档号(DocSkip),
还保存了到其他结构的指针。

同样在.prx文件中也有一项TermPositions对应一个Term。

每个Positions记录了一篇文档中出现该词的位置。

PositionDelta是具体的位置偏移。

前面罗里罗嗦的解释了一番,总结一下,一共有四个文件(.tii, .tis,.frq,.prx)用来记录Term相关的信息.
在.tis文件中,对每个Term都有一项TermInfo,而且TermInfo是根据字典顺序排序。
TermInfo中记录的了Term本身,以及一些以在.frq和.prx中和该Term相关详细的指针。
.tii是对.tis的一个索引,用来加速对Term的查找。
.frq文件记录了每个Term出现的文档号以及其频率,
.prx文件记录了每个Term在每篇文档中出现的位置信息。

Lucene-2.3.1索引结构有变化。

运行测试程序后,在指定的索引文件目录(测试程序中指定为E:/Lucene/myindex)生成了很多文件:
_0.cfs _1.cfs  segments.gen  segments_f


segments_N文件和segments.gen文件的生成

1、先看segment_N文件:

在将文件写入到磁盘的目录中之前,一般来说首先要创建一个输出流。在SegmentInfos类中,有一个成员方法write(),在该方法中:

IndexOutput output = directory.createOutput(segmentFileName);

根据指定的索引段文件的名称segmentFileName,创建一个指向索引目录directory的输出流output。

关于这个segmentFileName,是要先从指定的索引目录中读取出来的,在write()方法中第一行代码中就获取了这个索引段的文件名:

String segmentFileName = getNextSegmentFileName();

这里的 getNextSegmentFileName()方法是SegmentInfos类的一个成员方法,了解它有助于我们继续追踪:

public String getNextSegmentFileName() {
    long nextGeneration;

    if (generation == -1) {
      nextGeneration = 1;
    } else {
      nextGeneration = generation+1;
    }
    return IndexFileNames.fileNameFromGeneration(IndexFileNames.SEGMENTS,"",nextGeneration);
}

该方法返回的就是我们将要处理的一个索引段文件的名称。最后一句return返回,调用了IndexFileNames类的fileNameFromGeneration()方法,它也很重要,因为要使用文件名称作为参数获取索引目录下的维护索引的文件都要从这里获得。

关注一下IndexFileNames类的实现:

package org.apache.lucene.index;

// 该类主要是对索引文件的命名进行管理
final class IndexFileNames {

/** 索引段文件名 */
static final String SEGMENTS = "segments";

/** generation reference文件名*/
static final String SEGMENTS_GEN = "segments.gen";

/** Name of the index deletable file (only used in pre-lockless indices) */
static final String DELETABLE = "deletable";
  
/** norms file的扩展名 */
static final String NORMS_EXTENSION = "nrm";

/** 复合文件扩展名*/
static final String COMPOUND_FILE_EXTENSION = "cfs";

/** 删除文件扩展名 */
static final String DELETES_EXTENSION = "del";

/** plain norms扩展名 */
static final String PLAIN_NORMS_EXTENSION = "f";

/** Extension of separate norms */
static final String SEPARATE_NORMS_EXTENSION = "s";

/**
   * Lucene的全部索引文件扩展名列表
   */
static final String INDEX_EXTENSIONS[] = new String[] {
      "cfs", "fnm", "fdx", "fdt", "tii", "tis", "frq", "prx", "del",
      "tvx", "tvd", "tvf", "gen", "nrm"
};

/** 被添加到复合索引文件上的文件扩展名 */
static final String[] INDEX_EXTENSIONS_IN_COMPOUND_FILE = new String[] {
      "fnm", "fdx", "fdt", "tii", "tis", "frq", "prx",
      "tvx", "tvd", "tvf", "nrm"
};

/** old-style索引文件扩展名 */
static final String COMPOUND_EXTENSIONS[] = new String[] {
    "fnm", "frq", "prx", "fdx", "fdt", "tii", "tis"
};

/** 词条向量支持的文件扩展名 */
static final String VECTOR_EXTENSIONS[] = new String[] {
    "tvx", "tvd", "tvf"
};

/**
   * 根据基础文件名(不包括后缀,比如segments.gen文件,segments部分为基础文件名)、扩展名和generarion计算指定文件的完整文件名
   */
static final String fileNameFromGeneration(String base, String extension, long gen) {
    if (gen == SegmentInfo.NO) {
      return null;
    } else if (gen == SegmentInfo.WITHOUT_GEN) {
      return base + extension;
    } else {
      return base + "_" + Long.toString(gen, Character.MAX_RADIX) + extension;
    }
}
}

fileNameFromGeneration实现的功能:根据传进来的base(比如segments)、扩展名、gen来生成一个新的文件名,并返回。

在SegmentInfos类中getNextSegmentFileName() 方法调用了fileNameFromGeneration,如下所示:

return IndexFileNames.fileNameFromGeneration(IndexFileNames.SEGMENTS,"",nextGeneration);

第一个参数值为"segments",第二个参数值为"",第三个是一个gen(它是一个Long型的数字),如果假设这里的nextGeneration=5,调用fileNameFromGeneration()方法后,返回的是一个索引段文件名:segments_5。

这样,就可以根据生成的segments_N文件名,创建一个输出流,将需要的信息写入到该文件中。

2、再看segments.gen文件:

仔细观察,其实SegmentInfos类的write方法就是对segments_N文件和segments.gen文件进行写入操作的。

在写入segments_N文件以后,紧接着就是处理segments.gen文件:

output = directory.createOutput(IndexFileNames.SEGMENTS_GEN);

因为在一个索引目录下,属于同一个索引段的索引文件就是通过一个segments.gen文件来维护的,segments.gen文件的文件名自然不需要那么麻烦地去获取。直接使用IndexFileNames.SEGMENTS_GEN = segments.gen作为参数构造一个输出流,进行输出,写入到索引目录中即可。

关于segments_N文件和segments.gen文件保存的信息

同样在SegmentInfos类的write方法中能够看到,这两个文件中都加入了哪些信息。

■ 关于segments_N文件

如下所示:

output.writeInt(CURRENT_FORMAT); // write FORMAT
      output.writeLong(++version); // every write changes the index
      output.writeInt(counter); // write counter
      output.writeInt(size()); // write infos
      for (int i = 0; i < size(); i++) {
        info(i).write(output);
      }       

(1) CURRENT_FORMAT

其中,CURRENT_FORMAT是SegmentInfos类的一个成员:

/* This must always point to the most recent file format. */
private static final int CURRENT_FORMAT = FORMAT_SINGLE_NORM_FILE;

上面CURRENT_FORMAT的值就是FORMAT_SINGLE_NORM_FILE的值-3:

/** This format adds a "hasSingleNormFile" flag into each segment info.
   * See <a href="http://issues.apache.org/jira/browse/LUCENE-756">LUCENE-756</a> for details.
   */
public static final int FORMAT_SINGLE_NORM_FILE = -3;

(2) version

version是SegmentInfos类的一个成员,版本号通过系统来获取:

/**
   * counts how often the index has been changed by adding or deleting docs.
   * starting with the current time in milliseconds forces to create unique version numbers.
   */
private long version = System.currentTimeMillis();

(3) counter

用于为当前待写入索引目录的索引段文件命名的,即segments_N中的N将使用counter替换。

counter也是SegmentInfos类的一个成员,初始化是为0:

public int counter = 0;

在read()方法中,使用从索引目录中已经存在的segment_N中读取的出format的值,然后根据format的值来指派counter的值,如下所示:

      int format = input.readInt();
      if(format < 0){     // file contains explicit format info
       // check that it is a format we can understand
        if (format < CURRENT_FORMAT)
          throw new CorruptIndexException("Unknown format version: " + format);
        version = input.readLong(); // read version
        counter = input.readInt(); // read counter
      }
      else{    // file is in old format without explicit format info
        counter = format;
      }

(4) size()

size()就是SegmentInfos的大小,SegmentInfos中含有多个SegmentInfo,注意:SegmentInfos类继承自Vector。

(5) info(i)

info()方法的定义如下所示:

public final SegmentInfo info(int i) {
    return (SegmentInfo) elementAt(i);
}

可见,SegmentInfos是SegmentInfo的一个容器,它只把当前这个索引目录中的SegmentInfo装进去,以便对他们管理维护。

这里,info(i).write(output);又调用了SegmentInfo类的write()方法,来向索引输出流output中加入信息。SegmentInfo类的write()方法如下所示:

/**
   * Save this segment's info.
   */
void write(IndexOutput output)
    throws IOException {
    output.writeString(name);
    output.writeInt(docCount);
    output.writeLong(delGen);
    output.writeByte((byte) (hasSingleNormFile ? 1:0));
    if (normGen == null) {
      output.writeInt(NO);
    } else {
      output.writeInt(normGen.length);
      for(int j = 0; j < normGen.length; j++) {
        output.writeLong(normGen[j]);
      }
    }
    output.writeByte(isCompoundFile);
}

从上可以看到,还写入了SegmentInfo的具体信息:name、docCount、delGen、(byte)(hasSingleNormFile ? 1:0)、NO/normGen.length、normGen[j]、isCompoundFile。

■ 关于segments.gen文件

通过SegmentInfos类的write()方法可以看到:

         output.writeInt(FORMAT_LOCKLESS);
        output.writeLong(generation);
        output.writeLong(generation);

segments.gen文件中只是写入了两个字段的信息:FORMAT_LOCKLESS和generation。

因为segments.gen文件管理的就是segments_N文件中的N的值,与该文件相关就只有一个generation,和一个用于判断是否是无锁提交的信息:

/** This format adds details used for lockless commits. It differs
   * slightly from the previous format in that file names
   * are never re-used (write once). Instead, each file is
   * written to the next generation. For example,
   * segments_1, segments_2, etc. This allows us to not use
   * a commit lock. See <a
   * href="http://lucene.apache.org/java/docs/fileformats.html"
   * formats</a> for details.
   */
public static final int FORMAT_LOCKLESS = -2;

最后,总结一下:

现在知道了segments_N文件和segment.gen文件都记录了什么内容。

你可能感兴趣的:(lucene索引结构分析)