1、简介 算法
lucene是一个全文检索类库,提供结构化以及非结构化文本检索。 数据库
全文检索的概念与传统数据库的模糊查询不一样。 数组
lucene将文本切分词后创建成倒排索引,当用户查询时lucene将用户输入的短语切分词后从倒排索引中匹配出与之最相近的结果集。结果集按照相关度由大到小排序展现。 数据结构
传统数据库的模糊查询仅仅匹配出知足先后缀匹配的结果集,而且结果集没有相关度可言。试想让一个用户在上万的数据中找出本身最想要的记录是多么痛苦的事! post
关键词:切分词、倒排索引、相关度 优化
2、原理分析 this
切分词:去除文本中无关字符如(他、的、是、好的)等等并提取文本中的重要信息。这是一个复杂的过程,当前除了众所周知的IK分词器、中科院分词器、庖丁分词器等有一个相对比较好的Jcseg分词器。更多分词算法信息能够从网上搜索。 编码
倒排索引:相似于图书的目录!试想一下若是没有目录检索,那查找某个章节只能从头至尾翻一遍显得特别费力。切分词处理后,将每一个词在文档中对应的位置记录下来,保存成“词A=》文本A第10个字符位置”这样的格式,lucene中使用了更加复杂的存储方式,这些信息将以文件的形式保存,由于内存不足以容纳大量的索引数据。 spa
相关度:用户须要从大量索引数据中找出与查询语句最相近的数据集,而且按照相关度从大到小排列。该版本中使用了最最传统的向量空间模型经过计算向量之间的夹角来排序,咱们将查询语句和结果集都视为文本向量,夹角越小说明越相关反之越不相关。该算法的最大缺点就是忽略了单词之间的关联性,即不考虑单词之间出现的顺序,所以会致使语义发生改变!固然lucene最新版本提供了更多的算法,如基于几率模型的BM25算法、基于语言模型的LMJelinekMercer算法等等,这些算法也更加复杂。 orm
3、代码分析
准备分析倒排索引的创建与检索和相关度排序三个部分。
先分析索引的读取,而后分析索引创建,最后分析检索排序。
一、lucene索引文件的存储格式,这里先忽略norm文件(该文件主要用在相关度排序中)
二、索引读取
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的位置,方法内部主要包含两个部分:
一、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); }
二、若是不符合第一个部分,就跳到下面的步骤
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即包含该词条的文档数,因此当遍历到最后一篇文档时返回false
return 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的其它方法,在了解了上述几个方法后会很好理解,因此这边略过。
二、索引建立
索引建立过程比读取过程要复杂,入口类是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 term
writeTerm(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;
}
至此分析的是写入一篇文档的流程,后面还有索引合并。
未完成!