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;
}
至此分析的是寫入一篇文檔的流程,後面還有索引合併。
未完成!