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子类,使用 |
NIOFSDirectory | 使用 |
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操作等性能原因而使用。