lucene3.0.3中的數字索引以及數字範圍查詢

      我看了3個下午,加上一個上午終於看懂了lucene對於數字的索引和對於數字範圍的檢索,主要的時間都是花在了NumericRangeQuery上,儘管一次一次的失敗但是我並沒有放棄的打算,研究與探索本來就是我的一大興趣,最後的喜悅要比之前所有的痛苦都要來的爽!謝謝筆記,方便可能正在迷茫的你。備註:如果你對lucene的索引格式不熟悉尤其剛接觸lucene的話,請繞行,這片筆記只適合對源碼有深入研究的程序員。

      對於數字的索引並不是直接將數字變爲字符串,因爲這樣的話沒法進行範圍搜索,比如我們索引1、5、20、56、89、200、201、202、203...299、500,如果按照lucene的字符排序,這些term在詞典表中的排序爲1、20、200、201、202、203...299、5、56、500、89,顯然他的順序是沒有用於範圍查詢的,如果我們要進行範圍搜索1-300的話,我們就要窮盡所有該域下的term,因爲可能有以9開頭的term,但是他出現在所有的term的最後面,所以明顯按照字符順序排序不是最好的排序方式。我們看卡luene是怎麼樣存儲的,對於數字的域,是使用lucene的NumeircField,他裏面有個最重要的屬性是TokenStream——NumericTokenStream,它用來將這個域的數字進行分詞(也就是形成一個特里樹),看下他的incrementToken方法,該方法用於對數字進行分詞,這裏我們以long(也就是64位作爲例子)型的數字作爲例子,進行分詞的方法是調用的NumericUtils.longToPrefixCoded方法:

/** 將一個數字值分多次進行shift,一次處理precisionStep的位數 */
@Override
public boolean incrementToken() {
	
	if (valSize == 0)
		throw new IllegalStateException("call set???Value() before usage");
	if (shift >= valSize)
		return false;
	clearAttributes();
	final char[] buffer;
	switch (valSize) {
		case 64:
			buffer = termAtt.resizeTermBuffer(NumericUtils.BUF_SIZE_LONG);
			// 將數字變爲字符,返回的字符內容放入buffer中
                        // 每調用一次會形成一個字符串,放入到buffer中,所以關鍵的就是這個方法,他會使用到當前已經處理的位數已經精度的位數
			termAtt.setTermLength(NumericUtils.longToPrefixCoded(value, shift, buffer));
			break;
		case 32:
			buffer = termAtt.resizeTermBuffer(NumericUtils.BUF_SIZE_INT);
			termAtt.setTermLength(NumericUtils.intToPrefixCoded((int) value, shift, buffer));
			break;
		default:
			// should not happen
			throw new IllegalArgumentException("valSize must be 32 or 64");
	}
	typeAtt.setType((shift == 0) ? TOKEN_TYPE_FULL_PREC : TOKEN_TYPE_LOWER_PREC);
	posIncrAtt.setPositionIncrement((shift == 0) ? 1 : 0);//第一次的時候表示是一個數字,非第一次爲0表示是同一個數字,所以位置增量是0
	shift += precisionStep;//增加偏移量
	return true;
	
}

 在上面的方法中有兩個重要的屬性,一個是shift,一個是precisionStep,在計算機中,數字用二進制表示,在進行分詞的時候他的思路是每次都向左偏移precisionStep的位數,被偏移過的數字都被忽略,而剩餘的數字則形成一個字符串,precisionStep就是每次都偏移的二進制的位數,而shift表示現在已經偏移過的總的位數,我們看下NumericUtils.longToPrefixCoded的源碼:

public static int longToPrefixCoded(final long val, final int shift, final char[] buffer) {
		
	if (shift > 63 || shift < 0) 
		throw new IllegalArgumentException("Illegal shift value, must be 0..63");
	
	// 計算本次處理的所有的位數要生成的char的個數,計算的規則爲 64-已經處理的位數(也就是shift)然後除以7,因爲後面用每7個bit位形成一個字符,(63-shift)/7 + 1等價於這個意思。
	int nChars = (63 - shift) / 7 + 1;
	
	// 這個是本次處理的所有的位數加上記錄偏移量的字符產生的char的總個數,比上面的多一的原因是第一個字符用於存儲偏移量,偏移量用32+shift表示,形成一個字符。
	int len = nChars + 1;
	// 填寫第一個字符——偏移的位數,爲32+shift 用第一位表示偏移量也是有原因的,因爲偏移量越大他的位數也就越高,那麼表示的數也就越大(所有的數字在存儲時都是有偏移量的,剛上來的是偏移量爲0), 這一點和字符串的字符順序也是對應的,如果高位(靠左邊)更大則這個字符串會排在後面(即 bx 一定在ax後面)
	buffer[0] = (char) (SHIFT_START_LONG + shift);
		
	//0x8000000000000000L這個數字是第64位爲1,其他位爲0。抑或他的目的是將最高位取反,也就是如果原來是正數符號位變爲1,負數的符合位變爲0。通過這個操作後,所有的負數排在正數的前面,且正數的相對位置不變,負數的也不變化。(此步驟自己想,我在這裏不做解釋)
	long sortableBits = val ^ 0x8000000000000000L;
	sortableBits >>>= shift; //不計算已經shift的位數,只計算還剩餘的位數。
	while (nChars >= 1) { //循環,每7位處理一次,這裏之所以用7位形成一個字符也是有原因的,因爲最終在磁盤上是使用utf-8格式,7位的話最大是127,在127以下時utf8編碼採用的是一個字節表示一個字符,這時最節省空間。
		// 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.  。
		// 只取最後的7個bit,形成一個char。也就是每7個bit形成一個char,放入到buffer中
		buffer[nChars--] = (char) (sortableBits & 0x7f);
		sortableBits >>>= 7;//繼續處理下一個7位
	}
	return len;
} 

 

 

上面的方法用二進制描述不太形象,我們用十進制來舉個例子,假設我們要索引的是8153這個數字,我們的precisionStep是1,也就是每一次偏移一個十進制的位,第一次shift是0,所以整個8152會形成一個字符串,外加上用於存儲偏移量的位數(在這裏我們省去用來表示偏移量的位數,並直接用數字的字符串形式作爲最終形成的字符串),所以第一次形成的字符串是8153,第二次是815(但是偏移量是1),第三次是81(偏移量是2),第四次是8(偏移量是3),所以很容易發現他是一個特里結構,他形成的這四個term的意思也可以這樣理解,8xxx的或者是81xx的或者是815x的,還有最精確的8153都是可以搜索到當前的文檔。現在我們把precision變大一點,爲兩個十進制的位,則會形成8153和81,他表示在8153和81xx這兩個term都可以搜到這個document。可以發現,當precision更小的時候,會生成更多的term,那麼索引也一定會更大(如果precision過大,其實在進行範圍搜索的時候會降低速度,這個到搜索的時候再說)。當我們在索引別的數字的時候就會繼續形成不同的term,所有的這些term會最終組成一個特里樹,並且precisionStep越小這個樹的節點就會越多,最終的索引的體積也會越大。

      

        搜索:NumericRangeQuery  這個類用於使用特里樹進行範圍的搜索,他的關鍵是對要進行搜索的範圍進行分詞,找到在特里樹上的節點(也就是之前建立索引時聲稱的term)然後再按照重寫規則對所有找到的term進行重寫,比如聲稱一個booleanQuery,或者是生成一個filter,就這麼簡單,但是他花費了我很長的時間纔看懂。NumericRangeQuery最關鍵的部分就是找到之前的term,通過調用它的getEnum方法,該方法返回了一個NumericRangeTermEnum,我們看看它的代碼:

// 將查詢區間分解成若干個小的查詢區間
NumericUtils.splitLongRange(new NumericUtils.LongRangeBuilder() {
	@Override
	public final void addRange(String minPrefixCoded, String maxPrefixCoded) {
		rangeBounds.add(minPrefixCoded);
		rangeBounds.add(maxPrefixCoded);
	}
}, precisionStep, minBound, maxBound);

 上述方法就是根據要查找的區間範圍(也就是最大值和最小值)以及prerecisionStep,找到形成的特里樹上的節點,將這些節點放入到一個鏈表裏面,提一句,在NumericRangeTermEnum的構造方法裏面,已經將所有的對比轉化爲>=或者是<=,我們看一下NumericUtils.splitLongRange方法

/** 將查詢區間分解成若干個小的查詢區間*/
private static void splitRange(final Object builder, final int valSize, final int precisionStep, long minBound,//下線
		long maxBound/*上線*/) {
	if (precisionStep < 1)
		throw new IllegalArgumentException("precisionStep must be >=1");
	if (minBound > maxBound)
		return;
	for (int shift = 0;; shift += precisionStep) {
		
		// calculate new bounds for inner precision 
		final long diff = 1L << (shift + precisionStep); 
		final long mask = ((1L << precisionStep) - 1L) << shift;//當前精度範圍的最大值
			
		// minBound在本次處理的精度內有沒有要限制的部分,如果等於0說明是本精度範圍內沒有任何限制,也就是本精度範圍的任何值都是可以的。繼續匹配上一個精度即可。
		// 如果是true,則說明本精度範圍要添加限制,所以在後面有addRange (看下面的解釋1)。 
		final boolean hasLower = (minBound & mask) != 0L;
		// 因爲mask是次精度範圍的最大值,如果等於mask說明本層次的所有值都符合要求,不等於說明是有限制的,有的term是不包含的。
		// 如果是true,則本精度要添加限制,所以後面有addRange (看解釋2)
		final boolean hasUpper = (maxBound & mask) != mask;  
		
		
		// 如果在當前區間有值的話就會要加diff,即下一次的區間一定要大於下一個精度的最小值(也就是加一個下一個精度的最小值),然後把當前的精度的限制去掉之後
		// 比如十進制中的 632,precisionStp是1,在把632中的2刪掉之後,不能僅僅是63x,因爲這樣話,631  630也會匹配,所以要加一個十進位的1,表示64x的任意值都是可以的,而不是63x。
		final long nextMinBound = (hasLower ? (minBound + diff) : minBound) & ~mask;//&~mask 將shift到shift+precisionStep位變爲0.
		
		// 道理和上面的一樣,比如十進制中的對比,如果最後一位是9的話則任何本精度的值都會滿足,則直接對比下一個精度即可,如果maxBound是765,則不能僅僅是刪掉最後的精度5,因爲單純的76x並不能完全限制,
		// 因爲769  768 這樣的值在第二個精度的時候也會滿足條件。所以必須減小一位下一個精度,也就是使用75x是可以的。
		final long nextMaxBound = (hasUpper ? (maxBound - diff) : maxBound) & ~mask;
		
		// 這兩個是極端的情況,看下面的解釋三
		final boolean lowerWrapped = nextMinBound < minBound;
		final boolean upperWrapped = nextMaxBound > maxBound;
		
		// 1、第一個判斷是有沒有下一個精度
		// 2、第二個判斷是下一個精度是不是交叉,即最小值比最大值要大,如果是這樣,就不再對比了,因爲在繼續下去仍然是最小值大於最大值。
		// 3、4的原理在解釋3、4中
		if (shift + precisionStep >= valSize || nextMinBound > nextMaxBound || lowerWrapped || upperWrapped) {
			// We are in the lowest precision or the next precision is not available.
			addRange(builder, valSize, minBound, maxBound, shift);
			// exit the split recursion loop
			break;
		}
		
		if (hasLower)
                        //添加最小的區間限制
			addRange(builder, valSize, minBound, minBound | mask, shift);//minBound是當前精度的最小值,minBound|mask表示當前精度的最大值,
		
		if (hasUpper)
                         //添加最大的區間限制
			addRange(builder, valSize, maxBound & ~mask, maxBound, shift);

		minBound = nextMinBound;
		maxBound = nextMaxBound;
	}
}

       解釋1:final boolean hasLower = (minBound & mask) != 0L;  如果minBound和當前精度的最大值做對比等於0,說明minBound在當前精度範圍的所有位都是0,那麼次精度範圍任何一個值都滿足>=0的要求,所以他就不用添加一個節點了,此精度範圍內不會形成一個限制的節點,繼續減小精度即可。相反,如果當前精度範圍內是有限制的,也就是不是最小值,那麼就要添加一個限制的節點用來限制查找的結果,所以當前爲true的時候,會在後面添加addRange方法(等會再看)。

 

      解釋2final boolean hasUpper = (maxBound & mask) != mask,如果maxBound 和當前精度的最大值相等,則在當前精度範圍內,所有的值都會滿足<=maxBound的要求,這是就不用添加當前精度的限制了,繼續減小精度即可。

  解釋3nextMinBound < minBound; 貌似是所有的nextMinBound都要大於minBound,因爲nextMinBound都加上了下一個精度的最小值,怎麼可能比minBound小呢?其實不是,因爲他是有64位或者是32位限制的,我們還是用10進制來描述,因爲計算機中有32位 64位的限制,我們在10進制中用3位的限制,所以我們在處理997的時候,已經在處理過最後的7之後,我們要加上10,再刪除最後的一位,則就會變爲100x,由於3位的限制,所以就會變爲00x,這裏的判斷就是這種情況。當出現這種情況時,說明minBound已經是下一個精度範圍的最大值了,現在已經不能繼續往特里樹的根前進了,所以要退出查找更多term的循環。

  解釋4nextMaxBound > maxBound;道理和上面的3一樣,我們還是舉10進制的例子:比如我們處理的是003,precisionStep是1,十進制的位數限制爲3,,現在處理的3,然後前往下一個precisionStep,減10,則變爲負數了,所以就會出現這種情況,他的意思是已經無法再特里樹裏面找到更深的節點了。

 

 

addRange方法:最終還是調用的longToPrefix方法,將數字變爲字符串,然後放入到一個鏈表裏面,對於每一次調動都會添加兩個term。

 

我們再看一下最後他是如何使用產生的這些term的,在org.apache.lucene.search.NumericRangeQuery.NumericRangeTermEnum.next()方法中

/** Increments the enumeration to the next element. True if one exists. */
@Override
public boolean next() throws IOException {
	
        //這裏的操作貌似沒有必要關閉當前的termEnum,爲什麼不通過調用一個termEnum不停的讀呢?爲什麼還要關掉然後重新打開?
        //因爲在蒐集詞典表中的term的時候,他們不是挨着的,還有一個原因是特里樹裏面的限制性的節點限制的term通過從前向後讀取是讀取不到的,要重新從頭讀取,不一點不像一般的term的查找,
        //而通過使用詞典表的索引(通過使用3.0中的tii文件),也就是在下面while中的創建termEnum的方式,可以更加快速的找到要查找的term。
	// if a current term exists, the actual enum is initialized:
	// try change to next term, if no such term exists, fall-through
	if (currentTerm != null) {
		assert actualEnum != null;
		if (actualEnum.next()) {
			currentTerm = actualEnum.term();
			if (termCompare(currentTerm))
				return true;
		}
	}
	
	// if all above fails, we go forward to the next enum, if one is available
	currentTerm = null;
	while (rangeBounds.size() >= 2) {//只要還剩最後的兩個,也就值最後退出循環的addRange
		
		assert rangeBounds.size() % 2 == 0;
		// close the current enum and read next bounds
		if (actualEnum != null) {
			actualEnum.close();
			actualEnum = null;
		}
		final String lowerBound = rangeBounds.removeFirst();//
		this.currentUpperBound = rangeBounds.removeFirst();
		// create a new enum
		actualEnum = reader.terms(termTemplate.createTerm(lowerBound));
		currentTerm = actualEnum.term();
		if (currentTerm != null && termCompare(currentTerm))
			return true;
		// clear the current term for next iteration
		currentTerm = null;
	}
			
	// no more sub-range enums available
	assert rangeBounds.size() == 0 && currentTerm == null;
	return false;
}

 他的思路是根據產生的用來作爲限制條件的term,來查找處於他們區間的所有term,所以到這裏思路就明朗起來了,概括一下:他是根據maxBoung和minBound和precisionStep產生多個限制的term,然後根據這些限制性的term在詞典表中來查找所有的處於他們去見的term,使用這個辦法的好處是:他能減少對葉子節點的使用,因爲在生成所有的限制性term的時候,都是使用的特里樹的父節點,以及父節點的父節點,直到已經沒有父節點了或者是maxBound和minBound相互交叉,這樣在查找的時候詞典表就會繞過大量的term,只尋找在限制性節點之間的term,使得搜索到的term的數量大大減少,從而加速搜索的速度。

在搜索時使用的precisionStep的大小的影響:此值越大,則生成的特里樹深度越淺,限制性節點的範圍越大,查找到的term的數量就會更多,在合併倒排表的時候就會越慢;此值越小,則生成的特里樹的深度越大,限制性節點的個數越大,搜索到的term的數量越小,合併時的速度越大。

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章