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

文档和域

文档是Lucene索引和搜索的原子单位。文档为包含一个或多个域的容器,而域则依次包含“真正的”被搜索的内容。每个域都有一个标识名称,该名称为一个文本值或二进制值。

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

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

理解索引过程

向索引添加文档

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

每个段都包含多个文件,文件格式为_X.,这里X代表段名称。各个独立文件共同组成了索引的不同部分(项向量、存储的域、倒排索引等)。如果你使用混合文件格式,那么上述索引文件都会被压缩成一个单一的文件:_X.cfs。

还有一个特殊的文件,段文件,用段_标识,该文件指向所有激活的段。

向索引添加文档:

import junit.framework.TestCase;

import lia.common.TestUtil;

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

import java.io.IOException;

// From chapter 2
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 {     //1
    directory = new RAMDirectory();

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

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

  private IndexWriter getWriter() throws IOException {            // 2
    return new IndexWriter(directory, new WhitespaceAnalyzer(),   // 2
                           IndexWriter.MaxFieldLength.UNLIMITED); // 2
  }

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

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

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

/*
    #1 Run before every test
    #2 Create IndexWriter
    #3 Add documents
    #4 Create new searcher
    #5 Build simple single-term query
    #6 Get number of hits
    #7 Verify writer document count
    #8 Verify reader document count
  */

从索引中删除文档:

public void testDeleteBeforeOptimize() throws IOException {
    IndexWriter writer = getWriter();
    assertEquals(2, writer.numDocs());           //A 确认索引中的两个文档
    writer.deleteDocuments(new Term("id", "1"));  //B 删除第一个文档
    writer.commit();
    assertTrue(writer.hasDeletions());          //1 确认被标记为删除的文档
    assertEquals(2, writer.maxDoc());  //2 确认删除一个文档并剩余一个文档
    assertEquals(1, writer.numDocs()); //2
    writer.close();
  }

  public void testDeleteAfterOptimize() throws IOException {
    IndexWriter writer = getWriter();
    assertEquals(2, writer.numDocs());
    writer.deleteDocuments(new Term("id", "1"));
    writer.optimize();                //3 使删除生效
    writer.commit();
    assertFalse(writer.hasDeletions());
    assertEquals(1, writer.maxDoc());  //C 确认没有删除文档并剩余一个文档
    assertEquals(1, writer.numDocs()); //C
    writer.close();
  }

  /*
    #A 2 docs in the index
    #B Delete first document
    #C 1 indexed document, 0 deleted documents
    #1 Index contains deletions
    #2 1 indexed document, 1 deleted document
    #3 Optimize compacts deletes
  */

更新索引中的文档:

public void testUpdate() throws IOException {

    assertEquals(1, getHitCount("city", "Amsterdam"));

    IndexWriter writer = getWriter();

    Document doc = new Document();                   //A 为“Haag”建立新文档
    doc.add(new Field("id", "1",
                      Field.Store.YES,
                      Field.Index.NOT_ANALYZED));    //A
    doc.add(new Field("country", "Netherlands",
                      Field.Store.YES,
                      Field.Index.NO));              //A
    doc.add(new Field("contents",
                      "Den Haag has a lot of museums",
                      Field.Store.NO,
                      Field.Index.ANALYZED));       //A
    doc.add(new Field("city", "Den Haag",
                      Field.Store.YES,
                      Field.Index.ANALYZED));       //A

    writer.updateDocument(new Term("id", "1"), doc); //B 更新文档版本
    writer.close();

    assertEquals(0, getHitCount("city", "Amsterdam"));//C 确认旧文档已删除
    assertEquals(1, getHitCount("city", "Haag"));     //D 确认新文档已被索引
  }

  /*
    #A Create new document with "Haag" in city field
    #B Replace original document with new version
    #C Verify old document is gone
    #D Verify new document is indexed
  */

域选项

域索引选项

域索引选项(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——与Index.NOT_ANALYZED选项类似,但也是不存储norms。该选项常用语在搜索期间节省索引空间和减少内存耗费,因为single-token域并不需要norms信息,除非它们已被加权操作。
  • Index.NO——使对应的域值不被搜索。

域存储选项

域存储选项(Field.Store.*)用来确定是否需要存储域的真实值:

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

域的项向量选项

介于索引域和存储域的一个中间结构。

域选项组合

索引选项

存储选项

项向量

使用范例

NOT_ANALYZED_NO_NORMS

YES

NO

标识符(文件名、主键),电话号码和社会安全号码、URL、姓名、日期、用于排序的文本域

ANALYZED

YES

WITH_POSITIONS_OFFSETS

文档标题、摘要

ANALYZED

NO

WITH_POSITIONS_OFFSETS

文档正文

NOT_ANALYZED

NO

NO

隐藏的关键词

域排序选项

用于排序的域是必须进行索引的,在每个document中,这些field每一个必须只含有一个token。

多值域

在程序内部,只要文档中出现同名的多值域,倒排索引和项向量都会在逻辑上将这些域的词汇单元附加进去,具体顺序由添加该域的顺序决定。

对文档和域进行加权操作

文档加权操作

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

public void docBoostMethod() throws IOException {

    Directory dir = new RAMDirectory();
    IndexWriter writer = new IndexWriter(dir, new StandardAnalyzer(Version.LUCENE_30), IndexWriter.MaxFieldLength.UNLIMITED);

    // START
    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("subject", subject,
                      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);     //1 员工域加权因子:1.5
    } else if (isUnimportant(lowerDomain)) {
      doc.setBoost(0.1F);     //2 非员工域加权因子:0.1
    }
    writer.addDocument(doc);
    // END
    writer.close();

    /*
      #1 Good domain boost factor: 1.5
      #2 Bad domain boost factor: 0.1
    */
  }

域加权操作

可以使用Field类的setBoost(float)方法。

值得注意的是,较短的域有一个隐含的加权,这取决于Lucene的评分算法具体实现。当进行索引操作时,IndexWriter对象会调用Similarity.lengthNorm方法来实现该算法。

public void fieldBoostMethod() throws IOException {

    String senderName = getSenderName();
    String subject = getSubject();

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

加权基准(Norms)

在索引期间,文档和文档中的域所有加权被合并成一个单一的浮点数的加权值,这些加权被合并到一处,并被编码成一个单一的字节值,作为域或文档信息的一部分存储起来。而在搜索期间,被搜素域的norms被加载到内存,并被解码还原为浮点数,然后用于计算相关性评分。

后续还是可以使用IndexReader的setNorm方法对它进行修改。

如果在索引进行一半时关闭norms选项,那么你必须对整个索引进行重建,因为即使只有一个文档域在索引时包含了norms选项,那么在随后的段合并操作中,这个情况会“扩散”,从而使得所有文档都会占用一个自己的norms空间。

索引数字、日期和时间

索引数字

public void numberField() {
    Document doc = new Document();
    // START
    doc.add(new NumericField("price").setDoubleValue(19.99));
    // END
  }

索引日期和时间

public void numberTimestamp() {
    Document doc = new Document();
    // START
    doc.add(new NumericField("timestamp")
             .setLongValue(new Date().getTime()));
    // END

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

    Date date = new Date();
    // START
    Calendar cal = Calendar.getInstance();
    cal.setTime(date);
    doc.add(new NumericField("dayOfMonth")
            .setIntValue(cal.get(Calendar.DAY_OF_MONTH)));
    // END
  }

优化索引

当你索引文档时,特别是索引多个文档或者在使用IndexWriter类的多个session索引文档时,你总会建立一个包含多个独立段的索引。

IndexWriter提供了4个优化方法。

  • optimize():将index减少到一个segment,只到操作完成才返回
  • optimize(int maxNumSeqments):部分优化,一般来说,index合并到最后一个segment最消耗时间,所以优化到5个segment会比优化到1个segment快
  • optimize(boolean doWait):同optimize()一样,只是当doWait为false的时候,该方法会立刻返回,合并索引操作在后台进行
  • optimize(int maxNumSegments,boolean doWait)

索引优化会消耗大量的CPU和I/O资源。

还有一项重要的开销是磁盘临时使用空间。

其他Directory子类

Directory子类

描述

SimpleFSDirectory

最简单的Directory子类,使用java.io.* API将文件存入文件系统,不能很好支持多线程操作

NIOFSDirectory

使用java.nio.* API将文件存入文件系统,很好支持除MircosoftWindows外的多线程操作

MMapDirectory

使用内存映射I/O进行文件访问。对于64位JRE来说是一个很好选择,对于32位JRE并且索引尺寸相对较小时也可以使用该类

RAMDirectory

将所有文件都存入RAM

FileSwitchDirectory

使用两个文件目录,根据文件扩展名在两个目录之间切换使用

并发、线程安全及锁机制

Lucene的并发处理规则:

  • 任意数量的只读属性的IndexReader类都可以同时打开一个索引。无论这些Reader是否同时属于一个JVM,以及是否属于同一台计算机都无关紧要。但需要记住:在单个JVM内,利用资源和发挥效率的最好办法是用多线程共享单个IndexReader实例。例如,多个线程或进程并行搜索同一个索引。
  • 对于一个索引来说,一次只能打开一个Writer。Lucene采用文件锁来提供保障。一旦建立IndexWriter对象,系统机会分配一个锁给它。该锁只有当IndexWriter对象被关闭时才会释放。注意如果你使用IndexReader对象来改变索引的话——比如修改norms或者删除文档。这时IndexReader对象会作为Writer使用:它必须在修改上述内容之前成功地获取Writer锁,并在被关闭时释放该锁。
  • IndexReader对象甚至可以在IndexWriter对象正在修改索引时打开。每个IndexReader对象将向索引展示自己被打开的时间点。该对象只有在IndexWriter对象提交修改或自己被重新打开后才能获知索引的修改情况。所以一个更好的选择是,在已经有IndexReader对象被打开的情况下,打开新的IndexReader时采用参数create=true:这样,新的IndexReader会持续检查索引的情况。
  • 任意多个线程都可以共享一个IndexReader类或IndexWriter类。这些类不仅是线程安全的,而且是线程友好的,也就是说它们能够很好地扩展到新增线程。

高级索引概念

用IndexReader删除文档

IndexReader和IndexWriter两种方式来进行,区别:

  • IndexReader能够根据文档号删除文档。Writer不能这样操作,是因为文档号可能因为段合并操作而立即发生变化。
  • IndexReader能够根据Term对象删除文档,这与IndexWriter类似。但IndexReader会返回被删除的文档号,而IndexWriter则不能。IndexReader可以立即决定删除哪个文档,因此就能够对这些文档数量进行计算;而IndexWriter仅仅是将被删除的Term进行缓存,后续再进行实际的删除操作。
  • 如果程序使用相同的reader进行搜索的话,IndexReader的删除操作会即时生效。这意味着你可以在删除操作后马上进行搜索操作,并发现被删除文档已经不会出现在搜索结果中了。
  • IndexWriter可以通过Query对象执行删除操作,但IndexReader则不行。
  • IndexReader提供了一个有时非常有用的方法undeleteAll,该方法能反向操作索引中所有挂起的删除。

回收磁盘空间

记录索引中被删除的文档:bit数组。

可以显式地调用expungeDeletes方法来回收被删除文档所占用的磁盘空间。

缓冲和刷新

当一个新的文档被添加至Lucene索引时,或者当挂起一个删除操作时,这些操作首先被缓存至内存,而不是立即在磁盘中进行。这种缓冲技术主要是出于降低磁盘I/O操作等性能原因而使用。