工程

使用向量字段进行文本相似度搜索

从最初作为一个菜谱搜索引擎开始,Elasticsearch 的设计宗旨始终是提供快速且强大的全文本搜索体验。由于这些是我们的根本,所以提升文本搜索效果一直深深激励着我们继续从事向量方面的工作。在 Elasticsearch 7.0 中,我们针对高维向量推出了实验性字段类型,当前 7.3 版本则支持使用这些向量进行文档评分。

本文专注于一项名为文本相似度搜索的特定技术。在此类搜索中,用户输入简短的自由文本查询内容,然后系统便会基于文档与查询内容的相似度对文档进行排名。文本相似度在各种用例中都有很大用处:

  • 回答问题: 如果已有一系列常见问题,则可通过文本相似度来查找与用户所输入问题相似的问题。
  • 文章搜索:从一系列研究文章中,返回名称与用户查询内容紧密相关的文章。
  • 图片搜索:对于由包含说明的图片组成的数据集,从中查找哪些图片的说明与用户描述相似。

应用相似度搜索的一种直接方法是基于文档中有多少与查询相同的词语来对文档进行排名。然而,即使文档与查询内容仅有少数几个词语相同,此文档仍有可能与查询内容类似,所以更加强大的相似度概念还会考虑文档的句法和语义内容。

自然语言处理 (NLP) 社区开发了一项名为文本嵌入的技术,该技术能够将词语和句子编码为数字向量。这些“向量表示”旨在采集文本的语言学内容,并可用来评估查询内容和文档之间的相似度。

本文探究了如何使用文本嵌入和 Elasticsearch 的 dense_vector类型来支持相似度搜索。我们首先会概览一下嵌入技术,然后使用 Elasticsearch 逐步完成一个简单的相似度搜索原型。

注意:在搜索中使用文本嵌入是一个复杂且不断发展的领域。我们希望此篇博文可以作为您探索的起点,本文并非旨在推荐特定的搜索架构或实施方法。

什么是文本嵌入?

我们深入了解一下文本嵌入的不同类型,并看看其与传统搜索方法有哪些不同。

词嵌入

词嵌入模型会用密集数字向量表示一个词语。这些向量的目的是采集词语的语义属性;如果词语的向量相近,则它们在语义意义上应该相似。在良好的嵌入实践中,向量空间的方向与词语意义的不同方面密切相关。举例说明,“加拿大”的向量可能在一个方向上与“法国”相近,而在另一个方向上则与“多伦多”相近。

NLP 和搜索社区长期以来对词语的“向量表示”就很感兴趣。在过去几年,随着使用神经网络重新审视很多传统任务,人们对词嵌入的兴趣又重新高涨了起来。现已开发出一些成功的词嵌入算法,包括 word2vecGloVe。这些方法会利用大量的文本采集内容,然后检查每个词语所在的上下文以确定该词的“向量表示”:

  • word2vec 的 Skip-gram 模型会对神经网络进行训练,以预测句子内词语周围上下文中的词语。词嵌入信息由神经网络分配给每个词语的内在权重决定。
  • 在 GloVe 中,词语的相似度取决于它与上下文中的其他词语同时出现的频率。算法会基于词语同现计数来训练一个简单的线性模型。

很多搜索小组会分发已针对大型文本语料库(例如 Wikipedia 或 Common Crawl)进行预训练的模型,让人们便捷地下载并将这些模型整合到下游任务中。尽管人们通常会直接使用预训练版本,但如能对模型进行调整以适应具体的目标数据集和任务,会非常有用。这通常是通过在预训练模型的基础上运行一个轻量型微调步骤来实现的。

词嵌入经证明十分强大和有效,在 NLP 任务(例如机器翻译以及情感分类)中,目前用嵌入来代替单独词元已成为普遍实践。

句子嵌入

研究人员最近开始专注的这项嵌入技术不仅能表示词语,还能表示较长的文本节段。最新方法基于复杂的神经网络架构,有时还会在训练时整合标签数据以帮助采集语义信息。

训练完毕后,这些模型便能处理句子并针对上下文中的每个词语创建一个向量,同时还会针对整个句子生成一个向量。与词嵌入类似,目前很多模型也有预训练版本,允许用户跳过花费不菲的训练过程。尽管训练过程可能会耗费大量资源,但调用模型的过程却要轻量得多;句子嵌入模型通常速度很快,足够在实时应用程序中使用。

一些常见的句子嵌入技术包括 InferSentUniversal Sentence EncoderELMo 以及 BERT。改善词语和句子嵌入技术是一个十分活跃的研究领域,之后有可能还会推出更多强大的模型。

与传统搜索方法的对比

在传统的信息检索中,将文本表示为数字向量的一种常见方式是为词汇表中的每个词语分配一个维度。然后便会基于词汇表中每个字词的出现次数得出这段文本的向量。这种表示文本的方法通常称为“词袋”,因为我们只是简单地数一数词语的出现次数,而不会考虑句子架构。

文本嵌入在以下几个重要方面与传统“向量表示”有所不同:

  • 编码后的向量比较密集,维度相对较低,维数通常介于 100 到 1,000 之间。与之形成鲜明对比的是,词袋的向量稀疏,可包含 5 万多个维度。在对语义意义建模的过程中,嵌入算法会将文本编码为较低维度的空间。理想情况下,意思相近的单词或短语最终在新的向量空间中为相似的表示。
  • 句子嵌入在确定“向量表示”时会将词语顺序考虑在内。例如,短语“tune in”可能就会映射为与“in tune”完全不同的向量。
  • 在实践中,句子嵌入通常不能很好地泛化到大的文本节段,通常只能用来表示一小段内容。

将嵌入技术用于相似度搜索

假设我们有一大组问题和答案。用户可以提问,我们则希望在自己的内容集合中检索到最相似的问题,从而帮助用户找到答案。

我们可以使用文本嵌入来允许检索相似问题:

  • 索引过程中,每个问题都会在句子嵌入模型中运行一遍,从而生成一个数字向量。
  • 当用户输入查询内容时,查询内容也会在同一个句子嵌入模型中运行一遍,并生成一个向量。为了对响应进行排名,我们要计算每个问题和查询向量之间的向量相似度。比较嵌入向量时的一种常见方法是使用余弦相似度

此存储库针对如何在 Elasticsearch 中完成这一操作给出了一个简单示例。主脚本对来自 StackOverflow 数据集的约两万个问题进行了索引,然后允许用户输入自由文本查询内容以与数据集进行比较。

我们很快将会详细讲解脚本的每一部分,但我们首先来看一些示例的结果。在很多情况下,即使查询内容和索引后的问题之间并没有很多重复词语,该方法也能够采集到相似性:

  • “zipping up files”(文件打包)返回了 “Compressing / Decompressing Folders & Files”(对文件夹和文件进行压缩/解压缩)
  • “determine if something is an IP”(确定某一内容是否为 IP)返回了“How do you tell whether a string is an IP or a hostname”(如何确定某个字符串是 IP 还是主机名)
  • “translate bytes to doubles”(将字节转译为双精度)返回了“Convert Bytes to Floating Point Numbers in Python”(在 Python 中将字节转化为浮点数)

实施详情

脚本首先会在 TensorFlow 中下载并创建嵌入模型。我们选择的是 Google 的 Universal Sentence Encoder,但可能还有很多其他嵌入方法可供选择。脚本使用的是“原样的”嵌入模型,未经过任何额外训练或微调。

接下来,我们创建 Elasticsearch 索引,其中包括针对问题标题和标签的映射,以及已编码为向量的问题标题:

"mappings": {
  "properties": {
    "title": {
      "type": "text"
    },
    "title_vector": {
      "type": "dense_vector",
      "dims": 512
    }
    "tags": {
      "type": "keyword"
    },
    ...
  }
}

在针对 dense_vector的映射中,我们需要指定向量中包含的维数。对 title_vector字段进行索引时,Elasticsearch 会检查其中的维数是否与映射中所指定的数字相同。

如要对文档进行索引,我们需要通过嵌入模型来将问题标题运行一遍,以获得一个数字阵列。这个阵列会添加到文档中的 title_vector字段。

用户输入查询内容时,文本首先会通过同一嵌入模型运行一遍,然后存储在参数 query_vector中。从 7.3 开始,Elasticsearch 在其原生脚本语言中提供了一个 cosineSimilarity(余弦相似度)函数。所以如要基于问题与用户查询内容的相似度对问题排名,我们需使用一个 script_score查询::

{
  "script_score": {
    "query": {"match_all": {}},
    "script": {
      "source": "cosineSimilarity(params.query_vector, 'title_vector') + 1.0",
      "params": {"query_vector": query_vector}
    }
  }
}

我们确保将查询向量作为脚本参数进行传输,从而避免对每个新查询均重新编辑脚本。由于 Elasticsearch 不允许分数为负,所以有必要在余弦相似度的基础上加一。

注意:本篇博文最初使用的是针对矢量函数的不同语法,该语法可在 Elasticsearch 7.3 中使用,但在 7.6 中已弃用。

重要限制

script_score查询旨在将限制性查询包起来,并编辑所返回文档的分数。然而,我们提供了一个 match_all查询,这意味着此脚本会在索引中的所有文档上运行。目前 Elasticsearch 中的向量相似度有个限制:向量可用于文档评分,但不能用于最初的检索步骤。基于向量相似度提供检索支持是当前工作中的一个重要领域。.

为避免扫描所有文档,也为了保持快速性能,可以用选择性更强的查询来代替 match_all查询。至于用哪个查询进行检索比较合适,这很可能取决于具体用例。

尽管我们在上面看到了一些鼓舞人心的示例,但需注意很重要的一点:结果也可能造成干扰并且不直观。例如,“zipping up files”(文件打包)对“Partial .csproj Files”(部分 .csproj 文件)和“How to avoid .pyc files?”(如何避免 .pyc 文件?)也打了高分。而且,当此方法返回令人惊讶的结果时,有时用户并不知道如何对问题进行故障排查,因为每个向量成分的意义通常是不透明的,并不对应至一个可解释的概念。借助基于词语重复情况的传统评分技术,通常可以更轻松地回答“为什么这个文档的排名靠前?”这个问题。

如之前所讲,此原型旨在作为一个示例,向您展示可以如何结合矢量字段来使用嵌入模型,其并非可直接应用于生产环境的解决方案。开发新的搜索战略时,很关键的一点是基于您自己的数据测试新方法的表现如何,确保与强大的基准(例如 match 查询)进行对比。您可能需要对此战略进行重大变动,然后才能实现稳健结果,这些变动包括针对目标数据集对嵌入模型进行微调,或者在纳入嵌入时尝试不同的方法,例如单词级查询扩展。

结论

嵌入技术为采集一段文本的语义内容提供了一种强大方法。通过对嵌入信息进行索引并基于向量距离进行评分,我们能够借助相似度概念对文档进行排名,而不再局限于词语层面的重复性。

我们希望能够基于向量字段类型推出更多功能。使用向量进行搜索是一个十分重要的领域,有很多细微差别。一如既往,我们欢迎您在 Github论坛上与我们分享您的用例和经验!