四、具體格式
上面曾經交代過,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)
|