http://www.cnblogs.com/LBSer/p/4419052.html
1 問題描述
我們的檢索排序服務往往需要結合個性化算法來進行重排序,一般來說分兩步:1)進行粗排序,這一過程由檢索引擎快速完成;2)重排序,粗排序后將排名靠前的結果發送給個性化服務引擎,由個性化服務引擎進行深度排序。在我們的業務場景下檢索引擎除了傳遞doc列表,還要傳業務字段如商家id以及用戶位置與該doc的最近距離。
我們的檢索引擎基於lucene,而lucene查詢的結果只包含docId以及對應的score,並未直接提供我們要傳給個性化服務的業務字段列表以及對應的距離,因此本文要解決的問題是:如何根據docId快速查找field字段以及該doc對應的距離?
2 傳統方法—從正排文件中獲取數據
通過倒排檢索得到的是docId,而直觀上看可以根據docId從正排中得到具體的doc內容字段例如dealId等。
首先需要將數據寫入正排,如果沒寫入當然就查詢不了。如何寫入呢?我們將dealId(DealLuceneField.ATTR_ID)、該deal對應的經緯度字符串(DealLuceneField.ATTR_LOCATIONS,多個時以“,”分隔)寫入索引中,Field.Store.YES表示將信息存儲在正排里,lucene會將正排信息存放在fdx、fdt兩個文件中,fdt存放具體的數據,fdx是對fdt的一個索引(第n個doc數據在fdt中的位置)。
Document doc = new Document(); doc.add(new StringField(LuceneField.ATTR_ID, String.valueOf(id, Field.Store.YES)); doc.add(new StringField(LuceneField.ATTR_LOCATIONS, buildMlls(mllsSet, id), Field.Store.YES));
如何查詢呢?
1)直接查詢
通過docId直接查詢得到document,並將document的內容取出,比如取出經緯度字符串后需要計算最近的距離。
for (int i = 0; i < sd.length; i++) { Document doc = searcher.doc(sd[i].doc); //sd[i].doc就是docId,earcher.doc(sd[i].doc)就是根據docId查找相應的document didList.add(Integer.parseInt(doc.get(LuceneField.ATTR_ID))); if (query.getSortField() == DealSortEnum.distance) { 。。。 String[] mlls = locations.split(" "); double dis = findMinDistance(mlls, query.getMyPos()) / 1000; distBuilder.append(dis).append(","); } }
在實際運行中,根據docId獲取經緯度信息並計算最短距離這一過程將耗費8ms左右,而且有的時候抖動至20多ms。
2)優化查詢
直接查詢時將返回所有Field.Store.YES的field數據,而事實上我們僅需要獲取id、localtion這兩個field的數據,因此優化方法是調用doc函數時傳入需要獲取的field集合,這樣避免獲取了整個數據帶來的開銷。
for (int i = 0; i < sd.length; i++) { Document doc = searcher.doc(sd[i].doc, fieldsToLoad); didList.add(Integer.parseInt(doc.get(LuceneField.ATTR_ID))); if (query.getSortField() == DealSortEnum.distance) { String locations = doc.get(LuceneField.ATTR_LOCATIONS); String[] mlls = locations.split(" "); double dis = findMinDistance(mlls, query.getMyPos()) / 1000; distBuilder.append(dis).append(","); } }
然而在實際應用中相對於直接查詢性能上並未有所提升。
原因有兩點:1)使用Field.Store.YES的字段較少,除了id和location之外,只有兩個field存進正排索引中,這種優化對於大量field存儲進正排索引才有效果;2)從正排獲取數據底層是通過讀取文件來獲得的,雖然我們已經通過內存映射打開索引文件,但是由於每次查詢還需要定位解析數據,浪費大量開銷。
3 優化方法1—從倒排的fieldcache中獲取數據
從正排獲取dealId以及location這兩個字段的數據比較緩慢,如果能將這兩個字段進行緩存那么將大大提高計算效率,比如類似一個map,key是docId,value是dealId或者mlls。可惜lucene並未向正排提供這種緩存,因為lucene主要優化的是倒排。
在lucene中,一些用於排序的字段,比如我們使用的“weight”字段,為了加快速度,lucene 在首次使用的時候將該“weight”這個field下所有term轉換成float(如下圖所示),並存放入FieldCache中,這樣在第二次使用的時候就能直接從該緩存中獲取。
FieldCache.Floats weights = FieldCache.DEFAULT.getFloats(reader, "weight", true); //獲取“weights”這一field的緩存,該緩存key是docId,value是相應的值 float weightvalue = weights.get(docId); // 通過docId獲取值
for (int i = 0; i < sd.length; i++) { 。。。 if (query.getSortField() == DealSortEnum.distance) { BytesRef bytesRefMlls = new BytesRef(); mllsValues.get(sd[i].doc, bytesRefMlls); String locations = bytesRefMlls.utf8ToString(); if (StringUtils.isBlank(locations)) continue; String[] mlls = locations.split(" "); double dis = findMinDistance(mlls, query.getMyPos())/1000; distBuilder.append(dis).append(","); } }
通過這種方式優化之后根據docId獲取經緯度信息並計算最短距離這一過程平均響應時間從8ms降低為2ms左右,即使抖動響應時間也不超過10ms。
4 優化方法2—使用ShapeFieldCache
使用fieldcache增加了內存消耗,尤其是location這一字段,這里面存放的是該文檔對應的經緯度字符串,對內存的消耗尤其巨大,尤其是某些文檔的location字段存放着幾千個經緯度(這在我們業務場景里不算少見)。
事實上我們不需要location這一字段,因為我們在建立索引的時候已經通過如下方式將經緯度寫入到索引中,而且lucene在使用時會一次性將所有doc對應的經緯度都放至ShapeFieldCache這一緩存中。
for (String mll : mllsSet) { String[] mlls = mll.split(","); Point point = ctx.makePoint(Double.parseDouble(mlls[1]),Double.parseDouble(mlls[0])); for (IndexableField f : strategy.createIndexableFields(point)) { doc.add(f); } }
查詢代碼如下。
StringBuilder distBuilder = new StringBuilder(); BinaryDocValues idValues = binaryDocValuesMap.get(LuceneField.ATTR_ID); FunctionValues functionValues = distanceValueSource.getValues(null, context); BinaryDocValues idValues = binaryDocValuesMap.get(LuceneField.ATTR_ID); for (int i = 0; i < sd.length; i++) { BytesRef bytesRef = new BytesRef(); idValues.get(sd[i].doc, bytesRef); String id = bytesRef.utf8ToString(); didList.add(Integer.parseInt(id)); if (query.getSortField() == SortEnum.distance) { double dis = functionValues.doubleVal(doc)/1000; distBuilder.append(dis).append(","); } }
a)進一步優化
上面方法節省了內存開銷,但未避免計算開銷。我們知道lucene是提供按距離排序功能的,但是lucene只是完成了排序,並告訴我們相應的docId以及score,但並未告訴我們每個deal與用戶的最近距離值。有沒有什么方法能將距離保存下來呢?
我的方法是通過改寫lucene的collector以及lucene使用的隊列PriorityQueue,通過重新實現這兩個數據結構從而將距離值保存為score,這樣就避免了冗余計算。核心代碼如下:
@Override protected void populateResults(ScoreDoc[] results, int howMany) { // avoid casting if unnecessary. FieldValueHitQueue<SieveFieldValueHitQueue.Entry> queue = (FieldValueHitQueue<FieldValueHitQueue.Entry>) pq; for (int i = howMany - 1; i >= 0; i--) { FieldDoc fieldDoc = queue.fillFields(queue.pop()); results[i] = fieldDoc; results[i].score = Float.valueOf(String.valueOf(fieldDoc.fields[0])); //記錄距離 } }
這樣優化后,獲取數據的平均響應時間從2ms將至0ms,且從未出現抖動。
此外由於避免了在內存中加載location這個字段,gc的響應時間下降一半,服務整體平均響應時間也下降許多。
5 展望
針對如何通過docId快速查找field字段以及最近距離等信息這一問題,本文提供了多種方法並一一嘗試,包括從正排文件獲取,從倒排fieldcache里獲取,以及經緯度從ShapeFieldCache獲取。此外通過改造lucene的收集器和隊列,避免了距離的二次計算。上述這些優化大幅度提升了檢索服務的性能。
通過docId獲取field數據的方式還有很多,例如docvalue等,以后將對這些方法進行探索。
檢索實踐文章系列: