下面我們簡單的學習(或者複習)一下Lucene的建索引過程,我們將給出lucene 2.x/3.x 和 最新trunk正在開發的4.0的建立索引的方法,尤其是它們的區別。
Lucene 2.x/3.x裏建立索引並進行簡單搜索的例子
- Directory dir=FSDirectory.open(new File("./testindex"));
- for(String fn:dir.listAll()){
- dir.deleteFile(fn);
- }
- IndexWriter writer=new IndexWriter(dir,new WhitespaceAnalyzer(Version.LUCENE_36),IndexWriter.MaxFieldLength.UNLIMITED);
- Document doc=new Document();
- doc.add(new Field("id","0001",Field.Store.YES,Field.Index.NOT_ANALYZED));
- doc.add(new Field("body","hello world, this is text body part. ",Field.Store.NO,Field.Index.ANALYZED));
- doc.add(new Field("clickCount","10",Field.Store.YES,Field.Index.NO));
- writer.addDocument(doc);
- doc=new Document();
- doc.add(new Field("id","0002",Field.Store.YES,Field.Index.NOT_ANALYZED));
- doc.add(new Field("body","good bye. that is it. ",Field.Store.NO,Field.Index.ANALYZED));
- doc.add(new Field("clickCount","3",Field.Store.YES,Field.Index.NO));
- writer.addDocument(doc);
- writer.close();
- IndexReader reader=IndexReader.open(dir);
- IndexSearcher searcher=new IndexSearcher(reader);
- Query q=new TermQuery(new Term("body","is"));
- TopDocs docs=searcher.search(q, 10);
- for(int i=0;i<docs.totalHits;i++){
- int docId=docs.scoreDocs[i].doc;
- float score=docs.scoreDocs[i].score;
- doc=searcher.doc(docId);
- System.out.println("id="+doc.get("id")+", clickcount="+doc.get("clickCount"));
- }
- reader.close();
Lucene 4 裏建立索引並進行簡單搜索的例子
- Directory dir=FSDirectory.open(new File("./testindex"));
- IndexWriterConfig cfg=new IndexWriterConfig(Version.LUCENE_40,new WhitespaceAnalyzer(Version.LUCENE_40));
- cfg.setOpenMode(OpenMode.CREATE);
- IndexWriter writer=new IndexWriter(dir,cfg);
- Document doc=new Document();
- doc.add(new Field("id","0001",StringField.TYPE_STORED));
- doc.add(new TextField("body","hello world, this is text body part. "));
- doc.add(new DocValuesField("clickcount",10,DocValues.Type.FIXED_INTS_8));
- writer.addDocument(doc);
- doc=new Document();
- doc.add(new Field("id","0002",StringField.TYPE_STORED));
- doc.add(new TextField("body","good bye. that is it. "));
- doc.add(new DocValuesField("clickcount",3,DocValues.Type.FIXED_INTS_8));
- writer.addDocument(doc);
- writer.close();
- IndexReader reader=DirectoryReader.open(dir);
- IndexSearcher searcher=new IndexSearcher(reader);
- Query q=new TermQuery(new Term("body","is"));
- TopDocs docs=searcher.search(q, 10);
- DocValues docValues = MultiDocValues.getDocValues(reader, "clickcount");
- Source source = docValues.getSource();
- for(int i=0;i<docs.totalHits;i++){
- int docId=docs.scoreDocs[i].doc;
- float score=docs.scoreDocs[i].score;
- doc=searcher.doc(docId);
- System.out.println("id="+doc.get("id")+", clickcount="+source.getInt(docId));
- }
- reader.close();
注意一下里面的區別。
首先在lucene4裏構建IndexWriter必須使用IndexWriterConfig,這個類是3.1纔開始有的。以前建立索引相關的一些參數,比如使用什麼DeletePolicy或者什麼MergeScheduler,都是調用IndexWriter.setXXX,現在把所有這些配置都放到這個類裏頭了。而且2.x時構造IndexWriter時要特別小心,尤其是傳入的Directory裏有以前的索引時,你需要小心的處理以前的索引——到底是刪除原來的所以索引從新構建還是在原來的索引的基礎上增量索引。
我上面的例子裏需要清空原來的索引,在2.x/3.x的版本里,我需要自己刪除原來的索引。當然你也可以使用這個構造函數:
public IndexWriter(Directory d, Analyzer a, boolean create, IndexDeletionPolicy deletionPolicy, MaxFieldLength mfl)
create爲true就會刪除原來的索引,false就會append原來的索引。
但是有個問題:你如果想這樣——如果原來索引存在,那麼append,如果不存在,那麼就create。原來是解決不了的,你必須先打開原來的索引,根據是否
拋出異常來判斷原來是否存在,如果不存在就create,否則append
使用IndexWriterConfig就不用擔心了,可以簡單的使用IndexWriterConfig.setOpenMode(OpenMode.CREATE_OR_APPEND);就好了。
Document的變化
Document是lucene裏基本的一個索引單元,如果和數據庫比較的話,一般可以對應到表的一行。概念上,document包含許多的Field,Field可以對應到表的一列。不過與數據庫使用固定的schema不同。lucene中一個document的Field是不固定的,比如document 0可能有title ,document 1可能沒有。增加新的document到索引裏是可以使用任意的Field。不過實際使用時Field一般還是固定的。
在lucene3.x裏,Document裏用List<Fieldable> fields = new ArrayList<Fieldable>();
Fieldable接口的繼承關係爲:
另外還有個DateField,不過這個類已經deprecated了,在3.x保留它只是爲了能讀取老版本的索引。我們應該使用NemericField或者DateTools。
在lucene in action第二版的2.6.1節講到了這個類的用法,其實和JavaDoc裏的基本沒有區別,裏面稍微提到了實現的細節,不過不是很詳細,如果你是讀的中文版,估計更會雲裏霧裏,Trie竟然被翻譯成了特里,google translate也不會這麼翻譯啊。
我們繼續回來把NumericField相關的內容講完。
不過在這之前需要說明一下在NumericField出現之前怎麼索引數值類型。
如果不需要支持範圍查詢,那麼我們簡單的把整數變成字符串就行了。但是要支持範圍查詢,那就有點麻煩了。因爲Lucene2.x/3.x索引的基本單位是Term,保存在tis或者tii(前者是字典文件,後者是字典的索引,後面的學習筆記會詳細說明,如果你想現在就瞭解,可以參考http://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/fileformats.html 和 http://www.cnblogs.com/forfuture1978/archive/2009/12/14/1623597.html)文件裏。
lucene要求term都是按照字典序(lexicographic sortable)排列,然後它的範圍查詢根據tii找到範圍的起始Term,然後把這中間的所以Term展開成一個BooleanQuery。
如果我們簡單的把數字變成字符串,那麼2 4 8 31的字母順序變成了 2 31 4 8,那麼查找[2, 4]的RangeQuery也會把31也搜索出來,這顯然是錯誤的。
當然最容易想到的trick就是在前面補0,比如把上面的數字變成字符串 02 04 08 31,這樣就不會有問題了。但是補多少個0是個問題。補0太多了,浪費空間(不過lucene的tis會使用前綴壓縮,所以還不算太壞);補0太少了,不能保存太大的數值。
這是RangeQuery的第一個問題。第二個問題就是展開成所有的Term的BooleanOr的query有一個問題,那就是如果範圍太大,那麼可能包含非常多的Boolean Clause,較早的版本可能會拋出Too Many Boolean Clause的Exception。後來的版本做了改進,不展開所以的term,而是直接合並這些term的倒排表。這樣的缺點是合併後的term的算分成了問題,比如tf,你是把所有的term的tf加起來算一個term,idf呢,coord呢?(lucene的Scoring也會在後面講到,可以參考http://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/api/core/org/apache/lucene/search/Similarity.html和
http://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/scoring.html)
算法我們暫且放下,即使我們可以合併成一個term,合併這些term的docIds也是很費時間的,因爲這些信息都在磁盤上。
Uwe Schindler(現在也是lucene PMC)基於Trie的數據結構做了優化。我們簡單的介紹一下他的思路,具體的代碼就不講了,我會說明它們的位置,有興趣的同學可以自己去看。
其實思想很簡單:
1. 首先可以把數值轉換成一個字符串,並且保持順序。也就是說如果 number1 < number2 ,那麼transform(number) < transform(number)。transform就是把數值轉成字符串的函數,如果拿數學術語來說,transform就是單調的。
1.1 首先float可以轉成int,double可以轉成long,並且保持順序。
這個是不難實現的,因爲float和int都是4個字節,double和long都是8個字節,從概念上講,如果是用科學計數法,把指數放在前面就行了,因爲指數大的肯定大,指數相同的尾數大的排前面。 比如 0.5e3, 0.4e3, 0.2e4,那麼邏輯上保存的就是<4, 0.2> <3, 0.5> <3, 0.4>,那麼顯然是保持順序的。Java的浮點數採用了ieee 754的表示方法(參考http://docs.oracle.com/javase/6/docs/api/java/lang/Float.html#floatToIntBits(float)),它的指數在前,尾數在後。這很好,不過有一點,它的最高位是符號位,正數0,負數1。這樣就有點問題了。
那麼我們怎麼解決這個問題呢?如果這個float是正數,那麼把它看成int也是正數,而且根據前面的說明,指數在前,所以順序也是保持好的。如果它是個負數,把它看出int也是負數,但是順序就反了,舉個例子 <4,-0.2> <3, -0.5>,如果不看符號,顯然是前者大,但是加上符號,那麼正好反過來。也就是說,負數的順序需要反過來,怎麼反過來呢? 就是符號位不變,其它位0變成1,1變成0?具體怎麼實現呢?還記得異或嗎?1 ^ 0 = 1; 1 ^ 1 = 0,注意左邊那個加粗的1,然後看第二個操作數,也就是想把一個位取反,那麼與1異或運算就行了。類似的,如果想保持某一位不變,那麼就讓它與0異或。
因此我們可以發現NumericUtils有這樣一個方法,就是上面說的實現。
- public static int floatToSortableInt(float val) {
- int f = Float.floatToIntBits(val);
- if (f<0) f ^= 0x7fffffff;
- return f;
- }
同理,double也可以轉成long
1.2 一個int可以轉換成一個字符串,並且保持順序
我們這裏考慮的是java的int,也就是有符號的32位正數,補碼錶示。如果只考慮正數,從0x0-0x7fffffff,那麼它的二進制位是升序的(也就是把它看成無符號整數的時候);如果只考慮負數,從0x10000000-0xffffffff,那麼它的二進制位也是升序的。唯一美中不足的就是負數排在正數後面。
因此如果我們把正數的最高符號位變成1,把負數的最高符號位變成0,那麼就可以把一個int變成有序的二進制位。
我們可以在intToPrefixCoded看到這樣的代碼:int sortableBits = val ^ 0x80000000;
因爲lucene只能索引字符串,那麼現在剩下的問題就是怎麼把一個4個byte變成字符串了。Java在內存使用Unicode字符集,並且一個Java的char佔用兩個字節(16位),我們可能很自然的想到把4個byte變成兩個char。但是Lucene保存Unicode時使用的是UTF-8編碼,這種編碼的特點是,0-127使用一個字節編碼,大於127的字符一般兩個字節,漢字則需要3個字節。這樣4個byte最多需要6個字節。其實我們可以把32位的int看出5個7位的整數,這樣的utf8編碼就只有5個字節了。這段代碼就是上面算法的實現:
- int sortableBits = val ^ 0x80000000;
- sortableBits >>>= shift;
- while (nChars>=1) {
- // Store 7 bits per character for good efficiency when UTF-8 encoding.
- // The whole number is right-justified so that lucene can prefix-encode
- // the terms more efficiently.
- buffer[nChars--] = (char)(sortableBits & 0x7f);
- sortableBits >>>= 7;
- }
首先把val用前面說的方法變成有序的二進制位。然後把一個32位二進制數變成5個7位的正數(0-127)。
細心的讀者可以會發現sortableBits >>>= shift;這行代碼。我們下面會講到這點。
總結一下,我們可以通過上面的方法把Java裏常見的數值類型(int,float,long,double)轉成字符串,並且保持順序。【大家可以思考一下其它的類型比如short】。這樣很好的解決了用原來的方法需要給整數補0的問題。
現在我們來看看第二個問題:範圍查詢時需要展開的term太多的問題。參考下圖:
引自Schindler, U, Diepenbroek, M, 2008. Generic XML-based Framework for Metadata Portals. Computers & Geosciences 34 (12)
我們可以建立trie結構的索引。比如我們需要查找423--642直接的文檔。我們只需要構建一個boolean or query,包含6個term(423,44,5,63,641,642)就行了。而不用構建一個包含11個term的query。當然要做到這點,那麼需要在建索引的時候把445和446以及448的docId都合併到44。怎麼做到這一點呢?我們可以簡單的構建一個分詞器。比如423我們同時把它分成3個詞,4,42和423。當然這是把數字直接轉成字符串,我們可以用上面的方法把一個整數變成一個UTF8的字符串。但現在的問題是怎麼索引它的前綴。比如在上圖中,我們把423“分詞”成423,42,4;類似的,我們可以把一個二進制位也進行“前綴”分詞,比如6的二進制位表示是110,那麼我們可以同時索引它的前綴11和1。當然對於上圖,對於423,我們可以只分詞成423和4,也就是隻索引百位,這樣trie索引本身要小一些,對某些query,比如搜索300-500,和原來一樣,只需要搜索term “4”,但是某些query,比如搜索420-450,那麼需要搜索更多的term。
因此NumericRangeQuery有一個precisionStep,默認是4,也就是隔4位索引一個前綴,比如0100,0011,0001,1010會被分成下列的二進制位“0100,0011,0001,1010“,”0100,0011,0001“,”0100,0011“,”0100“。這個值越大,那麼索引就越小,那麼範圍查詢的性能(尤其是細粒度的範圍查詢)也越差;這個值越小,索引就越大,那麼性能越差。這個值的最優選擇和數據分佈有關,最優值的選擇只能通過實驗來選擇。
另外還有一個問題,比如423會被分詞成423,42和4,那麼4也會被分詞成4,那麼4表示哪個呢?
所以intToPrefixCoded方法會額外用一個char來保存shift:buffer[0] = (char)(SHIFT_START_INT + shift);
比如423分詞的4的shift是2(這裏是10進制的例子,二進制也是同樣的),423分成423的shift是0,4的shift是0,因此前綴肯定比後綴大。
上面說了怎麼索引,那麼Query呢?比如我給你一個Range Query從423-642,怎麼找到那6個term呢?
需要說明的一點:我們雖然概念上有一棵樹,實際上,我們的這棵樹和一般書的表示方法有些不同。一般的樹保存了一個節點的孩子節點(當然有的還保存了父親節點),我們這裏正好相反,只能知道一個節點的父親節點(前綴)。
我們首先可以用shift==0找到範圍的起點後終點(有可能沒有相等的,比如搜索422,也會找到423)。然後一直往上找,直到找到一個共同的祖先(肯定能找到,因爲樹根是所有葉子節點的祖先),對應起點,每次往上走的時候都要把它右邊的兄弟節點都加進去。
比如423沒有兄弟,42右邊的兄弟是44,4的兄弟是5;
642左邊的兄弟是641,64左邊的兄弟是63。
上面說明原理時我使用了十進制的例子,二進制也是一樣的,具體細節參考NumericUtils.splitRange
這一部分先就到這吧。因爲我事先也沒有規劃,代碼看到哪裏就寫到哪裏,有些地方可能過於詳細,過於細節了,不懂也沒有關係,一旦哪天用到了再來看體會就更深刻。另外如果對這個學習筆記有什麼建議,比如想了解lucene/solr哪些方面的實現也可以跟我交流,我也可以有針對性的寫一下那些內容。