Inside Lucene/超人气搜索引擎学习(1)-查询机制

上一节 下一节

Searching with TermQuery 查询机制

何用户, 包括系统开发者, 使用搜索引擎的共同方式只有一个: 查询(query). 整个搜索过程的目的是为了满足查询要求, 搜索过程是由查询贯穿的. 若没有指定查询, 而是从索引(index)的内容出发, "搜索"将是漫无目的且毫无意义的过程. 搜索过程的起点只能是索引.

下引用自Lucene in Action 的入门章节, 在其中能看到Query是如何用来启动查询的.

public class BasicSearchingTest extends LiaTestCase {
public void testTerm() throws Exception {
IndexSearcher searcher = new IndexSearcher(directory);
Term t = new Term(“subject”, “ant”);
Query query = new TermQuery(t);
Hits hits = searcher.search(query);

assertEquals(“JDwA”, 1, hits.length());

t = new Term(“subject”, “junit”);
hits = searcher.search(new TermQuery(t));

assertEquals(2, hits.length());

searcher.close();

}
}

称为面向对象设计经典范例的Lucene, 其架构确实能反映"查询"行为在真实世界模式, 这不愧是OOA/OOD方法带来的成熟设计. 这也让我对Lucene设计者的软件设计能力产生充足的敬佩, 要知道人家可是研究算法的. 通常搞学术和搞工程的人思想极不统一, 我同学在微软工作时曾经为研究院和工程院的代码差异头疼不已. Lucene的设计者们能把算法和代码集合得如此完美, 可以说是牛中之牛了.

真正关心的是搜索算法如何依据Query导出查询结果. 上面的代码给我一些启示, 我知道了起点, 从searcher.search(query)开始, 我可以一步步了解Query在搜索过程里的作用机制.
为了满足真实世界的语义, Lucene提供了众多Query. 上面代码中的TermQuery是最简单的Query, 日常搜索有许多直接或间就由TermQuery组合而成. search(termQuery)的构造最基础, 不经过繁琐的转换. 所以我从TermQuery出发, 一步步考察搜索的核心机制.

入search 方法前, 我了解了这些限制条件: TermQuery的语义中每一个Term指一个(field, keyword)对, 其描述的查询条件是: "在指定的字段(标题、作者、内容...)中出现指定的keyword"; 高级的search方法可以处理自定义的Filter, Sort, 此处的考察对象是不假这些自定义选项的最简单的search.

了以上的限制, 我考察的对象--TermQuery查询过程已经被彻底简化了. 但在他和更复杂的重载方法中, 开发人员应用了相同的思路, 举一反三是我们可以期望做到的.
public class IndexSearcher extends Searcher {
public TopDocs search(Query query, Filter filter, final int nDocs)
throws IOException {
Scorer scorer = query.weight(this).scorer(reader);
if (scorer == null)
return new TopDocs(0, new ScoreDoc[0]);

final BitSet bits = filter!=null?filter.bits(reader):null;
final HitQueue hq = new HitQueue(nDocs);
final int[] totalHits = new int[1];
scorer.score(new HitCollector() {
public final void collect(int doc, float score) {
if (score > 0.0f &&
(bits==null || bits.get(doc))) {
totalHits[0]++;
hq.insert(new ScoreDoc(doc, score));
}
}
});


ScoreDoc[] scoreDocs = new ScoreDoc[hq.size()];
for (int i = hq.size()-1; i >= 0; i--)
scoreDocs[i] = (ScoreDoc)hq.pop();

return new TopDocs(totalHits[0], scoreDocs);
}
...
}

主干流程是:1.获取scorer对象
 Scorer scorer = termQuery.weight.scorer(indexReader)

2.调用这个Scorer对象(此处是TermScorer)的score (Collector)方法.
 scorer.score(new HitCollector() {
...
});

这两步之后, collector就把hq用查询结果填满了, 而用户得到的结果就是从hq中一个一个取出的. 这段代码中, indexReader用于读取index数据, 以供查询使用, Scorer负责用查询结果把Collector填满. 问题是,scorer的'查询结果'从哪里来? 如果IndexReader向Scorer提供数据, 数据内容是如何从索引文件中选取的?

Scorer 用一个匿名类Collector来收集满足TermQuery的Doc, 但Scorer怎么能够知道哪些文档符合Query? 真实查询并非在score()方法中进行, 从数据提取的角度来说, 现代搜索引擎都是从inverted index中提取的满足Term的文档列表. 是个人都知道inverted index里有什么, 由指定的keyword得到所有对应的文档就是利用inverted index数据结构完成的, 这也是搜索过程的核心--一个延续了数百年的索引方法. 我要考察的是, 在这个新式的OO搜索引擎框架中, 谁(哪个对象/类)负责提取term对应的记录, 他怎样把结果交给Scorer?

取inverted index是IndexReader的责任, 这时已经提到过的. 关于这一点的知识来自于Lucene手册中星星点点的暗示和大量的代码阅读, 此处不纠缠这个问题, 现在关心的是Scorer和IndexReader怎样发生联系? 回忆一下上边的代码中TermQuery创建Scorer时用的参数, 正是IndexReader对象, 动动脚趾头也猜得出来TermQuery利用"创建"这种特权对score秘密动了手脚. 现在瞄一眼weight在构造scorer时玩了什么花样. 代码是这样写的
public scorer weight#scorer(IndexReader reader){
TermDocs termDocs = reader.termDocs(term);

if (termDocs == null)
return null;

return new TermScorer(this, termDocs, getSimilarity(searcher),
reader.norms(term.field()));
}


class Scorer{
...
public void score(HitCollector hc) throws IOException {
while (next()) {
hc.collect(doc(), score());
}
}
...
public boolean next() throws IOException {
pointer++;
if (pointer >= pointerMax) {
pointerMax = termDocs.read(docs, freqs); // refill buffer
if (pointerMax != 0) {
pointer = 0;
} else {
termDocs.close(); // close stream
doc = Integer.MAX_VALUE; // set to sentinel value
return false;
}
}
doc = docs[pointer];
return true;
}
...
}


可以看出, 上面的问题唯一可能的答案是: weight在构造Scorer时已经为Scorer决定了查询内容就在那个termDocs里. Scorer的代码也表明, 它在遍历所有合法文档时,背后的查询动作是在穷举一个数组:doc[], 而这个数组的来源就是TermDocs. 剩下的问题是,TermDoc在整个查询中扮演何种角色--它是怎么读数据的

看看TermDoc是个虾米东东
reader 创建好TermDoc后调用TermDoc.seek(term). 这个方法在硬盘索引文件中找到term所对应的所有文档记录, 每条记录包含文档id和term在文档中出现的次数tf. 这些文档信息是编制索引时就建好的, 索引文件中每个term对应的文档记录按顺序紧密排列在一起, seek方法能找到这些记录在索引中的开始位置及满足term的文档总数. 以后, TermDoc在scorer中的作用就是读入每个符合term的文档及term在该文档中的tf, 由于建好了索引只需在索引文件中遍历即可, termDoc包含的df将用于此过程的遍历计数.就是说scorer接收到的那个termDoc是调用过seek的, 已经定位到了term对应的数据位置,这便让scorer能遍历termQuery中所有包含那个term的Doc.

scorer怎样遍历全部doc
数据又是另一门学问, 感兴趣的人也许研究过读一个100M文件,与读1000个1K byte文件有何区别,结果当然是震撼的.只要有可能,尽量读取整块数据而不是零碎地读取小数总不会让人失望,然而同样没有人会在构造一个对象时就去对一个未知大小(可能真的包含100万个文档)的数据.只要没收到必要请求,任何人都会尽力避免这种冗长的操作. Lucene的设计者同样只是让在scorer在需要读入数据即第一次调用next()方法时调用termDoc的read方法读数据(代码中黑体部分).为了避免零碎读取降低硬盘效率,termDoc.read()会一次性读入所有合法文档(当然仅包括文档id和tf, 建立索引的过程中,这两个数据一组组的放在专门的文件中,每个term对应的全部文档在这个文件里连续排列以避免零碎读取),scorer调用next ()语句,遍历read()返回的文档id数组,整个遍历过程只需把读出来的doc[i]里的i进行++便万事大吉.

历过程中发生什么事情大家心里应该很清楚,无非是把这些doc(这就是搜索结果)一个个添加到Collector中. 查询结束后, 我们将得到一个int数组, 里面保存着每个结果文档的id. 要使用这些查询结果,用户还需要从按照每篇文档的id文档库中取出结果, 这些只需调用searcher.doc(id)即可完成的事务性过程不在本"技术"文章讨论范围中.

所谓搜索原来如此简单...


上一节 下一节

你可能感兴趣的:(Java,搜索引擎,lucene,文档,query,filter,search)