6. http://forfuture1978.javaeye.com 1.1 Lucene学习总结之一:全文检索的基本原理
左边保存的是一系列字符串,称为词典。
每个字符串都指向包含此字符串的文档(Document)链表,此文档链表称为倒排表(Posting List)。
有了索引,便使保存的信息和要搜索的信息一致,可以大大加快搜索的速度。
比如说,我们要寻找既包含字符串“lucene”又包含字符串“solr”的文档,我们只需要以下几步:
1. 取出包含字符串“lucene”的文档链表。
2. 取出包含字符串“solr”的文档链表。
3. 通过合并链表,找出既包含“lucene”又包含“solr”的文件。
看到这个地方,有人可能会说,全文检索的确加快了搜索的速度,但是多了索引的过程,两者加起来不一定比
顺序扫描快多少。的确,加上索引的过程,全文检索不一定比顺序扫描快,尤其是在数据量小的时候更是如
此。而对一个很大量的数据创建索引也是一个很慢的过程。
然而两者还是有区别的,顺序扫描是每次都要扫描,而创建索引的过程仅仅需要一次,以后便是一劳永逸的
了,每次搜索,创建索引的过程不必经过,仅仅搜索创建好的索引就可以了。
这也是全文搜索相对于顺序扫描的优势之一:一次索引,多次使用。
三、如何创建索引
全文检索的索引创建过程一般有以下几步:
第一步:一些要索引的原文档(Document)。
为了方便说明索引创建过程,这里特意用两个文件为例:
文件一:Students should be allowed to go out with their friends, but not allowed to drink beer.
文件二:My friend Jerry went to school to see his students but found them drunk which is not
allowed.
第 6 / 199 页
9. http://forfuture1978.javaeye.com 1.1 Lucene学习总结之一:全文检索的基本原理
their 1
friend 1
allow 1
drink 1
beer 1
my 2
friend 2
jerry 2
go 2
school 2
see 2
his 2
student 2
find 2
them 2
drink 2
allow 2
2. 对字典按字母顺序进行排序。
Term Document ID
allow 1
allow 1
allow 2
beer 1
drink 1
drink 2
find 2
friend 1
friend 2
go 1
第 9 / 199 页
10. http://forfuture1978.javaeye.com 1.1 Lucene学习总结之一:全文检索的基本原理
go 2
his 2
jerry 2
my 2
school 2
see 2
student 1
student 2
their 1
them 2
3. 合并相同的词(Term)成为文档倒排(Posting List)链表。
第 10 / 199 页
36. http://forfuture1978.javaeye.com 1.4 Lucene学习总结之三:Lucene的索引文件格式 (2)
• SegCount
◦ 段(Segment)的个数。
◦ 如上图,此值为2。
• SegCount个段的元数据信息:
◦ SegName
▪ 段名,所有属于同一个段的文件都有以段名作为文件名。
▪ 如上图,第一个段的段名为"_0",第二个段的段名为"_1"
◦ SegSize
▪ 此段中包含的文档数
▪ 然而此文档数是包括已经删除,又没有optimize的文档的,因为在optimize之前,
Lucene的段中包含了所有被索引过的文档,而被删除的文档是保存在.del文件中的,在
搜索的过程中,是先从段中读到了被删除的文档,然后再用.del中的标志,将这篇文档
过滤掉。
▪ 如下的代码形成了上图的索引,可以看出索引了两篇文档形成了_0段,然后又删除了其
中一篇,形成了_0_1.del,又索引了两篇文档形成_1段,然后又删除了其中一篇,形成
_1_1.del。因而在两个段中,此值都是2。
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new
StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
writer.setUseCompoundFile(false);
indexDocs(writer, docDir);//docDir中只有两篇文档
//文档一为:Students should be allowed to go out with their friends, but not allowed to
drink beer.
//文档二为:My friend Jerry went to school to see his students but found them drunk
which is not allowed.
writer.commit();//提交两篇文档,形成_0段。
writer.deleteDocuments(new Term("contents", "school"));//删除文档二
writer.commit();//提交删除,形成_0_1.del
indexDocs(writer, docDir);//再次索引两篇文档,Lucene不能判别文档与文档的不同,因而算两
篇新的文档。
writer.commit();//提交两篇文档,形成_1段
writer.deleteDocuments(new Term("contents", "school"));//删除第二次添加的文档二
writer.close();//提交删除,形成_1_1.del
第 36 / 199 页
62. http://forfuture1978.javaeye.com 1.5 Lucene学习总结之三:Lucene的索引文件格式 (3)
• 此文件包含TermCount个项,每一个词都有一项,因为每一个词都有自己的倒排表。
• 对于每一个词的倒排表都包括两部分,一部分是倒排表本身,也即一个数组的文档号及词频,另一部分
是跳跃表,为了更快的访问和定位倒排表中文档号及词频的位置。
• 对于文档号和词频的存储应用的是差值规则和或然跟随规则,Lucene的文档本身有以下几句话,比较
难以理解,在此解释一下:
For example, the TermFreqs for a term which occurs once in document seven and three
times in document eleven, with omitTf false, would be the following sequence of VInts:
15, 8, 3
If omitTf were true it would be this sequence of VInts instead:
7,4
首先我们看omitTf=false的情况,也即我们在索引中会存储一个文档中term出现的次数。
例子中说了,表示在文档7中出现1次,并且又在文档11中出现3次的文档用以下序列表示:15,
8,3.
那这三个数字是怎么计算出来的呢?
首先,根据定义TermFreq --> DocDelta[, Freq?],一个TermFreq结构是由一个DocDelta后面或
许跟着Freq组成,也即上面我们说的A+B?结构。
DocDelta自然是想存储包含此Term的文档的ID号了,Freq是在此文档中出现的次数。
所以根据例子,应该存储的完整信息为[DocID = 7, Freq = 1] [DocID = 11, Freq = 3](见全文检
索的基本原理章节)。
然而为了节省空间,Lucene对编号此类的数据都是用差值来表示的,也即上面说的规则2,Delta
规则,于是文档ID就不能按完整信息存了,就应该存放如下:
[DocIDDelta = 7, Freq = 1][DocIDDelta = 4 (11-7), Freq = 3]
然而Lucene对于A+B?这种或然跟随的结果,有其特殊的存储方式,见规则3,即A+B?规则,如果
DocDelta后面跟随的Freq为1,则用DocDelta最后一位置1表示。
如果DocDelta后面跟随的Freq大于1,则DocDelta得最后一位置0,然后后面跟随真正的值,从而
对于第一个Term,由于Freq为1,于是放在DocDelta的最后一位表示,DocIDDelta = 7的二进制
是000 0111,必须要左移一位,且最后一位置一,000 1111 = 15,对于第二个Term,由于Freq
第 62 / 199 页
100. http://forfuture1978.javaeye.com 1.7 Lucene学习总结之四:Lucene索引过程分析(2)
fieldsStream.writeVInt(fi.number);//文档号
byte bits = 0;
if (field.isTokenized())
bits |= FieldsWriter.FIELD_IS_TOKENIZED;
if (field.isBinary())
bits |= FieldsWriter.FIELD_IS_BINARY;
if (field.isCompressed())
bits |= FieldsWriter.FIELD_IS_COMPRESSED;
fieldsStream.writeByte(bits); //域的属性位
if (field.isCompressed()) {//对于压缩域
// compression is enabled for the current field
final byte[] data;
final int len;
final int offset;
// check if it is a binary field
if (field.isBinary()) {
data = CompressionTools.compress(field.getBinaryValue(), field.getBinaryOffset(), field.getBinaryLengt
} else {
byte x[] = field.stringValue().getBytes("UTF-8");
data = CompressionTools.compress(x, 0, x.length);
}
len = data.length;
offset = 0;
fieldsStream.writeVInt(len);//写长度
fieldsStream.writeBytes(data, offset, len);//写二进制内容
} else {//对于非压缩域
// compression is disabled for the current field
if (field.isBinary()) {//如果是二进制域
final byte[] data;
final int len;
final int offset;
data = field.getBinaryValue();
len = field.getBinaryLength();
offset = field.getBinaryOffset();
第 100 / 199 页
117. http://forfuture1978.javaeye.com 1.8 Lucene学习总结之四:Lucene索引过程分析(3)
• CharBlockPool是按照出现的先后顺序保存词(term)
• 在TermsHashPerField中,有一个成员变量RawPostingList[] postingsHash,为每一个term分配了一
个RawPostingList,将上述三个缓存关联起来。
abstract class RawPostingList {
final static int BYTES_SIZE = DocumentsWriter.OBJECT_HEADER_BYTES + 3*DocumentsWriter.INT_NUM_B
int textStart; //此词在CharBlockPool中的偏移量,由此可以知道是哪个词。
int intStart; //此词在IntBlockPool中的偏移量,在指向的位置有两个int,一个是docid + freq信息的偏移量,一个
int byteStart; //此词在ByteBlockPool中的起始偏移量
}
static final class PostingList extends RawPostingList {
int docFreq; // 此词在此文档中出现的次数
int lastDocID; // 上次处理完的包含此词的文档号。
int lastDocCode; // 文档号和词频按照或然跟随原则形成的编码
int lastPosition; // 上次处理完的此词的位置
}
这里需要说明的是,在IntBlockPool中保存了两个在ByteBlockPool中的偏移量,而在RawPostingList的byteStart又
中的偏移量,这两者有什么区别呢?
在IntBlockPool中保存的分别指向docid+freq及prox信息在ByteBlockPool中的偏移量是主要用来写入信息的,它记
入的docid+freq或者prox在ByteBlockPool中的位置,随着信息的不断写入,IntBlockPool中的两个偏移量是不断改
以写入的位置。
RawPostingList中byteStart主要是用来读取docid及prox信息的,当索引过程基本结束,所有的信息都写入在缓存中
应的文档号偏移量及位置信息,然后写到索引文件中去呢?自然是通过RawPostingList找到byteStart,然后根据byt
中找到docid+freq及prox信息的起始位置,从起始位置开始的两个大小为5的块,第一个就是docid+freq信息的源头
的源头,如果源头的块中包含了所有的信息,读出来就可以了,如果源头的块中有指针,则沿着指针寻找到下一个块
息。
• 下面举一个实例来表明如果进行缓存管理的:
第 117 / 199 页
118. http://forfuture1978.javaeye.com 1.8 Lucene学习总结之四:Lucene索引过程分析(3)
此例子中,准备添加三个文件:
file01: common common common common common term
file02: common common common common common term term
file03: term term term common common common common common
file04: term
(1) 添加第一篇文档第一个common
• 在CharBlockPool中分配6个char来存放"common"字符串
• 在ByteBlockPool中分配两个块,每个块大小为5,以16结束,第一个块用来存放docid+freq信息,第二个块
时docid+freq信息没有写入,docid+freq信息总是在下一篇文档的处理过程出现了"common"的时候方才写
处理完毕的时候,freq也即词频是无法知道的。而prox信息存放0,是因为第一个common的位置为0,但是
一位置0表示没有payload存储,因而0<<1 + 0 = 0。
• 在IntBlockPool中分配两个int,一个指向第0个位置,是因为当前没有docid+freq信息写入,第二个指向第6
置写入了prox信息。所以IntBlockPool中存放的是下一个要写入的位置。
(2) 添加第四个common
• 在ByteBlockPool中,prox信息已经存放了4个,第一个0是代表第一个位置为0,后面不跟随payload。第二
原则)为1,后面不跟随payload(或然跟随原则),1<<1 + 0 =2。第三个第四个同第二个。
第 118 / 199 页
165. http://forfuture1978.javaeye.com 2.2 有关Lucene的问题(2):stemming和lemmatization
然而
drove –> drove
可见stemming是通过规则缩减为词根的,而不能识别词型的变化。
在最新的Lucene 3.0中,已经有了PorterStemFilter这个类来实现上述算法,只可惜没有Analyzer向匹配,不
过不要紧,我们可以简单实现:
public class PorterStemAnalyzer extends Analyzer
{
@Override
public TokenStream tokenStream(String fieldName, Reader reader) {
return new PorterStemFilter(new LowerCaseTokenizer(reader));
}
}
把此分词器用在你的程序中,就能够识别单复数和规则的词型变化了。
public void createIndex() throws IOException {
Directory d = new SimpleFSDirectory(new File("d:/falconTest/lucene3/norms"));
IndexWriter writer = new IndexWriter(d, new PorterStemAnalyzer(), true,
IndexWriter.MaxFieldLength.UNLIMITED);
Field field = new Field("desc", "", Field.Store.YES, Field.Index.ANALYZED);
Document doc = new Document();
field.setValue("Hello students was driving cars professionally");
doc.add(field);
writer.addDocument(doc);
writer.optimize();
writer.close();
}
public void search() throws IOException {
Directory d = new SimpleFSDirectory(new File("d:/falconTest/lucene3/norms"));
IndexReader reader = IndexReader.open(d);
IndexSearcher searcher = new IndexSearcher(reader);
第 165 / 199 页
166. http://forfuture1978.javaeye.com 2.2 有关Lucene的问题(2):stemming和lemmatization
TopDocs docs = searcher.search(new TermQuery(new Term("desc", "car")), 10);
System.out.println(docs.totalHits);
docs = searcher.search(new TermQuery(new Term("desc", "drive")), 10);
System.out.println(docs.totalHits);
docs = searcher.search(new TermQuery(new Term("desc", "profession")), 10);
System.out.println(docs.totalHits);
}
(2) 有关lemmatization
至于lemmatization,一般是有字典的,方能够由"drove"对应到"drive".
在网上搜了一下,找到European languages lemmatizer[http://lemmatizer.org/],只不过是在linux下面
C++开发的,有兴趣可以试验一下。
首先按照网站的说明下载,编译,安装:
libMAFSA is the core of the lemmatizer. All other libraries depend on it. Download the last
version from the following page, unpack it and compile:
# tar xzf libMAFSA-0.2.tar.gz
# cd libMAFSA-0.2/
# cmake .
# make
# sudo make install
After this you should install libturglem. You can download it at the same place.
# tar xzf libturglem-0.2.tar.gz
# cd libturglem-0.2
# cmake .
# make
# sudo make install
Next you should install english dictionaries with some additional features to work with.
第 166 / 199 页
170. http://forfuture1978.javaeye.com 2.2 有关Lucene的问题(2):stemming和lemmatization
drove
processing drove
3
PARADIGM 0: normal form 'DROVE'
part of speech:0
PARADIGM 1: normal form 'DROVE'
part of speech:2
PARADIGM 2: normal form 'DRIVE'
part of speech:2
was
processing was
3
PARADIGM 0: normal form 'BE'
part of speech:3
PARADIGM 1: normal form 'BE'
part of speech:3
PARADIGM 2: normal form 'BE'
part of speech:3
第 170 / 199 页
176. http://forfuture1978.javaeye.com 2.4 有关Lucene的问题(4):影响Lucene对文档打分的四种方式
field f in d named as t
它包括三个参数:
• Document boost:此值越大,说明此文档越重要。
• Field boost:此域越大,说明此域越重要。
• lengthNorm(field) = (1.0 / Math.sqrt(numTerms)):一个域中包含的Term总数越多,也即文档越
长,此值越小,文档越短,此值越大。
其中第三个参数可以在自己的Similarity中影响打分,下面会论述。
当然,也可以在添加Field的时候,设置Field.Index.ANALYZED_NO_NORMS或
Field.Index.NOT_ANALYZED_NO_NORMS,完全不用norm,来节约空间。
根据Lucene的注释,No norms means that index-time field and document boosting and field length
normalization are disabled. The benefit is less memory usage as norms take up one byte of RAM per
indexed field for every document in the index, during searching. Note that once you index a given
field with norms enabled, disabling norms will have no effect. 没有norms意味着索引阶段禁用了文档
boost和域的boost及长度标准化。好处在于节省内存,不用在搜索阶段为索引中的每篇文档的每个域都占用一
个字节来保存norms信息了。但是对norms信息的禁用是必须全部域都禁用的,一旦有一个域不禁用,则其他
禁用的域也会存放默认的norms值。因为为了加快norms的搜索速度,Lucene是根据文档号乘以每篇文档的
norms信息所占用的大小来计算偏移量的,中间少一篇文档,偏移量将无法计算。也即norms信息要么都保
存,要么都不保存。
下面几个试验可以验证norms信息的作用:
试验一:Document Boost的作用
public void testNormsDocBoost() throws Exception {
File indexDir = new File("testNormsDocBoost");
IndexWriter writer = new IndexWriter(FSDirectory.open(indexDir), new
StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
writer.setUseCompoundFile(false);
Document doc1 = new Document();
Field f1 = new Field("contents", "common hello hello", Field.Store.NO, Field.Index.ANALYZED);
doc1.add(f1);
doc1.setBoost(100);
writer.addDocument(doc1);
第 176 / 199 页
191. http://forfuture1978.javaeye.com 2.4 有关Lucene的问题(4):影响Lucene对文档打分的四种方式
@Override
public TokenStream tokenStream(String fieldName, Reader reader) {
TokenStream result = new WhitespaceTokenizer(reader);
result = new BoldFilter(result);
return result;
}
}
class BoldFilter extends TokenFilter {
public static int IS_NOT_BOLD = 0;
public static int IS_BOLD = 1;
private TermAttribute termAtt;
private PayloadAttribute payloadAtt;
protected BoldFilter(TokenStream input) {
super(input);
termAtt = addAttribute(TermAttribute.class);
payloadAtt = addAttribute(PayloadAttribute.class);
}
@Override
public boolean incrementToken() throws IOException {
if (input.incrementToken()) {
final char[] buffer = termAtt.termBuffer();
final int length = termAtt.termLength();
String tokenstring = new String(buffer, 0, length);
if (tokenstring.startsWith("<b>") && tokenstring.endsWith("</b>")) {
tokenstring = tokenstring.replace("<b>", "");
tokenstring = tokenstring.replace("</b>", "");
termAtt.setTermBuffer(tokenstring);
payloadAtt.setPayload(new Payload(int2bytes(IS_BOLD)));
} else {
payloadAtt.setPayload(new Payload(int2bytes(IS_NOT_BOLD)));
}
return true;
第 191 / 199 页
192. http://forfuture1978.javaeye.com 2.4 有关Lucene的问题(4):影响Lucene对文档打分的四种方式
} else
return false;
}
public static int bytes2int(byte[] b) {
int mask = 0xff;
int temp = 0;
int res = 0;
for (int i = 0; i < 4; i++) {
res <<= 8;
temp = b[i] & mask;
res |= temp;
}
return res;
}
public static byte[] int2bytes(int num) {
byte[] b = new byte[4];
for (int i = 0; i < 4; i++) {
b[i] = (byte) (num >>> (24 - i * 8));
}
return b;
}
}
然后,实现自己的Similarity,从payload中读出信息,根据信息来打分。
class PayloadSimilarity extends DefaultSimilarity {
@Override
public float scorePayload(int docId, String fieldName, int start, int end, byte[] payload, int offset, int length
int isbold = BoldFilter.bytes2int(payload);
if(isbold == BoldFilter.IS_BOLD){
System.out.println("It is a bold char.");
} else {
System.out.println("It is not a bold char.");
第 192 / 199 页
193. http://forfuture1978.javaeye.com 2.4 有关Lucene的问题(4):影响Lucene对文档打分的四种方式
}
return 1;
}
}
最后,查询的时候,一定要用PayloadXXXQuery(在此用PayloadTermQuery,在Lucene 2.4.1中,用
BoostingTermQuery),否则scorePayload不起作用。
public void testPayloadScore() throws Exception {
PayloadSimilarity sim = new PayloadSimilarity();
File indexDir = new File("TestPayloadScore");
IndexWriter writer = new IndexWriter(FSDirectory.open(indexDir), new BoldAnalyzer(), true,
IndexWriter.MaxFieldLength.LIMITED);
Document doc1 = new Document();
Field f1 = new Field("contents", "common hello world", Field.Store.NO, Field.Index.ANALYZED);
doc1.add(f1);
writer.addDocument(doc1);
Document doc2 = new Document();
Field f2 = new Field("contents", "common <b>hello</b> world", Field.Store.NO, Field.Index.ANALYZED);
doc2.add(f2);
writer.addDocument(doc2);
writer.close();
IndexReader reader = IndexReader.open(FSDirectory.open(indexDir));
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setSimilarity(sim);
PayloadTermQuery query = new PayloadTermQuery(new Term("contents", "hello"), new
MaxPayloadFunction());
TopDocs docs = searcher.search(query, 10);
for (ScoreDoc doc : docs.scoreDocs) {
System.out.println("docid : " + doc.doc + " score : " + doc.score);
}
}
如果scorePayload函数始终是返回1,则结果如下,<b></b>不起作用。
第 193 / 199 页
194. http://forfuture1978.javaeye.com 2.4 有关Lucene的问题(4):影响Lucene对文档打分的四种方式
It is not a bold char.
It is a bold char.
docid : 0 score : 0.2101998
docid : 1 score : 0.2101998
如果scorePayload函数如下:
class PayloadSimilarity extends DefaultSimilarity {
@Override
public float scorePayload(int docId, String fieldName, int start, int end, byte[] payload, int offset, int length
int isbold = BoldFilter.bytes2int(payload);
if(isbold == BoldFilter.IS_BOLD){
System.out.println("It is a bold char.");
return 10;
} else {
System.out.println("It is not a bold char.");
return 1;
}
}
}
则结果如下,同样是包含hello,包含加粗的文档获得较高分:
It is not a bold char.
It is a bold char.
docid : 1 score : 2.101998
docid : 0 score : 0.2101998
继承并实现自己的collector
以上各种方法,已经把Lucene score计算公式的所有变量都涉及了,如果这还不能满足您的要求,还可以继承
实现自己的collector。
在Lucene 2.4中,HitCollector有个函数public abstract void collect(int doc, float score),用来收集搜索的
结果。
第 194 / 199 页
195. http://forfuture1978.javaeye.com 2.4 有关Lucene的问题(4):影响Lucene对文档打分的四种方式
其中TopDocCollector的实现如下:
public void collect(int doc, float score) {
if (score > 0.0f) {
totalHits++;
if (reusableSD == null) {
reusableSD = new ScoreDoc(doc, score);
} else if (score >= reusableSD.score) {
reusableSD.doc = doc;
reusableSD.score = score;
} else {
return;
}
reusableSD = (ScoreDoc) hq.insertWithOverflow(reusableSD);
}
}
此函数将docid和score插入一个PriorityQueue中,使得得分最高的文档先返回。
我们可以继承HitCollector,并在此函数中对score进行修改,然后再插入PriorityQueue,或者插入自己的数据
结构。
比如我们在另外的地方存储docid和文档创建时间的对应,我们希望当文档时间是一天之内的分数最高,一周之
内的分数其次,一个月之外的分数很低。
我们可以这样修改:
public static long milisecondsOneDay = 24L * 3600L * 1000L;
public static long millisecondsOneWeek = 7L * 24L * 3600L * 1000L;
public static long millisecondsOneMonth = 30L * 24L * 3600L * 1000L;
public void collect(int doc, float score) {
if (score > 0.0f) {
long time = getTimeByDocId(doc);
第 195 / 199 页