要飛起來了,Lucene 高階查詢技巧

  • 2019 年 10 月 4 日
  • 筆記

在前面的章節中我們使用了最基礎的關鍵詞查詢 TermQuery 和 複合查詢 BooleanQuery,本節我們來嘗試 Lucene 內置的其它高級查詢功能。

字元串前綴查詢 PrefixQuery

同關係資料庫索引一樣,得益於 FST 的前綴共享屬性,Lucene 也支援前綴查詢。它會將共享同樣前綴的一系列關鍵詞都找出來,然後再 merge 它們的文檔列表。

var query = new PrefixQuery(new Term("title", "動"));  

查詢結果如下,可以看到 「動物」和「動作」等辭彙都搜索出來了

圖片

但是默認的 PrefixQuery 不會對搜索的結果進行排序,它對所有被搜索出來的文檔統一打分 1.0,在實現上可以讓查詢效率快很多,直接省去了收集所有文檔進行排序的過程。如果你希望結果可以排序,可以給 PrefixQuery 設置一個選項,查詢的結果可以觀察 hit.score 值的大小。

var query = new PrefixQuery(new Term("content", "動"));  query.setRewriteMethod(MultiTermQuery.SCORING_BOOLEAN_REWRITE);  var hits = searcher.search(query, 10).scoreDocs;  for (var hit : hits) {      var doc = searcher.doc(hit.doc);      System.out.printf("%s:%.2f => %sn", doc.get("url"), hit.score, doc.get("title"));  }  

圖片

PrefixQuery 為什麼要默認關閉排序呢?這是因為前綴查詢能匹配到的關鍵詞可能會很多,merge 所有的文檔列表並排序將會是一個非常耗費性能的過程。

數字範圍查詢 NumericRangeQuery

數字查詢和字元串查詢不太一樣,在內部實現結構上它並不是像字元串那樣使用 FST 來組織關鍵詞。如果每一個數字都是關鍵詞,那麼使用數字範圍定位出的關鍵詞會非常多,需要 merge 的文檔列表也會非常多,這樣的查詢效率就會很差。如果是浮點型數字,那這個問題就更加突出了,可能每個浮點數字只會關聯一個文檔,而浮點數關鍵詞將會太多太多。

Lucene 將數字進行特殊處理,內部使用 BKD-Tree 來組織數字鍵。BKD 樹也是一個較為複雜的數據結構,簡單理解就是將數值比較接近的一些數字聯合起來作為一個葉節點共享一個 PostingList,同時在 PostingList 的元素需要存儲數字鍵的值,在查詢時會額外多出一個過濾步驟。粗略來看,BKD 樹會將一個大的數值範圍進行二分,然後再繼續二分,一直分到幾層之後發現關聯的文檔數量小於設定的閾值,就不再繼續拆分了。BKD 樹還可以索引多維數值,這樣它就可以應用於地理位置查詢。

下面我們給前面的文章索引增加一個欄位,文章的 ID,我們需要將目錄里的所有文件全部刪除,然後運行 Indexer.java 重新構建索引。

var doc = new Document();  doc.add(new TextField("title", title, Field.Store.YES));  doc.add(new TextField("content", content, Field.Store.YES));  doc.add(new IntPoint("article_id", Integer.parseInt(id)));  doc.add(new StoredField("url", url));  indexWriter.addDocument(doc);  

IntPoint 類型只索引,不存儲,對應的選項默認是 Field.Store.NO,Lucene 只會將它的值放入 PostingList 的元素中。如果需要存儲還需要單獨增加一個 StoreField 類型,在本例中我們不需要存儲。之所以叫 Point 類型是因為它可以支援多維數值,IntPoint 的構造器是一個可變參數,可以填入多個整數形成的數值對。下面我們嘗試查詢

var query = IntPoint.newRangeQuery("article_id", 400000, 450000);  var hits = searcher.search(query, 10).scoreDocs;  

查詢結果如下

圖片

對數值型的查詢結果進行排序是沒有意義的,返回的文檔 score 值都是默認的 1.0。

關於 BKD 樹的更多細節,我們後面再繼續討論。

字元串範圍查詢 TermRangeQuery

同數字範圍查詢類似,字元串也有範圍查詢,它是通過遍歷關鍵詞前綴樹 FST 來實現的,它會按照字典序將範圍內的所有辭彙都列出來,然後 merge 所有關鍵詞的文檔列表。如果這個範圍覆蓋的關鍵詞很多,可以預見性能是很差的。不過,字典序對於中文來說是沒有意義的,所以通常我們不會去使用它。

var query = TermRangeQuery.newStringRange("level", "level1", "level9", true, true);  

上面的查詢表示等級範圍在 [level1, level9] 之間的所有數據,參數中的兩個 bool 值表示是否包含邊界值。如果是兩個 false,那麼等級範圍就是 (level1, level9)。

短語查詢 PhraseQuery

「北京大學」是一個辭彙,我們可以使用 TermQuery 關鍵詞查詢來快速定位所有包含「北京大學」的內容。比如下面這個查詢就可以查出很多文章,因為「北京大學」上鏡率很高。

var query = new TermQuery(new Term("content", "北京大學"));  

那如何定位「北京xx大學」這樣的內容呢?它可以是「北京科技大學」、「北京交通大學」、「北京化工大學」等詞,但是不可以匹配「我是北京人,我沒上過大學」這樣的語句。這時候就可以用到短語查詢 PhraseQuery。

var query = new PhraseQuery.Builder()          .add(new Term("content", "北京"))          .add(new Term("content", "大學"))          .setSlop(2)          .build();  

這裡有一個關鍵參數 Slop 表示兩個辭彙之間最多允許間隔的單詞數量,注意這裡是「單詞」數量,而不是「字元」數量。如此它就可以匹配「北京工業大學」,同時還可以匹配「北京大學」。下面我們來看看它的查詢結果

圖片

奇怪的是它只搜尋到了三篇文章,點開鏈接進去後發現它只匹配到了「北京林業大學」、「北京體育大學」,就是沒有匹配到「北京大學」,難道 PhraseQuery 的 Slop 欄位含義另有解釋? 我也被這個問題愣了一段時間,結果發現是因為在構建索引的時候使用的中文切詞分析器的問題。我們用的是 HanLPAnalyser,它會將「北京大學」切成單個辭彙,而不是切成三個辭彙「北京」、「大學」和「北京大學」,這樣就會導致相關文章只會收錄到倒排索引中的「北京大學」這個詞,而不會索引到「北京」和「大學」這兩個詞上面。那該如何解決這個問題呢?這需要我們在構建索引時對分詞器進行適當的修改。

var analyser = new Analyzer() {      protected TokenStreamComponents createComponents(String fieldName) {          var tokenizer = new HanLPTokenizer(HanLP.newSegment().enableOffset(true).enableIndexMode(true), null, false);          return new TokenStreamComponents(tokenizer);      }  };  

將 HanLP 的 indexMode 打開,如此就可以將 「北京大學」分詞成三個辭彙「北京」、「大學」和「北京大學」。重新建立索引後,再次嘗試查詢,就可以看到期望的搜尋結果。

圖片

從結果中我們可以注意到文章是攜帶排序分值資訊的,「北京」和「大學」辭彙越接近,出現的越頻繁,文章的評分就越高。同時我們還要注意到它是攜帶順序的,它不能匹配「大學xx北京」這樣的內容。

正則查詢 WildcardQuery

查詢「北京xx大學」的方式除了上面的短語查詢之外,Lucene 還提供了正則查詢。比如上面的查詢可以改寫成

var query = new WildcardQuery(new Term("content", "北京*大學"));  

它可以匹配「北京大學」和「北京林業大學」,* 號表示 0 到多個字元。但是如果你仔細查看查詢結果的內容你會發現,所有的內容都是包含「北京大學」這樣的辭彙,「北京xx大學」這樣的文章卻沒有找出來。這是為什麼呢?因為正則匹配的對象是倒排索引的關鍵詞。使用上面的分詞器是不會得到「北京xx大學」這樣的辭彙的,分詞的結果是「北京」、「xx」和「大學」這三個辭彙。從這點來說它和 PhraseQuery 差異還是很大的。除了 * 號之外還有 ? 號表示單個字元,它不能使用任意的複雜正則表達式。注意如果 * 號位於辭彙開頭,查詢將會嘗試掃描所有關鍵詞來尋找出匹配的候選詞,這對性能將是一個很大的傷害。所以我們要儘可能避免使用首字母 * 號的正則查詢,辭彙的前綴越長查詢性能越好。

模糊查詢 FuzzyQuery

最後我們來看一個更加高級的查詢模式 —— 模糊查詢。它跟上面的查詢方式都不太一樣,它是基於「編輯距離」的查詢。當我們目標查詢是「北京大學」時它可以匹配「北方大學」,還可以匹配「北京中學」,它的性能不怎麼樣,因為和指定辭彙相似的辭彙會有很多選擇,如此就會匹配非常多的辭彙,需要 merge 非常多的文檔列表,然後還需要根據編輯距離和辭彙的頻率進行評分排序。除了 merge 文檔列表和排序的代價之外,尋找到相似的辭彙也需要一定的代價。它需要搜尋整個關鍵詞的前綴樹(FST),然後計算它們之間的編輯距離,再挑選出「最大編輯距離」範圍內的辭彙。

var query = new FuzzyQuery(new Term("content", "北京大學"));  

為了加速關鍵詞匹配的效率,減少相似的關鍵候選詞,FuzzyQuery 給查詢做了若干限制條件。它可以設置關鍵詞的前綴數量 prefixLength,比如如果前綴數量為2,那麼指定「北京大學」,就不會匹配「北方大學」,但是可以匹配「北京中學」。它可以設置編輯距離的最大值 maxEdits,跟關鍵詞差距太大的辭彙就不用考慮了。它還可以設置相似辭彙候選集的最大數量 maxExpansions,避免需要 merge 太多的文檔列表。這三個選項的默認值分別是

var defaultMaxEdits = 2  var defaultPrefixLength = 0  var defaultMaxExpansions = 50  

因為默認的前綴長度是 0,所以它需要掃描整顆關鍵詞樹,這個性能很差。使用時務必注意,保險起見,還是別輕易使用 FuzzyQuery。

全表遍歷 MatchAllDocsQuery

同關係資料庫一樣,Lucene 也提供了全表遍歷查詢 MatchAllDocsQuery,它是不走倒排索引的,因為基數太大,所以默認不評分不排序。如果數據量很大,進行全量評分排序估計會把伺服器卡死,最好也不要使用它。

var query = new MatchAllDocsQuery();  

下一節我們來講 Lucene 最高級的查詢模式 —— 表達式查詢 QueryParser,它可以將一個文本表達式字元串解析成上面各種 Query 的邏輯組合 BooleanQuery,有了 QueryParser 我們就可以非常方便地構造出一個十分複雜的組合邏輯查詢。