使用 Apache Lucene 进行对话全文搜索:教程
已发表: 2022-03-11Apache Lucene 是一个用于文档全文搜索的 Java 库,是 Solr 和 Elasticsearch 等搜索服务器的核心。 它还可以嵌入到 Java 应用程序中,例如 Android 应用程序或 Web 后端。
虽然 Lucene 的配置选项非常广泛,但它们旨在供数据库开发人员在通用文本语料库上使用。 如果您的文档具有特定结构或内容类型,您可以利用其中任何一种来提高搜索质量和查询能力。
作为这种定制的一个例子,在这个 Lucene 教程中,我们将索引 Project Gutenberg 的语料库,它提供了数以千计的免费电子书。 我们知道其中许多书都是小说。 假设我们对这些小说中的对话特别感兴趣。 Lucene、Elasticsearch 和 Solr 都没有提供开箱即用的工具来将内容识别为对话。 事实上,他们会在文本分析的最初阶段抛弃标点符号,这与识别文本中对话的部分背道而驰。 因此,我们的定制必须在这些早期阶段开始。
Apache Lucene 分析管道的片段
Lucene 分析 JavaDoc 很好地概述了文本分析管道中的所有活动部分。
在高层次上,您可以将分析管道视为在开始时消耗原始字符流并在结束时产生大致对应于单词的“术语”。
标准分析管道可以这样可视化:
我们将看到如何自定义此管道以识别由双引号标记的文本区域,我将其称为对话,然后增加在这些区域中搜索时发生的匹配。
读字符
最初将文档添加到索引时,字符是从 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
从管道末端提取令牌。 此拉取通过管道返回,直到第一阶段Tokenizer
从InputStream
读取。
另请注意,我们不会关闭流,因为 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 实际上提供了一对方法, captureState
和restoreState
,它们会为你做这件事。 但是这些方法涉及到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
用于检查通过管道的令牌的内容:
如果我们已经到达令牌流的末尾(即
hasNext
为假),我们就完成了并且简单地返回。如果我们有多个字符的标记,并且其中一个字符是引号,我们拆分标记。
如果令牌是一个单独的报价,我们假设它是一个结束报价。 要理解为什么,请注意起始引号总是出现在单词的左侧(即没有中间标点符号),而结尾引号可以跟在标点符号之后(例如在句子中,
[He told us to "go back the way we came."]
)。 在这些情况下,结束引号已经是一个单独的标记,所以我们只需要设置它的类型。
splitTermQuoteFirst
和splitTermWordFirst
将设置属性以使当前标记成为单词或引号,并设置“额外”字段以允许稍后使用另一半。 这两种方法是相似的,所以我们只看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
中加入了一些过滤器: LowerCaseFilter
和StopFilter
。 这两个必须在QuotationTokenFilter
之后,以确保所有引号都已分开。 我们可以更灵活地放置DialoguePayloadTokenFilter
,因为QuotationTokenFilter
之后的任何地方都可以。 我们将它放在StopFilter
之后以避免浪费时间将对话有效负载注入最终将被删除的停用词中。
这是我们正在运行的新管道的可视化(减去我们已删除或已经看到的标准管道的那些部分):
DialogueAnalyzer
现在可以像任何其他股票Analyzer
一样使用,现在我们可以建立索引并继续搜索。
对话全文检索
如果我们只想搜索对话,我们可以简单地丢弃引号之外的所有标记,我们就完成了。 取而代之的是,通过保留所有原始标记不变,我们可以灵活地执行将对话考虑在内的查询,或者将对话视为文本的任何其他部分。
查询 Lucene 索引的基础知识是有据可查的。 就我们的目的而言,只要知道查询是由Term
对象和MUST
或SHOULD
等运算符组合在一起的,以及基于这些术语的匹配文档就足够了。 然后根据可配置的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);
执行此查询时,我们可以看到查询结构和相似性实现如何协同工作:
查询执行与解释
要执行查询,我们将其交给IndexSearcher
:
TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();
Collector
对象用于准备匹配文档的集合。
可以组合收集器来实现排序、限制和过滤的组合。 例如,为了获得对话中至少包含一个词的前十个评分文档,我们结合TopScoreDocCollector
和PositiveScoresOnlyCollector
。 只取正分数可确保过滤掉零分数匹配项(即对话中没有术语的匹配项)。
要查看此查询的实际效果,我们可以执行它,然后使用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 或只是老式的方式)。
快乐索引!