Lucene學習總結之三:Lucene的索引檔案格式(2)
四、具體格式
上面曾經交代過,Lucene儲存了從Index到Segment到Document到Field一直到Term的正向資訊,也包括了從Term到Document對映的反向資訊,還有其他一些Lucene特有的資訊。下面對這三種資訊一一介紹。
4.1. 正向資訊
Index –> Segments (segments.gen, segments_N) –> Field(fnm, fdx, fdt) –> Term (tvx, tvd, tvf)
上面的層次結構不是十分的準確,因為segments.gen和segments_N儲存的是段(segment)的元資料資訊(metadata),其實是每個Index一個的,而段的真正的資料資訊,是儲存在域(Field)和詞(Term)中的。
4.1.1. 段的元資料資訊(segments_N)
一個索引(Index)可以同時存在多個segments_N(至於如何存在多個segments_N,在描述完詳細資訊之後會舉例說明),然而當我們要開啟一個索引的時候,我們必須要選擇一個來開啟,那如何選擇哪個segments_N呢?
Lucene採取以下過程:
- 其一,在所有的segments_N中選擇N最大的一個。基本邏輯參照SegmentInfos.getCurrentSegmentGeneration(File[] files),其基本思路就是在所有以segments開頭,並且不是segments.gen的檔案中,選擇N最大的一個作為genA。
- 其二,開啟segments.gen,其中儲存了當前的N值。其格式如下,讀出版本號(Version),然後再讀出兩個N,如果兩者相等,則作為genB。
-
IndexInput genInput = directory.openInput(IndexFileNames.SEGMENTS_GEN);//"segments.gen"
int version = genInput.readInt();//讀出版本號
if (version == FORMAT_LOCKLESS) {//如果版本號正確
long gen0 = genInput.readLong();//讀出第一個N
long gen1 = genInput.readLong();//讀出第二個N
if (gen0 == gen1) {//如果兩者相等則為genB
genB = gen0;
}
} - 其三,在上述得到的genA和genB中選擇最大的那個作為當前的N,方才開啟segments_N檔案。其基本邏輯如下:
if (genA > genB)
gen = genA;
else
gen = genB;
如下圖是segments_N的具體格式:
- Format:
- 索引檔案格式的版本號。
- 由於Lucene是在不斷開發過程中的,因而不同版本的Lucene,其索引檔案格式也不盡相同,於是規定一個版本號。
- Lucene 2.1此值-3,Lucene 2.9時,此值為-9。
- 當用某個版本號的IndexReader讀取另一個版本號生成的索引的時候,會因為此值不同而報錯。
- Version:
- 索引的版本號,記錄了IndexWriter將修改提交到索引檔案中的次數。
- 其初始值大多數情況下從索引檔案裡面讀出,僅僅在索引開始建立的時候,被賦予當前的時間,已取得一個唯一值。
- 其值改變在IndexWriter.commit->IndexWriter.startCommit->SegmentInfos.prepareCommit->SegmentInfos.write->writeLong(++version)
- 其初始值之所最初取一個時間,是因為我們並不關心IndexWriter將修改提交到索引的具體次數,而更關心到底哪個是最新的。IndexReader中常比較自己的version和索引檔案中的version是否相同來判斷此IndexReader被開啟後,還有沒有被IndexWriter更新。
//在DirectoryReader中有一下函式。 public boolean isCurrent() throws CorruptIndexException, IOException { |
- NameCount
- 是下一個新段(Segment)的段名。
- 所有屬於同一個段的索引檔案都以段名作為檔名,一般為_0.xxx, _0.yyy, _1.xxx, _1.yyy ……
- 新生成的段的段名一般為原有最大段名加一。
- 如同的索引,NameCount讀出來是2,說明新的段為_2.xxx, _2.yyy
- SegCount
- 段(Segment)的個數。
- 如上圖,此值為2。
- SegCount個段的元資料資訊:
- SegName
- 段名,所有屬於同一個段的檔案都有以段名作為檔名。
- 如上圖,第一個段的段名為"_0",第二個段的段名為"_1"
- SegSize
- 此段中包含的文件數
- 然而此文件數是包括已經刪除,又沒有optimize的文件的,因為在optimize之前,Lucene的段中包含了所有被索引過的文件,而被刪除的文件是儲存在.del檔案中的,在搜尋的過程中,是先從段中讀到了被刪除的文件,然後再用.del中的標誌,將這篇文件過濾掉。
- 如下的程式碼形成了上圖的索引,可以看出索引了兩篇文件形成了_0段,然後又刪除了其中一篇,形成了_0_1.del,又索引了兩篇文件形成_1段,然後又刪除了其中一篇,形成_1_1.del。因而在兩個段中,此值都是2。
- SegName
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED); //文件一為: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"));//刪除文件二 |
-
- DelGen
- .del檔案的版本號
- Lucene中,在optimize之前,刪除的文件是儲存在.del檔案中的。
- 在Lucene 2.9中,文件刪除有以下幾種方式:
- IndexReader.deleteDocument(int docID)是用IndexReader按文件號刪除。
- IndexReader.deleteDocuments(Term term)是用IndexReader刪除包含此詞(Term)的文件。
- IndexWriter.deleteDocuments(Term term)是用IndexWriter刪除包含此詞(Term)的文件。
- IndexWriter.deleteDocuments(Term[] terms)是用IndexWriter刪除包含這些詞(Term)的文件。
- IndexWriter.deleteDocuments(Query query)是用IndexWriter刪除能滿足此查詢(Query)的文件。
- IndexWriter.deleteDocuments(Query[] queries)是用IndexWriter刪除能滿足這些查詢(Query)的文件。
- 原來的版本中Lucene的刪除一直是由IndexReader來完成的,在Lucene 2.9中雖可以用IndexWriter來刪除,但是其實真正的實現是在IndexWriter中,儲存了readerpool,當IndexWriter向索引檔案提交刪除的時候,仍然是從readerpool中得到相應的IndexReader,並用IndexReader來進行刪除的。下面的程式碼可以說明:
- DelGen
IndexWriter.applyDeletes() -> DocumentsWriter.applyDeletes(SegmentInfos) -> reader.deleteDocument(doc); |
-
-
-
- DelGen是每當IndexWriter向索引檔案中提交刪除操作的時候,加1,並生成新的.del檔案。
-
-
IndexWriter.commit() -> IndexWriter.applyDeletes() -> IndexWriter$ReaderPool.release(SegmentReader) -> SegmentReader(IndexReader).commit() -> SegmentReader.doCommit(Map) -> SegmentInfo.advanceDelGen() -> if (delGen == NO) { |
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED); indexDocs(writer, docDir);//索引兩篇文件,一篇包含"school",另一篇包含"beer" 形成的索引檔案如下: |
-
- DocStoreOffset
- DocStoreSegment
- DocStoreIsCompoundFile
- 對於域(Stored Field)和詞向量(Term Vector)的儲存可以有不同的方式,即可以每個段(Segment)單獨儲存自己的域和詞向量資訊,也可以多個段共享域和詞向量,把它們儲存到一個段中去。
- 如果DocStoreOffset為-1,則此段單獨儲存自己的域和詞向量,從儲存檔案上來看,如果此段段名為XXX,則此段有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx檔案。DocStoreSegment和DocStoreIsCompoundFile在此處不被儲存。
- 如果DocStoreOffset不為-1,則DocStoreSegment儲存了共享的段的名字,比如為YYY,DocStoreOffset則為此段的域及詞向量資訊在共享段中的偏移量。則此段沒有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx檔案,而是將資訊存放在共享段的YYY.fdt,YYY.fdx,YYY.tvf,YYY.tvd,YYY.tvx檔案中。
- DocumentsWriter中有兩個成員變數:String segment是當前索引資訊存放的段,String docStoreSegment是域和詞向量資訊儲存的段。兩者可以相同也可以不同,決定了域和詞向量資訊是儲存在本段中,還是和其他的段共享。
- IndexWriter.flush(boolean triggerMerge, boolean flushDocStores, boolean flushDeletes)中第二個引數flushDocStores會影響到是否單獨或是共享儲存。其實最終影響的是DocumentsWriter.closeDocStore()。每當flushDocStores為false時,closeDocStore不被呼叫,說明下次新增到索引檔案中的域和詞向量資訊是同此次共享一個段的。直到flushDocStores為true的時候,closeDocStore被呼叫,從而下次新增到索引檔案中的域和詞向量資訊將被儲存在一個新的段中,不同此次共享一個段(在這裡需要指出的是Lucene的一個很奇怪的實現,雖然下次域和詞向量資訊是被儲存到新的段中,然而段名卻是這次被確定了的,在initSegmentName中當docStoreSegment == null時,被置為當前的segment,而非下一個新的segment,docStoreSegment = segment,於是會出現如下面的例子的現象)。
- 好在共享域和詞向量儲存並不是經常被使用到,實現也或有缺陷,暫且解釋到此。
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED); //flush生成segment "_0",並且flush函式中,flushDocStores設為false,也即下個段將同本段共享域和詞向量資訊,這時DocumentsWriter中的docStoreSegment= "_0"。 indexDocs(writer, docDir); //commit生成segment "_1",由於上次flushDocStores設為false,於是段"_1"的域以及詞向量資訊是儲存在"_0"中的,在這個時刻,段"_1"並不生成自己的"_1.fdx"和"_1.fdt"。然而在commit函式中,flushDocStores設為true,也即下個段將單獨使用新的段來儲存域和詞向量資訊。然而這時,DocumentsWriter中的docStoreSegment= "_1",也即當段"_2"儲存其域和詞向量資訊的時候,是存在"_1.fdx"和"_1.fdt"中的,而段"_1"的域和詞向量資訊卻是存在"_0.fdt"和"_0.fdx"中的,這一點非常令人困惑。 如圖writer.commit的時候,_1.fdt和_1.fdx並沒有形成。 indexDocs(writer, docDir); //段"_2"形成,由於上次flushDocStores設為true,其域和詞向量資訊是新建立一個段儲存的,卻是儲存在_1.fdt和_1.fdx中的,這時候才產生了此二檔案。 indexDocs(writer, docDir); //段"_3"形成,由於上次flushDocStores設為false,其域和詞向量資訊是共享一個段儲存的,也是是儲存在_1.fdt和_1.fdx中的 indexDocs(writer, docDir); //段"_4"形成,由於上次flushDocStores設為false,其域和詞向量資訊是共享一個段儲存的,也是是儲存在_1.fdt和_1.fdx中的。然而函式commit中flushDocStores設為true,也意味著下一個段將新建立一個段儲存域和詞向量資訊,此時DocumentsWriter中docStoreSegment= "_4",也表明了雖然段"_4"的域和詞向量資訊儲存在了段"_1"中,將來的域和詞向量資訊卻要儲存在段"_4"中。此時"_4.fdx"和"_4.fdt"尚未產生。 indexDocs(writer, docDir); //段"_5"形成,由於上次flushDocStores設為true,其域和詞向量資訊是新建立一個段儲存的,卻是儲存在_4.fdt和_4.fdx中的,這時候才產生了此二檔案。 indexDocs(writer, docDir); //段"_6"形成,由於上次flushDocStores設為false,其域和詞向量資訊是共享一個段儲存的,也是是儲存在_4.fdt和_4.fdx中的 |
-
- HasSingleNormFile
- 在搜尋的過程中,標準化因子(Normalization Factor)會影響文件最後的評分。
- 不同的文件重要性不同,不同的域重要性也不同。因而每個文件的每個域都可以有自己的標準化因子。
- 如果HasSingleNormFile為1,則所有的標準化因子都是存在.nrm檔案中的。
- 如果HasSingleNormFile不是1,則每個域都有自己的標準化因子檔案.fN
- NumField
- 域的數量
- NormGen
- 如果每個域有自己的標準化因子檔案,則此陣列描述了每個標準化因子檔案的版本號,也即.fN的N。
- IsCompoundFile
- 是否儲存為複合檔案,也即把同一個段中的檔案按照一定格式,儲存在一個檔案當中,這樣可以減少每次開啟檔案的個數。
- 是否為複合檔案,由介面IndexWriter.setUseCompoundFile(boolean)設定。
- 非符合檔案同符合檔案的對比如下圖:
- HasSingleNormFile
非複合檔案: |
複合檔案: |
-
- DeletionCount
- 記錄了此段中刪除的文件的數目。
- HasProx
- 如果至少有一個段omitTf為false,也即詞頻(term freqency)需要被儲存,則HasProx為1,否則為0。
- Diagnostics
- 除錯資訊。
- DeletionCount
- User map data
- 儲存了使用者從字串到字串的對映Map
- CheckSum
- 此檔案segment_N的校驗和。
讀取此檔案格式參考SegmentInfos.read(Directory directory, String segmentFileName):
|
4.1.2. 域(Field)的元資料資訊(.fnm)
一個段(Segment)包含多個域,每個域都有一些元資料資訊,儲存在.fnm檔案中,.fnm檔案的格式如下:
- FNMVersion
- 是fnm檔案的版本號,對於Lucene 2.9為-2
- FieldsCount
- 域的數目
- 一個數組的域(Fields)
- FieldName:域名,如"title","modified","content"等。
- FieldBits:一系列標誌位,表明對此域的索引方式
- 最低位:1表示此域被索引,0則不被索引。所謂被索引,也即放到倒排表中去。
- 僅僅被索引的域才能夠被搜到。
- Field.Index.NO則表示不被索引。
- Field.Index.ANALYZED則表示不但被索引,而且被分詞,比如索引"hello world"後,無論是搜"hello",還是搜"world"都能夠被搜到。
- Field.Index.NOT_ANALYZED表示雖然被索引,但是不分詞,比如索引"hello world"後,僅當搜"hello world"時,能夠搜到,搜"hello"和搜"world"都搜不到。
- 一個域出了能夠被索引,還能夠被儲存,僅僅被儲存的域是搜尋不到的,但是能通過文件號查到,多用於不想被搜尋到,但是在通過其它域能夠搜尋到的情況下,能夠隨著文件號返回給使用者的域。
- Field.Store.Yes則表示儲存此域,Field.Store.NO則表示不儲存此域。
- 倒數第二位:1表示儲存詞向量,0為不儲存詞向量。
- Field.TermVector.YES表示儲存詞向量。
- Field.TermVector.NO表示不儲存詞向量。
- 倒數第三位:1表示在詞向量中儲存位置資訊。
- Field.TermVector.WITH_POSITIONS
- 倒數第四位:1表示在詞向量中儲存偏移量資訊。
- Field.TermVector.WITH_OFFSETS
- 倒數第五位:1表示不儲存標準化因子
- Field.Index.ANALYZED_NO_NORMS
- Field.Index.NOT_ANALYZED_NO_NORMS
- 倒數第六位:是否儲存payload
- 最低位:1表示此域被索引,0則不被索引。所謂被索引,也即放到倒排表中去。
要了解域的元資料資訊,還要了解以下幾點:
- 位置(Position)和偏移量(Offset)的區別
- 位置是基於詞Term的,偏移量是基於字母或漢字的。
- 索引域(Indexed)和儲存域(Stored)的區別
- 一個域為什麼會被儲存(store)而不被索引(Index)呢?在一個文件中的所有資訊中,有這樣一部分資訊,可能不想被索引從而可以搜尋到,但是當這個文件由於其他的資訊被搜尋到時,可以同其他資訊一同返回。
- 舉個例子,讀研究生時,您好不容易寫了一篇論文交給您的導師,您的導師卻要他所第一作者而您做第二作者,然而您導師不想別人在論文系統中搜索您的名字時找到這篇論文,於是在論文系統中,把第二作者這個Field的Indexed設為false,這樣別人搜尋您的名字,永遠不知道您寫過這篇論文,只有在別人搜尋您導師的名字從而找到您的文章時,在一個角落表述著第二作者是您。
- payload的使用
- 我們知道,索引是以倒排表形式儲存的,對於每一個詞,都儲存了包含這個詞的一個連結串列,當然為了加快查詢速度,此連結串列多用跳躍表進行儲存。
- Payload資訊就是儲存在倒排表中的,同文檔號一起存放,多用於儲存與每篇文件相關的一些資訊。當然這部分資訊也可以儲存域裡(stored Field),兩者從功能上基本是一樣的,然而當要儲存的資訊很多的時候,存放在倒排表裡,利用跳躍表,有利於大大提高搜尋速度。
- Payload的儲存方式如下圖:
-
- Payload主要有以下幾種用法:
- 儲存每個文件都有的資訊:比如有的時候,我們想給每個文件賦一個我們自己的文件號,而不是用Lucene自己的文件號。於是我們可以宣告一個特殊的域(Field)"_ID"和特殊的詞(Term)"_ID",使得每篇文件都包含詞"_ID",於是在詞"_ID"的倒排表裡面對於每篇文件又有一項,每一項都有一個payload,於是我們可以在payload裡面儲存我們自己的文件號。每當我們得到一個Lucene的文件號的時候,就能從跳躍表中查詢到我們自己的文件號。
- Payload主要有以下幾種用法:
//宣告一個特殊的域和特殊的詞 public static final String ID_PAYLOAD_FIELD = "_ID"; public static final String ID_PAYLOAD_TERM = "_ID"; public static final Term ID_TERM = new Term(ID_PAYLOAD_TERM, ID_PAYLOAD_FIELD); //宣告一個特殊的TokenStream,它只生成一個詞(Term),就是那個特殊的詞,在特殊的域裡面。 static class SinglePayloadTokenStream extends TokenStream { SinglePayloadTokenStream(String idPayloadTerm) { void setPayloadValue(byte[] value) { public Token next() throws IOException { //對於每一篇文件,都讓它包含這個特殊的詞,在特殊的域裡面 SinglePayloadTokenStream singlePayloadTokenStream = new SinglePayloadTokenStream(ID_PAYLOAD_TERM); long id = 0; |
-
-
- 影響詞的評分
- 在Similarity抽象類中有函式public float scorePayload(byte [] payload, int offset, int length) 可以根據payload的值影響評分。
- 影響詞的評分
-
- 讀取域元資料資訊的程式碼如下:
FieldInfos.read(IndexInput, String)
|
4.1.3. 域(Field)的資料資訊(.fdt,.fdx)
- 域資料檔案(fdt):
- 真正儲存儲存域(stored field)資訊的是fdt檔案
- 在一個段(segment)中總共有segment size篇文件,所以fdt檔案中共有segment size個項,每一項儲存一篇文件的域的資訊
- 對於每一篇文件,一開始是一個fieldcount,也即此文件包含的域的數目,接下來是fieldcount個項,每一項儲存一個域的資訊。
- 對於每一個域,fieldnum是域號,接著是一個8位的byte,最低一位表示此域是否分詞(tokenized),倒數第二位表示此域是儲存字串資料還是二進位制資料,倒數第三位表示此域是否被壓縮,再接下來就是儲存域的值,比如new Field("title", "lucene in action", Field.Store.Yes, …),則此處存放的就是"lucene in action"這個字串。
- 域索引檔案(fdx)
- 由域資料檔案格式我們知道,每篇文件包含的域的個數,每個儲存域的值都是不一樣的,因而域資料檔案中segment size篇文件,每篇文件佔用的大小也是不一樣的,那麼如何在fdt中辨別每一篇文件的起始地址和終止地址呢,如何能夠更快的找到第n篇文件的儲存域的資訊呢?就是要藉助域索引檔案。
- 域索引檔案也總共有segment size個項,每篇文件都有一個項,每一項都是一個long,大小固定,每一項都是對應的文件在fdt檔案中的起始地址的偏移量,這樣如果我們想找到第n篇文件的儲存域的資訊,只要在fdx中找到第n項,然後按照取出的long作為偏移量,就可以在fdt檔案中找到對應的儲存域的資訊。
- 讀取域資料資訊的程式碼如下:
Document FieldsReader.doc(int n, FieldSelector fieldSelector)
|
4.1.3. 詞向量(Term Vector)的資料資訊(.tvx,.tvd,.tvf)
詞向量資訊是從索引(index)到文件(document)到域(field)到詞(term)的正向資訊,有了詞向量資訊,我們就可以得到一篇文件包含那些詞的資訊。
- 詞向量索引檔案(tvx)
- 一個段(segment)包含N篇文件,此檔案就有N項,每一項代表一篇文件。
- 每一項包含兩部分資訊:第一部分是詞向量文件檔案(tvd)中此文件的偏移量,第二部分是詞向量域檔案(tvf)中此文件的第一個域的偏移量。
- 詞向量文件檔案(tvd)
- 一個段(segment)包含N篇文件,此檔案就有N項,每一項包含了此文件的所有的域的資訊。
- 每一項首先是此文件包含的域的個數NumFields,然後是一個NumFields大小的陣列,陣列的每一項是域號。然後是一個(NumFields - 1)大小的陣列,由前面我們知道,每篇文件的第一個域在tvf中的偏移量在tvx檔案中儲存,而其他(NumFields - 1)個域在tvf中的偏移量就是第一個域的偏移量加上這(NumFields - 1)個數組的每一項的值。
- 詞向量域檔案(tvf)
- 此檔案包含了此段中的所有的域,並不對文件做區分,到底第幾個域到第幾個域是屬於那篇文件,是由tvx中的第一個域的偏移量以及tvd中的(NumFields - 1)個域的偏移量來決定的。
- 對於每一個域,首先是此域包含的詞的個數NumTerms,然後是一個8位的byte,最後一位是指定是否儲存位置資訊,倒數第二位是指定是否儲存偏移量資訊。然後是NumTerms個項的陣列,每一項代表一個詞(Term),對於每一個詞,由詞的文字TermText,詞頻TermFreq(也即此詞在此文件中出現的次數),詞的位置資訊,詞的偏移量資訊。
- 讀取詞向量資料資訊的程式碼如下:
TermVectorsReader.get(int docNum, String field, TermVectorMapper)
|
分類: Lucene原理