Lucene实战-第2章-构建索引

本章要点

  • 执行基本索引操作
  • 在索引过程中对文档和域进行加权操作
  • 对日期、数字和可排序域进行索引
  • 高级索引技术

如果想要搜索存储在硬盘上的文件、电子邮件、网页或是数据库中的数据,Lucene都可帮你完成。但在进行搜索前,你必须对搜索内容进行索引,Lucene同样能帮你完成,这就是本章要讨论的内容。

在第1章我们演示了一个索引示例。本章将更深入讲解索引更新操作,如何通过参数来调整索引过程,以及更高级的索引技巧,从而帮助你更好理解Lucene。本章还将涉及Lucene索引的结构,使用多线程和多进程访问Lucene索引时要关注的重点内容,Lucene索引API的事务语义,通过远程文件系统共享索引,以及防止并发修改索引的锁机制等内容。

在进入大量细节内容之前,别忘了概况:索引操作仅仅是整个搜索程序的一个简单的步骤而已。重要的是搜索程序带给用户的搜索体验;索引操作“只不过”是为了增强用户搜索体验而需要跨越的一道障碍而已。因此,尽管本章有很多有趣的索引操作细节,你最好还是将主要精力花在如何提升用户搜索体验上。对于几乎所有搜索程序来说,搜索功能都比索引操作细节重要得多。话虽这么说,在实现搜索功能时还是要依赖于索引操作期间的相关重要步骤的,正如本章所述。

注意:本章内容非常长。这个长度是必要的,因为Lucene公开了大量索引操作细节。好消息是大多数搜索程序都不比采用Lucene的高级索引选项。事实上,2.1小节、2.2小节、和2.3小节所介绍的内容才是这些搜索程序所需要的。如果你对索引比较感兴趣,同时又不想丢掉其中任何一部分内容,或者你的搜索程序需要使用所有的其他功能,那么可以研读本章余下内容。

下面我们从Lucene有关搜索内容的概念模型开始介绍

2.1 Lucene如何对搜索内容进行建模

我们首先阐述有关内容建模的方法概念。我们从Lucene有关索引和搜索、文档和域的基本单元开始,然后将重点转移到Lucene与当代数据库更为结构化的数据模型之间的区别。

2.1.1 文档和域

文档是Lucene索引和搜索的原子单位。文档为包含一个或多个域的容器,而域则依次包含【真正的】被搜索内容。每个域都有一个标识名称,该名称为一个文本值或二进制值。当你将文档加入到索引中时,可以通过一系列选项来控制Lucene的行为。在对原始数据进行索引操作时,你得首先将数据转换成Lucene所能识别的文档和域。在随手的搜索过程中,被搜索对象则为值域;例如,用户在输入搜索内容“title:lucene”时,搜索结果则为标题值域包含单词【lucene】的所有文档。

进一步的,Lucene可以针对域进行3种操作。

  • 值域可以被索引(或者不被索引)。如果需要搜索一个域,则必须首先对它进行索引。被索引的值域必须是文本格式的(二进制格式的值域只能被存储而不能被索引)。在索引一个域时,需要首先使用分析过程将域值转换成语汇单元,然后将语汇单元加入到索引中。有关索引域值的具体操作选项可以参考2.4.1小结
  • 域被索引后,还可以选择性的存储项向量,后者可以看做该域的一个小型反向索引集合,通过该向量可以检索该域的所有语汇单元。这个机制有助于实现一些高级功能,比如搜索与当前文档相似的文档(更多高级共轭能详见5.7小节)。有关控制索引项向量的具体选项请参考2.4.3小节。
  • 域值可以被单独存储,即是说备份洗钱的域值备份也可以写进索引中,以便后续的检索。这个机制可以使你将原始域值展现给用户,比如文档的标题或摘要。域值的存储选项请参考2.4.2小节。

如何将包含各类信息的原始数据转换成Lucene文档和域呢?这一版需要将搜索程序设计成递归处理方式来完成。Lucene并不知道搜索程序使用哪些域,以及对应的域名称等。文档一般包含多个域,比如标题、作者、日期、只要、正文、URL和关键词等。有时还需要使用杂项域,即包含所有文本的一个独立域以供搜索。一旦建好文档,并将它加入到索引后,就可以在随后的搜索过程中检索哪些匹配查询条件的文档,并将读取到的文档对应域值作为搜索结果展现给用户。

人们通常将Lucene与数据库进行比较,因为二者都会存储数据内容并提供内容检索功能。但两者之间有着重大差别,首先是灵活架构的差别

当搜索程序从通过索引检索文档时,只有被存储的域才会被作为搜索结果展现。例如,被索引但未被存储于文档的域是不会被作为搜索结果展现的。这种机制通常会使得搜索结果具有不确定性。

2.1.2 灵活的架构

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

Lucene的这种特性非常实用:这使你能够递归访问文档并建立对应的索引。你可以随时对文档进行索引,而不必提前设计文档的数据结构表。如果随后你想向文档中添加域,那么可以完成添加后重新索引该文档或重建索引即可。

Lucene的灵活架构还意味着单一的索引可以包含不同实体的多个文档,例如,用一个文档的诸如名称和价格等域来表示零售产品,而用另一个文档的诸如姓名、年龄和性别等域来表示人,另外还可以使用一个不可达的【中间态】文档,该文档只包含有关索引或搜索程序的一些中间数据(比如最近一次更新索引的时间,或者被索引的产品目录),同时该【中间态】文档内容不在搜索结果中出现。

Lucene和数据库之间的第二个主要区别是,Lucene要求你在进行索引操作时简单化或者返现规格化原始数据。

2.1.3 反向规格化(Denormalization)

我们面临的一个挑战是解决文档真是结构和Lucene表示能力之间的“不匹配”问题。举例来说,XML文档通过嵌套标记来表示一个递归的文档结构,数据库可能有任意数量的连接点,表之间可以通过主键和次键相互关联起来。微软的Object Linking & Embedding文档可以指向其他嵌入类文档。然而Lucene文档却都是单一文档,因此在创建对应的Lucene文档之前,必须对上述递归文档结构和连接点进行反向规格化操作。建立在Lucene基础之上的开源项目。如Hibernate Search、Compass、LuSQL、DBSight、Browse Engine和Oracle/Lucene integration等,都有各自不同而有趣的方法来解决反向规格化问题。

至此你已经在概念层面上了解了Lucene的文档模型,下面我们将深入阐述Lucene索引步骤。

2.2 理解索引过程

正如第1章所述,索引一个文件只需要调用Lucene公用API的几个方法即可完成。结果是,从表面上看来,用Lucene进行索引操作是一个简单而独立的操作。其实隐藏在这些简单API背后的却是一套巧妙而相对复杂的操作。这些操作从功能上主要分为3个部分,如图2.1所示,下面几节我们将对此进行详述。

图2.1 Lucene索引过程分为3个主要操作步骤:将原始文档转换成文本、分析文本、将分析好的文本保存至索引中
在索引操作期间,文本首先从原始数据中提取出来,并用于创建对应的Document实例,该实例包含多个Field实例,他们都用来保存原始数据信息。随后的分析过程将域文本处理成大量语汇单元。最后将语汇单元加入到段结构中。下面我们从文本提取开始阐述。

2.2.1 提取文本和创建文档

使用Lucene索引数据时,必须先从数据中提取纯文本格式信息,一遍Lucene识别该文本并建立对应的Lucene文档。在第一章我们将搜索和索引的示例文本限制为.txt格式文本,这使得我们能轻易地拆分这些文本内容,并用之建立起Field实例。但事实上文本格式并不都这么简单,图1.4有关“建立文档”步骤其实包含了很多隐藏内容。

假设你需要对一堆PDF格式的手册进行索引,你必须首先想法从这些PDF文件中提取文本格式信息,并用这些文本信息来创建Lucene文档和域。而Java中并没有对应的方法来处理PDF格式文件,该问题同样存在与Microsoft Word文件或者其他非纯文本格式文件中。即使再处理XML或HTML等纯文本格式文件时,也需要灵活考虑到底索引哪些内容,你得索引他们所表达的真正文本内容,而不是XML元素或HTML标签等无意义的文本。

有关提取文本信息的细节将在第7章结合Tika框架详述,使用该框架能使你很轻易地从各种格式的文件中提取文本信息。一旦提取出预想的文本信息并建立起对应的、包含各个域的文档后,下一步就是对这些文本信息进行分析了。

2.2.2 分析文档

一旦建立起Lucene文档和域,就可以调用IndexWriter对象的addDocument方法将数据传递给Lucene进行索引操作了。在索引操作时,Lucene首先分析文本,将文本数据分割成语汇单元串,然后对它们进行一些可选操作。例如,语汇单元在所引起钠需要统一转换为小写,以使搜索不对大小写敏感,这个操作可以通过调用Lucene的LowerCaseFilter类实现。通常还需要调用StopFilter类从输入中去掉一些使用很频繁却没有实际意义的词(如a、an、the、in
等英文文本)同样地,我们也需要分析输入的语汇单元,去掉他们的词干,如调用PorterStemFilter类处理英文文本(对于其他语种,Lucene的contrib分析模块有对应的类调用)。这些将原始数据转换为语汇单元,随后用一系列filter来修正该语汇单元的操作,一起构成了分析器。另外你还可以通过链接Lucene的语汇单元和filter来搭建自己的分析器,或者通过其他自定义方式来搭建分析器。

以上称之为分析的步骤很重要,它涵盖了图1.4中“文档分析“步骤。Lucene对输入的处理方式多种多样,有趣且实用,我们将在第4章对此详细讲解。分析过程会产生大批的语汇单元,随后这些语汇单元将被写入索引文件中。

2.2.3 想索引添加文档

对输入数据分析完毕后,就可以将分析结果写入索引文件中。Lucene将数据以一种倒排索引(inverted index)的数据结构进行存储。在进行关键字快速查找时,这种数据结构能够有效利用磁盘空间。Lucene使用倒排索引数据结构的原因是:把文档中提取出的语汇单元作为查询关键字,而不是将文档作为中心实体,这种思想很像本书索引与页码的对应关系。换句话说,倒排索引并不是回答“这个文档中包含哪些单词”这个问题,而是经过优化后用来快速回答“哪些文档包含单词x”这个问题。

仔细回想一下自己最喜欢的Web搜索引起和最常用的查询方式,你会发现上述查询方式正是你想要的最快的查询方式。现在所有的Web搜索引擎核心都是采用的倒排索引技术。

Lucene的索引文件目录有唯一一个段结构,接下来我们将讨论这个问题。

索引段

Lucene的索引文件格式丰富而详细,而且它随着时间的推移已经被很好地优化过。尽管你在使用Lucene之前并不需要知道该格式细节,但还是建议你在较高层次上对他有一个基本理解。如果你确实对格式细节很感兴趣,可以参考附录B。

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

在这里插入图片描述
每个段都包含多个文件,文件格式为_X.<ext>, 这里X代表段名称,<ext>为扩展名,用来标识该文件对应索引的某个部分。各个独立的文件共同组成了索引的不同部分(项向量、存储的域、倒排索引,等等)。如果你是用混合文件格式(这是Lucene默认处理方式,但可以通过IndexWriter.setUseCompoundFile方法进行修改),那么上述索引文件会被压缩成一个单一的文件,_X.cfs。这种方式能在搜索期间减少打开的文件数量。第11章会对该权衡方案有详细介绍。

还有一个特殊文件,名叫段文件,用段_<N>标识,该文件指向所有激活的段。段文件非常重要!Lucene会首先打开改文件,然后打开它所指向的其他文件。值<N>被称为“the generation”,他是一个证书,Lucene每次想索引提交更改时都会将这个数加1。

久而久之,索引会积累很多段,特别是当程序打开和关闭writer较为频繁时。这种情况是没问题的。IndexWriter会周期性地选择一些段,将他们合并到一个新段中,然后删除老的段。被合并段的选取策略由一个独立的MergePolicy类主导。一旦选取好这些段,具体合并操作由MergeScheduler类实现。这些类属于进阶主题范畴,详见2.13.6小节。

下面我们介绍索引时需要进行的基本操作(添加、更新、删除)。

2.3 基本索引操作

前面我们讲到Lucene有关文档建模的相关概念,并随后给出了索引过程的逻辑步骤。现在我们开始通过研究相关代码,了解Lucene有关添加、更新、和删除文档的api。我们的研究从添加索引开始,因为这是使用最为频繁的操作。

2.3.1 向索引添加文档

我们先看看如何创建新的索引并向其添加文档。添加文档的方法有两个:

  • addDocument(Document) - 使用默认分析器添加文档,该分析器在创建IndexWriter对象时指定,用于语汇单元化操作。
  • addDocument(Document, Analyzer) - 使用指定的分析器添加文档和语汇单元化操作。但是要小心!为了让搜索模块正常工作,需要分析器在搜索时能够“匹配”它在索引时生成的语汇单元。具体请参考4.1.2小节。

程序2.1显示了创建新索引和添加两个小文档的必要步骤。在本例中,文档内容包含在源代码字符串中,但实际文档内容是从外部数据源获取的。本例中setUp()方法是在每次单元测试前由JUnit框架调用的。

package com.vava.test.lucene;

import java.io.IOException;

import org.apache.lucene.analysis.WhitespaceAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.Field.Index;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriter.MaxFieldLength;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.RAMDirectory;

import junit.framework.TestCase;

/**
 * @author Steve
 * Created on 2020-08
 */
public class IndexingTest extends TestCase {
    protected String[] ids = {"1", "2"};
    protected String[] unindexed = {"Netherlands", "Italy"};
    protected String[] unstored = {"Amsterdam has lots of bridges", "Venice has lots of canals"};
    protected String[] text = {"Amsterdam", "Venice"};

    private Directory directory;

    protected void setUp() throws Exception {
        // 每次测试前运行
        directory = new RAMDirectory();

        // 创建IndexWriter对象
        IndexWriter writer = getWriter();

        // 添加文档
        for (int i = 0; i < ids.length; i++) {
            Document doc = new Document();
            doc.add(new Field("id", ids[i], Store.YES, Index.NOT_ANALYZED));
            doc.add(new Field("country", unindexed[i], Store.YES, Index.NO));
            doc.add(new Field("contents", unstored[i], Store.NO, Index.ANALYZED));
            doc.add(new Field("city", text[i], Store.YES, Index.ANALYZED));
            writer.addDocument(doc);
        }
        writer.close();
    }

    private IndexWriter getWriter() throws IOException {
        // 创建IndexWriter对象
        return new IndexWriter(directory, new WhitespaceAnalyzer(), MaxFieldLength.UNLIMITED);
    }

    protected int getHitCount(String filedName, String searchString) throws IOException {
        // 创建新的IndexSearcher对象
        IndexSearcher searcher = new IndexSearcher(directory);
        // 建立简单的单term查询
        Term t = new Term(filedName, searchString);
        Query query = new TermQuery(t);
        // 获取命中数
        //        int hitCount = TestUtil.hitCount(searcher, query);
        searcher.close();
        return 0;
        //        return hitCount;
    }

    public void testIndexWriter() throws IOException {
        IndexWriter writer = getWriter();
        // 核对写入的文档数
        assertEquals(ids.length, writer.numDocs());
        writer.close();
    }

    public void testIndexReader() throws IOException {
        IndexReader reader = IndexReader.open(directory);
        // 核对读入的文档数
        assertEquals(ids.length, reader.maxDoc());
        assertEquals(ids.length, reader.numDocs());
        reader.clone();
    }

}

1setup()方法首先建立新的RAMDirectory对象用来存放索引。
2接下来在Directory对象上创建IndexWriter对象。这里创建了getWriter方法,因为程序中很多地方都会使用这个方法获取IndexWriter对象。
3最后,setUp()方法递归处理原始内容,并创建Document对象和Field对象,然后将Document对象加入索引。
4 5 6 我们创建IndexSearcher对象,并通过指定字符串来执行基本的单项查询,最后返回与查询内容匹配的文档数。
7 8 我们通过IndexReader对象和IndexWriter对象来确认与查询条件匹配的新增文档数量。索引包含两个文档,分别表示国家及其城市,对应的文本使用WhitespaceAnalyzer类分析的。由于在每项测试之前都要调用setUp()方法,因而每项测试都是针对新创建的索引进行的。

调用getWriter方法时,我们传入3个变量来创建IndexWriter类。

  • Directory类,索引对象存储于该类
  • 分析器,被用来索引语汇单元化的域(分析器详见第4章)
  • MaxFieldLength.UNLIMITED,该变量是必要的,它指示IndexWriter索引文档中所有的语汇单元(具体设置内容详见2.7小节)

IndexWriter类若侦测到Directory类还没索引的话,会自动创建一个新的索引。如果后者已经包含一个索引,前者则只想该索引添加内容。

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

IndexWriter类有多个初始化方法。其中一些方法会显示包含创建索引的参数,这允许你强制建立新的索引并覆盖原来的索引。更高级的初始化方法允许你制定自己专用的IndexDeletionPolicy类或IndexCommit类。

一旦建立起索引,就可以使用for循环来初始化文档对象了。过程很简单:首先创建一个新的Document对象,然后根据你的需要想着跟Document对象中逐个添加Field对象。每个文档都有4个域,每个域都有各自不同的选项,最后可以调用writer.addDocument方法来索引文档。for循环结束后,程序将关闭writer,后者会想Directory对象提交所有变化。其实也可以调用commit()方法,这样就能在不关闭writer的情况下提交更改,从而保留writer至下一次提交时刻。

TestUtil:

package com.vava.lucene;

import java.io.IOException;

import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;

/**
 * @author Steve
 * Created on 2020-08
 */
public class TestUtil {
    public static int hitCount(IndexSearcher searcher, Query query) throws IOException {
        return searcher.search(query, 1).totalHits;
    }
}

2.3.2 删除索引中的文档

虽然大多数应用程序更加关注如何将文档加入Lucene索引中,但也有一部分应用程序需要关注如何从索引中删除文档。例如,某家报社想要在索引中只保留上周的有用的新闻,在此之前的新闻全部删除。还有一些程序可能需要删除包含某一项的所有文档,或者在文档内容有所变化时使用新版文档来覆盖旧文档。IndexWriter类提供了各种方法来从索引中删除文档。

  • deleteDocuments(Term)负责删除包含项的所有文档
  • deleteDocuments(Term[]) 负责删除包含项数组任一元素的所有文档
  • deleteDocuments(Query) 负责删除匹配查询语句的所有文档
  • deleteDocuments(Query[]) 负责删除匹配查询语句数组任一元素的所有文档
  • deleteAll() 负责删除索引中所有文档。该功能与关闭writer再用参数create=true重新打开writer等效,但前者不用关闭writer。

如果需要通过Term类删除单个文档,需要确认在每个文档中都已索引过对应的Field类,还需要确认所有域值都是唯一的,这样才能将这个文档单独找出来删除。这个概念跟数据库中主键的概念类似,但Lucene并不一定要这样执行。你可以对这个域进行任意命名,(通常用id命名),该域需要被索引成未被分析的域以保证分析器不会将它分解成语汇单元。然后利用该域来删除对应的文档,操作如下:writer.deleteDocuments(new Term(“ID”, documentID));

调用该方法时一定小心!如果碰巧制定了一个错误的Term对象(例如,由一个普通的被索引的域文本值创建的Term对象,而不是由唯一ID值创建的域),那么Lucene将很容易地快速删除索引中的大量文档。在所有清欠款下,删除操作不会马上执行,而是放入内存缓冲区中,与加入文档的操作类似,最后Lucene会通过周期性刷新文档目录来执行该操作。与加入文档一样,你必须调用writer的commit()或close()方法向索引提交更改。不过即使删除操作已完成,存储该文档的磁盘空间也不会马上释放,Lucene只是将该文档标记为“删除”。

我们看程序2.2中的deleteDocuments方法是如何运行的。这里建立了两个测试用例,用来分别说明deleteDocuments方法的调用以及删除文档后的优化效果。

   public void testDeleteBeforeOptimize() throws IOException {
        IndexWriter writer = getWriter();
        assertEquals(2, writer.numDocs());
        writer.deleteDocuments(new Term("id", "1"));
        writer.commit();
        // 1、测试用例显示hasDeletions()方法用于检查索引中是否包含被标记为已删除的文档
        assertTrue(writer.hasDeletions());
        // 2、代码显示了两个通常容易混淆的方法:maxDoc()方法和numDoc()方法。前者返回索引中被删除和未被删除的文档总数,而后者只返回索引中未被删除的文档总数。
        // 本例中由于被索引包含两个文档其中一个已经被删除,所以numDocs()方法返回1而maxDoc()方法返回2
        assertEquals(2, writer.maxDoc());
        assertEquals(1, writer.numDocs());
        writer.close();
    }
    
    public void testDeleteAfterOptimize() throws IOException {
        IndexWriter writer = getWriter();
        assertEquals(2, writer.numDocs());
        writer.deleteDocuments(new Term("id", "1"));
        // 3、在方法testDeleteAfterOptimize()中,我们调用索引优化来强制Lucene在删除一个文档后合并索引段。所以随后maxDoc()方法返回1而不是2,因为在删除和优化
        // 操作完成后,Lucene实际上已经将该文档删除,最后索引中只包含最后一个文档。
        writer.optimize();
        writer.commit();
        assertFalse(writer.hasDeletions());
        assertEquals(1, writer.maxDoc());
        assertEquals(1, writer.numDocs());
        writer.close();
    }

NOTE 程序员通常会将IndexWriter类和IndexReader类中的maxDoc()方法和numDocs()方法搞混淆。第一个方法maxDoc()返回索引中包括被删除和未删除的文档总数,而numDocs()只返回索引中未被删除的文档数。

讲完添加和删除文档后,下面我们来看看更新文档

2.3.3 更新索引中的文档

很多搜索程序在首次索引完文档后,由于该文档可能被后续修改,而需要对它进行再次索引。例如,如果你的文档是从Web服务器中抓取的,一个检测文档内容是否改变的方法是找到改变后的ETag HTTP文件头。如果该头与你上次索引文档时对应的头不一致,则说明文档内容已发生变化,并且你需要在索引中更新该文档。

在某些情况下,你可能指向更新文档中的部分域,如标题发生改变而正文未发生改变的情况下。遗憾的是,尽管这个需求很普遍,Lucene还是不能做到:Lucene只能删除整个旧文档,然后向索引中添加新文档。这要求新文档必须包含旧文档中所有域,包括内容未发生改变的域。IndexWriter提供了两个简便的方法来更新索引中的文档。

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

updateDocument方法可能是最常用的删除文档的方法了。因为更新文档也包含删除操作。注意这两个方法是通过调用deleteDocument(Term)和addDocument两个方法合并实现的。updateDocument方法就像如下使用:

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

由于updateDocument方法要在后台调用deleteDocument方法,我们给出同样的警告:要确认被更新文档的Term标识的唯一性。程序2.3为更新文档示例。

    public void testUpdate() throws IOException {
        assertEquals(1, getHitCount("city", "Amsterdam"));
        IndexWriter writer = getWriter();
        // 为"Haag"建立新文档
        Document doc = new Document();
        doc.add(new Field("id", "1", Store.YES, Index.NOT_ANALYZED));
        doc.add(new Field("country", "Netherlands", Store.YES, Index.NO));
        doc.add(new Field("contents", "Den Haag has a lot of museums", Store.NO, Index.ANALYZED));
        doc.add(new Field("city", "Den Haag", Store.YES, Index.ANALYZED));
        // 更新文档版本
        writer.updateDocument(new Term("id", "1"), doc);
        writer.close();
        // 确认旧文档已删除
        assertEquals(0, getHitCount("city", "Amsterdam"));
        // 确认新文档已被索引
        assertEquals(1, getHitCount("city", "Den Haag"));
    }

在本例中,我们用新文档来替换id为1的旧文档。随后调用updateDocument方法更新文档。这样,我们完成了索引中的文档更新操作。

我们已经讲了添加、删除和更新文档的基本操作。现在,我们将深入研究创建文档时需要指定的域选项。

2.4 域选项

Field类也许是在文档索引期间最重要的类了:该类在事实上控制着被索引的域值。当创建好一个域时,你可以指定多个域选项来控制Lucene在将文档添加进索引后针对该域的行为。本章开头我们曾较为深入地谈到这些选项,现在我们将对此做个回顾,然后列决出有关他们的更多细节。

域选项分为几个独立的类别,我们在接下来的几个小结分别讲解这几类选项:索引选项,存储选项和项向量使用选项。讲完这几个选项,我们还将讲到域的值(包括String的值)。最后我们会提到域选项的常见组合方式。

下面我们从如何控制将域值加入倒排索引开始介绍。

2.4.1 域索引选项

域索引选项(Field.Index.*)通过倒排索引控制域文本是否可被搜索。具体选项如下:

  • Index.ANALYZED: 使用分析器将域值分解成独立的语汇单元流,并使每个语汇单元能被搜索。该选项适用于普通文本域(如正文、标题、摘要等)
  • Index.NOT_ANALYZED:对域进行索引,但不对String值进行分析。该操作实际上将域值作为单一语汇单元并使之能被搜索。该选项适用于索引那些不能被分解的域值,如URL、文件路径、日期、人名、社保号码和电话号码等。该选项尤其适用于“精确匹配”搜索。在程序2.1和程序2.3中我们曾使用这个选项索引ID域。
  • Index.ANALYZED_NO_NORMS: 这是Index.ANALYZED选项的一个变体,他不会在索引中存储norms信息。norms记录了索引中的index-time boost信息,但是当你进行搜索时肯恩会比较耗费内存。
  • Index.NOT_ANALYZED_NO_NORMS:与Index.NOT_ANALYZED选项类似,但也是不存储norms。该选项常用语在搜索器件节省索引空间和减少内存耗费,因为single-token域并不需要norms信息,除非他们已被进行加权操作。
  • Index.NO:使对应的域值不被搜索。

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

2.4.2 域存储选项

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

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

Lucene包含一个很实用的工具类,CompressTools,该类提供静态方法压缩和解压字节数组。该类运行时会在后台调用Java内置的java.util.Zip类。你可以使用CompressTools在存储域值之前对它进行压缩。注意,尽管该方法可以为索引节省一些空间。但节省的幅度跟域值的可压缩程度有关,而且该方法会降低索引和搜索速度。这样其实就是通过消耗更多CPU计算能力来换取更多的磁盘空间,对于很多程序来说,需要仔细权衡一下。如果域值所占空间很小,建议少使用压缩。

下面介绍有关索引项向量的选项。

2.4.3 域的项向量选项

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

但是项向量到底是什么呢?它是介于索引域和存储域的一个中间结构。

2.4.4 Reader、TokenStream和byte[]域值

Field对象还有其他几个初始化方法,允许传入除String以外的其他参数。

  • Field(String name, Reader value, TermVector termVector)方法使用Reader而不是String对象来表示域值。在这种情况下,域值是不能被存储的(域存储选项被硬编码成Store.NO),并且该域会一直用于分析和索引(Index.ANALYZED)如果在内存中保存String代价较高或者不方便时,如存储的域值较大时,使用这个初始化方法则比较有效。
  • Field(String name, Reader value),与前述方法类似,使用Reader而不是String对象来表示域值,但使用该方法时,默认的termVector为TermVector.NO。
  • Field(String name, TokenStream tokenStream, TermVector termVector)允许程序对域值进行预分析并生成TokenStream对象。此外,这个域不会被存储并将一直用于分析和索引。
  • Field(String name, Token Stream) ,与前一个方法类似,允许程序对域值进行与分析并生成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对应的字节数来表示。

现在我们应当清楚,Field类是一个非常复杂的类,他提供了大量的初始化选项,以向Lucene传达精确的域值处理指令。下面我们看看几个有关如何联合使用这些选项的示例。

2.4.5 域选项组合

现在你已经了解所有的3类域选项(索引、排序和项向量)。这些选项可以单独设置,但设置完会形成若干可能的组合。表2.1列出了经常使用的域选项组合以及他们的使用范例,但要注意,这些域选项是可以任意设置的。

索引选项存储选项项向量使用范例
NOT_ANALYZED_NO_NORMSYESNO标识符(文件名、主键),电话号码和社会安全号码、URL、姓名、日期、用于排序的文本域【不需要分析切词、需要能查到、需要存储原文用于展示】
ANALYZEDYESWITH_POSITIONS_OFFSETS文档标题、摘要【需要分析切词、需要存储原文用于展示、需要偏移量用作高亮】
ANALYZEDNOWITH_POSITIONS_OFFSETS文档正文【需要分析切词、不需要存储原文用于展示、需要偏移量用作高亮】
NOYESNO文档类型、数据库主键(如果没有用于搜索)【不需要搜索,需要存储原文用于展示】
NOT_ANALYZEDNONO隐藏的关键词

下面我们来看一看有关域排序的选项。

2.4.6 域排序选项

当Lucene返回撇配搜索条件的文档时,一般是按照默认评分对文档进行排序的。又是你可能需要依照其他标准对结果进行排序,比如在搜索E-mail信息时,你可能会根据发送或接收日期排序,或者根据信息大小或寄件人排序。5.2小节描述了一些排序细节,但为了实现域排序功能,你必须首先正确地完成对域的索引。

如果域是数值类型的,在将它加入文档和进行排序时候,要用NumericField类来表示,具体见2.6.1小节。如果域时文本类型的,如邮件发送者姓名,你得用Field类来表示它和索引它,并且要用Field.Index.NOT_ANALYZED选项避免对它进行分析。如果你的域未进行加权操作,那么在对其索引时就不能带有norm选项,使用Field.Index.NOT_ANALYZED_NO_NORMS,这可以节省磁盘空间和内存空间。

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

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

这样,我们已经比较详细地介绍了各个域选项,下面还有最后一个有关域的话题,即多值域。

2.4.7 多值域

设想一下你的文档有一个域表示作者名字,但有时该文档的作者数不止一个。一个解决方案可能是依次处理每个作者名字,将它们加入单个String,然后用后者建立对应的Lucene域。还有一个方法也许更简洁。那就是向这个域写入几个不同的值,像这样:

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

这种处理方式是完全可以接受并鼓励使用的,因为这是逻辑上具有多个值域的域的自然的表示方式。在程序内部,只要文档中出现同名的多值域,倒排索引和项向量都会在逻辑上将这些域的语汇单元附加进去,具体顺序有添加该域的顺序决定。你可以在分析期间使用高级选项来控制有关附加顺序的中啊哟细节,特别是防止针对两个不同值域的匹配搜索。然而与索引操作不同的是,当存储这些域时,它们在文档中的存储顺序是分离的,因此当你在搜索期间对文档进行检索时,你会发现多个Field实例。

我们已介绍完Lucene的各种域选项。这些种类繁多的选项会随着时间推移而改进,以支持针对Lucene的多种应用。我们已向你介绍了大量用于控制域索引的具体选项,它们包括是否对域进行存储、是否对项向量进行计算和存储。除String之外,域值还可以以二进制格式进行存储、TokenStream值(用于对域的预分析)或者Reader(如果在内存中对整个域值进行保存会导致较大开销或者不方便保存时可以使用该选项)。用于排序的域必须以恰当的范式进行索引。最后,我们在本节还看到Lucene能够很好地处理带有多个值的域。

下面我们将介绍Lucene另外一个域处理操作:加权。该功能用于在Lucene对域进行评分期间控制域和文档的重要程度。

2.5 对文档和域进行加权操作

文档和域并不是同等创建的–或者至少你得了解加权的用法。加权操作可以在索引期间完成,这会在本节讲述。搜索期间的加权操作会更加动态化,因为每次搜索操作都可以根据不同的加权因子独立选择加权或者不加权,但这个策略也可能稍微多消耗一点CPU效率。由于搜索期间的加权操作太动态化,该策略还可以将加权选项提供给用于控制,如用选择框询问用户“是否对最近修改过的文档进行加权”

无论是在索引期间还是搜索期间进行加权操作,你都得小心:过多的加权操作,特别是在用户界面没有提示相应文档已被加权操作的情况下,这可能很快侵蚀掉用户对搜索的信任度。你的抖索程序得反复仔细地选择适当的权值,以确认文档不会被过于加权以至于用户最后被迫浏览不相关的搜索结果。本节我们将展示在索引期间如何有选择性地对文档或域进行加权,然后讲解加权信息时如何通过norms记录到索引中的。

2.5.1 文档加权操作

设想一下你为公司设计搜索程序来索引和搜索公司E-mail的情况。该程序可能要求在进行搜索结果排序时,实得员工的E-mail能排在比其他E-mail更重要的位置。那么你会如何实现这个功能呢呢?

文档加权操作能很容易实现该功能。默认情况下,所有文档都没有加权值–或者说它们都具有同样的加权因子1.0.通过改变文档的加权因子,你就能指示Lucene在计算相关性时或多或少地考虑到该文档针对索引中其他文档的重要程度。调用加权操作的API只包含一个方法:setBoost(float),该方法的使用方式如程序2.4所示(注意本程序片段中某些方法如getSenderEmail和isImportant等并未预先定义,他们是在本书源码的完整版中定义的。

    public void testBoost() throws IOException {
        Document doc = new Document();
        String senderEmail = getSenderEmail();
        String senderName = getSenderName();
        String subject = getSubject();
        String body = getBody();
        doc.add(new Field("senderEmail", senderEmail, Store.YES, Index.NOT_ANALYZED));
        doc.add(new Field("senderName", senderName, Store.YES, Index.ANALYZED));
        doc.add(new Field("subject", subject, Store.YES, Index.ANALYZED));
        doc.add(new Field("body", body, Store.NO, Index.ANALYZED));
        String lowerDomain = getSendDomain().toLowerCase();
        if (isImportant(lowerDomain)) {
            doc.setBoost(1.5F);
        } else {
            doc.setBoost(0.1F);
        }
        getWriter().addDocument(doc);
    }

在本例中,我们通过核对Email发送者的域名来确定发送者是否本公司员工。

1、当我们对公司员工发送的邮件消息进行索引时,将它们的加权因子设为1.5,大于默认的加权因子1.0
2、当我们遇到一个由其他域名发送的邮件消息时,将它们的加权因子设为0.1,从而将它们标识为可以忽略不计的信息。

在搜索期间,Lucene将自动根据加权情况来加大或减小文档的评分。有时我们还需要更惊喜的适合域粒度的加权操作,这样可以通过Lucene实现。

2.5.2 域加权操作

正如对文档进行加权操作一样,你还可以对文档中的域进行加权操作。当加权一个文档时,Lucene在内部采用同一个加权因子对该文档中的域进行加权。我们考虑另一个有关索引Email信息的情况,即怎样使得邮件的主题比邮件的作者更重要呢?换句话说,在上例的匹配搜索时,如何才能让主题域变得比senderName域更重要呢?为了达到这个目的,我们可以使用Field类的setBoost(float)方法:

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

在本例中,正如之前选取文档对象的加权因子1.5和1.0一样,我们任意选取一个加权因子1.2.加权因子的设定值取决于你的预期目标:这里你可能需要做一些实验,并不断调整这个加权因子已达到想要的效果。但要记住,当你改变一个域或者一个文档的加权因子的时候,必须完全删除并创建对应的文档,或者使用updateDocument方法达到同样的效果。

值得注意的是,较短的域有一个隐含的加权,这取决于Lucene的评分算法实现。当进行索引操作时,IndexWriter会调用Similarity.lengthNorm方法实现该算法。你可以用自己实现的逻辑来覆盖它,具体可使实现自己的Similarity类并且告诉IndexWriter类跳过调用自己的setSimilarity类来覆盖。总的来说,加权操作是一项高级操作,很多搜索程序没有它也能正常运行,所以使用加权的时候需要小心!

有关搜索时对文档和域进行加权操作的内容参考3.3.1小节。Lucene依据文档对查询语句的匹配程度来对搜索结果排名,每个匹配的文档都被赋予一个评分。Lucene评分机制包含大量的因子,其中就有加权因子。

Lucene如何将加权因子写入索引呢?这就是属于norms的范畴了。

2.5.3 加权基准(Norms)

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

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

norms经常面临的问题之一就是它在搜索期间的高内存用量。这是因为norms的全部数组需要在加载至RAM时,需要对被搜索文档的每个域都分配一个字节空间。对于文档中包含多个域的较大索引来说,这个价在操作会很快占用大量RAM空间。所幸的是,你可以很容易关掉norms相关操作,方法是使用Field.Index中的NO_NORMS索引选项,或者在对包含该域的文档进行索引前调用Field.setOmitNorms(true)方法。这个操作会潜在影响评分效果,因为这样一来,搜索期间程序就不会处理索引时刻的加权信息了,但这种影响有可能是轻微的,特别是当这些域的长度基本相同并且我们并未对它们进行任何加权处理时。

值得注意的是,如果在索引进行一半时关闭norms选项,那么你必须对整个索引进行重建,因为即使只有一个文档域在索引时包含了norms选项,那么随后的段合并操作中,这个情况会“扩散”,从而使得所有文档都会占用一个字节的norms空间,即使它们在此前的索引操作中关闭了norms选项也是如此。发生这种情况主要是因为Lucene并不针对norms进行松散存储。

下面我们将探索如何对数字、日期和时间进行索引。

2.6 索引数字、日期和时间

尽管大多被搜索内容实际上都是文本格式的,很多情况下仍然必须处理数字或日期、时间值。在商业环境中,产品的价格(可能还有其他诸如重量、高度等数值类属性)是它重要的属性。一个视频搜索引擎可能需要对每个视频的播放时长进行索引。新闻稿和文章需要有出版时间标记。以上只是反映当代搜索引擎处理数字属性的几个示例而已。

本节将展示如何用Lucene处理这些数字。程序在处理数字的时候会面临两个截然不同的场景,本节将展示Lucene如何做到两者都支持的。当Lucene索引数字时,他会在索引中建立一个复杂数据机构(Rich Data Structure),这会在后面讲到。最后我们将探讨一些处理日期和时间的方法。

2.6.1 索引数字

索引数字,有两个场景很重要。其中一个场景是,数字内嵌在将要索引的文本中,而想保留这些数字,并将它们作为单独的语汇单元处理,这样就可以在随后的搜索过程中用到它们。例如,你的文档中可能包含类似的句子“Be sure to include Form 1099 in your tax return”: 你希望能搜索到1099,就像能搜索到短语“tax return”一样,并且同样能检索到包含这个句子的文档。

要实现这样的数字索引,其实只要选择一个不丢其数字的分析器即可。正如我们将在4.2.3小节提到的,WhitespaceAnalyzer和StandardAnalyzer两个类可以作为候选。如果将句子“Be sure to include Form 1099 in your tax return”输入,它们会将1099作为语汇单元提取出来并写入索引,从而可以在后续直接输入1099搜索该句子。另外两个类的功能却相反,SimpleAnalyzer和StopAnalyzer两个类会将语汇单元流中的数字剔除,这样的话再输入1099搜索就不会找到任何匹配文档了。如果对此有疑问,请使用Luke来核实数字是否有分析器保留下来并写入索引,Luck是一款用于检查Lucene索引细节的优秀工具,具体用法请见8.1小节。

另外一个场景是,一些域只包含数字,而你希望能将它们作为数字域值来索引,并能在搜索和排序中对它们进行精确(相等)匹配。例如,若你正在索引零售目录的产品,而每款产品都有数字形式的价格,这样你必须使得用户能够通过输入某个价格方位来搜索这些产品。

Lucene的上个版本只能处理文本格式的项。这样的话就需要小心处理数字,如采用0填充(zero-padding)手段或高级数字-文本编码手段将数字转换成字符串,以便能欧通过文本格式的项进行排序和范围搜索。所幸的是,自2.9版本开始,Lucene就加入了对数字域的支持,这就是全新的NumericField类。你只需奥创建一个NumericField对象,使用其中一个set value方法(该方法支持的数字类型有int、long、float和double,然后返回自身)记录数值,然后将NumericField类加入到文档中,就像添加其他Field类一样。示例如下:

doc.add(new NumericField("price").setDoubleValue(19.9))

Lucene会在后台用一些奇特的算法来确认数值是否被成功索引,以便后续能胜任范围搜索和数字排序。每个数值都用trie stucture进行索引,它在逻辑上为越来越大的预定义括号数分配了一个单一的数值。针对每个括号都在索引中分配有了一个唯一的项,因此我们能够很快的在所有文档中检索这个单一的括号。在搜索期间,搜索请求的范围被转换成等效的括号并集,这样就能实现高效的范围搜索或过滤功能。【疑问:什么鬼括号】

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

还有一个高级参数precisionStep允许你对连续出现的括号之间的空隙(以bit形式表示)进行控制,默认的空隙为4bit。更小的空隙值会导致更多的trie括号,这样就会增大索引尺寸(一般增加不太多),但这也能带来更快的范围搜索效果。对应的java文档提供了有关这类权衡的详细信息,但一般来说默认值已经能使用大多数应用程序场合了。3.5.4小节将介绍如何对值域进行搜索。

NumericField类还能处理日期和时间,方法是将他们转换成等小的int型或者long型整数。

2.6.2 索引日期和时间

Email消息包括发送和接受日期,文件有与之相关的多个时间戳,而HTTP相应有一个最后修改的报头,他包括请求页面的最后修改日期。你可能跟很多其他Lucene用户一眼需要对日期和时间戳进行索引。这些值很容易处理,首先将他们转换成相等的int或者long,然后将这些值作为数字进行索引。具体可使用Date.getTime获取精确到毫秒的数字值:

doc.add(new NumericField("timestamp")).setLongValue((int) (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)));

正如上文所述,Lucene是的数字域的索引变得简单起来。刚才已介绍了几个有关将日期和时间转换成数值并索引的方法。下面我们开始介绍有关域的最后一个概念:截取(truncation)

2.7 域截取 (Field truncation)

一些应用程序需要对尺寸未知的文档进行索引。作为一个控制RAM和硬盘空间使用量的安全机制,你可能需要在为每个域进行索引时对输入的文档尺寸进行限制。很有可能一个大的二进制文档被意外的错误分类为文本文档,或者该文档包含了大量内置的二进制数据,这样会导致对该文档的过滤失败,从而会很快导致程序向索引中添加大量无用的二进制项,这个结果是令人恐惧的。其他应用程序则只处理具有已知尺寸的文档,但此时你可能只想要对文档的部分内容进行索引。举例来说,你可能只想对每隔文档的前面200个单词进行索引。

为了支持这些不同的索引需求,IndexWriter允许你对域进行截取后再索引它们。这样一来,被分析的域就只有前面N个项会被编入索引。当实例化IndexWriter后,你必须向其传入MaxFieldLength实例从而向程序传递具体的截取数量。MaxFieldLength类提供了两个易用的默认实例:MaxFieldLength.UNLIMITED和MaxFieldLength.LIMITED,前者表示不采取截取策略,后者表示只截取域中前1000项。在实例化MaxFieldLength时还可以设置自己所需要的截取数。

在建立IndexWriter后,你可以在任意时刻调整截取限制,方法是调用setMaxFieldLength方法;或者你也可以通过调用getMaxFieldLength方法来检索当前的截取限制。但是,对于任何已经被编入索引的文档来说,被截取的项数量是通过前一次设置的值来确定的,因为MaxFieldlength的修改不具有追溯效果。如果文档中包含具有相同域名的多个域实例,那么截取操作会在这这些域中全部生效,这意味着所有这些域都只有前N个项会编入索引。如果你想知道截取操作的具体实施情况,那么可以调用IndexWriter.setInfoStream(System.out)方法并搜索包含”maxFieldLength reached for field X, ignoring following tokens“的任何行来达到目的(需要注意的是,infoSream还可以接收很多其他诊断信息,这些信息有他们自己的用法)。

建议读者在使用域截取功能前一定要考虑清楚。因为这意味着只有域中前N个项才能被搜索到,而其后的文本则会全部被程序忽略。如果只有这些被忽略的项才能与搜索文档相匹配的话,这样就会导致搜索失败。最后当用户发现该搜索引擎不能在特定环境下找到特定文档时,他们会认为这是程序缺陷。有人曾在Lucene用户列表多次寻味”为什么这个搜索条件找不到这个文档“?我们的回答只能是你必须增加maxFieldLength值。

NOTE 使用MaxFieldLength时一定要谨慎!因为域截取意味着程序会完全忽略一部分文档文本,使得这些文本无法被搜索到,从而会让你的用户最终发现:这个搜索引擎连文档都找不到。这个结果很快毁掉用户对搜索引擎的信任度

至此,我们已经讲完有关域的所有有用特性。正如上文所述,Lucene的Field类包含大量域选项以支持针对域值的各种处理方式。下面,我们将探讨如何减小在添加文档和搜索文档之间的转换时间

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

Lucene从2.9版本开始新增了一项被称为实时搜索的重要功能,该功能解决了一个长期困扰搜索引擎的问题:文档的及时索引和即时搜索问题。很多搜索程序都有这个需求,但实现难度比较大。所幸的是,Lucene已经能通过调用IndexWriter中的对应方法轻松实现它了:

IndexReader getReader()

该方法能实时刷新缓冲区中新增或删除的文档,然后创建新的包含这些文档的只读型IndexReader实例。下一章我们会讲解如何使用IndexReader类,但现在,相信我!这程序后台会使用有效的手段新打开一个reader,以使得前后两个reader能共享旧的段。这样,如果只有少量文档加入,转换时间就会很短。注意调用getReader方法会降低索引效率,因为这会使得IndexWriter马上刷新段内容,而不是等到内存缓冲填满再刷新。

下面我们讲讲索引优化操作

2.9 优化索引

当你索引文档时,特别是索引多个文档或者在使用IndexWriter类的多个session索引文档时,你总会建立一个包含多个独立段的索引。当你搜索索引时,Lucene必须分别搜索每个段,然后合并各段的搜索结果。这种工作方式尽管没有问题,但对于处理大容量索引的程序来说,最好还是能够优化一下索引以提高搜索效率,优化索引就是将索引的多个段合并成一个或者少量的段。同时优化后的索引还可以在搜索器减少使用一些文件描述符。讲完优化过程和优化方法后,我们将谈到优化过程的磁盘空间消耗情况。

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

在不进行索引优化的情况下,要想获得很高的搜索效率也是完全可能的,所以你得事先确认是否需要进行索引优化。IndexWriter提供了4个优化方法。

  • optimize()将索引压缩至一个段,操作完成再返回
  • optimize(int maxNumSegments)也称作部分优化(Partial Optimize),将索引压缩为最多maxNumSegment个段,由于将多个段合并成一个段开销最大,建议优化至5个段,它能比优化至一个段更快完成,allowing you to trade less optimization time for slower search speed.
  • optimize(boolean doWait)跟optimize()类似,若doWait参数传入false值,这样的话调用会立即执行,但合并工作是在后台运行的。注意doWait=false选项只适用于后台线程调用合并程序,如默认的ConcurrentMergeScheduler。2.13.6小节给出了调度合并的使用细节。
  • optimize(int maxNumSegments, boolean doWait)也是部分优化方法,如果doWait参数传入false也在后台运行。

请记住,索引优化会消耗大量的CPU和I/O资源,因此在使用时一定要明确这点。它是以一次性大量系统开销来换取更快的搜索速度。如果索引更新的次数并不频繁,并且索引跟新期间会处理大量的搜索请求的话,以上权衡是可以考虑的。如果搜索程序在单台计算机上同时完成索引和搜索功能,那么可以考虑将优化操作预设在多个小时以后或者每周执行一次,这样就不会对正在进行的搜索功能造成太大影响。

搜索期间还需要注意的一项重要开销就是磁盘临时使用空间。由于Lucene必须将多个段进行合并,而在合并操作期间,磁盘临时空间会被用于保存新段对应的文件。但在合并完成并通过IndexWriter.commit或关闭IndexWriter进行提交之前,旧段不能被删除。这意味着你必须为程序预留大约3倍于优化用量的临时磁盘空间。一旦完成优化操作并调用commit()后,磁盘用量会降低到较低水平。另外,索引中任何打开的reader都会潜在影响磁盘空间。11.3.1小节给出了Lucene对于磁盘空间总用量的相关细节信息。

NOTE 在优化期间,索引会占用较大的磁盘空间,大约为优化初期的3倍。当结束后,索引所占用的磁盘空间会被启动优化时少。

下面我们来看看一些不同FSDirectory类的Directory实现。

2.10 其他Directory子类

我们回想一下第一章的相关内容,Lucene的抽象类Directory主要是为我们提供一个简单的文件类存储API,它隐藏了实现存储的细节信息。当Lucene需要对索引中的文件进行读写操作时,它会调用Directory子类的对应方法来进行。表2.2列出了Lucene3.0所支持的5个核心Directory实现

Directory子类描述
SimpleFSDirectory最简单的Directory子类,使用java.io.* API将文件存入文件系统,不能很好地支持多线程操作
NIOFSDirectory使用java.nio.*API将文件保存到文件系统。能很好支持除Microsoft Windows之外的多线程操作,原因是Sun的JRE在Windows平台上长期存在问题
MMapDirectory使用内存映射I/O进行文件访问。对于64位JRE来说是一个很好的选择,对于32位JRE并且索引尺寸相对较小时也可以使用该类
RAMDirectory将所有文件都存入RAM
FileSwitchDirectory使用两个文件目录,根据文件扩展名在两个目录之间切换使用

这些Directory子类负责从文件系统中读写文件。它们都是继承于抽象基类FSDirectory。遗憾的是,我们无法提供一个最好的FSDirectory子类。这几个字类都会在一些情况下面临很大的局限:

  • SimpleFSDirectory 使用java.io.* API访问文件。遗憾的是,该类并不支持多线程情况下的读操作,因为要做到这点就必须在内部加入锁,而java.io.*并不支持按位置读取。
  • NIOFSDirectory使用java.nio.* API所提供的位置读取接口,这样就能在没有内部锁的情况下支持多线程读取操作。遗憾的是,由于长期以来Sun的JRE运行在Windows系统时都有一定问题。在Windows操作系统中性能差,甚至可能比SimpleFSDirectory的性能还差
  • MMAPDirectory使用内存映射的I/O接口进行读操作,这样就不需要采用锁机制,并能很好地支持多线程读操作。但由于内存映射的I/O所消耗的地址空间是与索引尺寸相等的,所以建议最好只是用64位的JRE,如果你能保证使得索引尺寸相遇于32位地址空间所能提供给程序的操作空间来说很小,那么也可以使用32位JRE。Java并没提供方法来取小文件在内存中的映射关系,这意味着只有在JVM进行垃圾回收时才会关闭文件和释放内存空间,这样会使得索引未见占用大量地址空间,同时这些文件处于打开状态所持续的时间会远远超过你的预期。并且,对于32位的JRE来说,程序可能由于内存碎片问题而遇到OutOfMemoryError异常。MMapDirectory提供了setMaxChunkSize方法来处理该问题。

所有的Directory子类在进行写操作时都共享相同的代码(代码来自于SimpleFSDirectory,使用java.io.*)

那么你应该采用哪个Directory子类呢?一个很好的方法就是使用静态的FSDirectory.open方法。该方法会根据当前的操作系统和平台来尝试选择最合适的默认FSDirectory子类,具体选择算法会随着Lucene版本的更新而改进(但还是要注意,对应Lucene3.0版本来说,他不会选择MMapDirectory)或者你也可以直接初始化自己想要的DIrectory子类,但这需要提交前了解前面所介绍的相关内容(若要了解最新的细节,请一定阅读对应的java文档)

Lucene还提供了RAMDirectory类,该Directory子类将所有文件都存入内存而不是磁盘中,这使得文件的读写非常迅速,对于索引尺寸相对于内存来说较小,或者能够很快捷的从文档源中建立索引的情况下,该类是很好的选择。但如果学计算机拥有足够的RAM,大多数操作系统都会使用多余的RAM作为I/O缓存,这意味着FSDirectory在初始化以后的速度能够达到与RAMDiretory一样快。Lucene的单元测试中广泛利用RAMDirectory来创建短期索引用于测试。若要在RAMDirectory中建立一个新的索引,需要按如下放肆实例化writer:

Directory ramDir = new RAMDirectory();
IndexWriter writer = new IndexWriter(ramDir, analyzer, IndexWriter.MaxFieldLength.UNLIMITED);

然后就可以使用通常的方式使用writer进行文档的添加、删除和更新操作了。只需要记住一点,只要有JVM,那么索引就搞定了。

或者,就你可以按照类似如下的方式将另一个Directory中的内容拷贝到RAMDirectory中:

Directory.copy(Directory sourceDie, Directory destDir, boolean closeDirSrc)

但需要注意,该操作会对destDir中已有的文件进行盲目覆盖,你必须确定源目录中并未打开IndexWriter,因为拷贝操作是没有锁机制的。如果destDir中已有索引,并且你需要向其中加入srcDir中的所有文档时,可以将destDir中的索引保存至otherDir,然后使用方法IndexWriter.adIndexesNoOptimize;

IndexWriter writer =  new IndexWriter(otherDir, analyzer, IndexWriter.MaxFieldLength.UNLIMITED);
writer.addIndexesNoOptimize(New Directory[] (ramDir));

IndexWriter还包括其他几个addIndexes方法,但每个方法都会自行完成optimize操作,这点你是无需了解的。

对于Lucene的早期版本来说,他们有利于控制内存缓冲,方法是首先江苏银批量写入RAMDirectory,然后将这个索引周期性写入磁盘。但对于Lucene2.3以后的版本来说,IndexWriter会有效地使用内存缓存保存索引变化,因此以上策略就不再是最好的了。对于其他几种提升索引吞吐量的方法可以参考11.1.4小节。

最后一个FileSwitchDirectory负责在给定的两个Directory之间进行文件操作,操作方式基于文件扩展名。举例来说,该类可以用于处理分别存储域RAMDirectory和MMAPDirectory中的索引文件。但我们必须意识到该方案是一种高级用法,使用时必须依赖与当前的Lucene索引文件格式,而在Lucene的不同版本中,文件格式是经常改变的。

下面我们将介绍有关并发操作等复杂内容

2.11 并发、线程安全及锁机制

本节内容包含3个紧密联系的主题:索引文件的并发访问、IndexReader和IndexWriter的线程安全性以及Lucene用于实现前两项内容的锁机制。准确理解这些内容是重要的,因为当索引程序同时服务于各类用户时,或者当它并行处理一些操作时,这些内容会帮助你消除一些程序设计方面的疑问。

2.11.1 线程安全和多虚拟机安全

Lucene的并发处理规则非常简单。

  • 任意数量的只读属性的IndexReader类都可以同时打开一个索引。无论这些Reader是否属于同一个JVM,以及是否属于同一个计算机都无关紧要。但需要记住:在单个JVM内,利用资源和发挥效率最好的办法是用多线程共享单个IndexReader实例。例如,多个线程或者进程并行搜索同一个索引。
  • 对于一个索引来说,一次只能打开一个writer,Lucene采用文件所来提供保障。一旦建立起IndexWriter对象,系统即会分配一个锁给它。该锁只有当IndexWriter被关闭时才会释放。注意如果你是用IndexReader对象来改变索引的话:比如修改norms或者删除文档,这是IndexReader对象会作为Writer使用:它必须在修改上述内容之前成功获取到Write锁,并在被关闭时释放该锁。
  • IndexReader对象甚至可以在IndexWriter对象正在修改索引时打开。每个IndexReader对象将想索引展示自己被打开的时间点。该对象只有在IndexWriter对象提交修改或自己被重新打开后才能获知索引的修改情况。所以一个更好的选择是,在已经有IndexReader对象被打开的情况下,打开新IndexReader是采用参数create=true;这样,新的IndexReader会持续检查索引的情况。
  • 任意多个线程都可以共享同一个IndexReader类或IndexWriter类。这些类不仅是线程安全的,而且是线程友好的,即是说他们能够很好地扩展到新增线程(假定你的硬件支持并发访问,因为这些类中标识为同步(Synchronized)的代码并不多,仅为最小值)图2.3描述了这样一个场景。
    在这里插入图片描述
    正如所述,Lucene能很好地支持多线程或多虚拟机访问。但若通过远程文件系统来共享索引的话,我们还面临一个有趣的挑战。

2.11.2 通过远程文件系统访问索引

如果准备使用不同计算机的多个虚拟机来访问同一个索引的话,你得提供索引的远程访问方式。一个通用的配置方案是由一台专用计算机保存和修改本地索引,然后用其他计算机通过远程文件访问来搜索该索引。该方案虽然能用,但执行效果通常比搜索本机索引要差得多。通过将远程文件系统挂载到个计算机可能会提高一点执行效果,但为了达到最好效果,最好是将索引复制到个台计算机自己的文件系统,然后在进行锁搜。Solr是一款商业化搜索服务器,它建立在Lucene基础之上,能很好地支持这种复制策略。

如果你还是想通过远程文件系统来范围广文索引,关键是要对可能的局限有所了解。遗憾的是,一些常用的远程文件系统都存在一些问题,如表2.3所示。

远程文件系统注释
Samba/CIFS 1.0Windows标准远程文件系统,能够很好地共享Lucene索引
Samba/CIFS 2.0新版本的Samba/CIFS 为Windows Server 2007 和Windows Vista默认配置,由于不连贯的客户端缓存,Lucene不能很好地运行。
Network File System针对大多数UNIX操作系统的标准远程文件系统,由于不连贯的客户端缓存,以及NFS对被其他计算机打开打开的文件的删除方式,Lucene不能很好的运行
Apple File Protocal(AFP)Apple的远程文件协议。由于不连续的客户端缓存,Lucene不能很好的运行

众所周知,NFS、AFP和Samba/CIFS2.0都会在打开或重新打开索引时,由于不连贯的客户端缓存(incoherent client-side caching)而出现间断性问题。问题仅在writer刚向索引提交完修改,随之另一计算机的reader或另一writer被重新打开是才发生。这样的话,如果你经常重新打开reader和writer并且经常想索引提交修改的话,就容易遇到这个问题。但该问题出现时,程序将在open或reopen方法中抛出FileNotFoundException异常。不过所幸的是,该问题的解决方法很简单:只要稍后重新操作即可,因为客户端缓存会再出现问题一段时间后自动修复。

NFS方案尤其会面临一个更大的问题,即如何删除一个被其他计算机打开着的文件。大多数文件系统都会组织删除被打开的文件。例如Windows系统只是不允许删除打开状态的文件,而大多UNIX本地文件系统却允许删除操作,单系统会保留该文件的磁盘分配空间,直到所有打开文件的句柄都被关闭为止(该策略被称为”最后关闭删除“语义)在以上两种情况下,被打开的文件句柄仍能用于读取整个文件,直到文件被删除为止。而NFS却不是这样,他只是简单的删除文件,以至于拥有该文件句柄的计算机在后续I/O操作的时候会面临极为可怕的”过时的NFS文件句柄“IOException异常。

为了在搜索时避免这类差错,你得创建自己的IndexDeletionPolicy类,在先前更改的提交点控制删除操作,知道所有针对该索引的搜索都结束为止。例如一个常用的个方法是仅仅在4小时候删除索引,只要你能确定每个正在读取索引的IndexReader类能在上次提交更改后4小时内被重新打开即可。还有一种选择,就是当搜索出现”过时的NFS文件句柄“宜昌市,你可以重新打开searcher并重新做一次搜索。这个方法只有在重启seracher不用花太多时间时候才是可行的,否则这条出现错误的不幸搜索讲好费太多时间来重新获取搜索结果。

正如前述,Lucene高度支持索引的并发访问。多个reader可以共享统一索引,多个线程可以共享同一个IndexWriter和IndexReader类等。针对并发放稳唯一的限制是不能同时打开多余一个writer。接下来我们会讲到Lucene如何实现这点,以及如何使用Lucene锁机制进行控制。总的来说,锁是个复杂的话题,甚至Lucene公开的简单的所选项也是如此。因此我们将花更多时间来列举这些选项,这些内容要比前面讲解的Lucene并发机制丰富得多!

2.11.3 索引锁机制

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值