使用Apache Lucene实现全文搜索

本文概述

  • Apache Lucene分析管道的组成部分
  • 对话全文搜索
  • 本文总结
Apache Lucene是用于文档全文搜索的Java库, 并且是诸如Solr和Elasticsearch之类的搜索服务器的核心。它也可以嵌入到Java应用程序中, 例如Android应用程序或Web后端。
尽管Lucene的配置选项很广泛, 但它们是供数据库开发人员在通用文本集上使用的。如果你的文档具有特定的结构或内容类型, 则可以利用二者之一来提高搜索质量和查询能力。
使用Apache Lucene实现全文搜索

文章图片
作为这种定制的一个示例, 在本Lucene教程中, 我们将对Gutenberg项目的语料进行索引, 该项目提供了数千本免费的电子书。我们知道这些书很多都是小说。假设我们对这些小说中的对话特别感兴趣。 Lucene, Elasticsearch和Solr都不提供现成的工具来将内容标识为对话。实际上, 它们会在文本分析的最早阶段就丢弃标点符号, 这与能够识别文本中对话的部分背道而驰。因此, 必须在这些早期阶段开始我们的定制。
Apache Lucene分析管道的组成部分 Lucene分析JavaDoc很好地概述了文本分析管道中的所有活动部件。
从较高的层次上讲, 你可以认为分析管道在开始时就消耗了原始字符流, 而在结尾处产生了与词大致对应的” 术语” 。
标准分析管道可以这样可视化:
使用Apache Lucene实现全文搜索

文章图片
我们将看到如何自定义此管道以识别用双引号标记的文本区域, 我将其称为” 对话” , 然后提高在这些区域中搜索时发生的匹配项。
阅读字符
最初将文档添加到索引时, 将从Java InputStream中读取字符, 因此它们可以来自文件, 数据库, Web服务调用等。要为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文档, 因此, 稍后, 我们的搜索结果将是一个匹配书的列表。 Store.YES表示我们存储标题字段, 而标题字段只是文件名。但是, 我们不想存储电子书的正文, 因为在搜索时不需要它, 只会浪费磁盘空间。
流的实际读取以addDocument开始。 IndexWriter从管道的末尾提取令牌。此拉回操作通过管道继续进行, 直到第一级令牌生成器从InputStream读取。
另请注意, 我们不会关闭视频流, 因为Lucene会为我们处理此问题。
标记字符
Lucene StandardTokenizer会丢弃标点符号, 因此我们的定制将从此处开始, 因为我们需要保留引号。
StandardTokenizer的文档邀请你复制源代码并根据需要进行调整, 但是此解决方案不必要地复杂。相反, 我们将扩展CharTokenizer, 它允许你将字符指定为” accept” , 其中不被” accepted” 的字符将被视为标记之间的分隔符并被丢弃。由于我们对单词及其周围的引号感兴趣, 因此我们的自定义Tokenizer很简单:
public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }

给定输入流[他说, “ 好日子” 。], 产生的令牌将是[他], [说], [“ 好], [天]]
请注意引号如何散布在标记中。可以编写一个Tokenizer来为每个报价生成单独的令牌, 但是Tokenizer还需要考虑易于处理的细节, 例如缓冲和扫描, 因此最好使Tokenizer保持简洁并清理令牌流进一步在管道中。
使用过滤器拆分令牌
令牌生成器之后是一系列TokenFilter对象。注意, 顺便说一下, 该过滤器有点用词不当, 因为TokenFilter可以添加, 删除或修改令牌。
Lucene提供的许多过滤器类都期望使用单个单词, 因此不会让我们混合使用单词和引号的令牌流入其中。因此, 我们的Lucene教程的下一个自定义项必须是引入一个过滤器, 该过滤器将清理QuotationTokenizer的输出。
如果引号出现在单词的开头, 则该清理将涉及产生额外的开始引号令牌, 如果引号出现在结尾, 则产生结束引号令牌。为了简单起见, 我们将单引号的处理放在一边。
创建TokenFilter子类涉及实现一种方法:增量令牌。此方法必须在管道中的上一个过滤器上调用增量令牌, 然后操纵该调用的结果以执行过滤器负责的任何工作。可以通过Attribute对象获得增量令牌的结果, 该对象描述令牌处理的当前状态。在实现了递增令牌返回之后, 可以预期已对属性进行了操作, 以为下一个过滤器(或索引, 如果我们位于管道的末尾)设置令牌。
我们目前在管道中感兴趣的属性是:
  • 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;

因为我们的过滤器将引入原始流中不存在的新令牌, 所以我们需要一个位置在两次调用增量令牌之间保存该令牌的状态。由于我们将现有令牌分为两部分, 因此只需知道新令牌的偏移量和类型即可。我们还有一个标志, 告诉我们下一次调用增量令牌是否会发出此额外令牌。 Lucene实际上提供了一对方法catchState和restoreState, 它们将为你完成此操作。但是, 这些方法涉及到分配State对象, 实际上比仅自己管理该状态要棘手, 因此我们避免使用它们。
@Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }

作为积极避免分配的一部分, Lucene可以重用过滤器实例。在这种情况下, 预计将调用一次重置将使筛选器返回其初始状态。因此, 在这里, 我们只需重置额外的令牌字段即可。
@Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...

现在我们来看看有趣的地方。当我们调用增量令牌的实现时, 我们就有机会在管道的早期阶段不调用增量令牌。这样, 我们就有效地引入了新令牌, 因为我们没有从令牌生成器中提取令牌。
相反, 我们调用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; }

其余部分的增量令牌将做三件事之一。回想一下termBufferAttr用于检查通过管道到达的令牌的内容:
  1. 如果我们已经到达令牌流的末尾(即hasNext为false), 我们就完成了, 只是返回即可。
  2. 如果我们有一个超过一个字符的令牌, 并且其中一个字符是一个引号, 我们将拆分令牌。
  3. 如果令牌是单引号, 则假定它是结束引号。要理解原因, 请注意, 引号始终出现在单词的左侧(即中间没有标点符号), 而引号可以在标点之后(例如句子中, [他告诉我们” 。” ]。在这些情况下, 结束引号已经是一个单独的标记, 因此我们只需要设置其类型。
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。它以指向” 额外” 标记(即引号后面的单词)的偏移量来调用。
整个QuotationTokenFilter可在GitHub上获得。
顺便说一句, 虽然此过滤器仅产生一个额外的令牌, 但是可以扩展此方法以引入任意数量的额外令牌。如果可以产生额外令牌的数量有限制, 只需用一个集合或一个固定长度的数组替换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; } }

由于此过滤器只需要在Dialogue内维护单个状态, 因此它要简单得多。引号表示我们现在处于对话部分, 而引号则表明对话部分已经结束。在这两种情况下, 都将通过再次调用crementToken来丢弃报价令牌, 因此, 实际上, 开始报价或结束报价令牌永远不会流过管道中的此阶段。
例如, DialoguePayloadTokenFilter将转换令牌流:
[the], [program], [printed], ["], [hello], [world], ["]`

进入这个新流:
[the][0], [program][0], [printed][0], [hello][1], [world][1]

将标记器和过滤器捆绑在一起
分析器通常通过将Tokenizer与一系列TokenFilter组合来负责组装分析管道。分析人员还可以定义如何在分析之间重用该管道。我们不需要担心, 因为我们的组件只需要在使用之间调用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之后, 以避免浪费时间将对话有效载荷注入停用词中, 这些停用词最终将被删除。
这是正在运行的新管道的可视化(减去我们已删除或已经看到的标准管道的那些部分):
使用Apache Lucene实现全文搜索

文章图片
DialogueAnalyzer现在可以像使用任何其他股票分析器一样使用, 现在我们可以建立索引并继续搜索。
对话全文搜索 如果我们只想搜索对话, 我们可以简单地丢弃引号之外的所有标记, 就可以了。取而代之的是, 通过保留所有原始标记的完整性, 我们可以灵活地执行将对话考虑在内的查询, 或者将对话像文本的其他任何部分一样对待。
查询Lucene索引的基础知识已得到充分证明。就我们的目的而言, 足以知道查询由与操作符(如MUST或SHOULD)卡在一起的Term对象以及基于这些术语的匹配文档组成。然后, 基于可配置的” 相似性” 对象对匹配的文档进行评分, 然后可以对这些结果进行评分, 过滤或限制。例如, Lucene允许我们查询必须包含术语[hello]和[world]的前十个文档。
你可以根据对话来自定义搜索结果, 方法是根据有效负载调整文档的得分。第一个扩展点是” 相似性” , 它负责对匹配项进行加权和计分。
相似度和计分
默认情况下, 查询将使用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简单地将非对话有效负载计为零。由于每个术语可以多次匹配, 因此可能会有多个有效载荷得分。这些分数的解释直至Query实现。
请密切注意包含有效负载的BytesRef:我们必须检查偏移量的字节, 因为我们不能假定字节数组与之前存储的有效负载相同。读取索引时, Lucene不会浪费内存仅为对scorePayload的调用分配单独的字节数组, 因此我们可以引用现有字节数组。当使用Lucene API进行编码时, 请记住, 性能是重中之重, 远远超过了开发人员的便利性。
现在我们有了新的” 相似性” 实现, 然后必须在用于执行查询的IndexSearcher上设置它:
IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());

查询和条款
现在我们的IndexSearcher可以对有效载荷进行评分, 我们还必须构造一个可感知有效载荷的查询。 PayloadTermQuery可用于匹配单个术语, 同时还可检查这些匹配的有效载荷:
PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());

此查询在正文字段中匹配术语[hello](请记住, 这是我们放置文档内容的位置)。我们还必须提供一个函数, 用于根据所有词语匹配项计算最终的有效载荷得分, 因此我们插入了AveragePayloadFunction, 可以对所有有效载荷得分取平均值。例如, 如果术语[hello]在对话内部两次出现, 在对话外部一次出现, 则最终有效载荷得分将为2??。最终的有效负载分数乘以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);

执行此查询后, 我们可以看到查询结构和相似性实现如何一起工作:
使用Apache Lucene实现全文搜索

文章图片
查询执行和说明
要执行查询, 我们将其交给IndexSearcher:
TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();

收集器对象用于准备匹配文档的收集。
可以组成收集器以实现排序, 限制和过滤的组合。例如, 要获得对话中至少包含一个术语的前十个得分最高的文档, 我们将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()...

尽管输出中充满了行话, 但我们可以看到如何在评分中使用自定义的相似性实现, 以及MaxPayloadFunction如何为这些匹配项生成1.0的乘数。这意味着有效载荷已加载并计分, 并且” Hello” 的所有匹配项都在对话中发生, 因此这些结果恰好在我们期望它们的顶部。
还值得指出的是, 带有有效负载的古腾堡计划的索引大小接近4 GB, 但是在我的普通开发机器上, 查询是即时发生的。我们没有牺牲任何速度来实现我们的搜索目标。
本文总结 Lucene是一个功能强大的, 针对特定用途的全文本搜索库, 它吸收原始字符流, 将其捆绑为令牌, 并将其作为术语保留在索引中。它可以快速查询该索引并提供排名结果, 并提供足够的扩展机会, 同时保持效率。
通过直接在我们的应用程序中或作为服务器的一部分使用Lucene, 我们可以在千兆字节的内容上实时执行全文搜索。此外, 通过自定义分析和评分, 我们可以利用文档中特定于域的功能来提高结果或自定义查询的相关性。
该Lucene教程的完整代码清单可在GitHub上找到。该回购包含两个应用程序:用于建立索引的LuceneIndexerApp和用于执行查询的LuceneQueryApp。
可通过BitTorrent以磁盘映像的形式获得古腾堡计划的语料库, 其中包含许多值得一读的书(使用Lucene或只是老式的方式)。
【使用Apache Lucene实现全文搜索】索引愉快!

    推荐阅读