搜索引擎lucene-03,倒排索引与lucene创建索引

全文检索和倒排索引

一、什么是全文检索

我们生活中的数据通常可以分为两类:
**结构化数据:**有固定格式和固定长度。例如成绩单、工资条、花名册等等。
**非结构化数据:**无固定格式,无固定长度。例如邮件、会议纪要、通知书等等。非结构化数据也叫全文数据。

对结构化数据的搜索:可以把数据导入数据库表中,通过sql语句进行搜索。

对非结构化数据的搜索:有两种方法。
一种是顺序扫描,例如查找内容包含某一个字符串的文件,则要扫描所有的文档。如果遇到特大文件,这种方式就会很慢。

那有没有优化的方法呢?结构化数据使用了一定的搜索加速算法,可以提升检索速度,那么把非结构化数据尽量转变成结构化数据,是不是就可以了?因此我们又了第二种方法的思路:即把非结构化数据的部分信息提取出来,重新组织,使其具有一定的结构。

这部分从非结构化数据中提取出的然后重新组织的信息,我们称之索引。

举个例子,我们都使用过现代汉语字典,现代汉语字典的内容都是对汉字的解释,每个汉字有拼音、释义、词语、例句等等,都是非结构化的信息,但是每个汉字的拼音和部首是结构化的,通过把汉字的拼音和部首提取出来,组成拼音检索表和部首检索表,从而可以很快的找到汉字所在的页码,也就可以找到汉字的释义、词语和例句了。
这里拼音和部首就是索引。

这种先建立索引,再对索引进行搜索的过程就叫全文检索(Full-text Search)。

二、全文检索的过程

全文检索的过程分为两个部分:

**1.创建索引:**把非结构化的数据和结构化的数据,整理并提取信息,创建索引的过程。
**2.数据检索:**根据用户的查询请求,搜索创建的索引,根据索引找到对应的数据。
创建索引的过程是很耗时,但是是一次性的,提供的查询速度是非常快的。一次索引,多次使用。
首先我们确定一下什么是索引?

我们想一想字段中的部首检索表,某一个部首下,有汉字列表,每个汉字对应多个页码。根据部首我们能快速找到汉字在哪个位置。这个部首检索表,就是索引。简单来说,索引中存储的是数据的部分信息和数据所在位置的对应关系,并能返回查询到所有的数据。

在现代搜索引擎中,索引设计的目的是为了方便(加速)检索。在一般的检索系统一次检索由以下两步组成:
1.根据关键词(token)检索包含token的文档Id根据文档ID检索到文档内容。
2.根据文档ID检索到文档内容
第一个步骤称为反向索引或倒排索引,即inverted index。
第二个步骤称为正向索引或正排索引,即forward index。

1.正排索引: 正排索引是指文档ID为key,表中记录每个关键词出现的次数,查找时扫描表中的每个文档中字的信息,直到找到所有包含查询关键字的文档。当我们要根据某个关键词查询文档时,只能扫描索引库中所有的文档,然后根据某种方式进行排序返回。在面对海量数据的时候,这种方式无疑效率很低。
**2.倒排索引:**倒排索引是实现“单词-文档矩阵”的一种具体存储形式,通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。倒排索引主要由两个部分组成:“单词词典”和“倒排文件”。
单词词典(Lexicon):搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。 倒排列表(PostingList):倒排列表记载了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个倒排项(Posting)。根据倒排列表,即可获知哪些文档包含某个单词。 倒排文件(Inverted File):所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件即被称之为倒排文件,倒排文件是存储倒排索引的物理文件。

创建索引并添加文档

1.代码

public static void index()throws Exception{
    
    
        //索引存放目录
        String indexPath = "/Users/pauljiang/Desktop/goodgoodstudy/spring-boot-demo/src/main/resources/lucene/index";

        // 创建使用的分词器
        Analyzer analyzer = new StandardAnalyzer();

        // 存放到文件系统中
        Directory dir = FSDirectory.open(Paths.get(indexPath));

        // 索引配置对象
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);

        // 设置索引库的打开模式:新建、追加、新建或追加
        indexWriterConfig.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);

        // 创建索引写对象
        IndexWriter indexWriter = new IndexWriter(dir,indexWriterConfig);

        // 创建document
        Document doc = new Document();

        // 往document中添加 商品id字段
        doc.add(new StoredField("prodId", "p0001"));

        // 往document中添加 商品名称字段
        doc.add(new TextField("name", "ThinkPad", Field.Store.YES));

        // 将文档添加到索引
        indexWriter.addDocument(doc);

        // 刷新,索引构建首先是攒在内存中的(比如对于倒排来讲,所有Field共享内存池),内存超过阈值会将当前索引刷盘成一个段(Segment)
        indexWriter.flush();

        // 提交
        indexWriter.commit();

        // 关闭 会提交
        indexWriter.close();
        dir.close();
    }

2.分析

上面是一段示例代码,总共分三个步骤:

  • step1.创建IndexWriter

创建IndexWriter需要传入Directory对象和IndexWriterConfig对象。Directory代表索引文件存放的位置,IndexWriterConfig代表索引的相关配置。

  • step2.创建Document

Document即文档,Document由Field构成。Lucene提供多种不同类型的Field,其FiledType决定了它所支持的索引模式,当然也支持自定义Field。

  • step3.把文档写入索引库

调用addDocument方法写入文档,同时根据FieldType创建不同的索引。文档写入完成后,因为此时文档还存在于内存的buffer中,还不可被搜索,最后需要调用IndexWriter的commit,在commit完后Lucene才保证文档被持久化并且是searchable的。

Directory

Directory类的继承关系
Directory是一个抽象类,是索引文件的集合。Directory的作用,就是负责管理这些索引文件,包括数据的写入和读取,以及索引的添加和删除等。

Lucene的索引体系,支持读共享,写独占的方式来访问索引目录,也就是说,它允许多个线程实例同时并发的读取,而不允许多个线程同时写入,大家可能会有疑问,为什么不支持多线程写入呢?这其实是因为索引目录有自己的某一时刻的内部状态,比如说文件指针,而多线程写入时,会造成指针混乱,从而引起索引结构损坏或某些数据丢失,所以lucene任何时候都禁止有多个线程并发的写入索引,即使是多线程写,每次也只能通过队列的方式,一次只允许一个线程操作索引,按这样的情况分析,多线程写入与单线程写入,在性能上的提升,并不是明显的,那么lucene又是怎么控制一次只能有一个线程写入呢,打开Directory的源码,我们就会发现,它其实是在内部维护了一个锁的实例,通过加锁方式,来禁止后来线程的写入操作,当然锁的作用不仅仅是防止并发写入,它还可以通过锁名字来判断,这两份索引是否为同一份索引,那么如果我们想使用多线程来提升写入速度,一个折中的办法就是,每个线程写一份目录,最后在对这些目录,进行合并。

几种Directory:

**RAMDirectory:**是把操作的索引放在内存中,优点是操作速度快,缺点是内存中不可保存,一旦关闭后索引目录便会丢失。将索引全部加载进内存的实现。但是需要注意,此类不适用于大型索引。超过几百兆字节的索引都会浪费资源(如:GC周期),因为它使用1024字节的内部缓冲区,会产生数百万个长度为1024的byte数组。 当然,此类针对小型内存驻留索引进行了优化。但是它在多线程环境中还存在一些并发问题。

**FSDirectory:**把操作的索引放在磁盘上,优点是文件可以被保存,缺点是速度慢。FSDirectory是一个抽象类,它有以下几个实现类: - RAFDirectory 使用java.io.randomaccessfile实现,并发性能较差。 - SimpleFSDirectory 直接根据一个文件夹地址来创建索引目录,最简单的FSDirectory子类,使用java.io.API将索引文件存入文件系统中,不能很好支持多线程操作。因为要做到这点就必须在内部加入锁,而java.io.并不支持按位置读取文件。

MMapDirectory
使用mmap(memory mapping)进行索引文件读取,使用FSDirectory.FSIndexOutput进行索引写入。是lucene官方推荐使用的大索引加载类。使用内存映射的I/O接口进行读操作,这样不需要采取锁机制,并能很好的支持多线程读操作。但由于内存映射的I/O所消耗的地址空间是与索引尺寸相等,所以建议最好只是用64位JRE。

NIOFSDirectory
使用java.nio的FileChannell实现索引文件位置读取,在没有同步操作的情况下,支持多线程从同一个索引文件中读取索引数据。

WindowsDirectory 对Microsoft Windows系统的支持
NativeUnixDirectory 对Unix系统的支持

从FSDirectory.open方法的实现可以看出,一般使用的是MMapDirectory和NIOFSDirectory,在windows平台上运行的时候使用SimpleFSDirectory。

public static FSDirectory open(Path path, LockFactory lockFactory) throws IOException {
    
    
    if (Constants.JRE_IS_64BIT && MMapDirectory.UNMAP_SUPPORTED) {
    
    
      return new MMapDirectory(path, lockFactory);
    } else if (Constants.WINDOWS) {
    
    
      return new SimpleFSDirectory(path, lockFactory);
    } else {
    
    
      return new NIOFSDirectory(path, lockFactory);
    }
  }

IndexWriterConfig

IndexWriterConfig内提供了一些供高级玩家做性能调优和功能定制的核心参数,我们列几个主要的看下:

**IndexDeletionPolicy:**Lucene开放对commit point的管理,通过对commit point的管理可以实现例如snapshot等功能。Lucene默认配置的DeletionPolicy,只会保留最新的一个commit point。
Similarity:搜索的核心是相关性,Similarity是相关性算法的抽象接口,Lucene默认实现了TF-IDF和BM25算法。相关性计算在数据写入和搜索时都会发生,数据写入时的相关性计算称为Index-time boosting,计算Normalizaiton并写入索引,搜索时的相关性计算称为query-time boosting。
**MergePolicy:**Lucene内部数据写入会产生很多Segment,查询时会对多个Segment查询并合并结果。所以Segment的数量一定程度上会影响查询的效率,所以需要对Segment进行合并,合并的过程就称为Merge,而何时触发Merge由MergePolicy决定。

MergeScheduler:当MergePolicy触发Merge后,执行Merge会由MergeScheduler来管理。Merge通常是比较耗CPU和IO的过程,MergeScheduler提供了对Merge过程定制管理的能力。

Codec:Codec可以说是Lucene中最核心的部分,定义了Lucene内部所有类型索引的Encoder和Decoder。Lucene在Config这一层将Codec配置化,主要目的是提供对不同版本数据的处理能力。对于Lucene用户来说,这一层的定制需求通常较少,能玩Codec的通常都是顶级玩家了。
**IndexerThreadPool:**管理IndexWriter内部索引线程(DocumentsWriterPerThread)池,这也是Lucene内部定制资源管理的一部分。
**FlushPolicy:**FlushPolicy决定了In-memory buffer何时被flush,默认的实现会根据RAM大小和文档个数来判断Flush的时机,FlushPolicy会在每次文档add/update/delete时调用判定。
**MaxBufferedDoc:**Lucene提供的默认FlushPolicy的实现FlushByRamOrCountsPolicy中允许DocumentsWriterPerThread使用的最大文档数上限,超过则触发Flush。
**RAMBufferSizeMB:**Lucene提供的默认FlushPolicy的实现FlushByRamOrCountsPolicy中允许DocumentsWriterPerThread使用的最大内存上限,超过则触发flush。

**RAMPerThreadHardLimitMB:**除了FlushPolicy能决定Flush外,Lucene还会有一个指标强制限制DocumentsWriterPerThread占用的内存大小,当超过阈值则强制flush。

**Analyzer:**即分词器,这个通常是定制化最多的,特别是针对不同的语言。

IndexWriter
indexWriter提供很简单的几种操作接口,这一章节会做一个简单的功能和用途解释,下一个章节会对其内部实现做一个详细的剖析。IndexWrite的提供的核心API如下:

**addDocument:**比较纯粹的一个API,就是向Lucene内新增一个文档。Lucene内部没有主键索引,所有新增文档都会被认为一个新的文档,分配一个独立的docId。addDocument函数的目的就是把Document添加到IndexWriter维护的内存索引中,然后就返回。但是addDocument也会做两个额外的操作:一是当添加的document达到一定的阈值(内存使用量或者document个数)的时候,将内存的索引flush到底层的Directory中,这个操作等同于flush函数;二是当发现底层的segment数目超过一定阈值的时候,把多个segment merge成少数个segment,这个操作等同于forceMerge操作。需要注意的是,这两个额外的操作之间没有因果关系。
**updateDocuments:**更新文档,但是和数据库的更新不太一样。数据库的更新是查询后更新,Lucene的更新是查询后删除再新增。流程是先delete by term,后add document。但是这个流程又和直接先调用delete后调用add效果不一样,只有update能够保证在Thread内部删除和新增保证原子性。
**deleteDocument:**删除文档,支持两种类型删除,by term和by query。在IndexWriter内部这两种删除的流程不太一样,在下一章节再细说。
**flush:**触发强制flush,将所有Thread的In-memory buffer flush成segment文件,这个动作可以清理内存,强制对数据做持久化。IndexWriter的flush操作就是在满足一定阈值的条件下,把保存在内存中的索引,刷到底层的Directory中,以释放IndexWriter的内存来构建新的索引。这里需要特别注意的是:flush操作只是执行内存索引到底层Directory中的移动,它并不保证这些索引数据真正到了磁盘中(有可能在OS的cache中),同时,这些被flush到Directory中的索引数据,其他的并发线程/进程并不能从这个Directory中查询到。flush操作有可能会触发merge操作,将多个segment合并成少数个segment。
prepareCommit/commit/rollback
commit后数据才可被搜索,commit是一个二阶段操作,prepareCommit是二阶段操作的第一个阶段,也可以通过调用commit一步完成,rollback提供了回滚到last commit的操作。commit操作会将所有内存的索引、flush的结果、forceMerge的结果刷到底层Directory并保证数据成功刷到磁盘上。只有当commit成功执行之后,Directory中的最新索引才能够被查询到。同时,commit成功后,内存空间可以被重新用来构建新的索引,merge占用的额外磁盘空间会被释放。它类似数据库事务中的commit行为。
maybeMerge/forceMerge:maybeMerge触发一次MergePolicy的判定,而forceMerge则触发一次强制merge。forceMerge操作的功能是将多个segment合并(merge)成少数个segment。forceMerge是一个很重的操作,而且它需要额外消耗磁盘空间,具体消耗磁盘空间的算法请参考:消耗磁盘空间算法
同样,forceMerge的操作结果,其他并发线程/进程并不能从这个Directory中查询到。
并发模型
IndexWriter提供的核心接口都是线程安全的,并且内部做了特殊的并发优化来优化多线程写入的性能。IndexWriter内部为每个线程都会单独开辟一个空间来写入,这块空间由DocumentsWriterPerThread来控制。整个多线程数据处理流程为:

  • 1.多线程并发调用IndexWriter的写接口,在IndexWriter内部具体请求会由DocumentsWriter来执行。DocumentsWriter内部在处理请求之前,会先根据当前执行操作的Thread来分配DocumentsWriterPerThread。
  • 2.每个线程在其独立的DocumentsWriterPerThread空间内部进行数据处理,包括分词、相关性计算、索引构建等。
  • 3.数据处理完毕后,在DocumentsWriter层面执行一些后续动作,例如触发FlushPolicy的判定等。

引入了DocumentsWriterPerThread(后续简称为DWPT)后,Lucene内部在处理数据时,整个处理步骤只需要对以上第一步和第三步进行加锁,第二步完全不用加锁,每个线程都在自己独立的空间内处理数据。而通常来说,第一步和第三步都是非常轻量级的,而第二步是对计算和内存资源消耗最大的。所以这样做之后,能够将加锁的时间大大缩短,提高并发的效率。每个DWPT内单独包含一个In-memory buffer,这个buffer最终会flush成不同的独立的segment文件。

这种方案下,对多线程并发写入性能有很大的提升。特别是针对纯新增文档的场景,所有数据写入都不会有冲突,所以非常适合这种空间隔离式的数据写入方式。但对于删除文档的场景,一次删除动作可能会涉及删除不同线程空间内的数据,这里Lucene也采取了一种特殊的交互方式来降低锁的开销,在剖析delete操作时会细说。

在搜索场景中,全量构建索引的阶段,基本是纯新增文档式的写入,而在后续增量索引阶段(特别是数据源是数据库时),会涉及大量的update和delete操作。从原理上来分析,一个最佳实践是包含相同唯一主键Term的文档分配相同的线程来处理,使数据更新发生在一个独立线程空间内,避免跨线程。

add & update
add接口用于新增文档,update接口用于更新文档。但Lucene的update和数据库的update不太一样。数据库的更新是查询后更新,Lucene的更新是查询后删除再新增,不支持更新文档内部分列。流程是先delete by term,后add document。 IndexWriter提供的add和update接口,都会映射到DocumentsWriter的udpate接口,看下接口定义:

long updateDocument(final Iterable<? extends IndexableField> doc, final Analyzer analyzer,
    final Term delTerm) throws IOException, AbortingException

这个函数内的处理流程是: - 1.根据Thread分配DWPT - 2.在DWPT内执行delete - 3.在DWPT内执行add

add操作会直接将文档写入DWPT内的In-memory buffer。

delete
delete相对add和update来说,是完全不同的一个数据路径。而且update和delete虽然内部都会执行数据删除,但这两者又是不同的数据路径。文档删除不会直接影响In-memory buffer内的数据,而是会有另外的方式来达到删除的目的。

在Delete路径上关键的数据结构就是Deletion queue,在IndexWriter内部会有一个全局的Deletion Queue,称为Global Deletion Queue,而在每个DWPT内部,还会有一个独立的Deletion Queue,称为Pending Updates。DWPT Pending Updates会与Global Deletion Queue进行双向同步,因为文档删除是全局范围的,不应该只发生在DWPT范围内。

Pending Updates内部会按发生顺序记录每个删除动作,并且标记该删除影响的文档范围,文档影响范围通过记录当前已写入的最大DocId(DocId Upto)来标记,即代表这个删除动作只删除小于等于该DocId的文档。

update接口和delete接口都可以进行文档删除,但是有一些差异: - update只能进行by term的文档删除,而delete除了by term,还支持by query。 - update的删除会先作用于DWPT内部,后作用于Global,再由Global同步到其他DWPT。 - delete的删除会作用在Global级别,后异步同步到DWPT级别。

update和delete流程上的差异也决定了他们行为上的一些差异,update的删除操作会先发生在DWPT内部,并且是和add同时发生,所以能够保证该DWPT内部的delete和add的原子性,即保证在add之前的所有符合条件的文档一定被删除。

DWPT Pending Updates里的删除操作什么时候会真正作用于数据呢?在Lucene Segment内部,数据实际上并不会被真正删除。Segment中有一个特殊的文件叫live docs,内部是一个位图的数据结构,记录了这个Segment内部哪些DocId是存活的,哪些DocId是被删除的。

所以删除的过程就是构建live docs标记位图的过程,数据实际上不会被真正删除,只是在live docs里会被标记删除。Term删除和Query删除会在不同阶段构建live docs,Term删除要求先根据Term查询出它关联的所有doc,所以很明显这个会发生在倒排索引构建时。

而Query删除要求执行一次完整的查询后才能拿到其对应的docId,所以会发生在segment被flush完成后,基于flush后的索引文件构建IndexReader后执行搜索才能完成。

还有一点要注意的是,live docs只影响倒排,所以在live docs里被标记删除的文档没有办法通过倒排索引检索出,但是还能够通过doc id查询到store fields。当然文档数据最终是会被真正物理删除,这个过程会发生在merge时。

flush
flush是将DWPT内In-memory buffer里的数据持久化到文件的过程,flush会在每次新增文档后由FlushPolicy判定自动触发,也可以通过IndexWriter的flush接口手动触发。

每个DWPT会flush成一个segment文件,flush完成后这个segment文件是不可被搜索的,只有在commit之后,所有commit之前flush的文件才可被搜索。

commit
commit时会触发数据的一次强制flush,commit完成后再此之前flush的数据才可被搜索。commit动作会触发生成一个commit point,commit point是一个文件。Commit point会由IndexDeletionPolicy管理,lucene默认配置的策略只会保留last commit point,当然lucene提供其他多种不同的策略供选择。

merge
merge是对segment文件合并的动作,合并的好处是能够提高查询的效率以及回收一些被删除的文档。Merge会在segment文件flush时触发MergePolicy来判定自动触发,也可通过IndexWriter进行一次force merge。

Document
文档,是索引的基本单元,数据库中对应一行,索引构建时会分配一个DocID

Filed
字段,一个Document会由一个或多个Field组成,数据库中可以对应一列,当然一列可以有多个field,每个Field有其对应的属性。比如 - 全文对应的TextField - 文本对应的StringField - 数值类型int对应的IntPoint,long对应的longPoint等等。且可以根据属性定制Field。较早版本中数值类型都是一种Field,而使用BKD-Tree之后,不同类型数值 分别对应一种Field,如IntPoint,对应为点的概念。

往document添加field,field有很多选项,是否分词是否添加存储等,根据实际情况选择。

Field.Store.*
Field类是文档索引期间很重要的类,控制着被索引的域值。Field.Store.* 域存储选项通过倒排序索引来控制文本是否可以搜索

Field.Store.YES//表示会把这个域中的内容完全存储到文件中,方便进行还原[对于主键,标题可以是这种方式存储]
Field.Store.NO//表示把这个域的内容不存储到文件中,但是可以被索引,此时内容无法完全还原

猜你喜欢

转载自blog.csdn.net/u011967767/article/details/113643682