Lucene是一個優秀的開源全文檢索搜尋項目,很多項目的搜尋模組都是使用Lucene。
例如大名鼎鼎的eclipse的協助系統就是使用的Luence作為起做索引的核心。
Lucene良好的體繫結構使得其API介面非常方便易用,使得非自然語言處理的專業人員可以不用關心內部的索引結構,也可以很快的搭建起一個搜尋引擎。但是對於進階使用者和專業人員,瞭解其背後使用的索引結構也是必不可少的。
在研究生階段,我自己也弄了一個索引結構,好奇心促使我將Luence的索引結構和自己的索引結構進行了一番比較。
要比較,首先就要瞭解;在Lucene的官方首頁上有其文檔結構的詳細文檔,下面是我翻譯的一部分:
定義:
Lucene中最基本的概念有:index, document, term.
Index包含一個document序列(document的有序集合)。
1 一個document是一個field序列(field的有序集合)
2 一個field是一個term的命名序列。
3 一個term是一個字串。
在兩個不同field中的同一個字串被認為是不同的term。因此terms是用一個字串對來表示,第一個字串的名字是field,第二個字串的名字是text.
倒排索引
索引儲存關於terms的統計資訊以便於基於term的搜尋更加有效。Lucene的索引屬於被稱為倒排索引的索引家族。這是由於針對每個term,他可以列出包含該term的所有文檔。這是對自然文檔關係的一種倒置,在自然文檔關係中,文檔包含term。
Fields的類型
在Lucene中,Field分為兩種。一種Field是可以被儲存的域,在這種情況下,Field中的所有文字都被逐字儲存在索引檔案中,而不是以倒排的方式儲存。另一種是以倒排的方式把文字儲存在索引檔案中,這種域被稱為是倒排的域(Field)。一個域既可以是儲存的又是倒排的。
域中的文字可以先被序列化(tokenized)成Terms,然後再索引。域中的文字也可以直接被當成一個Term而被索引。大多數域都被tokenized過,但是有些時候針對某些域直接索引也是很有用的。
Segments(段)
Lucene索引可以由多個子索引構成,這個子索引便叫做段。每個段都是一個完全獨立的索引,可以被獨立查詢。索引是從以下兩個方面進化而來:
1 通過增加新的文檔建立新的段。
2 合并現存的段。
查詢可以包含多個段,也可以包含多個索引,每個索引也可以由多個段構成。
Document Numbers(文檔號碼)
在內部,Lucene通過整數文檔號碼來引用文檔。第一篇加入索引的文檔被稱為文檔0,接下來的每個文檔得到比前一個文檔增一的號碼。
注意文檔的號碼可以改變,因此當把這些號碼儲存到Lucene以外的系統時必須格外小心。特別的,號碼可能在以下情況下發生變化:
1 儲存在每個段中的號碼只是在該段中唯一,在更大的環境下使用時必須轉換。標準的技術是對每個段分配一個值段。當把來自段中的文檔號碼轉換成外部值時,段的基文檔號必須被加上。當把外部值轉換成段相關的值時,通過文檔號所在的範圍來確定段,然後減去斷的基文檔號。例如兩個包含五篇文檔的段被合并時,第一個段擁有基號碼0,第二個擁有基號碼5。第二個段的文檔三具有外部值八。
2 當文檔被刪除時,號碼中會產成縫隙。這些縫隙最終會被刪除當索引通過合并演化時。被當段合并的時候,刪除的文檔會被丟棄。因此一個剛合并的段在文檔號碼上是不會有縫隙的。
概述
每個段索引維護以下內容:
網域名稱(Field Name): 包含在該段中使用的所有域的名字的集合。
儲存的域值: 對於每個文檔,他包含一個屬性-值對,屬性是域的名字。這些被用來儲存關於文檔的附加資訊,例如文檔的標題,URL,存取資料庫的標記等等。每次查詢的結果都將返回存取域的集合,儲存域有文檔號碼所標記。
Term字典:字典包含了所有文檔中所有索引域中使用的Term。字典還記錄了包含該Term的文檔的號碼,以及指向該Term頻率和proximity資料的指標。
Term Frequency 資料: 對於字典中的每個Term,包含該Term的文檔的號碼以及在該文檔中出現的頻率。
Term Proximity 資料: 對於字典中的每個Term, Term在每個文檔中出現的位置。
Normalization factors: 對於每個文檔中的每個域,儲存著一個值,該值用來標誌該域的重要性(在計算score的時候)。
Term Vectors: 對於每篇文檔中的每個域,Term向量被儲存。Term向量包括Term的文本以及Term頻率。
Deleted document: 一個可選的檔案用來指示那篇文檔被刪除了。
檔案命名:
屬於同一個段的所有檔案具有相同的名字,不同的尾碼。不同的尾碼對應於下面描述的不同的檔案格式。通常情況下,一個索引的所有段都位於同一個目錄下,儘管這不是必須的。
基本類型:
Byte
最基本的初等資料類型是八位位元組。檔案是以位元組序列的方式存取。所有其他的資料類型都被定義成位元組序列,因此檔案格式是位元組順序無關的。
UInt32
32位無符號整型被寫成四個位元組,高位先儲存。
UInt64
64位無符號整型被寫成八個位元組,高位先儲存。
VInt
正整數的邊長格式,每個位元組的最高位表示是否還有更多的位元組需要讀取。後七位被當作高位追加到結果整數值中。因此,從0到127的值可以儲存在一個位元組中,從128到16383被儲存在兩個位元組中。
Chars
Lucen使用Java的”modified UTF-8 encoding來儲存Unicode字串。
String
Lucene用長度,字元資料來表示String
String->VInt,Chars.
在lucene中按照所屬關係可以將檔案分成兩大類,第一類是每個索引相關,也就是說檔案是屬於某一個索引的。
另一類是段相關的,也就是說檔案是屬於一個段的。
索引相關的檔案主要是記錄給索引包含多少個段,每個段的名字是什麼,有多大等等統計資訊。
索引相關檔案還包括一個鎖檔案(lock file)用來控制對索引的並發存取。
索引檔案還包括一個可刪除文檔列表檔案用來記錄所有沒有被索引使用的檔案名稱。
在Lucene1.4中還引入了一個複合檔案。
複合檔案為屬於該索引的所有段相關檔案提供一個容器,所有段相關的檔案都可以放在給複合檔案中。
眾所周知,全文索引的瓶頸在於檔案IO的次數,我
推測在Lucene1.4中之所以把所有的段相關檔案放到一個作為容器的複合檔案中就是為了減少檔案IO次數,提高查詢速度。
在段相關檔案中,按照用途有可以分成三類,第一類檔案是用來記錄域(Field)相關資訊的檔案。
第二類是用來記錄”詞典(Term Dictionary)"的相關檔案;
他們記錄了索引中所有的單詞,單詞在那些文檔中出現,出現了多少次,具體在文檔中的什麼位置等等資訊。
第三類是用來記錄單詞權重和統計資訊的檔案,主要是為計算文檔向量服務。
例如針對某一具體的查詢對所有匹配的文檔進行排序的時候,我們可以對某些關鍵詞賦予較高的權重,從而將最相關的文檔排在最前面。
使用者使用Google感覺其查詢結果較其他搜尋引擎更準確一些,就是由於它在對匹配項進行排序的時候有他的獨特演算法PageRank.
PageRank根據每張網頁的入度以及指向該網頁的網頁的“權威度”計算一個PageRank值,(以前的Google工具條上有這個值,不知道為什麼現在沒有了)
然後根據PageRank值來排序。
這方面也是現在Lucene研究最熱的地方。
扯遠了,就全文檢索索引本身而言,最核心的結構是第二類檔案,是對這類檔案的圖解。(我費了一個小時畫這幅圖啊)
下面是對的一些說明,可以看到三個紅線筐,每個筐代表一個檔案。
左邊筐的檔案尾碼是.tis,其中各個欄位的名字都能夠很清楚的說明其具體意義,需要對IndexInternal和SkipInternal解釋一下,
由於索引中的詞一般都比較多,對圖中最左邊的矩形筐中的TermInfo有進行的索引,索引儲存在另外一個尾碼為.tii的檔案中。
.tii和.tis中的結構和基本相同,只是在.tis中的TermInfo在.tii中換成TermIndex,(TermIndx->),
其中IndexDelta指向tis中相應的termInfo項。
在tii中不是每個Term都有對應的TermIndex,而是每隔IndexInternal個Term項後才在該檔案中儲存一項TermIndx。
這樣可以加速對Term的尋找速度。
同樣的技術也運用在對Document的尋找上。
可以看出,對每一個Term,在 .frq檔案中有一項(TermFreqs 和SkipData)對應於出現該Term所有文檔(一個TermFreq對於一偏文檔),
用來記錄文檔號碼和在該文檔(DocDelta)中出現的頻率(Freq)
(在lucene中記錄DocDelta時不是真的記錄的文檔號碼,而是和前一個文檔號碼的差值,採用這種技術可以減小索引檔案大小,
另外在記錄頻率時也不是直接記錄頻率,也是採用了一個小技巧用來壓縮索引大小)。
SkipData是用來加速對文檔的定位,其中每一項SkipDatum記錄了每隔SkipInternal篇文檔出現該Term的文檔號(DocSkip),
還儲存了到其他結構的指標。
同樣在.prx檔案中也有一項TermPositions對應一個Term。
每個Positions記錄了一篇文檔中出現該詞的位置。
PositionDelta是具體的位置位移。
前面羅裡羅嗦的解釋了一番,總結一下,一共有四個檔案(.tii, .tis,.frq,.prx)用來記錄Term相關的資訊.
在.tis檔案中,對每個Term都有一項TermInfo,而且TermInfo是根據字典順序排序。
TermInfo中記錄的了Term本身,以及一些以在.frq和.prx中和該Term相關詳細的指標。
.tii是對.tis的一個索引,用來加速對Term的尋找。
.frq檔案記錄了每個Term出現的文檔號以及其頻率,
.prx檔案記錄了每個Term在每篇文檔中出現的位置資訊。
Lucene-2.3.1索引結構有變化。
運行測試程式後,在指定的索引檔案目錄(測試程式中指定為E:/Lucene/myindex)產生了很多檔案:
_0.cfs _1.cfs segments.gen segments_f
segments_N檔案和segments.gen檔案的產生
1、先看segment_N檔案:
在將檔案寫入到磁碟的目錄中之前,一般來說首先要建立一個輸出資料流。在SegmentInfos類中,有一個成員方法write(),在該方法中:
IndexOutput output = directory.createOutput(segmentFileName);
根據指定的索引段檔案的名稱segmentFileName,建立一個指向索引目錄directory的輸出資料流output。
關於這個segmentFileName,是要先從指定的索引目錄中讀取出來的,在write()方法中第一行代碼中就擷取了這個索引段的檔案名稱:
String segmentFileName = getNextSegmentFileName();
這裡的 getNextSegmentFileName()方法是SegmentInfos類的一個成員方法,瞭解它有助於我們繼續追蹤:
public String getNextSegmentFileName() {
long nextGeneration;
if (generation == -1) {
nextGeneration = 1;
} else {
nextGeneration = generation+1;
}
return IndexFileNames.fileNameFromGeneration(IndexFileNames.SEGMENTS,"",nextGeneration);
}
該方法返回的就是我們將要處理的一個索引段檔案的名稱。最後一句return返回,調用了IndexFileNames類的fileNameFromGeneration()方法,它也很重要,因為要使用檔案名稱作為參數擷取索引目錄下的維護索引的檔案都要從這裡獲得。
關注一下IndexFileNames類的實現:
package org.apache.lucene.index;
// 該類主要是對索引檔案的命名進行管理
final class IndexFileNames {
/** 索引段檔案名稱 */
static final String SEGMENTS = "segments";
/** generation reference檔案名稱*/
static final String SEGMENTS_GEN = "segments.gen";
/** Name of the index deletable file (only used in pre-lockless indices) */
static final String DELETABLE = "deletable";
/** norms file的副檔名 */
static final String NORMS_EXTENSION = "nrm";
/** 複合檔案副檔名*/
static final String COMPOUND_FILE_EXTENSION = "cfs";
/** 刪除副檔名 */
static final String DELETES_EXTENSION = "del";
/** plain norms副檔名 */
static final String PLAIN_NORMS_EXTENSION = "f";
/** Extension of separate norms */
static final String SEPARATE_NORMS_EXTENSION = "s";
/**
* Lucene的全部索引副檔名列表
*/
static final String INDEX_EXTENSIONS[] = new String[] {
"cfs", "fnm", "fdx", "fdt", "tii", "tis", "frq", "prx", "del",
"tvx", "tvd", "tvf", "gen", "nrm"
};
/** 被添加到複合索引檔案上的副檔名 */
static final String[] INDEX_EXTENSIONS_IN_COMPOUND_FILE = new String[] {
"fnm", "fdx", "fdt", "tii", "tis", "frq", "prx",
"tvx", "tvd", "tvf", "nrm"
};
/** old-style索引副檔名 */
static final String COMPOUND_EXTENSIONS[] = new String[] {
"fnm", "frq", "prx", "fdx", "fdt", "tii", "tis"
};
/** 詞條向量支援的副檔名 */
static final String VECTOR_EXTENSIONS[] = new String[] {
"tvx", "tvd", "tvf"
};
/**
* 根據基礎檔案名稱(不包括尾碼,比如segments.gen檔案,segments部分為基礎檔案名稱)、副檔名和generarion計算指定檔案的完整檔案名稱
*/
static final String fileNameFromGeneration(String base, String extension, long gen) {
if (gen == SegmentInfo.NO) {
return null;
} else if (gen == SegmentInfo.WITHOUT_GEN) {
return base + extension;
} else {
return base + "_" + Long.toString(gen, Character.MAX_RADIX) + extension;
}
}
}
fileNameFromGeneration實現的功能:根據傳進來的base(比如segments)、副檔名、gen來產生一個新的檔案名稱,並返回。
在SegmentInfos類中getNextSegmentFileName() 方法調用了fileNameFromGeneration,如下所示:
return IndexFileNames.fileNameFromGeneration(IndexFileNames.SEGMENTS,"",nextGeneration);
第一個參數值為"segments",第二個參數值為"",第三個是一個gen(它是一個Long型的數字),如果假設這裡的nextGeneration=5,調用fileNameFromGeneration()方法後,返回的是一個索引段檔案名稱:segments_5。
這樣,就可以根據產生的segments_N檔案名稱,建立一個輸出資料流,將需要的資訊寫入到該檔案中。
2、再看segments.gen檔案:
仔細觀察,其實SegmentInfos類的write方法就是對segments_N檔案和segments.gen檔案進行寫入操作的。
在寫入segments_N檔案以後,緊接著就是處理segments.gen檔案:
output = directory.createOutput(IndexFileNames.SEGMENTS_GEN);
因為在一個索引目錄下,屬於同一個索引段的索引檔案就是通過一個segments.gen檔案來維護的,segments.gen檔案的檔案名稱自然不需要那麼麻煩地去擷取。直接使用IndexFileNames.SEGMENTS_GEN = segments.gen作為參數構造一個輸出資料流,進行輸出,寫入到索引目錄中即可。
關於segments_N檔案和segments.gen檔案儲存的資訊
同樣在SegmentInfos類的write方法中能夠看到,這兩個檔案中都加入了哪些資訊。
■ 關於segments_N檔案
如下所示:
output.writeInt(CURRENT_FORMAT); // write FORMAT
output.writeLong(++version); // every write changes the index
output.writeInt(counter); // write counter
output.writeInt(size()); // write infos
for (int i = 0; i < size(); i++) {
info(i).write(output);
}
(1) CURRENT_FORMAT
其中,CURRENT_FORMAT是SegmentInfos類的一個成員:
/* This must always point to the most recent file format. */
private static final int CURRENT_FORMAT = FORMAT_SINGLE_NORM_FILE;
上面CURRENT_FORMAT的值就是FORMAT_SINGLE_NORM_FILE的值-3:
/** This format adds a "hasSingleNormFile" flag into each segment info.
* See <a href="http://issues.apache.org/jira/browse/LUCENE-756">LUCENE-756</a> for details.
*/
public static final int FORMAT_SINGLE_NORM_FILE = -3;
(2) version
version是SegmentInfos類的一個成員,版本號碼通過系統來擷取:
/**
* counts how often the index has been changed by adding or deleting docs.
* starting with the current time in milliseconds forces to create unique version numbers.
*/
private long version = System.currentTimeMillis();
(3) counter
用於為當前待寫入索引目錄的索引段檔案命名的,即segments_N中的N將使用counter替換。
counter也是SegmentInfos類的一個成員,初始化是為0:
public int counter = 0;
在read()方法中,使用從索引目錄中已經存在的segment_N中讀取的出format的值,然後根據format的值來指派counter的值,如下所示:
int format = input.readInt();
if(format < 0){ // file contains explicit format info
// check that it is a format we can understand
if (format < CURRENT_FORMAT)
throw new CorruptIndexException("Unknown format version: " + format);
version = input.readLong(); // read version
counter = input.readInt(); // read counter
}
else{ // file is in old format without explicit format info
counter = format;
}
(4) size()
size()就是SegmentInfos的大小,SegmentInfos中含有多個SegmentInfo,注意:SegmentInfos類繼承自Vector。
(5) info(i)
info()方法的定義如下所示:
public final SegmentInfo info(int i) {
return (SegmentInfo) elementAt(i);
}
可見,SegmentInfos是SegmentInfo的一個容器,它只把當前這個索引目錄中的SegmentInfo裝進去,以便對他們管理維護。
這裡,info(i).write(output);又調用了SegmentInfo類的write()方法,來向索引輸出資料流output中加入資訊。SegmentInfo類的write()方法如下所示:
/**
* Save this segment's info.
*/
void write(IndexOutput output)
throws IOException {
output.writeString(name);
output.writeInt(docCount);
output.writeLong(delGen);
output.writeByte((byte) (hasSingleNormFile ? 1:0));
if (normGen == null) {
output.writeInt(NO);
} else {
output.writeInt(normGen.length);
for(int j = 0; j < normGen.length; j++) {
output.writeLong(normGen[j]);
}
}
output.writeByte(isCompoundFile);
}
從上可以看到,還寫入了SegmentInfo的具體資訊:name、docCount、delGen、(byte)(hasSingleNormFile ? 1:0)、NO/normGen.length、normGen[j]、isCompoundFile。
■ 關於segments.gen檔案
通過SegmentInfos類的write()方法可以看到:
output.writeInt(FORMAT_LOCKLESS);
output.writeLong(generation);
output.writeLong(generation);
segments.gen檔案中只是寫入了兩個欄位的資訊:FORMAT_LOCKLESS和generation。
因為segments.gen檔案管理的就是segments_N檔案中的N的值,與該檔案相關就只有一個generation,和一個用於判斷是否是無鎖提交的資訊:
/** This format adds details used for lockless commits. It differs
* slightly from the previous format in that file names
* are never re-used (write once). Instead, each file is
* written to the next generation. For example,
* segments_1, segments_2, etc. This allows us to not use
* a commit lock. See <a
* href="http://lucene.apache.org/java/docs/fileformats.html"
* formats</a> for details.
*/
public static final int FORMAT_LOCKLESS = -2;
最後,總結一下:
現在知道了segments_N檔案和segment.gen檔案都記錄了什麼內容。
其中,segments_N檔案與SegmentInfo類的關係十分密切,接下來要學習SegmentInfo類了。