一、简介
lucene是一个全文检索类库,提供结构化以及非结构化文本检索。
全文检索的概念与传统数据库的模糊查询不同。
lucene将文本切分词后建立成倒排索引,当用户查询时lucene将用户输入的短语切分词后从倒排索引中匹配出与之最相近的结果集。结果集按照相关度由大到小排序展示。
传统数据库的模糊查询仅仅匹配出满足前后缀匹配的结果集,并且结果集没有相关度可言。试想让一个用户在上万的数据中找出自己最想要的记录是多么痛苦的事!
关键词:切分词、倒排索引、相关度
二、原理分析
切分词:去除文本中无关字符如(他、的、是、好的)等等并提取文本中的重要信息。这是一个复杂的过程,当前除了众所周知的IK分词器、中科院分词器、庖丁分词器等有一个相对比较好的Jcseg分词器。更多分词算法信息可以从网上搜索。
倒排索引:类似于图书的目录!试想一下如果没有目录检索,那查找某个章节只能从头到尾翻一遍显得特别费力。切分词处理后,将每个词在文档中对应的位置记录下来,保存成“词A=》文本A第10个字符位置”这样的格式,lucene中使用了更加复杂的存储方式,这些信息将以文件的形式保存,因为内存不足以容纳大量的索引数据。
相关度:用户需要从大量索引数据中找出与查询语句最相近的数据集,并且按照相关度从大到小排列。该版本中使用了最最传统的向量空间模型通过计算向量之间的夹角来排序,我们将查询语句和结果集都视为文本向量,夹角越小说明越相关反之越不相关。该算法的最大缺点就是忽略了单词之间的关联性,即不考虑单词之间出现的顺序,因此会导致语义发生改变!当然lucene最新版本提供了更多的算法,如基于概率模型的BM25算法、基于语言模型的LMJelinekMercer算法等等,这些算法也更加复杂。
三、代码分析
准备分析倒排索引的建立与检索和相关度排序三个部分。
先分析索引的读取,然后分析索引建立,最后分析检索排序。
1、lucene索引文件的存储格式,这里先忽略norm文件(该文件主要用在相关度排序中)
![](http://static.oschina.net/uploads/space/2013/1002/162445_c1Yu_1268334.jpg)
![](http://static.oschina.net/uploads/space/2013/1002/162505_HpEE_1268334.jpg)
2、索引读取
IndexReader
IndexReader打开索引时实际调用的是SegmentReader,如果是多个段,则是SegmentsReader。
段:系统默认每10篇文档合并成一个段,段与段之间也参与合并,所以系统最后存在多段与一个段。多段会增加系统的文件句柄开销,但会提高检索效率。一个段会减少文件句柄开销,但会降低检索效率。假设我们下面讨论的都是一个段的SegmentReader。
IndexReader document(int n)方法分析:
该方法内部通过调用SegmentReader document(int n)方法,方法首先检测该文档是否删除(我们先跳过这一步),然后调用FieldsReader的doc方法。该方法主要代码如下:
indexStream.seek(n * 8L);//indexStream是fdx的文件流,里面记录的是fdt写入的字节数,由于字节数用long类型存储,所以需要将n*8L。
long position = indexStream.readLong();//读取fdt写入的字节数
fieldsStream.seek(position);//fieldsStream是fdt文件流,这一步跳到读取数据的位置 Document doc = new Document();// int numFields = fieldsStream.readVInt();//读取存储字段个数 for (int i = 0; i < numFields; i++) { int fieldNumber = fieldsStream.readVInt();//读取字段存储序号 FieldInfo fi = fieldInfos.fieldInfo(fieldNumber);//fieldInfos通过读取fnm文件获取字段信息byte bits = fieldsStream.readByte();
doc.add(new Field(fi.name, // name
fieldsStream.readString(), // read value true, // stored fi.isIndexed, // indexed (bits & 1) != 0)); // tokenized }//整个步骤联系上面的文件存储格式看一下,并不复杂
IndexReader terms(Term t) 方法分析:
方法描述:所有的Term是按照字典顺序排序后存储的,方法返回的是比给定的term大的Terms集合。
方法内部会调用TermInfosReader的terms(Term term)方法并返回SegmentTermEnum对象。
在TermInfosReader的构造方法中有一个比较重要的方法readIndex();
readIndex方法根据tii文件格式读取所有索引词条信息到内存中,索引词条默认每128个记录一次。将该索引文件与数据文件tis联系起来就是一个跳跃链表结构,所以词条的检索过程会遵循该结构。
接着看TermInfosReader的terms(Term term)方法,首先调用get(term)方法,然后返回SegmentTermEnum对象。get方法是为了将SegmentTermEnum定位到最接近该Term的位置,方法内部主要包含两个部分:
1、if (termEnum.term() != null && ((termEnum.prev != null && term.compareTo(termEnum.prev) > 0) || term.compareTo(termEnum.term()) >= 0)) { //是否比上一个词条或者当前词条大
int enumOffset = (termEnum.position/TermInfosWriter.INDEX_INTERVAL)+1;//词条每128个建立一次跳跃链表索引,它返回词条索引下标。 if (indexTerms.length == enumOffset || term.compareTo(indexTerms[enumOffset]) < 0)//当只有一个词条索引或者比词条索引小的时候scanEnum,这个部分仅仅起到优化作用,避免频繁做seek操作 return scanEnum(term); }2、如果不符合第一个部分,就跳到下面的步骤
seekEnum(getIndexOffset(term));//通过跳跃链表找到最接近给定的term的位置,然后从这个位置开始寻找,如果找到返回遍历对象
return scanEnum(term);注意: termEnum是真正的倒排索引文件即tis文件的Terms集合。
对于scanEnum方法有必要强调一下里面的termEnum.next()方法
private final TermInfo scanEnum(Term term) throws IOException {
while (term.compareTo(termEnum.term()) > 0 && termEnum.next()) {} if (termEnum.term() != null && term.compareTo(termEnum.term()) == 0) return termEnum.termInfo(); else return null; }termEnum.next()方法内部按照tii和tis文件格式读取词条,代码就不贴出来了,具体结合tii和tis文件格式就能看懂。
IndexReader terms() 方法分析:
当以上IndexReader terms(Term t) 方法了解后,该方法自然就理解了。
IndexReader docFreq(Term t)方法分析:
该方法返回所有包含给定词条的文档数。方法内部依然调用SegmentReader的docFreq(Term t)方法,代码如下:public final int docFreq(Term t) throws IOException {
TermInfo ti = tis.get(t);//这个部分又回到IndexReader terms(Term t)方法分析了,但是两个方法的主要目的不同,terms方法主要是从给定的词条开始遍历,而该方法强调必须获得给定词条的TermInfo对象。 if (ti != null) return ti.docFreq; else return 0; }
IndexReader termDocs(Term t)方法分析:
首先调用TermInfo ti = tis.get(t)方法内容如上,然后创建SegmentTermDocs对象
SegmentTermDocs(SegmentReader p) throws IOException {
parent = p; freqStream = parent.getFreqStream();//frq文件流 deletedDocs = parent.deletedDocs; //这一步先略过 }SegmentTermDocs(SegmentReader p, TermInfo ti) throws IOException {
this(p); seek(ti); } void seek(TermInfo ti) throws IOException {//TermInfo 对象用来freqStream.seek操作freqCount = ti.docFreq;
doc = 0; freqStream.seek(ti.freqPointer); }该对象最主要的方法还是next方法:
public boolean next() throws IOException {
while (true) { if (freqCount == 0) //freqCount即包含该词条的文档数,所以当遍历到最后一篇文档时返回falsereturn false;
int docCode = freqStream.readVInt();//后面的读取方式就按照frq文件格式
doc += docCode >>> 1; if ((docCode & 1) != 0) freq = 1; else freq = freqStream.readVInt(); freqCount--; if (deletedDocs == null || !deletedDocs.get(doc)) break; skippingDoc(); } return true; }对于IndexReader的其它方法,在了解了上述几个方法后会很好理解,所以这边略过。
2、索引创建
索引创建过程比读取过程要复杂,入口类是IndexWriter
public final void addDocument(Document doc) throws IOException {
DocumentWriter dw = new DocumentWriter(ramDirectory, analyzer, maxFieldLength); String segmentName = newSegmentName(); dw.addDocument(segmentName, doc);//通过DocumentWriter来添加一篇文档 synchronized (this) { segmentInfos.addElement(new SegmentInfo(segmentName, 1, ramDirectory)); maybeMergeSegments();//达到一定条件就开始合并 } } 先关注DocumentWriter类,基本所有的写入流程都被封装在该类中public final void addDocument(String segment, Document doc)
throws IOException { // write field names fieldInfos = new FieldInfos(); fieldInfos.add(doc); fieldInfos.write(directory, segment + ".fnm");//写入字段信息 // write field values FieldsWriter fieldsWriter = new FieldsWriter(directory, segment, fieldInfos);//写入字段值 try { fieldsWriter.addDocument(doc); } finally { fieldsWriter.close(); } // invert doc into postingTable postingTable.clear(); // clear postingTable fieldLengths = new int[fieldInfos.size()]; // init fieldLengths invertDocument(doc); // sort postingTable into an array Posting[] postings = sortPostingTable(); /* * for (int i = 0; i < postings.length; i++) { Posting posting = * postings[i]; System.out.print(posting.term); * System.out.print(" freq=" + posting.freq); System.out.print(" pos="); * System.out.print(posting.positions[0]); for (int j = 1; j < * posting.freq; j++) System.out.print("," + posting.positions[j]); * System.out.println(""); } */ // write postings writePostings(postings, segment);//写入倒排表 // write norms of indexed fields writeNorms(doc, segment);//写入标准化因子 }这里面复杂一点的就是如何写入倒排表了,即writePostings方法
分析之前必须分析invertDocument方法
private final void invertDocument(Document doc) throws IOException {
Enumeration fields = doc.fields(); while (fields.hasMoreElements()) { Field field = (Field) fields.nextElement(); String fieldName = field.name(); int fieldNumber = fieldInfos.fieldNumber(fieldName); int position = fieldLengths[fieldNumber]; //每个字段的位置信息 if (field.isIndexed()) { if (!field.isTokenized()) { // un-tokenized field addPosition(fieldName, field.stringValue(), position++); } else { Reader reader; // find or make Reader if (field.readerValue() != null) reader = field.readerValue(); else if (field.stringValue() != null) reader = new StringReader(field.stringValue()); else throw new IllegalArgumentException( "field must have either String or Reader value"); // Tokenize field and add to postingTable TokenStream stream = analyzer .tokenStream(fieldName, reader); try { for (Token t = stream.next(); t != null; t = stream .next()) {//分词处理,每个词占用一个位置。分词时可能会有重复词,那么重复的词只存储一份,但是位置信息会用数组来保存,数据结构是Posting类。 addPosition(fieldName, t.termText(), position++); if (position > maxFieldLength) break; } } finally { stream.close(); } } fieldLengths[fieldNumber] = position; // save field length } } } 添加完成后对 Posting列表进行自然排序,排序规则是先按照字段的字典顺序,再按字段值的字典顺序,所以同一字段的内容全部紧靠在一起。排序后就开始写入文件,即进入writePostings方法
方法内部需要记录三个文件首先是词频文件frq、然后是位置信息文件prx、最后是倒排索引文件(tii与tis)。
private final void writePostings(Posting[] postings, String segment)
throws IOException { OutputStream freq = null, prox = null; TermInfosWriter tis = null;try {
freq = directory.createFile(segment + ".frq");//记录每个词条的频率 prox = directory.createFile(segment + ".prx");//记录每个词条的位置 tis = new TermInfosWriter(directory, segment, fieldInfos);//倒排信息逻辑封装在该类中 TermInfo ti = new TermInfo();for (int i = 0; i < postings.length; i++) {
Posting posting = postings[i];// add an entry to the dictionary with pointers to prox and freq
// files ti.set(1, freq.getFilePointer(), prox.getFilePointer()); tis.add(posting.term, ti);// add an entry to the freq file
int f = posting.freq; if (f == 1) // optimize freq=1 freq.writeVInt(1); // set low bit of doc num. else { freq.writeVInt(0); // the document number freq.writeVInt(f); // frequency in doc }int lastPosition = 0; // write positions
int[] positions = posting.positions; for (int j = 0; j < f; j++) { // use delta-encoding int position = positions[j]; prox.writeVInt(position - lastPosition);//差值编码 lastPosition = position; } } } finally { 略。。。 } }复杂的逻辑部分在TermInfosWriter类中,该类就是写入倒排信息与索引信息,因为倒排信息非常大,存储在磁盘中,所以需要一个索引信息来指定传入的词条应该从磁盘的哪个位置开始检索,实际上该索引信息默认以128个词条为间隔,是常驻于内存中的。
核心代码如下:
final public void add(Term term, TermInfo ti)
throws IOException, SecurityException { if (!isIndex && term.compareTo(lastTerm) <= 0) throw new IOException("term out of order"); if (ti.freqPointer < lastTi.freqPointer) throw new IOException("freqPointer out of order"); if (ti.proxPointer < lastTi.proxPointer) throw new IOException("proxPointer out of order");if (!isIndex && size % INDEX_INTERVAL == 0)
other.add(lastTerm, lastTi); // add an index termwriteTerm(term); // write term
output.writeVInt(ti.docFreq); // write doc freq output.writeVLong(ti.freqPointer - lastTi.freqPointer); // write pointers output.writeVLong(ti.proxPointer - lastTi.proxPointer);if (isIndex) {
output.writeVLong(other.output.getFilePointer() - lastIndexPointer); lastIndexPointer = other.output.getFilePointer(); // write pointer }lastTi.set(ti);
size++; }private final void writeTerm(Term term)
throws IOException { int start = stringDifference(lastTerm.text, term.text); int length = term.text.length() - start; output.writeVInt(start); // write shared prefix length output.writeVInt(length); // write delta length output.writeChars(term.text, start, length); // 使用前缀编码output.writeVInt(fieldInfos.fieldNumber(term.field)); // write field num
lastTerm = term;
}至此分析的是写入一篇文档的流程,后面还有索引合并。
未完成!