Lucene 索引技术

来源:互联网 发布:crossover软件下载 编辑:程序博客网 时间:2024/05/16 09:30

1 信息检索技术基础

1.1 全文检索基本过程

我们处理的数据包含两类,一是具有固定格式或有限长度的结构化数据,如数据库、元数据等;另一个是非结构化的数据,如图片、邮件、word文档等。对结构化数据的存储和查询技术比较简单和成熟,可以利用数据的结构特点利用一些算法快速定位数据,效率较高。而对非结构化数据进行检索相对复杂,最原始的方法是顺序扫描,如grep命令,顺序扫描的缺点是显而易见的,每次检索海量数据都进行全范围扫描是不可取和不现实的。另一种方法是将非结构化的数据结构化,然后利用结构化数据查询的方法进行查询。索引就是这个思路的一个实现方式。所谓索引就是从非结构化数据中提取并重新组织的结构化数据,用于提高数据检索的效率。


这里写图片描述

图1 全文检索的一般过程

一般全文检索包含两个基本过程:创建索引和搜索索引。
创建索引的过程主要包括下面几个步骤:

  • 导入原始数据,如文档;
  • 对导入的数据进行处理统计,创建词典和反向索引表;
  • 将索引数据持久化到硬盘。

搜索索引的过程主要包括以下几个步骤:

  • 获取查询语句;
  • 分析查询语句,得到查询树(一种描述查询逻辑的树形结构);
  • 利用查询树搜索索引,得到每个词的原始数据链表,对链表进行交、差、并运算得到结果集;
  • 按与查询语句的相关性对结果集进行排序;
  • 返回排序后的结果集。

1.2 倒排索引

1.2.1 基本形式

所有现代搜素引擎的索引都是基于倒排索引的。到目前为止,倒排索引仍是最有效、最高效的索引结构。
通常认为词是文档的组成部分,因此,正向索引一般描述的是这样的信息:该索引包含哪些文档,这些文档中又包含哪些词。如果换个角度,认为文档是依附于词的,如字典,字的解释依附于字,按照这个思路创建的索引就是倒排索引。倒排表中每个词对应的索引项成为posting。
一个最简单的倒排索引保存了一个词对应的文档列表,如图2所示:
文档信息

这里写图片描述

倒排索引

这里写图片描述

图2 简单的倒排索引

如上所示,当我们输入的查询语句是”How AND you”,通过语言解析组件生成以”how”和”you”为节点的查询树,通过查询倒排表可以获得包含”how”的文档有3和4,包含”you”的文档为3,通过两个结果集取交集,得到既包含”how”又包含”you”的查询文档为3。以上倒排索引只保存了词和文档的对应关系,实际倒排索引中还可以保存词的在文档中出现的频率、位置、所属域等信息。

1.2.2 压缩

搜索引擎的性能与索引的存储形式关系密切。大规模数据集的倒排表自身也非常大,有时候倒排表的大小和被索引的数据集大小相当。压缩这些数据有利于减少存储空间、提高索引容量、提升内存利用率和处理器的使用效率。

2 lucene框架

上节简述了全文检索的基本流程,着重介绍了倒排表技术的原理。本节开始介绍开源全文检索程序库lucene的工作原理。
图3显示了lucene全文检索库的框架

这里写图片描述

图3 lucene全文检索库框架

图4显示了lucene库中各个组件的关系

这里写图片描述

在lucene中,被索引的文档用Document对象表示,用户查询用Query对象表示。使用IndexWriter来创建索引,并持久化。lucene中使用IndexSearch类来搜索索引、对结果集排序,通过TopDocsCollector返回结果集。从图3和图4可以看出,lucene的各个组件与图1所示的全文检索流程基本上一一对应,因此,lucene实际上是对上述全文检索流程的具体实现。

3 lucene的索引

3.1 索引结构

在lucene 中有索引块的概念,每个索引块包含了一定数目的文档。我们能够对单独的索引块进行检索。图 5 显示了 Lucene 索引结构的逻辑视图。索引块的个数由索引的文档的总数以及每个索引块所能包含的最大文档数来决定。图6显示了一个lucene索引的实例。

这里写图片描述

图5 lucene索引结构

这里写图片描述

图6 lucene的索引文件

在lucene中一个目录代表一个索引,既保存了正向索引信息(Index->Segment->Document->Field->Term)也保存了倒排索引信息(Term->Document)。
正向索引信息:

  • segment.gen和segements_N为段数据的元数据,segements_N中保存了该索引包含多少个段,每个段包含多少篇文档等信息;
  • _n.fnm文件保存了该段包含多少个域,每个域的名称及索引方式;
  • _n.fdx, _n.fdt保存了该段包含的所有文档、每篇文档包含多少域、每个域保存了哪些信息;
  • _n.tvf, _n.tvd, _n.tvf保存了该段包含多少文档,每篇文档包含多少域,每个域包含多少词,每个词的字符串、位置等信息。

反向索引信息:

  • _n.tis, _n.tii保存了该段所有按字典序排列的词;
  • _n.frq保存了倒排表;
  • _n.prx保存了倒排表中每个词在包含此词的文档中的位置。

3.1.1 正向索引信息

3.1.1.1 段的元数据信息(Segments_N)

Segments_N包含了索引中每个索引块的名字以及大小等信息。Segments_N的文件格式由图7所示。

这里写图片描述

图7 Segments_N的文件格式

表1 Segments_N的文件格式

这里写图片描述

表2 Segments_N文件SegCount段结构

这里写图片描述

3.1.1.2 域的元数据信息(.fnm)

一个Segment包含多个域,每个域都有一些元数据信息,保存在.fnm文件中。

这里写图片描述

图8 域的元数据结构

表2 域的元数据文件结构

这里写图片描述

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

•偏移和位置: 位置基于词,偏移基于字母或汉字;

•索引域、存储域:被索引的域可以被搜到,否则不能。但是如果希望某个域不能被搜索到,但是可以作为其他域的附加数据被返回,可以存储域;

•Payload: 存储在倒排表中的,同文档号一起存放,多用于存储与每篇文档相关的一些信息。当然这部分信息也可以存储域里(stored Field),两者从功能上基本是一样的,然而当要存储的信息很多的时候,存放在倒排表里,利用跳跃表,有利于大大提高搜索速度。|

3.1.1.3 域的数据文件(.fdt, .fdx)

这里写图片描述

图9 域数据文件格式

域数据文件(fdt):
•真正保存存储域(stored field)信息的是fdt文件;
•在一个段(segment)中总共有segment size篇文档,所以fdt文件中共有segment size个项,每一项保存一篇文档的域的信息;
•对于每一篇文档,一开始是一个fieldcount,也即此文档包含的域的数目,接下来是fieldcount个项,每一项保存一个域的信息;
•对于每一个域,
•fieldnum是域号,
•接着是一个8位的byte,
这里写图片描述

•最后存储域的值。

域索引文件(fdx)
•每篇文档包含的域的个数,每个存储域的值都是不一样的,因而每篇文档占用域数据文件的大小也是不一样的,域索引文件记录了fdt文件中每一篇文档的起始地址和终止地址;
•域索引文件对应每篇文档都有一个项,每一项都是一个long,大小固定,每一项都是对应的文档在fdt文件中的起始地址的偏移量,这样如果我们想找到第n篇文档的存储域的信息,只要在fdx中找到第n项,然后按照取出的long作为偏移量,就可以在fdt文件中找到对应的存储域的信息。

3.1.1.4 词向量的数据信息(.tvx, .tvd, .tvf)

这里写图片描述

图10 词向量

•词向量索引文件(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(也即此词在此文档中出现的次数),词的位置信息,词的偏移量信息。

3.1.2 反向索引信息

反向索引主要由词典(tis,tii)和倒排表(frq, prx)组成,其中文档号和词频信息保存在.frq文件中,词的位置信息保存在.prx文件中。

3.1.2.1 词典(tis,tii)

这里写图片描述

图11 词典结构

词典文件(tis)
这里写图片描述

词典索引文件(tii):
词典索引文件在使用时会全部加载到内存中去。

3.1.2.2 倒排表

文档号及词频信息(frq)
.frq文件以跳跃表的形式保存了文档号及词频信息倒排表,其结构如图12所示,整个文件存放了包含TermCount个项的倒排表,每一项代表了一个词的Posting List。每个Posting List由两部分组成,一是倒排表本身(Term->[DocID, Frq]->[DocID, Frq]…),另一部分为该表的跳跃表。

这里写图片描述

图12 文档号-词频倒排表

跳跃表根据倒排表本身的长度(DocFreq)和跳跃幅度(SkipInterval)可分为不同的层次,层次数计算公式为:

这里写图片描述

第i层的节点数为:

这里写图片描述

每个跳跃节点都包含以下信息:文档号、payload的长度、文档号对应的倒排表中的节点在frq中的偏移量、文档号对应的倒排表中的节点在prx中的偏移量。
除最高层外,其他层都有SkipLevelLength来表示此层的二进制长度(而非节点个数),这样可以直接读取跳跃表中某层的缓存。

这里写图片描述

图13 DocFreq=35,SkipInterval=4的倒排表

词的位置信息(prx)
.prx表同样以跳跃表的形式存储词位置倒排表,如图14所示。

这里写图片描述

图14 词位置倒排表

3.2 索引的创建

Lucene遍历原始数据建立索引的过程十分复杂,如图15所示,每个节点负责索引文档中不同部分的信息。处理完文档后,各部分会同步的按照基本索引链将各部分信息依次写入索引文件中。
使用Lucene来索引数据,首先需要把它转换成一个纯文本tokens的数据流,并通过它创建出Document对象,其包含的Fields成员容纳这些文本数据。一旦准备好Document对象,就可以调用IndexWriter类的addDocument(Document)方法来传递这些对象到Lucene并写入索引中。Lucene首先分析这些数据来使得它们更适合索引。用户通过Lucene的IndexWriter对象创建索引,IndexWriter内部通过DocumentsWriter完成实际的索引过程。

这里写图片描述

图15 lucene创建索引过程

IndexWriter通过指定存放的目录以及文档分析器来构建,direcotry代表索引存储在哪里;analyzer表示如何来分析文档的内容;similarity用来归一化文档,给文档评分;IndexWriter类里还有一些SegmentInfos对象用于存储索引片段信息,以及发生故障回滚等。DocumentsWriter可以实现同时添加多个文档并将它们写入一个临时的segment中,完成后再由IndexWriter和SegmentMerger合并到统一的segment中去。DocumentsWriter支持多线程处理,即多个线程同时添加文档,它会为每个请求分配一个DocumentsWriterThreadState对象来监控此处理过程。处理时通过DocumentsWriter初始化时建立的DocFieldProcessor管理的索引处理链来完成的,依次处理为DocFieldConsumers、DocInverter、TermsHash、FreqProxTermsWriter、TermVectorsTermsWriter、NormsWriter以及StoredFieldsWriter等。

3.2.1 导入数据

 用户通过IndexWriter的addDocument()方法添加文档,deleteDocuments(Term)或者deleteDocuments(Query)方法删除文档,updateDocument()方法来更新(仅仅是先执行delete在执行add操作而已)。当完成了添加、删除、更新文档,应该需要调用close方法。

3.2.2 分析文档

文档分析类Analyzer负责分析文档结构并提取内容。

3.2.2.1 org.apache.lucene.store.Analyzer

Analyzer类构建用于分析文本的TokenStream对象,因此它表示用于从文本中分解出组成索引的terms的一个规则器。典型的实现首先创建一个Tokenizer,它将那些从Reader对象中读取字符流打碎为原始的Tokens。然后一个或更多的TokenFilters可以应用在这个Tokenizer的输出上。需要注意的是必须在你的子类中重载定义在这个类中的其中一个方法,否则的话Analyzer将会进入一个无限循环中。

这里写图片描述

3.2.2.2 org.apache.lucene.store.StandardAnalyzer

StandardAnalyzer类是使用一个English的stop words列表来进行tokenize分解出文本中word,使用StandardTokenizer类分解词,再加上StandardFilter以及LowerCaseFilter以及StopFilter这些过滤器进行处理的这样一个Analyzer类的实现。

这里写图片描述

3.2.3 编制索引

3.2.3.1 DocFieldProcessorPerThread.processDocument()

该方法是处理一个文档的调度函数,负责整理文档的各个fields数据,并创建相应的DocFieldProcessorPerField对象来依次处理每一个field。该方法首先调用索引链表的startDocument()来初始化各项数据,然后依次遍历每一个fields,将它们建立一个以field名字计算的hash值为key的hash表,值为DocFieldProcessorPerField类型。如果hash表中已存在该field,则更新该FieldInfo(调用FieldInfo.update()方法),如果不存在则创建一个新的DocFieldProcessorPerField来加入hash表中。注意,该hash表会存储包括当前添加文档的所有文档的fields信息,并根据FieldInfo.update()来合并相同field名字的域设置信息。
建立hash表的同时,生成针对该文档的fields[]数组(只包含该文档的fields,但会共用相同的fields数组,通过lastGen来控制当前文档),如果field名字相同,则将Field添加到DocFieldProcessorPerField中的fields数组中。建立完fields后再将此fields数组按field名字排序,使得写入的vectors等数据也按此顺序排序。之后开始正式的文档处理,通过遍历fields数组依次调用DocFieldProcessorPerField的processFields()方法进行,完成后调用finishDocument()完成后序工作,如写入FieldInfos等。
下面举例说明此过程,假设要添加如下一个文档:

这里写图片描述

下图描述处理后fields数组的数据结构

这里写图片描述

3.2.3.2 DocInverterPerField.processFields()

该方法负责进行文档field数据的倒排表(inverter)建立的处理工作。处理文档的有同一个名字的所有field数据,即上图的Fieldable数组,它负责将各个数据分解成(Tokenizer)一个个term并存储它们在文档中的位置及出现的频率,即Term向量和Term频率等数据。此方法负责文档的文本的分解工作(调用Analyzer),将term传递到别的consumers进行具体的数据处理,即InvertedDocConsumerPerField和InvertedDocEndConsumerPerField,在这里是TermsHash和NormsWriter。
该方法通过一个DocInverter.FieldInvertState对象来存储和统计文档的当前field的所有term出现的位置position和offset以及频率frequency等信息,同时计算该field的boost分值,为所有相同名字的fields的boost与文档的boost的乘积。通过循环调用各个consumer的start()方法检查该field是否需要进行invert处理,比如某些field是不需要索引的,或者term向量不用存储等。如果一个field需要索引但不需要分词(tokenize),则将该field整个文本数据当做一个term存储,term长度也是该文本的长度,即offset按此长度累加,position则累计加1。如果需要索引并且分词,则调用analyzer指定的tokenizer进行分词处理,分解出一个个token来创建term添加到TermsHash和NormsWriter。注意,该term的position会添加token的positionIncrement设置值(即用户指定的某些token的位置)。Offset也按token的offset值计算,这些数值由分词模块计算,可以通过分词模块调优这些数值,比如索引带有歧义的词,或者增加索引同义词等。
分词以及term添加完成后,即调用TermsHash和NormsWriter的finish()方法,统计和存储这些数据。下图描述方法处理中field的各项数据的结构:

这里写图片描述

3.2.3.3 TermsHashPerField.addToken()

该方法负责进行将上面分解出的token字符串添加到PostingList表中,添加位置等信息的处理会调用接下来的consumer的方法,如FreqProxTermsWriterPerField或TermVectorsTermsWriterPerField。此方法涉及到访问和管理三个内存中的数据池,即CharBlockPool、IntBlockPool和ByteBlockPool。这三个池均采用分片(slice或block)来管理,每一片有固定的长度,定义在DocumentsWriter中,并且也由DocumentsWriter来分配新的块或者回收不用的块,以达到节省内存和提高效率的作用。
TermsHashPerField会维护一个postingHash的散列表,用来存储和管理每一个添加的token及其位置信息,即RawPostingsList类。添加的时候先会计算termText的hashCode值,同时处理掉非法Unicode字符,然后在postingsHash表中查找该TermText的RawPostingsList,找不到则添加一个新的,否则就追加新的位置信息。TermText文本会写入到CharBlockPool中去,同时RawPostingsList中记录其位置即textStart值。ByteBlockPool中写入各个postiong和freq数据(freq在fieldInfo的omitTf设置为false时记录),这个处理在后面的类方法中进行(FreqProxTermsWriterPerField和TermVectorsTermsWriterPerField),RawPostingsList.byteStart记录起始偏移。IntBlockPool中记录这些数据在ByteBlockPool中位置偏移,RawPostingsList.intStart记录起始偏移。
该方法处理逻辑和内存中重要的数据对象的关系图如下(其中假设consumer为FreqProxTermsWriter)

这里写图片描述

3.2.3.4 FreqProxTermsWriterPerField.newTerm()/addTerm()

该方法负责将term在文档中出现的位置(position)和频率(frequency),以及term自带的payloads以及term所在的文档编号等信息写入内存中的缓冲区,即TermsHashPerThread中的ByteBlockPool对象。调用TermsHashPerThread.writeByte()时同时会更新IntBlockPool中的值,指向对应的ByteBlockPool位置。
当field的omitTf为true时,只会记录term出现的文档的编号,记录时写入docId与上一个文档docId的差值,第一个为docId本身。如果omitTf为false,则term出现的position以及payloads(如果有的话),position会记录当前位置与上一个位置的差值的一半,第一个值为本身的一半;并且在最后(term出现在另一个文档时)写入文档编号(方法同omitTf=true),以及term出现在该文档的频率docFreq,不过如果docFreq=1就不会写入docFreq,并且docCode写入与1的或运算值。
处理后的数据结构如下图所示,其中假设docId=0,并且只画了处理content的field的在omitTf设置为false的数据,另外图中ByteBlockPool记录的是原始int数字,实际会写成VInt的字节序列,所以某些offset值只是示例。

这里写图片描述

3.2.3.5 TermVectorsTermsWriterPerField.newTerm()/addTerm()

该方法负责将term在文档中出现的位置(position)和偏移(offset)向量(startOffset, endOffset)等信息写入内存中的缓冲区,即TermsHashPerThread中的ByteBlockPool对象。需要注意的是只有当field.isStorePositionWithTermVector()或field.isStoreOffsetWith TermVector()为true时,相应的信息(startOffset,endOffset)或position才会写入。
写入的这两项数据紧跟着上一节写入的ProxCode和Payload数据之后。写入position比较简单,即把该位置按VInt类型写入即可。偏移向量的写入是记录开始的偏移即startOffset,以及结束的偏移与开始偏移的差值即endOffset-startOffset,同样以VInt类型写入ByteBlockPool。另外这些数据只是写入到内存缓冲中,在finish()调用时才会把它们真正写入.tvx和.tvf和.tvd向量文件中。
处理后的数据结构如下图所示,另外写入数据到ByteBlockPool的方法与上一节中FreqProx写入的处理一致,同样需要调用TermsHashPerThread.writeByte()方法。

这里写图片描述

3.2.4 存储索引

Directory及相关类负责文档索引的存储。

3.2.4.1 org.apache.lucene.store.Directory

一个Directory对象是一系列统一的文件列表。文件可以在它们被创建的时候一次写入,一旦文件被创建,它再次打开后只能用于读取或者删除操作。并且同时在读取和写入的时候允许随机访问。所有I/O操作都是通过这个API处理的。这使得读写操作方式更统一起来,如基于内存的索引的实现(即RAMDirectory)、通过JDBC存储在数据库中的索引、将一个索引存储为一个文件的实现(即FSDirectory)。Directory的锁机制是一个LockFactory的实例实现的,可以通过调用Directory实例的setLockFactory()方法来更改。

这里写图片描述

3.2.4.2 org.apache.lucene.store.FSDirectory

FSDirectory类直接实现Directory抽象类为一个包含文件的目录。目录锁的实现使用缺省的SimpleFSLockFactory,但是可以通过两种方式修改,即给getLockFactory()传入一个LockFactory实例,或者通过调用setLockFactory()方法明确制定LockFactory类。
目录将被缓存起来,对一个指定的符合规定的路径来说,同样的FSDirectory实例通常通过getDirectory()方法返回。这使得同步机制能对目录起作用。

这里写图片描述

3.2.4.3 org.apache.lucene.store.RAMDirectory

RAMDirectory类是一个驻留内存的Directory抽象类的实现。目录锁的实现使用缺省的SingleInstanceLockFactory,但是可以通过setLockFactory()方法修改。

这里写图片描述

3.2.4.4 org.apache.lucene.store.IndexInput

IndexInput类是一个为了从一个目录中读取文件的抽象基类,是一个随机访问的输入流,用于所有Lucene读取Index的操作。BufferedIndexInput是一个实现了带缓冲的IndexInput的基础实现。

这里写图片描述

3.2.4.5 org.apache.lucene.store.IndexOutput

IndexOutput类是一个为了写入文件到一个目录中的抽象基类,是一个随机访问的输出流,用于所有Lucene写入Index的操作。BufferedIndexOutput是一个实现了带缓冲的IndexOutput的基础实现。RAMOuputStream是一个内存驻留的IndexOutput的实现类。

这里写图片描述

3.3 搜索索引

搜索的过程总的来说就是将词典及倒排表信息从索引中读出来,根据用户输入的查询语句合并倒排表,得到结果文档集并对文档进行打分的过程。可用如下图示:

这里写图片描述

总共包括以下几个过程:
•IndexReader打开索引文件,读取并打开指向索引文件的流。
•用户输入查询语句
•将查询语句转换为查询对象Query对象树
•构造Weight对象树,用于计算词的权重Term Weight,也即计算打分公式中与仅与搜索语句相关与文档无关的部分(红色部分)。
•构造Scorer对象树,用于计算打分(TermScorer.score())。
•在构造Scorer对象树的过程中,其叶子节点的TermScorer会将词典和倒排表从索引中读出来。
•构造SumScorer对象树,其是为了方便合并倒排表对Scorer对象树的从新组织,它的叶子节点仍为TermScorer,包含词典和倒排表。此步将倒排表合并后得到结果文档集,并对结果文档计算打分公式中的蓝色部分。打分公式中的求和符合,并非简单的相加,而是根据子查询倒排表的合并方式(与或非)来对子查询的打分求和,计算出父查询的打分。
•将收集的结果集合及打分返回给用户。

原创粉丝点击