使用 Apache Lucene 進行對話全文搜索:教程

已發表: 2022-03-11

Apache Lucene 是一個用於文檔全文搜索的 Java 庫,是 Solr 和 Elasticsearch 等搜索服務器的核心。 它還可以嵌入到 Java 應用程序中,例如 Android 應用程序或 Web 後端。

雖然 Lucene 的配置選項非常廣泛,但它們旨在供數據庫開發人員在通用文本語料庫上使用。 如果您的文檔具有特定結構或內容類型,您可以利用其中任何一種來提高搜索質量和查詢能力。

使用 apache lucene 進行全文搜索

作為這種定制的一個例子,在這個 Lucene 教程中,我們將索引 Project Gutenberg 的語料庫,它提供了數以千計的免費電子書。 我們知道其中許多書都是小說。 假設我們對這些小說中的對話特別感興趣。 Lucene、Elasticsearch 和 Solr 都沒有提供開箱即用的工具來將內容識別為對話。 事實上,他們會在文本分析的最初階段拋棄標點符號,這與識別文本中對話的部分背道而馳。 因此,我們的定制必須在這些早期階段開始。

Apache Lucene 分析管道的片段

Lucene 分析 JavaDoc 很好地概述了文本分析管道中的所有活動部分。

在高層次上,您可以將分析管道視為在開始時消耗原始字符流並在結束時產生大致對應於單詞的“術語”。

標準分析管道可以這樣可視化:

Lucene 分析管道

我們將看到如何自定義此管道以識別由雙引號標記的文本區域,我將其稱為對話,然後增加在這些區域中搜索時發生的匹配。

讀字符

最初將文檔添加到索引時,字符是從 Java InputStream 中讀取的,因此它們可以來自文件、數據庫、Web 服務調用等。要為 Project Gutenberg 創建索引,我們下載電子書,然後創建一個小應用程序來讀取這些文件並將它們寫入索引。 創建一個 Lucene 索引和讀取文件是非常流行的路徑,所以我們不會過多地探索它們。 生成索引的基本代碼是:

 IndexWriter writer = ...; BufferedReader reader = new BufferedReader(new InputStreamReader(... fileInputStream ...)); Document document = new Document(); document.add(new StringField("title", fileName, Store.YES)); document.add(new TextField("body", reader)); writer.addDocument(document);

我們可以看到,每本電子書都對應一個 Lucene Document ,所以稍後我們的搜索結果將是匹配書籍的列表。 Store.YES表示我們存儲標題字段,它只是文件名。 但是,我們不想存儲電子書的正文,因為搜索時不需要它,只會浪費磁盤空間。

流的實際讀取從addDocument開始。 IndexWriter從管道末端提取令牌。 此拉取通過管道返回,直到第一階段TokenizerInputStream讀取。

另請注意,我們不會關閉流,因為 Lucene 會為我們處理這個。

標記字符

Lucene StandardTokenizer 拋棄了標點符號,因此我們的定制將從這裡開始,因為我們需要保留引號。

StandardTokenizer的文檔邀請您複製源代碼並根據您的需要對其進行定制,但這種解決方案會不必要地複雜。 相反,我們將擴展CharTokenizer ,它允許您將字符指定為“接受”,其中那些未被“接受”的字符將被視為標記之間的分隔符並被丟棄。 由於我們對單詞及其周圍的引用感興趣,因此我們的自定義 Tokenizer 很簡單:

 public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }

給定一個[He said, "Good day".]的輸入流,生成的標記將是[He] , [said] , ["Good] , [day"]

請注意引號是如何散佈在標記中的。 可以編寫一個Tokenizer為每個報價生成單獨的標記,但是Tokenizer還關心一些繁瑣、易於搞砸的細節,例如緩沖和掃描,所以最好讓你的Tokenizer保持簡單並清理令牌流在管道中更進一步。

使用過濾器拆分令牌

在標記器之後是一系列TokenFilter對象。 請注意,順便說一下,該過濾器有點用詞不當,因為TokenFilter可以添加、刪除或修改令牌。

Lucene 提供的許多過濾器類都需要單個單詞,因此我們不會讓我們的混合單詞和引號標記流入它們。 因此,我們的 Lucene 教程的下一個定制必須是引入一個過濾器,它將清理QuotationTokenizer的輸出。

如果引用出現在單詞的開頭,則此清理將涉及產生額外的開始引用標記,或者如果引用出現在末尾,則產生結束引用標記。 為簡單起見,我們將撇開單引號單詞的處理。

創建TokenFilter子類涉及實現一種方法: incrementToken 。 此方法必須在管道中的前一個過濾器上調用incrementToken ,然後操縱該調用的結果以執行過濾器負責的任何工作。 incrementToken的結果可通過Attribute對象獲得,這些對象描述了令牌處理的當前狀態。 在我們實現incrementToken返回後,預計屬性已被操作以設置下一個過濾器的令牌(如果我們在管道的末端,則為索引)。

我們在管道中此時感興趣的屬性是:

  • CharTermAttribute :包含一個char[]緩衝區,保存當前令牌的字符。 我們將需要對其進行操作以刪除報價,或生成報價標記。

  • TypeAttribute :包含當前令牌的“類型”。 因為我們正在向令牌流添加開始和結束引號,所以我們將使用我們的過濾器引入兩種新類型。

  • OffsetAttribute :Lucene 可以選擇存儲對原始文檔中術語位置的引用。 這些引用稱為“偏移量”,它們只是原始字符流的開始和結束索引。 如果我們將CharTermAttribute中的緩衝區更改為僅指向令牌的子字符串,我們必須相應地調整這些偏移量。

您可能想知道為什麼用於操作令牌流的 API 如此復雜,特別是為什麼我們不能對傳入的令牌執行類似String#split的操作。 這是因為 Lucene 是為高速、低開銷的索引而設計的,因此內置的標記器和過濾器可以快速瀏覽千兆字節的文本,而只使用兆字節的內存。 為了實現這一點,在標記化和過濾期間很少或不進行分配,因此上面提到的Attribute實例旨在分配一次並重用。 如果您的分詞器和過濾器以這種方式編寫,並最小化它們自己的分配,您可以自定義 Lucene 而不會影響性能。

考慮到所有這些,讓我們看看如何實現一個過濾器,該過濾器採用諸如["Hello]之類的標記,並生成兩個標記["][Hello]

 public class QuotationTokenFilter extends TokenFilter { private static final char QUOTE = '"'; public static final String QUOTE_START_TYPE = "start_quote"; public static final String QUOTE_END_TYPE = "end_quote"; private final OffsetAttribute offsetAttr = addAttribute(OffsetAttribute.class); private final TypeAttribute typeAttr = addAttribute(TypeAttribute.class); private final CharTermAttribute termBufferAttr = addAttribute(CharTermAttribute.class);

我們首先獲取對我們之前看到的一些屬性的引用。 我們在字段名稱後加上“Attr”,以便稍後我們引用它們時會很清楚。 可能某些Tokenizer實現沒有提供這些屬性,所以我們使用addAttribute來獲取我們的引用。 如果缺少addAttribute將創建一個屬性實例,否則獲取對該類型屬性的共享引用。 請注意,Lucene 不允許同時具有相同屬性類型的多個實例。

 private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;

因為我們的過濾器將引入原始流中不存在的新令牌,所以我們需要在對incrementToken的調用之間保存該令牌的狀態。 因為我們將現有令牌分成兩部分,所以只知道新令牌的偏移量和類型就足夠了。 我們還有一個標誌,它告訴我們下一次對incrementToken的調用是否會發出這個額外的令牌。 Lucene 實際上提供了一對方法, captureStaterestoreState ,它們會為你做這件事。 但是這些方法涉及到State對象的分配,實際上可能比簡單地自己管理該狀態更棘手,因此我們將避免使用它們。

 @Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }

作為積極避免分配的一部分,Lucene 可以重用過濾器實例。 在這種情況下,預計調用reset將使過濾器恢復到其初始狀態。 所以在這裡,我們簡單地重置我們額外的令牌字段。

 @Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...

現在我們開始討論有趣的部分。 當調用我們的incrementToken實現時,我們有機會在管道的早期階段調用incrementToken 。 通過這樣做,我們有效地引入了一個新的令牌,因為我們沒有從Tokenizer中提取一個令牌。

相反,我們調用advanceToExtraToken來設置我們額外令牌的屬性,將emitExtraToken設置為 false 以避免在下次調用時出現此分支,然後返回true ,這表明另一個令牌可用。

 @Override public boolean incrementToken() throws IOException { ... (emit extra token) ... boolean hasNext = input.incrementToken(); if (hasNext) { char[] buffer = termBufferAttr.buffer(); if (termBuffer.length() > 1) { if (buffer[0] == QUOTE) { splitTermQuoteFirst(); } else if (buffer[termBuffer.length() - 1] == QUOTE) { splitTermWordFirst(); } } else if (termBuffer.length() == 1) { if (buffer[0] == QUOTE) { typeAttr.setType(QUOTE_END_TYPE); } } } return hasNext; }

incrementToken的其餘部分將做三種不同的事情之一。 回想一下termBufferAttr用於檢查通過管道的令牌的內容:

  1. 如果我們已經到達令牌流的末尾(即hasNext為假),我們就完成了並且簡單地返回。

  2. 如果我們有多個字符的標記,並且其中一個字符是引號,我們拆分標記。

  3. 如果令牌是一個單獨的報價,我們假設它是一個結束報價。 要理解為什麼,請注意起始引號總是出現在單詞的左側(即沒有中間標點符號),而結尾引號可以跟在標點符號之後(例如在句子中, [He told us to "go back the way we came."] )。 在這些情況下,結束引號已經是一個單獨的標記,所以我們只需要設置它的類型。

splitTermQuoteFirstsplitTermWordFirst將設置屬性以使當前標記成為單詞或引號,並設置“額外”字段以允許稍後使用另一半。 這兩種方法是相似的,所以我們只看splitTermQuoteFirst

 private void splitTermQuoteFirst() { int origStart = offsetAttr.startOffset(); int origEnd = offsetAttr.endOffset(); offsetAttr.setOffset(origStart, origStart + 1); typeAttr.setType(QUOTE_START_TYPE); termBufferAttr.setLength(1); prepareExtraTerm(origStart + 1, origEnd, TypeAttribute.DEFAULT_TYPE); }

因為我們想用首先出現在流中的引號分割這個標記,所以我們通過將長度設置為 1(即一個字符;即引號)來截斷緩衝區。 我們相應地調整偏移量(即指向原始文檔中的引用)並將類型設置為起始引用。

prepareExtraTerm將設置extra*字段並將emitExtraToken設置為 true。 它是用指向“額外”標記(即引號後面的單詞)的偏移量調用的。

GitHub 上提供了完整的QuotationTokenFilter

順便說一句,雖然這個過濾器只產生一個額外的令牌,但這種方法可以擴展到引入任意數量的額外令牌。 只需將extra*字段替換為集合,或者更好的是,如果可以生成的額外標記的數量有限制,則可以使用固定長度的數組。 有關此示例,請參閱SynonymFilter及其PendingInput內部類。

使用報價代幣和標記對話

現在我們已經竭盡全力將這些引號添加到令牌流中,我們可以使用它們來分隔文本中的對話部分。

由於我們的最終目標是根據術語是否是對話的一部分來調整搜索結果,因此我們需要將元數據附加到這些術語。 Lucene 為此提供了PayloadAttribute 。 有效負載是與索引中的術語一起存儲的字節數組,可以稍後在搜索期間讀取。 這意味著我們的標誌將浪費地佔據整個字節,因此可以將額外的有效負載實現為位標誌以節省空間。

下面是一個新過濾器DialoguePayloadTokenFilter ,它被添加到分析管道的最後。 它附加了指示令牌是否是對話的一部分的有效負載。

 public class DialoguePayloadTokenFilter extends TokenFilter { private final TypeAttribute typeAttr = getAttribute(TypeAttribute.class); private final PayloadAttribute payloadAttr = addAttribute(PayloadAttribute.class); private static final BytesRef PAYLOAD_DIALOGUE = new BytesRef(new byte[] { 1 }); private static final BytesRef PAYLOAD_NOT_DIALOGUE = new BytesRef(new byte[] { 0 }); private boolean withinDialogue; protected DialoguePayloadTokenFilter(TokenStream input) { super(input); } @Override public void reset() throws IOException { this.withinDialogue = false; super.reset(); } @Override public boolean incrementToken() throws IOException { boolean hasNext = input.incrementToken(); while(hasNext) { boolean isStartQuote = QuotationTokenFilter .QUOTE_START_TYPE.equals(typeAttr.type()); boolean isEndQuote = QuotationTokenFilter .QUOTE_END_TYPE.equals(typeAttr.type()); if (isStartQuote) { withinDialogue = true; hasNext = input.incrementToken(); } else if (isEndQuote) { withinDialogue = false; hasNext = input.incrementToken(); } else { break; } } if (hasNext) { payloadAttr.setPayload(withinDialogue ? PAYLOAD_DIALOGUE : PAYLOAD_NOT_DIALOGUE); } return hasNext; } }

由於這個過濾器只需要維護一個狀態, withinDialogue ,就簡單多了。 開始引號表示我們現在處於對話部分中,而結束引號表示對話部分已經結束。 在任何一種情況下,都會通過再次調用incrementToken來丟棄引用標記,因此實際上,開始引用結束引用標記永遠不會流過管道中的這個階段。

例如, DialoguePayloadTokenFilter將轉換令牌流:

 [the], [program], [printed], ["], [hello], [world], ["]`

進入這個新流:

 [the][0], [program][0], [printed][0], [hello][1], [world][1]

將分詞器和過濾器捆綁在一起

Analyzer負責組裝分析管道,通常通過將Tokenizer與一系列TokenFilter組合起來。 Analyzer還可以定義在分析之間如何重用該管道。 我們不需要擔心這一點,因為我們的組件除了在使用之間調用reset()之外不需要任何東西,Lucene 總是會這樣做。 我們只需要通過實現Analyzer#createComponents(String)來進行組裝:

 public class DialogueAnalyzer extends Analyzer { @Override protected TokenStreamComponents createComponents(String fieldName) { QuotationTokenizer tokenizer = new QuotationTokenizer(); TokenFilter filter = new QuotationTokenFilter(tokenizer); filter = new LowerCaseFilter(filter); filter = new StopFilter(filter, StopAnalyzer.ENGLISH_STOP_WORDS_SET); filter = new DialoguePayloadTokenFilter(filter); return new TokenStreamComponents(tokenizer, filter); } }

正如我們之前看到的,過濾器包含對管道中前一階段的引用,這就是我們實例化它們的方式。 我們還從StandardAnalyzer中加入了一些過濾器: LowerCaseFilterStopFilter 。 這兩個必須在QuotationTokenFilter之後,以確保所有引號都已分開。 我們可以更靈活地放置DialoguePayloadTokenFilter ,因為QuotationTokenFilter之後的任何地方都可以。 我們將它放在StopFilter之後以避免浪費時間將對話有效負載注入最終將被刪除的停用詞中。

這是我們正在運行的新管道的可視化(減去我們已刪除或已經看到的標準管道的那些部分):

apache lucene 中的新管道可視化

DialogueAnalyzer現在可以像任何其他股票Analyzer一樣使用,現在我們可以建立索引並繼續搜索。

對話全文檢索

如果我們只想搜索對話,我們可以簡單地丟棄引號之外的所有標記,我們就完成了。 取而代之的是,通過保留所有原始標記不變,我們可以靈活地執行將對話考慮在內的查詢,或者將對話視為文本的任何其他部分。

查詢 Lucene 索引的基礎知識是有據可查的。 就我們的目的而言,只要知道查詢是由Term對象和MUSTSHOULD等運算符組合在一起的,以及基於這些術語的匹配文檔就足夠了。 然後根據可配置的Similarity對像對匹配的文檔進行評分,這些結果可以按分數排序、過濾或限制。 例如,Lucene 允許我們查詢前十個必須同時包含術語[hello][world]的文檔。

可以通過根據有效負載調整文檔的分數來自定義基於對話的搜索結果。 第一個擴展點將在Similarity中,它負責對匹配項進行加權和評分。

相似度和評分

默認情況下,查詢將使用DefaultSimilarity ,它根據術語在文檔中出現的頻率來加權。 它是調整權重的一個很好的擴展點,因此我們將它擴展為也基於有效負載對文檔進行評分。 為此提供了DefaultSimilarity#scorePayload方法:

 public final class DialogueAwareSimilarity extends DefaultSimilarity { @Override public float scorePayload(int doc, int start, int end, BytesRef payload) { if (payload.bytes[payload.offset] == 0) { return 0.0f; } return 1.0f; } }

DialogueAwareSimilarity只是將非對話有效負載評分為零。 由於每個Term可以匹配多次,因此它可能具有多個有效載荷分數。 這些分數的解釋由Query實現決定。

密切注意包含有效負載的BytesRef :我們必須檢查offset處的字節,因為我們不能假設字節數組與我們之前存儲的有效負載相同。 在讀取索引時,Lucene 不會為了調用scorePayload而浪費內存分配一個單獨的字節數組,所以我們得到一個對現有字節數組的引用。 在針對 Lucene API 進行編碼時,需要牢記性能是重中之重,遠高於開發人員的便利性。

現在我們有了新的Similarity實現,然後必須在用於執行查詢的IndexSearcher上設置它:

 IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());

查詢和條款

現在我們的IndexSearcher可以對有效負載進行評分,我們還必須構建一個可識別有效負載的查詢。 PayloadTermQuery可用於匹配單個Term ,同時還檢查這些匹配項的有效負載:

 PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());

此查詢匹配正文字段中的術語[hello] (回想一下,這是我們放置文檔內容的位置)。 我們還必須提供一個函數來計算所有術語匹配的最終有效負載分數,因此我們插入AveragePayloadFunction ,它平均所有有效負載分數。 例如,如果術語[hello]在對話內出現兩次,在對話外出現一次,則最終有效載荷得分將為 ²⁄₃。 此最終有效負載分數與DefaultSimilarity為整個文檔提供的分數相乘。

我們使用平均值是因為我們希望不強調許多術語出現在對話之外的搜索結果,並且對於根本沒有對話中的任何術語的文檔產生零分。

如果我們想搜索對話中包含的多個術語,我們還可以使用BooleanQuery組合多個PayloadTermQuery對象(請注意,術語的順序在此查詢中無關緊要,儘管其他查詢類型是位置感知的):

 PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);

執行此查詢時,我們可以看到查詢結構和相似性實現如何協同工作:

lucene 對話分析管道

查詢執行與解釋

要執行查詢,我們將其交給IndexSearcher

 TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();

Collector對像用於準備匹配文檔的集合。

可以組合收集器來實現排序、限制和過濾的組合。 例如,為了獲得對話中至少包含一個詞的前十個評分文檔,我們結合TopScoreDocCollectorPositiveScoresOnlyCollector 。 只取正分數可確保過濾掉零分數匹配項(即對話中沒有術語的匹配項)。

要查看此查詢的實際效果,我們可以執行它,然後使用IndexSearcher#explain查看單個文檔的評分方式:

 for (ScoreDoc result : topDocs.scoreDocs) { Document doc = searcher.doc(result.doc, Collections.singleton("title")); System.out.println("--- document " + doc.getField("title").stringValue() + " ---"); System.out.println(this.searcher.explain(query, result.doc)); }

在這裡,我們遍歷搜索得到的TopDocs中的文檔 ID。 我們還使用IndexSearcher#doc來檢索標題字段以進行顯示。 對於我們的"hello"查詢,結果如下:

 --- Document whelv10.txt --- 0.072256625 = (MATCH) btq, product of: 0.072256625 = weight(body:hello in 7336) [DialogueAwareSimilarity], result of: 0.072256625 = fieldWeight in 7336, product of: 2.345208 = tf(freq=5.5), with freq of: 5.5 = phraseFreq=5.5 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.009765625 = fieldNorm(doc=7336) 1.0 = AveragePayloadFunction.docScore() --- Document daved10.txt --- 0.061311778 = (MATCH) btq, product of: 0.061311778 = weight(body:hello in 6873) [DialogueAwareSimilarity], result of: 0.061311778 = fieldWeight in 6873, product of: 3.3166249 = tf(freq=11.0), with freq of: 11.0 = phraseFreq=11.0 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.005859375 = fieldNorm(doc=6873) 1.0 = AveragePayloadFunction.docScore() ...

儘管輸出充滿了行話,但我們可以看到我們的自定義Similarity實現如何用於評分,以及MaxPayloadFunction如何為這些匹配生成1.0的乘數。 這意味著負載已加載並評分,所有"Hello"的匹配都發生在對話中,因此這些結果正好在我們預期的頂部。

還值得指出的是,帶有有效負載的古騰堡項目的索引大小接近 4 GB,但在我的普通開發機器上,查詢會立即發生。 我們沒有犧牲任何速度來實現我們的搜索目標。

包起來

Lucene 是一個功能強大的專用全文搜索庫,它採用原始字符流,將它們捆綁到標記中,並將它們作為術語保存在索引中。 它可以快速查詢該索引並提供排名結果,並在保持效率的同時提供充足的擴展機會。

通過在我們的應用程序中直接使用 Lucene,或者作為服務器的一部分,我們可以對千兆字節的內容進行實時全文搜索。 此外,通過自定義分析和評分,我們可以利用文檔中特定領域的特徵來提高結果或自定義查詢的相關性。

GitHub 上提供了此 Lucene 教程的完整代碼清單。 該 repo 包含兩個應用程序:用於構建索引的LuceneIndexerApp和用於執行查詢的LuceneQueryApp

Project Gutenberg 的語料庫可以通過 BitTorrent 以磁盤映像的形式獲得,其中包含大量值得一讀的書籍(使用 Lucene 或只是老式的方式)。

快樂索引!