Lucene同义词实现分析

Lucene的TokenStream类被应用于索引建立或者是查询词分析的时候产生Token(更加严谨的说法应该是Term,因为除了关键词之外,还包括其它的额外信息,例如Token在原字符串中的位置,Token的步长以及长度等信息)。它的API是一个迭代器,每次调用incrementToken可以前进到一下个Token,然后调用相关的属性类可以获得Token的相关信息。例如,CharTermAttribute保留了Token的文本信息;OffsetAttribute保存了包括起始位置和结束位置的偏移量。

TokenStream实际上是一个链的结构,起始位置是一个Tokenizer 用于实现传统的分词操作,后面跟着若干Filter用于对分词结果进行更改。此外,CharFilter的作用是在分词前进行预处理(早于Tokenizer被调用),例如,可以移除HTML文档的标记,或者是对一些特定字符进行映射操作等。在Lucene中,Analyzer是一个工厂类负责组装上面的各个部件,包括但是不限于TokenizerxxFilter等。

Lucene和Solr已经预先实现了多种TokenizerFilter,详情参考它们各自的官方文档。

为了能够更好的理解在Lucene和Solr中如何实现分词和同义词功能,下面举一个在全文使用的例子:fast wi fi network is down,假设我们不考虑通用词,Lucene内部对这段文本的解析可以看做是一个图模型。Lucene内部实现同义词最开始是通过SynonymFilter,但是它的内部实现无法有效处理映射到多个单词的同义词实现,因此后来又实现了SynonymGraphFilter类,它可以处理多种同义词映射的情况。下面先介绍SynonymFilter(在Lucene中已经不建议使用,但是通过介绍对比可以更好的理解内部同义词的实现逻辑)。

如果使用一个图模型来表示上面的文本输入,那么这些tokens看起来如下图:

flowchart LR s0((0)) s1((1)) s2((2)) s3((3)) s4((4)) s5((5)) s6((6)) s0-->|fast|s1-->|wi|s2-->|fi|s3-->|network|s4-->|is|s5-->|down|s6

其中每一个节点表示一个位置,而一条边表示一个tokenTokenStream迭代式遍历整个无环图,一次迭代访问一条边。

下一步,我们将SynonymFilter加入到分析链中,使用下面的同义词设置:

  • fast --> speedy

  • wi fi --> wifi

  • wifi network --> hostspot

现在上面的图将变成类似的下图表示:

flowchart LR s0((0)) s1((1)) s2((2)) s3((3)) s4((4)) s5((5)) s6((6)) s0-->|fast|s1-->|wi|s2-->|fi|s3-->|network|s4-->|is|s5-->|down|s6 s0-->|speedy|s1 s1-->|wifi|s3 s1-->|hotspot|s4

针对每一个token(边),需要关注两个属性:PositionIncrementAttribute告诉我们这条边(token)的起始节点是前面多少个节点开始的,而新增加的PosotionLengthAttribute告诉我们这条边覆盖(或者说跨越)多少个节点(这条边的结束节点是后面多少个节点)

一直以来Lucene的同义词实现方式都存在难以修复的问题,也一直被广大开发者诟病。

Problem 1:在索引过程中Lucene完全忽视了PositionLengthAttribute,只关注PositionIncrementAttribute,这就导致所有的边只关注起始节点,而忽视结束节点(具体原因可以看上面对于这两个属性的介绍)。因此很多同义词表示的边,不仅仅有共同的起始节点,连结束节点也相同(这其实是一个错误处理方式,下面会介绍带来的问题),因此上面的图实际上变成了下面的图:

flowchart LR s0((0)) s1((1)) s2((2)) s3((3)) s4((4)) s5((5)) s6((6)) s0-->|fast|s1-->|wi|s2-->|fi|s3-->|network|s4-->|is|s5-->|down|s6 s0-->|speedy|s1 s1-->|hotspot|s2 s1-->|wifi|s2

Lucene这种处理同义词的方式在遇到phrase query的时候会发生出乎意料的结果,一些应该匹配的短语查询实际上无法匹配到正确结果,而一些不应该匹配的短语查询却意外的匹配到了错误结果。例如对于短语查询 hostspot is down应该匹配确没有匹配上,而fast hotspot fi不应该匹配确匹配成功。当然有些短语匹配可以成功,例如fast hotspot。发生这种错误的根本原因是输入的图模型被错误的处理成了串行化的图模型。要修复这个问题比较有挑战性,因为需要更改索引结构,在每个位置保存一个额外的长度信息,并且需要同时修复包含位置的查询处理

QueryParser也存在忽略位置长度属性的问题,但是这个问题修复相对容易,因为我们可以在查询处理的时候对查询词扩展处理来获得正确的结果,这比在索引时修复问题更加简单,因为索引修复涉及到索引重新建立,这个代价很大。

SynonymFilter的另一个问题是在处理同义词的时候如果替换单元包括多个单词的时候,例如当同义词存在下面的情况:

  • dns --> domain name service

如果输入是dns is up and down对应的图模型如下图:

flowchart LR s0((0)); s1((1)); s2((2)); s3((3)); s4((4)); s5((5)); s0 --> |domain| s1; s0 --> |dns| s1; s1 --> |name| s2; s1 --> |is| s2; s2 --> |service| s3; s2 --> |up| s3; s3 --> |and| s4 --> |down| s5;

(name, is)(service, up)这两对代词发生了重叠现象,同样会导致短语查询出现问题。例如:domain name service is up应该匹配没有匹配,dns name up不应该匹配却匹配了。如果要使得同义词生效,必须保证被注入的同义词单元都是单独一个单词,例如把上面的同义词替换更改成domain name service --> dns,上面的两个例子都可以运行正常。

从上面的几个例子可以看出,SynonymFilter在遇上多个单词的同义词注入的时候就会发生问题,再说一次,这个问题也是被广大开发者一直诟病的一个严重问题。

下面介绍Lucene新引入的SynonumGraphFilter,它被Lucene推荐替换SynonymFilter使用,也就是在新的代码中大家应用使用这个新的类,可以解决SynonymFilter中遇到的问题。

例如,输入还是上面的fast wi fi netowrk is downSynonymGraphFilter的图模型输出如下:

flowchart LR s0((0)) s1((1)) s2((2)) s3((3)) s4((4)) s5((5)) s6((6)) s7((7)) s1-->|re/wi fi network|s2-->|dian/wi fi network|s5 s0-->|fast|s1-->|wi|s3-->|fi|s4-->|network|s5-->|is|s6-->|down|s7

可以看到,使用SynonymGraphFilter的分词结果是正确的。

解决方案

针对上述提到的同义词问题,在Lucene 6.4.0版本开始得到解决,这个版本引入了一些改进,只要我们在search-time引入同义词扩展而不是index-time引入。

【改进一】

第一个改进就是新引入了SynonymGraphFilter类,只要在代码中使用这个类替换之前的SynonymFilter类,就基本上能产出正确的图模型,无论被注入的同义词是单个单词还是多个单词。同时,如果一定要在索引阶段引入同义词,还可以在SynonymGraphFilter之后立刻加上FlattenGraphFilter类,可以基本将正确的信息加入到索引中,但是需要牢记,FlattenGraphFilter还是会在索引时丢失部分图信息(相对于完整的图模型)

【改进二】

The second set of vital improvements is to the query parsers. First, the classic query parser had to stop pre-splitting incoming query text at whitespace, fixed (after years of controversy!) in Lucene 6.2.0. Be sure to call setSplitOnWhitespace(false) since the whitespace splitting is still enabled by default to preserve backwards compatibility. This change empowers the query-time analyzer to see multiple tokens as a single string instead of seeing each token separately. These simplifications to the complex logic in QueryBuilder are also an important precursor.

【改进三】

The third query parser fix is to detect when the query-time analyzer produced a graph, and create accurate queries as a result, also first added in Lucene 6.4.0. The query parser (specifically the QueryBuilder base class) now watches the PositionLengthAttribute and computes all paths through the graph when any token has a value greater than 1.

【Limitatioins】

To make multi-token synonyms work correctly you must apply your synonyms at query time, not index-time, since a Lucene index cannot store a token graph. Search-time synonyms necessarily require more work (CPU and IO) than index-time synonyms, since more terms must be visited to answer the query, but the index will be smaller. Search-time synonyms are also more flexible, since you don't need to re-index when you change your synonyms, which is important for very large indices.

Another challenge is that SynonymGraphFilter, while producing correct graphs, cannot consume a graph, which means you cannot for example use WordDelimiterGraphFilter or JapaneseTokenizer followed by SynonymGraphFilter and expect synonyms to match the incoming graph fragments.

This is quite tricky to fix given the current TokenStream APIs, and there is a compelling experimental branch to explore a simpler API for creating graph token streams. While the synonym filter on that branch can already consume a graph, there is still plenty of work to be done before the change can be merged (patches welcome!).

updatedupdated2022-06-282022-06-28