同義詞搜索是如何做到的?
- 2019 年 10 月 4 日
- 筆記
前面幾個章節我們使用到了 Lucene 的中文分詞器 HanLPAnalyzer,它並不是 Lucene 自帶的中文分詞器。Lucene 確實自帶了一些中文分詞器,但是效果比較弱,在生產實踐中多用第三方中文分詞器。分詞的效果直接影響到搜索的效果,比如默認的 HanLPAnalyser 對「北京大學」這個短語的處理是當成完整的一個詞,搜索「北京」這個詞彙就不一定能匹配到包含「北京大學」的文章。對語句的處理還需要過濾掉停用詞,除掉諸於「的」、「他」、「是」等這樣的輔助型詞彙。如果是英文還需要注意消除時態對單詞形式的影響,比如「drive」和「driven」、「take」和「taked」等。還有更加高級的領域例如同義詞、近音詞等處理同樣也是分詞器需要考慮的範疇。
Lucence 中的分詞器包含兩個部分,分別是切詞器 Tokenizer 和過濾器 TokenFilter。切詞器顧名思義負責切,將一個句子切成一連串單詞流,切詞器輸出的單詞流是過濾器的輸入,它負責去掉無用的詞彙比如停用詞,過濾器還可以是詞彙轉換,比如大小寫轉換,過濾器還可以生成新詞彙,比如同義詞。抽象類 Tokenizer 和 TokenFilter 都繼承自 TokenStream 抽象類,Tokenizer 負責將文本(Reader)轉成單詞流,TokenFilter 負責將輸入單詞流轉成另一個單詞流。

圖片
有了上圖中的流水線構造出的最終的 TokenStream,Lucene 就會將輸入的文章灌入其中得到最終的單詞流,然後對單詞流中的每個單詞建立 Key 到 PostingList 的映射以形成倒排索引。這裡的單詞流串聯的是帶有 Payload 的單詞,每個單詞都會有一些附加屬性,諸於單詞的文本、單詞在文檔中的偏移量、單詞在單詞流中的位置等。
而 Lucene 的分詞器 Analyzer 就是上述流水線的工廠類,由它負責製造整條流水線。Lucene 內置了很多種不同功用的分詞器,每種分詞器都會生產出不同的流水線。
下面我們使用 Lucene 提供的標準切詞器觀察分詞效果,標準切詞器是一個基於空格的切詞器。
var tokenizer = new StandardTokenizer(); tokenizer.setReader(new StringReader("Dog eat apple and died")); tokenizer.reset(); var termAttr = tokenizer.addAttribute(CharTermAttribute.class); var offsetAttr = tokenizer.addAttribute(OffsetAttribute.class); var positionIncrAttr = tokenizer.addAttribute(PositionIncrementAttribute.class); while(tokenizer.incrementToken()) { System.out.printf("%s offset=%d,%d position_incr=%dn", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement()); } ------------- Dog offset=0,3 position_incr=1 eat offset=4,7 position_incr=1 apple offset=8,13 position_incr=1 and offset=14,17 position_incr=1 died offset=18,22 position_inc=1
incrementToken() 表示往前走一個詞,單詞位置+1,到了文本末尾它就會返回 false。termAttr、offsetAttr 和 positionIncrAttr 都是當前單詞位置上的附加屬性,分別是單詞的文本、字符偏移量的開始和結束位置和單詞的位置間隔(一般都是 1),這三個屬性就停在那裡「守株待兔、雁過拔毛」,來一個單詞,就立即抽取它的屬性值。其中 positionIncrement 代表單詞的位置間隔,通常連續兩個單詞之間的間隔都是 1。

圖片
下面我們再加上過濾器,將停用詞過濾掉,同時再加上大小寫轉換器,將大寫字母轉成小寫字母。從代碼形式上過濾器和切詞器會通過構造器串聯起來形成一條流水線。
var tokenizer = new StandardTokenizer(); tokenizer.setReader(new StringReader("Dog eat apple and died")); var stopFilter = new StopFilter(tokenizer, StopFilter.makeStopSet("and")); var lowercaseFilter = new LowerCaseFilter(stopFilter); lowercaseFilter.reset(); var termAttr = lowercaseFilter.addAttribute(CharTermAttribute.class); var offsetAttr = lowercaseFilter.addAttribute(OffsetAttribute.class); var positionIncrAttr = lowercaseFilter.addAttribute(PositionIncrementAttribute.class); while(lowercaseFilter.incrementToken()) { System.out.printf("%s offset=%d,%d position_incr=%dn", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement()); } ------------- dog offset=0,3 position_incr=1 eat offset=4,7 position_incr=1 apple offset=8,13 position_incr=1 died offset=18,22 position_incr=2
注意和前面的例子輸出進行對比,所有的單詞 offset 值並沒有發生變化,因為它表示的是在原文中的字符偏移量,而 position_incr 卻發生了變化,因為它代表的是單詞序列的位置。當停用詞被過濾後,單詞序列發生了變化,相應的位置也會跟着改變。

圖片
下面我們來編寫分詞器 Analyzer 將上述切詞器、過濾器進行打包封裝
var analyzer = new Analyzer(){ @Override protected TokenStreamComponents createComponents(String fieldName) { var tokenizer = new StandardTokenizer(); var stopFilter = new StopFilter(tokenizer, StopFilter.makeStopSet("and")); var lowercaseFilter = new LowerCaseFilter(stopFilter); return new TokenStreamComponents(tokenizer, lowercaseFilter); } }; var stream = analyzer.tokenStream("title", "dog eat apple and died"); stream.reset(); var termAttr = stream.addAttribute(CharTermAttribute.class); var offsetAttr = stream.addAttribute(OffsetAttribute.class); var positionIncrAttr = stream.addAttribute(PositionIncrementAttribute.class); while(stream.incrementToken()) { System.out.printf("%s offset=%d,%d position_incr=%dn", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement()); } ---------- dog offset=0,3 position_incr=1 eat offset=4,7 position_incr=1 apple offset=8,13 position_incr=1 died offset=18,22 position_incr=2
注意到 analyzer 的 createComponents 有一個 fieldName 參數,這意味着分析器支持為不同的字段定製不同的流水線,這裡的 Component 含義就是流水線。analyzer 之所以將流水線的製造過程抽象出來就是為了考慮對象的復用,流水線可以很複雜,涉及到非常繁多的對象構建,analyzer 內部會每個線程共用同一條流水線。當單個流水線對象處理一條又一條文本內容時,需要通過 reset() 方法來重置流水線的狀態避免前一條文本內容的狀態遺留給後面的內容。
同義詞過濾器 SynonymGraphFilter
有一個面試常見的題目就是 Lucene 的同義詞搜索是如何實現的?它的實現方式就是通過過濾器對單詞流進行泛化擴充,將一個單詞變成多個單詞,再插入到倒排索引中,在查詢階段也對查詢關鍵詞進行同義擴展成多個詞彙再合併查詢。Lucene 提供了同義詞過濾器的默認實現 SynonymFilter,如今在新的版本中它已經被 SynonymGraphFilter 替換,提供了更加精準的實現。同停用詞過濾器一樣,使用它需要用戶自己添加一個同義詞表。下面的代碼給詞彙 dog 增加了同義詞 puppy 和 pup。
var analyzer = new Analyzer(){ @Override protected TokenStreamComponents createComponents(String fieldName) { var tokenizer = new StandardTokenizer(); var lowercaseFilter = new LowerCaseFilter(tokenizer); var builder = new SynonymMap.Builder(); builder.add(new CharsRef("dog"), new CharsRef("puppy"), true); builder.add(new CharsRef("dog"), new CharsRef("pup"), true); SynonymMap synonymMap = null; try { synonymMap = builder.build(); } catch (IOException ignored) { } assert synonymMap != null; var synonymFilter = new SynonymGraphFilter(lowercaseFilter, synonymMap, true); var stopFilter = new StopFilter(synonymFilter, StopFilter.makeStopSet("and")); return new TokenStreamComponents(tokenizer, stopFilter); } }; var stream = analyzer.tokenStream("title", "dog eat apple and died"); stream.reset(); var termAttr = stream.addAttribute(CharTermAttribute.class); var offsetAttr = stream.addAttribute(OffsetAttribute.class); var positionIncrAttr = stream.addAttribute(PositionIncrementAttribute.class); while(stream.incrementToken()) { System.out.printf("%s offset=%d,%d position_incr=%dn", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement()); } ----------- puppy offset=0,3 position_incr=1 pup offset=0,3 position_incr=0 dog offset=0,3 position_incr=0 eat offset=4,7 position_incr=1 apple offset=8,13 position_incr=1 died offset=18,22 position_incr=2
從結果中我們能看出幾個問題,第一個是 puppy 的長度是 5,但是 offset 還是原詞 dog 的 offset,長度是 3。這意味着 TokenStream 中詞彙的長度和 offset 不一定會 match。第二個問題是 puppy 和 dog 、pup 是同義詞,但是 position_incr 很明顯不一樣,只有第一個詞彙的增量是 1,其它同義詞彙都是原地打轉。至於為什麼 puppy 在單詞流中排在第一個位置而不是 dog,這個實際上是不確定的,它也不會對後續的搜索結果產生任何影響。

圖片
位置對短語查詢 PhraseQuery 的影響
在上一節我們介紹了 Lucene 自帶的短語查詢功能,它有一個重要的參數 slop,代表着短語之間的最大位置間隔。下面我們來看看同義詞對短語查詢會產生怎樣的影響。下面的代碼將會用到上面構造的 analyzer 分析器實例,在構建索引和查詢階段都會用到。
var directory = new RAMDirectory(); var config = new IndexWriterConfig(analyzer); var indexWriter = new IndexWriter(directory, config); var doc = new Document(); doc.add(new TextField("title", "dog eat apple and died", Field.Store.YES)); indexWriter.addDocument(doc); doc = new Document(); doc.add(new TextField("title", "puppy eat apple and died", Field.Store.YES)); indexWriter.addDocument(doc); doc = new Document(); doc.add(new TextField("title", "pup eat apple and died", Field.Store.YES)); indexWriter.addDocument(doc); indexWriter.close(); var reader = DirectoryReader.open(directory); var searcher = new IndexSearcher(reader); var parser = new QueryParser("title", analyzer); var query = parser.parse(""dog eat"~0"); System.out.println(query); var hits = searcher.search(query, 10).scoreDocs; for (var hit : hits) { doc = searcher.doc(hit.doc); System.out.printf("%.2f => %sn", hit.score, doc.get("title")); } reader.close(); directory.close(); ------------ title:"(puppy pup dog) eat" 1.51 => dog eat apple and died 0.99 => puppy eat apple and died 0.99 => pup eat apple and died
從代碼中可以看到 QueryParser 會將查詢短語進行同義擴展變成 OR 表達式(puppy OR pup OR dog),三個文檔都被正確的匹配出來了,只不過原詞的得分會偏高一些。另外代碼中我們使用了 RAMDirectory,這個是用來進行測試的基於內存的虛擬文件目錄,使用起來比較方便不需要指定文件路徑拿來即用。這個類在 Lucene 的新版本中已經被置為 deprecated,被 MMapDirectory 所取代。MMapDirectory 使用起來和 FSDirectory 差不多,需要指定文件路徑。