欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Lucene学习笔记(二)--------构建索引

程序员文章站 2022-07-09 13:48:03
...

构建索引

对搜索内容建模

文档和域

文档是索引和搜索的原子单位,包含一个或多个域的容器,域则依次包含“真正的”被搜索内容。每个域都有一个标识名称(即一个文本值或二进制值)。将文档加入到索引中时,可以通过一系列选项控制Lucene的行为。在对原始数据进行索引操作时,先将数据转换成Lucene所能识别的文档和域,搜索过程中被搜索对象为阈值。

Lucene可以针对域进行3种操作:

  • 阈值可以被索引(或者不被索引),要搜索一个域就必须先进行索引。且被索引的域必须是文本格式的(二进制格式的域值只能被存储不能被索引),索引一个域时,首先需要使用分析过程将域值转换成语汇单元,然后将语汇单元加入到索引中

  • 域被索引后,还可以选择性的存储项向量,后者可以看作该域的一个小型反向索引集合,通过该向量能够检索该域的所有语汇单元,这个机制可以帮助实现一些高级功能,比如搜索与当前文档相似的文档

  • 域值可以被单独存储,即被分析前的域值备份也可以写进索引中

当搜索程序通过索引检索文档时,只有被存储的域才会被作为搜索结果展现。被索引但未被存储于文档的域是不会被作为搜索结果展示的

与数据库不同,Lucene没有一个确定的全局模式,即加入索引的每个文档都是独立的,与此前加入的文档完全没有关系:它可以包含任意的域,以及任意的索引、存储和项向量操作选项,不必包含与其他文档相同的域,甚至可以内容相同仅是相关操作选项有所区别

这种特性保证了可以递归访问文档并建立对应的索引,可以随时对文档进行索引,不必提前设计文档的数据结构表,若随后想向文档中添加域,可以完成添加后重新索引该文档或重建索引即可。

Lucene与数据库的第二个区别是,Lucene要求在进行索引操作时简单化或反向规格化原始数据。

反向规格化(Denormalization)是为了解决文档真实结构和Lucene表示能力之间的“不匹配”问题。

提取文本和创建文档

使用Lucene索引数据时,必须先从数据中提取纯文本格式信息,以便Lucene识别该文本并建立对应的Lucene文档。

分析文档

建立起Lucene文档和域,就可以调用IndexWriter对象的addDocument方法将数据传递给Lucene进行索引操作。索引操作时,首先分析文本,将文本数据分割成语汇单元串,然后对它们执行一些可选操作。
- LowerCaseFilter可以将词汇单元在索引前统一转换为小写,以使搜索不对大小写敏感
- StopFilter类从输入中去掉一些使用很频繁却没有实际意义的词(a、an、the、in、on、so on等)
- PorterStemFilter可以去掉英文词的词干

这些将原始数据转换为语汇单元,随后用一系列filter来修正该语汇单元的操作,一起构成了分析器。还可以通过链接Lucene的词汇单元和filter自定义分析器或通过其他方式自定义分析器

向索引添加文档

对输入数据分析完成,Lucene将分析结果以倒排索引(inverted index)的方式写入索引文件中存储。在进行关键字快速查找时,倒排索引能有效利用磁盘空间

Lucene使用倒排数据结构的原因是:把文档中提取出来的词汇单元作为查询关键字。

索引段
  • segment_N: —->Segment0, Segment1, Segment2, Segment3,….

Lucene索引都包含一个或多个段,每个段都是一个独立的索引,包含整个文档索引的一个子集。每当writer刷新缓冲区增加的文档,以及挂起目录删除操作时,索引文件都会建立一个新段。在搜索索引时,每个段都是单独访问的,但搜索结果是合并后返回的。

每个段都包含多个文件,文件格式为_X.,X代表段名称,是扩展名,用来标识该文件对应索引的某个部分。各个独立的文件共同组成了索引的不同部分(项向量、存储的域、倒排索引等)。

如果使用混合文件格式(Lucene的默认处理方式,但可以通过IndexWriter.setUseCompoundFile方法进行修改),上述索引文件会被压缩成一个单一的文件:_X.cfs。这种方式的好处是能在搜索期间减少打开的文件数量。

还有一个段文件,用段_标识,该文件指向所有**的段。Lucene会首先打开该文件,然后打开它所指向的其他文件。值被称为“the generation”,它是一个整数,Lucene每次向索引提交更改时都会将这个数加1.

久而久之,索引会积聚很多段,尤其当程序打开和关闭writer较为频繁时。IndexWriter类会周期性地选择一些段,然后将它们合并到一个新段中,然后删除老的段。被合并的段选取策略由一个独立的MergePolicy类主导。一旦选取好这些段,具体合并操作由MergeScheduler类实现。

基本索引操作

向索引添加文档

添加文档的方法有两个:

  • addDocument(Document)——使用默认分析器添加文档,在创建IndexWriter对象时指定分析器,用于词汇单元化操作

  • addDocument(Document,Analyzer)——使用指定的分析器添加文档和词汇单元化操作,但要注意分析器在搜索时能够匹配索引时生成的词汇单元才能正常工作

IndexWriter类初始化方法并不显式包含索引是否已创建的布尔值,它在初始化时会首先检查传入的Directory类是否已包含索引,如果索引存在,IndexWriter类则在该索引上追加内容,否则则向Directory类写入新创建的索引。

IndexWriter类有多个初始化方法。其中一些方法会显式包含创建索引的参数,这允许强制建立新的索引并覆盖原来的索引。

一旦建立起索引,就可以用for循环来初始化文档对象:首先创建一个新的Document空对象,然后根据需要向这个Documnet对象中逐个添加Field对象。每个文档都有4个域,每个域都有各自不同的选项。最后调用writer.addDocument方法来索引文档。

删除索引中的文档

IndexWriter类提供了各种方法从索引中删除文档:

  • deleteDocument(Term):删除包含项的所有文档
  • deleteDocument(Term[]):删除包含项数组任一元素的所有文档
  • deleteDocument(Query):删除匹配查询语句的所有文档
  • deleteDocument(Query[]):删除匹配查询语句数组任一元素的所有文档
  • deleteAll():删除索引中所有文档

如果需要通过Term类删除单个文档,需要确认在每个文档中都已索引过对应的Field类,还要确认所有域值都是唯一的。还可以对这个域进行任意命名(通常用ID命名),该域需要被索引成未被分析的域以保证分析器不会将它分解成语汇单元,然后利用该域来删除对应文档:

writer.deleteDocument(new Term("ID", documentID));

在所有情况下,删除操作不会马上执行,而是放入内存缓冲区,与加入文档的操作类似,Lucene会通过周期性刷新文档目录来执行该操作。不过即使删除操作已经完成,存储该文档的磁盘空间也不会马上释放,Lucene只是将该文档标记为“删除”。

  • writer.hasDeletions()方法用于检查索引中是否包含被标记为已删除的文档
  • IndexWriter.maxDoc()返回索引中被删除和未被删除的文档数
  • IndexWriter.numDocs()返回索引中未被删除的文档总数

更新索引中的文档

Lucene做不到只更新文档中的部分域,只能删除整个旧文档,然后向索引中添加新文档。这要求新文档必须包含旧文档中所有域,包括内容未发生改变的域。IndexWriter提供了两个方法更新索引中的文档

  • updateDocument(Term, Document)首先删除包含Term变量的所有文档,然后使用writer的默认分析器添加新文档
  • updateDocument(Term, Document, Analyzer)功能与上述一致,区别在于可以指定分析器添加文档

这两个方法是通过调用deleteDocument(Term)和addDocument两个方法合并实现的。

writer.updateDocument(new Term("ID", documentId), newDocument);

域选项

当创建好一个域时,可以指定多个域选项来控制Lucene在将文档添加进索引后针对该域的行为。域选项分为三类:索引选项、存储选项、项向量使用选项。

域索引选项(Field.Index.*)

通过倒排索引来控制域文本是否可被搜索

  • Index.ANALYZED:使用分析器将域值分解成独立的词汇单元流,并使每个词汇单元能被搜索。该选项适用于普通文本域(如正文、标题、摘要等)

  • Index.NOT_ANALYZED:对域进行索引,但不对String值进行分析。该操作实际上将域值作为单一词汇单元并使之能被搜索。该选项适用于索引那些不能被分解的域值,如URL、文件路径、日期、人名、社保号码和电话号码等。该选项尤其适用于“精确匹配”搜索。

  • Index.ANALYZED_NO_NORMS:这是Index.ANALYZED的一个变体,它不会在索引中存储norms信息。norms记录了索引中的index-time boost信息,但是当进行搜索时可能会比较耗费内存。

  • Index.NOT_ANALYZED_NO_NORMS:不存储norms信息。该选项用于在搜索期间节省索引空间和减少内存耗费,因为single-token域并不需要norms信息,除非它们已经被进行加权操作

  • Index.NO:使对应的域值不被搜索

当Lucene建立起倒排索引后,默认情况下会保存所有必要信息以实施Vector Space Model。该Model需要计算文档中出现的term数,以及它们出现的位置(通过词组搜索时用到)
但有些时候这些域只是在布尔搜索时用到,并不为相关评分做贡献,例如,域只是被用作过滤,如权限过滤和日期过滤。这时,可以通过调用Field.setOmitTermFreqAndPositions(true)方法让Lucene跳过对该项的出现频率和出现位置的索引。该方法可以节省一些索引在磁盘上的存储空间,还可以加速搜索和过滤过程,但会悄悄地阻止需要位置的搜索,如PhraseQuery和SpanQuery类的运行

域存储选项

域存储选项(Field.Store.*)用来确定是否需要存储域的真实值,以便后续搜索时能恢复这个值。

  • Stroe.YES——存储域值,此时,原始的字符串值全部被保存在索引中,并可以由IndexReder类恢复。该选项对需要展示搜索结果的一些域很有用(如URL、标题或数据库主键)。不要存储太大的值,会消耗掉索引的存储空间。
  • Store.NO——不存储域值。该选项通常跟Index.ANALYZED选项共同用来索引大的文本域值,通常这些域值不用恢复为初始格式,如Web页面的正文,或其他类型的文本文档

可以使用CompressionTools在存储域值之前对它进行压缩,该类提供静态方法压缩和解压字节数组。该类运行时会在后台调用Java内置的java.util.Zip类。该方法可以为索引节省一些空间,但节省幅度有限,且会降低索引和搜索速度。

域的项向量选项

项向量是介于索引域和存储域的一个中间结构

索引完文档,如果希望在搜索期间该文档所有的唯一项能完全从文档域中检索,可以在存储的域中加快高亮显示匹配的词汇单元,还可以使用链接“找到类似的文档”,当运行一个新的点击搜索时,使用原始文档中突出的项。其他解决方法是对文档进行自动分类。

Reader、TokenStream和byte[]域值

Field对象还有几个其他初始化方法,允许传入除String以外的其他参数。
- Field(String name, Reader value, TermVector termVector)方法使用Reader而不是String对象来表示域值。这时是不能存储域值的(域存储项被硬编码成Store.NO),并且该域会一直用于分析和索引(Index.ANALYZED)。

  • Field(String name, Reader value),与前述方法类似,使用Reader而不是String对象来表示域值,但使用该方法时,默认的termVector为TermVector.NO

  • Field(String name, TokenStream tokenStream, TermVector termVector):允许程序对域值进行预分析并生成TokenStream对象,这个域不会被存储并将一直用于分析和索引

  • Field(String name, TokenStream tokenStream):允许程序对域值进行预分析并生成TokenStream对象,但使用该方法时默认的termVector为TermVector.NO

  • Field(String name, byte[] value, Store store):用来存储二进制域,例如不用参与索引的域(Index.NO)和没有项向量的域(TermVector.NO)。其中store参数必须设置为Store.YES

  • Field(String name, byte[] value, int offset, int length, Store store):也能对二进制域进行索引,与前一个的区别在于该方法允许对这个二进制的部分片段进行引用,该片段的起始位置可以用offset参数表示,处理长度可以用参数length对应的字节数来表示

域排序选项

Lucene返回匹配搜索条件的文档时,一般是按照默认评分对文档进行排序的。如果域是数值类型的,在将它加入文档进行排序时,要用NumericField类来表示。如果域是文本类型的,如邮件发送者姓名,得用Field类表示它和索引它,并且要用Field.Index.NOT_ANALYZED选项避免对它进行分析。如果域未进行加权操作,那么索引时就不能带有norm选项,使用Field.Index.NOT_ANALYZED_NO_NORMS,可以节省磁盘空间和内存空间

new Field("auth", "Arthur C.Clark", Field.Store.YES,Field.Index.NOT_ANALYZED_NO_NORMS);

用于排序的域必须进行索引,而且每个对应文档必须包含一个词汇单元,通常这意味着使用Field.Index.NOT_ANALYZED或者Field.Index.NOT_ANALYZED_NO_NORMS(如果没对文档或域进行加权的话)选项,但若分析器只生成一个词汇单元,比如KeywordAnalyzer、Field.Index.ANALYZED或Field.Index.ANALYZED_NO_NORMS选项也可以使用

多值域

对于存在多个值的域,可以向这个域中写入几个不同的值:

Document doc = new Document();
for(String author : authors){
    doc.add(new Field("author", author, Field.Store.YES, Field.Index.ANALYZED));
}

在程序内部,只要文档中出现同名的多值域,倒排索引和项向量都会在逻辑上将这些域的词汇单元附加进去,具体顺序由添加该域的顺序决定。然而,与索引操作不同,它们在文档中的存储顺序是分离的,因此搜索期间对文档进行检索时,会发现有多个Field实例

对文档和域进行加权操作

加权操作可以在索引期间完成也可以在搜索期间完成。

文档加权操作

通过修改文档的加权因子,就能指示Lucene在计算相关性时或多或少考虑到该文档针对索引中其他文档的重要程度。

调用加权操作的API只包含一个方法:setBoost(float):

Document doc = new Document();
String senderEmail = getSenderEmail();
String senderName = getSenderName();
String subject = getSubject();
String body = getBody();
doc.add(new Field("senderEmail", senderEmail, Field.Store.YES, Field.Index.NOT_ANALYZED));
doc.add(new Field("senderName", senderName, Field.Store.YES, Field.Index.ANALYZED));
doc.add(new Field("body", body, Field.Store.NO, Field.Index.ANALYZED));
String lowerDomain = getSenderDomain().toLowerCase();
if(isImportant(lowerDomain)){
    doc.setBoost(1.5F);
}else if(isUnimportant(lowerDomain)){
    doc.setBoost(0.1F);
}
writer.addDocument(doc);
域加权操作

匹配搜索时,如何才能让主题域比senderName域更重要呢?
可以使用Field类的setBoost(float)方法:

Field subjectField = new Field("subject", subject, Field.Store.YES, Field.Index.ANALYZED);
subjectField.setBoost(1.2F);

当你改变一个域或者一个文档的加权因子时,必须完全删除并创建对应的文档,或者使用updateDocument方法达到同样效果。

较短的域有一个隐含的加权,这取决于Lucene的评分算法具体实现。当进行索引操作时,IndexWriter对象会调用Similarity.lengthNorm方法来实现该算法。也可以用自定义逻辑覆盖它,具体可以实现自己的Similarity类并且告诉IndexWriter类通过调用自己的setSimilarity类来覆盖。

加权基准(Norms)

索引期间,文档中域的所有加权都被合并成一个单一的浮点数。除了域,文档也有自己的加权值,Lucene会基于域的词汇单元数量自动计算出这些加权值(更短的域有更高的加权),将加权值合并,并编码(量化)成一个单一的字节值,作为域或文档信息的一部分存储起来。搜索期间,被搜索域的norms都被加载到内存,并被解码还原为浮点数,然后用于计算相关性得分(relevance score)

虽然norms是在索引期间首次进行计算的,后续还是可以使用IndexReader的setNorms方法(要求程序验算自身norms因子,用于动态计算加权因子的强大方法,如文档更新或用点击表示受欢迎程度等)对它进行修改的。

为了防止norms在搜索期间占用大量内存,可以关闭norms相关操作,方法是使用Field.Index中的NO_NORMS索引选项,或是对包含该域的文档进行索引前调用Field.setOmitNorms(true)。因为norms的全部数组需要加载到RAM时,需要对被搜索文档的每个域都分配一个字节空间。如果是文档中包含多个域的大索引,这个加载操作会很快占用大量RAM空间。

Lucene并不对norms进行松散存储,如果索引过程中有一个包含了norms选项,随后的段合并中所有文档都会有至少一个字节的norms空间。

索引数字、日期和时间

索引数字

当lucene索引数字时,会在索引中建立一个复杂数据结构(Rich Data Structure)。

索引数字

索引数字分两种情况,一种是数字内嵌在将要索引的文本中,要想保留这些数字,并将它们作为单独的词汇单元处理,这样就可以在随后的搜索过程中用到,实现这样的索引,需要选择一个不丢弃数字的分析器。WhitespaceAnalyzer和StandardAnalyzed两个类均可以做到。SimapleAnalyzed和StopAnalyzer两个类会将词汇单元流中的数字剔除。

另一种场景是域中只包含数字,希望可以作为数字域值来索引,并能在搜索和排序中对它们进行精确(相等)匹配。创建一个NumericField对象,使用其中一个setValue方法(该方法支持的数字类型有int、long、float和double,然后返回自身)记录数值,然后将NumericField类加入到文档:

doc.add(new NumericField("price").setDoubleValue(19.99));

每个数值都用trie structure进行索引,逻辑上为越来越大的预定义括号数分配了一个单一的数值。针对每个括号都在索引中分配了一个唯一的项,因此能够很快地在所有文档中检索这个单一的括号。搜索期间,搜索请求的范围被转换成等效的括号并集,这样就能实现高效的范围搜索或过滤功能。

每个NumericField实例只接受单一数值,但还是可以向文档中添加多个带有相同域名的实例。最后生成的NumericRangeQuery和NumericRangeFilter实例会将所有值用逻辑“or”连接起来。但这会对排序造成不确定影响。如果需要针对一个域进行排序,那就必须对只出现一次该域的各个NumericField进行索引。

NumericField类还能处理日期和时间,方法是将它们转换成等效的int型或long型。

索引日期和时间

首先将日期和时间转换成相等的int或long型值,然后将这些值作为数字进行索引。具体可以使用Date.getTime获取精确到毫秒的数字值:

doc.add(new NumericField("timestamp").setLongValue(new Date().getTime()));

如果并不需要精确到毫秒的日期,可以直接用除法向下量化到秒、分、小时或天。

doc.add(new NumericField("day").setIntValue(new Date().getTime()/24/3600));

如果要进一步量化到年月,或需要索引一天中的小时或一周中的日期,可以创建一个Calendar实例,并从中获取相关值:

Calendar cal = Calendar.getInstance();
cal.setTime(date);
doc.add(new NumericField("dayofMonth").setIntValue(cal.get(Calendar.DAY_OF_MONTH)));

域截取(Field truncation)

你可能只想对每个文档的前面200个单词进行索引。
为了支持这些不同的索引需求,IndexWriter允许对域进行截取后再索引它们,被分析的域只有前面N个项会被编入索引。实例化IndexWriter后,必须向其传入MaxFieldLength实例向程序传递具体的截取数量。MaxFieldLength类提供两个易用的默认实例:MaxFieldLength.UNLIMITED(不采取截取策略)和MaxFieldLength.LIMITED(截取域中前1000个项),实例化MaxFieldLength时还可以设置所需的截取数。

建立IndexWriter之后,可以调用setMaxFieldLength方法在任意时刻调整截取限制,getMaxFieldLength可以检索当前的截取限制。

如果文档中包含具有相同域名的多个域实例,那么截取操作会在所有同名域中全部生效。

近实时搜索(Near-real-time search)

Lucene通过调用IndexWriter中的对应方法:

IndexReader getReader();

该方法能实时刷新缓冲区中新增或删除的文档,然后创建新的包含这些文档的只读型IndexReader实例

优化索引

当索引文档尤其索引多个文档或使用IndexWriter类的多个session索引文档时,总会建立一个包含多个独立段的索引。这样搜索索引时,Lucene必须分别搜索每个段,然后合并各段的搜索结果。

对于处理大量索引的程序来说,优化索引能够提高搜索效率,优化索引就是将索引的多个段合并成一个或者少量段。同时优化后的索引还可以在搜索期间少使用一些文件描述符。

优化索引只能提高搜索速度,而不是索引速度。

IndexWriter提供了4个优化方法:
- optimize()将索引压缩至一个段,操作完成再返回

  • optimize(int manNumSegment)也称作部分优化(Partial Optimize),将索引压缩为最多maxNumSegment个段。由于将多个段合并到一个段的开销最大,建议优化至5个段,它能比优化至一个段更快完成

  • optimize(boolean doWait)doWait参数传入false值,调用会立即执行,但是合并工作是在后台运行的。doWait=false只适用于后台线程调用合并程序,如默认的ConcurrentMergeScheduler

-optimize(int maxNumSegments,boolean doWait)部分优化,doWait=false时在后台运行

索引优化会消耗大量的CPU和I/O资源,Lucene将多个段进行合并,合并操作期间,磁盘临时空间会被用于保存新段对应的文件。但在合并完成并通过调用IndexWriter.commit或关闭IndexWriter进行提交之前,旧段并不能被删除。即必须为程序预留大约3倍于优化用量的临时磁盘空间。完成优化操作并调用commit()方法后,磁盘用量会降低到较低水平。且索引中任何打开的reader都会潜在影响磁盘空间。

Directory子类

当Lucene需要对索引中的文件进行读写操作时,它会调用Directory子类的对应方法。

Lucene的几个核心Directory子类(父类FSDirectory):
- SimpleFSDirectory:使用java.io.* API将文件存入文件系统,不能很好的支持多线程
- NIOFSDirectory:使用java.io.*将文件保存至文件系统,能支持windows之外的多线程
- MMapDirectory:使用内存映射I/O进行文件访问,不需要使用锁机制就能很好的支持多线程读操作。但是由于java并没有提供方法“取消”文件在内存中的映射关系,这意味着只有在JVM进行垃圾回收时才会关闭文件和释放内存空间,这样索引文件就会占用大量地址空间。
- RAMDirectory:将所有文件都存入RAM
- FileSwitchDirectory:使用两个文件目录,根据文件扩展名在两个目录之间切换使用

使用静态的FSDirectory.open方法,会根据当前的操作系统和平台选择合适的默认FSDirectory子类,具体选择算法会随着Lucene版本更新而改进。

并发、线程安全及锁机制

Lucene的并发处理规则:
- 任意数量的只读属性的IndexReader类都可以同时打开一个索引,无论这些Reader是否属于同一个JVM,以及是否属于同一台计算机都无关紧要。在单个JVM内,最好是用多线程共享单个的IndexReader实例,例如,多个线程并行搜索同一个索引

  • 对于一个索引一次只能打开一个Writer。Lucene采用文件锁来提供保障。一旦建立起IndexWriter对象,系统即会分配一个锁给它,该锁只有当IndexWriter对象被关闭时才会释放。

  • IndexReader对象甚至可以在IndexWriter对象正在修改索引时打开。每个IndexReader对象将向索引展示自己被打开的时间点,该对象只有在IndexWriter对象提交修改或自己被重新打开后才能获知索引的修改情况。在已经有IndexReader对象被打开的情况下,打开新IndexReader时采用参数create=true:这样,新的IndexReader会持续检查索引的情况。

  • 任意多个线程都可以共享同一个IndexReader类或IndexWriter类,这些类不仅是线程安全的,而且时线程友好的,即它们能够很好的扩展到新增线程

为了实现单一的writer,即一个用于删除或修改norms的IndexWriter类或IndexReader类,Lucene采用了基于文件的锁:若锁文件(默认为writer.lock)存在于索引目录内,writer会马上打开该索引。若企图对同一索引创建其他writer的话,将产生一个LockObtainFailException异常。

Lucene允许修改锁实现方法,可以通过调用Directory.setLockFactory将任何LockFactory的子类设置为自定义的锁实现。在完成该操作后才能在Directory实例中打开IndexWriter类。

  • IndexWriter类的isLocked(Directory)————该方法会返回参数目录所指定的索引是否已被锁住,在程序试图创建一个新的IndexWriter对象前可以通过该方法检测索引是否已被锁住

  • IndexWriter类的unlock(Directory)————该方法能够在任意时刻对任意的Lucene索引进行解锁,

高级索引

用IndexReader删除文档
  • IndexReader能够根据文档号删除文档。IndexWriter可能因为段合并而改变文档号

  • IndexReader可以通过Term对象删除文档并返回被删除的文档号,IndexWriter可以通过Term删除文档,但不能返回文档号

  • 如果程序使用相同的reader进行搜索的话,IndexReader的删除操作会即时生效。

  • IndexWriter通过Query对象执行删除操作,但IndexReader则不行

  • IndexReader的undeleteAll方法能反向操作索引中所有被挂起的删除,但只能对还未进行段合并的文档进行反删除操作。该方法之所以能实现反删除,是因为IndexWriter只是将被删除文档标记为删除状态,但事实上并未真正移除这些文档,最终删除操作是在该文档对应的段进行合并时才执行。

Lucene只允许一个“writer”打开一次,且实施删除操作的IndexReader只能算作一个writer,即使用IndexReader进行删除操作之前必须关闭已打开的任何IndexWriter。

IndexWriter批量执行添加和删除可以获得更好的性能。

回收被删除文档所使用过的磁盘空间

Lucene使用bit数组的形式标识被删除的文档,该操作速度很快,但对应的文档数据仍会占用磁盘空间。只有在发生段合并操作时这些磁盘空间才能被回收(可以通过正常的合并操作也可以通过显示调用optimize方法进行)

还可以通过显式调用expungeDeletes方法来回收被删除文档所占用的磁盘空间。该调用会对被挂起的删除操作相关的所有段进行合并。

缓冲和刷新

为了降低磁盘I/O操作,当一个新文档被添加至Lucene索引时,或者挂起一个删除操作时,这些操作首先被缓存至内存,而不是立即在磁盘中进行。

IndexWriter触发刷新操作的标准:
- 当缓存所占用的空间超过预设的RAM比例时进行实施刷新,预设方法为setRAMBufferSizeMB。RAM缓存的尺寸不能被视为最大内存用量。IndexWriter并不占用所有的RAM使用空间,如段合并操作所占用的内存空间。
- 在指定文档号所对应的文档被添加进索引之后通过调用setMaxBufferedDocs完成刷新操作
- 在删除项和查询语句等操作所占用的缓存总量超过预设值时可以通过调用setMaxBufferedDeleteTerm方法来触发刷新操作

这几个触发器只要其中之一被触发都会启动刷新操作,与触发事件的顺序没有关系。
常量IndexWriter.DISABLE_AUTO_FLUSH可以传递给以上任一方法,用以阻止发生刷新操作。默认情况下,IndexWriter只在RAM用量为16MB时启动刷新操作。

当发生刷新操作时,Writer会在Directory目录创建新的段和被删除文件。但是,这些文件对于新打开的IndexReader既不可见也不可用,直到Writer向索引提交更改以及重新打开reader之后

刷新操作是用来释放被缓存的更改的。而提交操作是用来让所有的更改(被缓存的更改或已经刷新的更改)在索引中保持可见。即IndexReader看到的一直是索引的起始状态(IndexWriter被打开时的索引状态),直到Writer提交更改为止。

索引提交

IndexWriter的commit方法有两个:commit()创建一个新的索引提交,commit(Map

两阶段提交(TWO-PHRASE COMMIT)

对于需要提交包括Lucene索引和其他外部资源(如数据库)等事务的应用程序来说,Lucene提供了prepareCommit()方法和prepareCommit(Map

索引删除策略

IndexDeletionPolicy类负责通知IndexWriter何时能够安全删除旧的提交。默认策略是KeepOnlyLastCommitDeletionPolicy,即在每次创建完新的提交后删除先前的提交。

但在有些场景下,例如,通过NFS共享索引时,就需要自定义删除策略,只有当所有使用索引的reader都切换到最近的提交时才会删除此前的删除。

无论选择什么时候保留提交,都会不可避免地占用索引中额外的磁盘空间。

管理多个索引提交

通常,Lucene索引只有一个当前提交,即最近的提交。自定义提交可以实现在索引中聚集多个提交。可以使用静态的IndexReader.listCommits()方法检索索引中当前所有的提交。

ACID事务和索引连续性

Lucene实现了ACID事务模型,其限制是一次只能打开一个事务(writer)。
- Atomic(原子性)——所有针对writer的变更要么全部提交至索引,要么全都不提交;没有中间状态

  • Consistency(一致性)——索引必须是连续的;

  • Isolation(隔离性)——当使用IndexWriter进行索引变更时,只有进行后续提交时,新打开的IndexReader才能看到上一次提交的索引变化。即使新打开IndexWriter时传入参数create=true也是如此。IndexReader只能看到上一次成功提交所带来的索引变化。

  • Durability(持久性)——如果应用程序遇到无法处理的异常,如JVM崩溃、操作系统崩溃或计算机突然掉电,那么索引会保持连续性,并会保留上次成功提交的所有变更内容。此后的变更则会丢失。但硬盘错误、RAM或CPU错误会导致索引毁坏

索引段合并

合并索引段的好处:
- 该操作会减少索引中的段数量,能加快搜索速度,因为被搜索的段数量变小了,还能使搜索程序避免达到由操作系统限制的文件描述符使用上限
- 该操作会减小索引尺寸。比如释放文档删除标识所占用的数据位,即使没有挂起的删除操作,单一的合并段通常会占用更小的存储空间。

MergePolicy决定什么时候合并以及合并哪些段,而真正的合并操作由MergeScheduler完成。

段合并策略

IndexWriter依赖于抽象基类MergePolicy的子类决定何时进行段合并。当程序对变更操作进行刷新时,或者上一个合并操作已经完成时,程序将询问MergePolicy以确定当前是否需要进行新的合并操作,若是,MergePolicy会精确提供将被合并的段。除了选择一般的合并段,MergePolicy还会选择索引中需要进行优化的段,然后运行expungeDelete方法

Lucene提供了两个核心的合并策略,都是LogMergePolicy的子类,

  • LogByteSizeMergePolicy,默认由IndexWriter使用,该策略通过测量段尺寸,具体为该段包含的所有文件总字节数。

  • LogDocMergePolicy,同样是测量段尺寸,用段中文档数量表示尺寸。

也可以自定义合并策略,如基于时间或是尽量找到带有很多删除操作的段。

对于每个段来说,是否进行合并取决于公式:

(int)log(max(minMergeMB, size)) / log(mergeFactor)

这样可以将段按照尺寸级别分组。尺寸小于minMergeMB的小段通常会被强制转换成更低级别的段,避免索引中出现太多小段。

每个级别包含的段尺寸都为前一个级别段的mergeFactor倍。当使用LogDocMergePolicy策略时,段尺寸由段包含的文档数表示。

mergeFactor值不但要控制如何将段按照尺寸分配给各个级别以用于出发合并操作,还要控制一次合并的段数量。对于索引中指定数量的文档来说,mergeFactor越大,索引中会存在更多的段,合并频率越低,该值设置的越大,通常会获得更高的索引吞吐量,同时也可能会导致太多打开的文件描述符。

最好的办法是使用默认的值10,要避免大段的合并,可以通过maxMergeMB或maxMergeDocs进行设置。如果某个段的字节尺寸超过maxMergeMB,或段内文档数超过了maxMergeDocs,该段将永不被合并。

除了选择合并策略以维持索引正常运行状态以外,MergePolicy还要在程序调用optimize或expungeDeletes时选择将要被合并的段,

MergeScheduler子类完成合并工作

默认情况下,IndexWriter使用concurrentMergeScheduler利用后台线程完成段的合并,还有一个SerialMergeScheduler可以由调用它的线程完成段合并

IndexWriter.setInfoStream可以获取刷新和合并的相关信息

IndexWriter.waitForMerges方法要等待所有的段合并操作完成再进行下一步操作。