工程

一样,却又不同:借助同义词让 Elasticsearch 更加强大

毫无疑问,使用同义词是搜索工程师工具箱中最重要的技巧之一。尽管新手有时会低估同义词的重要性,但几乎所有搜索系统都离不开它。与此同时,人们有时仍会低估与使用同义词相关的一些复杂情况和微妙情形,甚至高级用户也不例外。同义词筛选器是将输入文本转化为可搜索字词这一分析过程中的一部分;虽然这一工具入门相对简单,但其使用方法十分多样,首先需要深入了解一些概念,然后才能在实际情境中成功应用。

最近我们在 Elasticsearch 中进行了一些分析方面的改进。最重大的一个功能可能就是允许重新加载搜索时使用的分析器,这样用户便可更改并重新加载搜索时使用的同义词。除了向大家演示这一新的 API 之外,本篇博文还会回答一些有关同义词使用方法的常见问题,并指出一些经常需要注意的相关事项。

为何使用同义词?

为帮助大家理解同义词的巨大作用和灵活性,我们来快速看一下当今大多数搜索引擎的内在工作原理。搜索引擎会对文档和查询进行分析并将其拆解为最小的单元(通常称为词元,实际上就是抽象的符号)。搜索时,匹配过程会使用简单字串相似度,所以如果查询中有一些十分微小的拼写错误(例如“hous”,只比“house”少一个字母 e)或者使用名词的复数形式(“houses”),即使文档中包含名词的单数形式(“house”),搜索引擎也不会匹配到这份文档。词干提取器模糊查询等工具虽然可以解决一些最常见的此类问题,但是它们并不能消除相关联的概念或想法之间的差异,也不能将文档或查询中稍有不同的单词用法视为等同。

这时同义词就派上了大用场。同义词的英文 synonym 来自于希腊语,分别是前缀 σύν(syn,表示“一起”) ὄνομα(ónoma,表示“名称”)。从它的词源可以看出,同义词表示的是在同一语言或领域中具有完全或基本相同意思的不同词语。实际上,同义词的范围非常广泛,包括一般同义词(“疲劳”和“困倦”)、缩写(英镑的两种写法“lb.”和“pound”)、电商搜索中产品的不同拼写(“iPod”和“i-Pod”)、细微的语言差异(例如均表示电梯的英式英语“lift”和美式英语“elevator”)、专业用词和普通用词(例如“犬”和“狗”),甚至单纯表示同一概念的两种方式(“宇宙”和“太空”)。通过提供恰当的同义词规则,搜索工程师能够就哪些词在各自领域内具有相似意思并应该采取相似处理方法提供相关信息。

对搜索引擎而言,至为重要的是知道文档中的哪个字词与查询内容相匹配,即使它们可能看起来并不一样。由于这涉及到十分具体的领域知识,所以用户需要提供恰当的规则。同义词筛选器可在定制分析器中使用,其能够基于用户定义的规则替换或添加其他词元,既可在索引时进行以便在索引后的文档中同时存储这些内容(例如词语的两种变体),也可在索引时进行以扩展搜索词并匹配到更多相关文档。我们稍后会讨论这两种方法的优缺点。

使用同义词时需要注意的几种情形

同义词分析器是一款十分灵活的工具,但可能会导致人们在特定情形中过度使用。例如,有时人们会强行用其来替代词干提取器,这样便会导致同义词文件很大,因为其中包含动词和名词的各种语法变形(针对英语)。尽管这种方法可能奏效,但相较于使用真正的词干提取器或词形还原工具,通常性能较差而且维护起来也较为困难。用其来纠正拼写错误的话,后果也是这样。如果仅有几个特别常见的拼写错误,例如针对电商平台,尝试通过使用同义词来纠正有时还算可取。但如果问题更加广泛,那么使用模糊查询或字符级别的 ngram 方法可能更具有持续性。在分析链中还可考虑使用同义词扩展法的替代方案。有时,相对于在限制性更强的分析过程中使用同义词,在采集管道或某些其他客户端过程中改善文档反倒更加灵活和易于管理。例如,您可以使用命名实体识别 (NER) 框架对您文档中的命名实体进行检测,然后在您的预处理管道中或者在采集时以您特有的标识符对其进行编码。如果您随后对用户的查询应用同样的过程,然后再将它们发送到 Elasticsearch,您可以实现同样的效果,但通常还能拥有更多控制权。

此外,您可能还会倾向于使用同义词来处理其他“相同”概念,例如将特定动物物种分组到一个常用字词下,甚至针对您的领域构建分类学支持内容。这时事情就会变得特别有趣,也有很多问题需要探索,但要记住同义词有时并非最佳选择,如使用不慎可能会导致您的系统出现异常行为。

索引时使用同义词和搜索时使用同义词的对比

同义词在分析器中使用,其既可在索引时使用,也可在搜索时使用。关于在 Elasticsearch 中如何使用同义词筛选器,最常见的问题之一就是:“我应该索引时使用,还是搜索时使用,还是同时都用?” 我们首先看一下在索引时应用同义词筛选。这意味着会在索引后的文档中对字词进行一次性替换或扩展,结果将一直保存在搜索索引中。

索引时使用同义词有几个劣势:

  • 由于必须对所有同义词进行索引,所以索引规模会变大。
  • 搜索得分(依赖于字词统计数据)可能会受影响,因为同义词也会计算在内,所以不常见单词的统计数据会存在偏差。
  • 除非进行重新索引,否则无法针对既有文档更改同义词规则。

最后两条尤其是巨大劣势。索引时应用同义词的唯一潜在好处是性能好,因为您在前期已费心完成了扩展过程,所以无需再在每次查询时完成一遍扩展过程,这有可能致使需要与更多的字词进行匹配。然而这一点在实践中通常并非真正的问题。

相反,在搜索时所用的分析工具中使用同义词则可以避免很多上述问题:

  • 索引规模不受影响。
  • 语料库中的字词统计数据保持不变。
  • 如需变更同义词规则,无需对文档进行重新索引。

这些优势通常要高出唯一的劣势,即每次查询时都必须执行同义词扩展操作,这有可能导致需要匹配更多字词。不仅如此,搜索时扩展同义词还能够允许使用更加复杂的 synonym_graph 词元筛选器,这一工具能够正确处理多单词同义词,并且仅可在搜索分析器中使用。

一般而言,搜索时使用同义词的优势通常要高于索引时使用同义词可能实现的微小性能改进。

然而,如果在搜索时使用同义词,过去还需要注意另外一个问题。尽管更改同义词规则不需要对文档进行重新索引,但是如要更改的话,您必须暂时关闭再重新打开索引。这一点很有必要,因为分析器在下列时候才会创建实例:创建索引时,重启节点时,以及重新打开已关闭的索引时。为了让对同义词规则文件所做的变更对索引可见,用户必须首先在所有节点上更新文件,然后再关闭并重新打开索引。但是这个问题已经得以解决。

同义词,重新加载成功

从 Elasticsearch 7.3 开始,无需重新打开索引便能看到同义词文件中的变更。我们新增了一个端点,让用户能够按需触发分析器资源的重新加载操作。调用这个新端点将会重新加载索引中的所有分析器,前提是这些索引中的组件已被标记为可更新。相应地,这会使这些组件只能在搜索时使用。

对同义词筛选器而言,将其标记为可更新并调用“重新加载 API”会使每个节点上的同义词配置文件对分析过程可见。虽然仍不能(通过同义词参数)更新筛选器定义中的同义词规则,但这些同义词规则应该主要用于偶尔的测试目的。无论何种情况,用配置文件来配置同义词有几个优势:

  • 管理更简单!在生产系统中,可能会有很多同义词规则,而且由于这些同义词规则会显著影响搜索相关度,所以应该将其视为配置中不可或缺的一部分,且对于任何更新,都需要进行版本控制和测试。
  • 同义词通常来自其他来源,或由您的数据上所运行的算法创建。从文件读取的话,便无需将这些同义词加入到筛选器配置中。
  • 同一份同义词文件可在不同筛选器中使用。
  • 较大的同义词规则集会占用 Elasticsearch 集群状态(用于存储索引设置的相关元信息)中的大量内存。为避免无谓地增加集群规模,我们建议将较大的同义词规则集存储在配置文件中。

为演示起见,我们假设您将包含下列单条规则的初始 my_synonyms.txt 文件添加到 Elasticsearch 节点的 config 目录中。我们假设此文件最初仅包含下列一条规则:

universe, cosmos

接下来,我们需要定义一个分析器,并让其在同义词筛选器中引用此文件:

PUT /synonym_test
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "synonym_analyzer": {
            "tokenizer": "whitespace",
            "filter": ["my_synonyms"]
          }
        },
        "filter": {
          "my_synonyms": {
            "type": "synonym",
            "synonyms_path": "my_synonyms.txt",
            "updateable": true
          }
        }
      }
    }
  }
}

请注意我们将同义词筛选器标记为了 updateable(可更新)。这一点很重要,因为当我们调用新的重新加载端点时,只会重新加载可更新的筛选器;但这样做也有缺点,因为在索引时不允许使用包含可更新筛选器的分析器。但是我们首先来检查一下同义词是否已正确应用,可以通过 _analyze 端点运行一个简短的测试:

GET /synonym_test/_analyze
{
  "analyzer": "synonym_analyzer",
  "text": "cosmos"
}

此操作应该会返回两个词元,正如我们所料,其中一个是“universe”。我们接下来通过添加第二行向 synonyms.txt 文件添加另一条规则:

lift, elevator

如果使用之前版本,您此时必须关闭并重新打开索引,以便显示这些变更。现在您可以简单地调用新端点:

POST /synonym_test/_reload_search_analyzers

虽然此请求并不要求有正文,但可能会限制为一个或多个使用典型索引通配符模式的索引。响应中包括的信息有已重新加载了哪些分析器,以及哪些节点受到了影响:

{
  [...],
  "reload_details": [{
    "index": "synonym_test",
    "reloaded_analyzers": ["synonym_analyzer"],
    "reloaded_node_ids": ["FXbmbgG_SsOrNRssrYcPow"]
  }]
}

现在对字词“lift”运行上面的 _analyze 请求,也会返回“elevator”(作为第二个同义词词元)。

然而,还有一些注意事项。如上面提到的,搜索时应该使用已标记为 updateable(可更新)的筛选器,所以在字段层面使用上面所定义的同义词分析器的正确方式如下:

POST /synonym_test/_mapping
{
  "properties": {
    "text_field": {
      "type": "text",
      "analyzer": "standard",
      "search_analyzer": "synonym_analyzer"
    }
  }
}

同样,重新加载仅适用于从文件中加载的同义词,亦即不支持更改通过筛选器中的设置所定义的同义词。最后,您在实践中需要确保对集群中的所有节点均应用对同义词文件进行的更新。如果某些节点上的分析器看到了文件的不同版本,则您可能会收到不同的搜索结果,具体取决于搜索中使用的是哪个节点。如果发生与同义词相关的此种情况,首先需要检查每个节点上的同义词文件是否一样,然后重新触发“重新加载”操作。

总结一下,新的 _reload_search_analyzer 端点能够让您快速修订和更改查询时应用的同义词,而无需重新打开索引。例如,通过检查查询日志,您可以确定用户查询时所用的字词与已索引文档中的既有字词是否不同,然后随时进行添加。但是添加同义词会对相关度得分产生意料之外的不良影响,所以我们建议首先进行某些形式的测试(A/B 测试,或者排名评估 API 等都可以),然后再直接在生产环境中应用这些变更。

作为分析链中的一部分

另外一个有关同义词筛选器的常见问题是其在更复杂分析链中的行为。在大多数情况下,您会在同义词筛选器之前加入一些常见的字符或词元筛选器,例如 lowercase(小写)筛选器。这意味着流经分析链的所有词元都会变为小写,然后才会应用同义词筛选器。这是否意味着同义词规则中的输入同义词也需要变为小写才能匹配呢?我们通过这个简单的示例看一下:

PUT /test_index
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "synonym_analyzer": {
            "tokenizer": "whitespace",
            "filter": ["lowercase", "my_synonyms"]
          }
        },
        "filter": {
          "my_synonyms": {
            "type": "synonym",
            "synonyms": ["Eins, Uno, One", "Cosmos => Universe"]
          }
        }
      }
    }
  }
}
GET /test_index/_analyze
{
  "analyzer": "synonym_analyzer",
  "text": "one"
}

在上面的示例中,您可以验证小写的输入文本扩展成了三个词元,这表示小写操作也会应用到同义词筛选器的规则中。同样,右边的替换规则(例如“Cosmos => Universe”规则)也会重新编写,正如您看到的下边示例的小写输出:

GET /test_index/_analyze
{
  "analyzer": "synonym_analyzer",
  "text": "cosmos"
}

一般而言,对于提供给前面分析链中所用分词器和筛选器的输入,同义词筛选器会对其进行重新编写。然后,这一规则有一些值得注意的例外情况:多个会输出堆叠式词元的筛选器(例如 common_gramsphonetic 筛选器)均不允许在同义词筛选器之前使用,如果您尝试这样做,系统会报错。对于其他筛选器,例如复合词筛选器或同义词筛选器自身,如果它们在分析链中位于另一个同义词筛选器之前,则会跳过他们。后面一条规则对于实现同义词筛选器链路十分重要。我们可以在下面的示例中看一下实现过程。

如果您连着使用两个或多个同义词筛选器,会怎样呢?前项的输出会成为后项的输入吗,也就是将同义词筛选器的链接操作转变为部分意义上的传递操作?我们用下面的示例来试一下:

PUT /synonym_chaining
{
  "settings": {
    "index": {
      "analysis": {
        "filter": {
          "first_synonyms": {
            "type": "synonym",
            "synonyms": ["a => b", "e => f"]
          },
          "second_synonyms": {
            "type": "synonym",
            "synonyms": ["b => c", "d => e"]
          }
        },
        "analyzer": {
          "synonym_analyzer": {
            "filter": [
              "first_synonyms",
              "second_synonyms"
            ],
            "tokenizer": "whitespace"
          }
        }
      }
    }
  }
}
GET /synonym_chaining/_analyze
{
  "analyzer": "synonym_analyzer",
  "text": "a"
}

输出词元为“c”,这表示两个筛选器已按顺序得以应用,即第一个筛选器将“a”替换为“b”,第二个筛选器继而将这一输入替换为“c”。如果您尝试将输入改为“d”,会将其替换为“e”(并未应用第一条规则);但如果您将输入改为“e”,会根据第一条规则将这个词元替换为“f”,导致第二个筛选器根本没有可以匹配的内容。

还记得吗?我们刚才讲到了基于前面的词元筛选器进行重新编写时有一些例外情况。如果上面的 second_synonyms(第二组同义词)筛选器对其规则集应用了第一个筛选器的规则,那么它会将自身的规则 d => e 更改为 d => f(因为会应用前面筛选器的规则 e => f)。在 Elasticsearch 的早期版本中,这一行为过去常常给人们造成困扰,因此现在处理后面筛选器中的同义词规则时,会跳过同义词筛选器。在 6.6 及之后的版本中,它将会按照我们所描述的那样运行。

展望未来

在这篇简短的博文中,我们只是就同义词的用途向大家介绍了冰山一角,并尝试解决了与使用同义词相关的一些常见问题。同义词是一项强大工具,能够用来提升您搜索系统的重新调用率,但是还有一些很重要的细节也需要您知道并进行试验,尤其是与系统性的相关度测试一起进行试验。

我们在 Elasticsearch 7.3 中增加了新 API 以便能够重新加载搜索时应用的分析器,这一 API 能够让您更加轻松地完成此类试验,因为您无需再像之前那样关闭再重新打开索引;此外,其还能够让您更新在搜索时应用的同义词规则,而无需让您的索引离线。我们希望通过一系列改进让用户更便利地管理大型集群中的同义词,此 API 只是这一过程中迈出的一小步而已。欢迎告诉我们您的想法,并在我们的论坛中给我们提供反馈或提问。祝您分析愉快!