likes
comments
collection
share

Elastic 中的向量搜索

作者站长头像
站长
· 阅读数 29

欢迎来到《在 Elastic 中开始学习向量搜索》。在本章中,我们将了解在 Elastic 中搜索的基本范式,以及向量搜索如何成为实时、上下文感知和准确信息检索的强大工具。

在本章中,我们将涵盖以下主题:

  1. 在引入向量搜索之前的 Elastic 搜索体验
  2. 需要新的表示形式,如向量和层次可导航小世界(HNSW)
  3. 新的向量数据类型
  4. 配置映射和存储向量的不同策略,以及优化实施的更好视角
  5. 如何构建查询,包括暴力搜索、k 最近邻搜索(kNN)和精确匹配

无论您是一个经验丰富的 Elastic 用户还是刚开始使用,本章都将为您展示向量搜索在 Elastic 中的强大能力。让我们开始吧!

Elastic 中向量搜索之前的搜索体验

在 Elastic 引入向量搜索之前,主要的相关性模型是基于文本搜索和分析能力的。Elasticsearch 提供了各种数据类型(数据类型)和分析器(分析器)以提供高效的搜索。在这一部分中,我们将进行基本介绍,确保大家了解“之前的状态”。

数据类型及其对相关性的影响

Elasticsearch 中有多种数据类型,但在这一部分中,我们不会逐一介绍它们,而是将它们分为两类:直接驱动相关性排名的类型和间接影响排名的类型。目标是了解它们与相关性模型的关系。

我们首先看一下直接影响相关性排名的类型:

  • 文本(Text) :文本数据类型是 Elasticsearch 相关性模型中最关键的数据类型。它用于存储和搜索文本数据,如文章和产品描述。文本数据通过内置分析器进行分析,分析器将文本分解为标记并执行如小写化、词干提取和过滤等操作。
  • 地理(Geo) :地理数据类型用于存储和搜索地理坐标。它使您能够执行基于地理的查询,如查找特定距离或边界框内的文档。尽管它不是基于文本的相关性模型的一部分,但基于地理的查询可以帮助缩小搜索结果范围并提高其相关性。

其次是改善相关性的类型:

  • 关键字(Keyword) :关键字数据类型用于存储未分析的文本数据,通常用于过滤和聚合。它通过应用过滤器或聚合来细化搜索结果,从而提高结果的相关性。
  • 数值类型(Numeric types)(整数、浮点数、双精度等) :这些数据类型用于存储和搜索数值数据。尽管它们不会直接影响基于文本的相关性模型,但它们可以用于过滤或排序搜索结果,从而间接影响结果的相关性。
  • 日期(Date) :日期数据类型用于存储和搜索日期和时间数据。类似于数值类型,日期数据类型可以用于过滤和排序搜索结果,从而间接影响整体相关性。
  • 布尔值(Boolean) :布尔数据类型用于存储 true/false 值。尽管它不会直接贡献于相关性模型,但它可以用于过滤搜索结果,从而提高其相关性。

相关性模型

尽管这本书不是 Elasticsearch 的食谱书,而是专注于向量搜索,但在深入研究向量之前,了解相关性排名模型是很重要的,这样我们可以在需要时使用它们。我们将看到,通过结合向量搜索和“传统”搜索来构建混合搜索是一种改进最终用户体验的技术。

Elasticsearch 本身在相关性模型方面经历了迭代,最初是使用词频-逆文档频率(TF-IDF),现在使用 BM25。两者都是用于根据查询的相关性对文档进行排序的文本检索算法。然而,它们有关键的不同,我们将在这里探讨。

TF-IDF

为了说明 TF-IDF 概���,我们将使用一个包含三个文档的小集合的简单示例:

  • 文档 1:“I love vector search. Vector search is amazing.”
  • 文档 2:“Vector search is a method for searching high-dimensional data.”
  • 文档 3:“Elasticsearch is a powerful search engine that supports vector search.”

我们想要计算每个文档中术语“vector search”的 TF-IDF 得分。

首先,我们计算每个文档的术语频率(TF);注意我们在这里对二元词组(文本中的两个相邻单词序列——即 vector 和 search 一起)应用了 TF:

  • 文档 1:“vector search” 出现两次,共 8 个词:TF = 2 / 8 = 0.25
  • 文档 2:“vector search” 出现一次,共 9 个词:TF = 1 / 9 = 0.111
  • 文档 3:“vector search” 出现一次,共 10 个词:TF = 1 / 10 = 0.110

然后,我们计算术语“vector search”的逆文档频率(IDF)。

我们知道包含术语“vector search”的文档数量 = 3。 总文档数量 = 3。

使用公式计算得到: IDF = log(3 / 3) = log(1) = 0

最后,我们计算每个文档中术语“vector search”的 TF-IDF 得分:

  • 文档 1:TF-IDF = TF * IDF = 0.25 * 0 = 0
  • 文档 2:TF-IDF = TF * IDF = 0.111 * 0 = 0
  • 文档 3:TF-IDF = TF * IDF = 0.110 * 0 = 0

在这个特定示例中,IDF 值为 0,因为术语“vector search”在所有文档中都出现,使其成为整个文档集合中的一个常见术语。因此,所有文档的 TF-IDF 得分也为 0。这说明了 TF-IDF 算法中的 IDF 组件如何惩罚文档集合中的常见术语,减少它们对相关性得分的影响。

通过将第三个文档修改为“Elasticseach is a powerful search engine that supports semantic search.”,搜索“semantic search”,并应用我们刚刚学到的知识,排名变为如下:

  • 文档 3(TF-IDF = 0.109)
  • 文档 1(TF-IDF = 0)
  • 文档 2(TF-IDF = 0)

尽管这种方法简单明了,但对于较长的文档,通常会因为术语频率较高而导致偏差。以下是两个需要考虑的要点:

  • IDF 组件计算为 log(N/df(t)),其中 N 是集合中的文档总数,df(t) 是包含术语 t 的文档数。IDF 组件旨在赋予稀有术语更重要的地位,而减少常见术语的重要性。
  • TF 没有上限,这意味着随着术语频率的增加,其对相关性得分的影响也会线性增加。

这引导我们考虑以下方法来对文档进行排名。

BM25

使用 BM25,Elasticsearch 能够增加数据的忠实度并优化方程式的 TF 和 IDF 组件。例如,BM25 引入了一个饱和组件,这意味着术语对相关性得分的影响在某个点上会达到上限,即使其在文档中的频率继续增加。这种饱和防止了极高的术语频率主导相关性得分。

以下图表显示了 TF-IDF 随着频率增加而不断增加,而 BM25 则对其进行中和的效果:

Elastic 中的向量搜索

你可以在这篇博客文章中找到更多信息: www.elastic.co/blog/practi…

为了简单起见并说明 BM25 的优势,我们将通过一个类似于之前 TF-IDF 的示例来说明。我们将使用相同的文档,但现在我们将计算术语 “search” 的 BM25 得分。目标是突出 BM25 的优势,例如术语频率归一化和饱和:

  • 文档 1:“I love vector search. Vector search is amazing.”
  • 文档 2:“Vector search is a method for searching high-dimensional data.”
  • 文档 3:“Elasticsearch is a powerful search engine that supports semantic search.”

首先,我们计算每个文档的 TF:

  • 文档 1:search 出现 2 次,共 8 个词:TF = 2 / 8 = 0.25
  • 文档 2:search 出现 1 次,共 9 个词:TF = 1 / 9 = 0.111
  • 文档 3:search 出现 2 次,共 10 个词:TF = 2 / 10 = 0.2

现在,我们将使用以下公式计算每个文档的 BM25 得分:

∑inIDF(qi)⋅f(qi,D)⋅(k1+1)f(qi,D)+k1⋅(1−b+b⋅fieldLenavgFieldLen)\sum_{i}^{n} \text{IDF}(q_{i}) \cdot \frac{f(q_{i}, D) \cdot (k1 + 1)}{f(q_{i}, D) + k1 \cdot (1 - b + b \cdot \frac{\text{fieldLen}}{\text{avgFieldLen}})}i∑n​IDF(qi​)⋅f(qi​,D)+k1⋅(1−b+b⋅avgFieldLenfieldLen​)f(qi​,D)⋅(k1+1)​

首先,让我们看看如何计算 TF 归一化和 IDF。

TF 归一化

假设 k1 = 1.2,b = 0.75。文档的平均长度(avgdl)为 (8 + 9 + 10) / 3 = 9:

  • 文档 1: (1.2+1)⋅21.2⋅(1−0.75+0.75⋅89)+2=1.405\frac{(1.2 + 1) \cdot 2}{1.2 \cdot (1 - 0.75 + 0.75 \cdot \frac{8}{9}) + 2} = 1.4051.2⋅(1−0.75+0.75⋅98​)+2(1.2+1)⋅2​=1.405
  • 文档 2: (1.2+1)⋅21.2⋅(1−0.75+0.75⋅99)+2=1.360\frac{(1.2 + 1) \cdot 2}{1.2 \cdot (1 - 0.75 + 0.75 \cdot \frac{9}{9}) + 2} = 1.3601.2⋅(1−0.75+0.75⋅99​)+2(1.2+1)⋅2​=1.360
  • 文档 3: (1.2+1)⋅21.2⋅(1−0.75+0.75⋅109)+2=1.317\frac{(1.2 + 1) \cdot 2}{1.2 \cdot (1 - 0.75 + 0.75 \cdot \frac{10}{9}) + 2} = 1.3171.2⋅(1−0.75+0.75⋅910​)+2(1.2+1)⋅2​=1.317

IDF

BM25 中的 IDF 组件计算公式为 log⁡(N−df(t)+0.5df(t)+0.5)\log \left(\frac{N - df(t) + 0.5}{df(t) + 0.5}\right)log(df(t)+0.5N−df(t)+0.5​),其中 N 是文档总数,df(t) 是包含术语 t 的文档数。

我们知道包含术语 “search” 的文档数量 = 3,总文档数量 = 3。因此,我们得到:

IDF=log⁡(3−3+0.53+0.5)≈0.287\text{IDF} = \log \left(\frac{3 - 3 + 0.5}{3 + 0.5}\right) ≈ 0.287IDF=log(3+0.53−3+0.5​)≈0.287

BM25 得分

  • 文档 1:BM25 = IDF * TF 归一化 = 0.287 * 1.405 ≈ 0.403
  • 文档 2:BM25 = IDF * TF 归一化 = 0.287 * 1.360 ≈ 0.390
  • 文档 3:BM25 = IDF * TF 归一化 = 0.287 * 1.317 ≈ 0.377

基于 BM25 得分,我们可以为查询 “search” 排序文档如下:

  • 文档 1(BM25 = 0.403)
  • 文档 2(BM25 = 0.390)
  • 文档 3(BM25 = 0.377)

这说明了 BM25 的一个优势,即它能够比 TF-IDF 更有效地处理多样化的文档长度。归一化有助于减少对较长文档的偏差,因为它们通常具有较高的术语频率,因为它们包含更多的单词。

在这个阶段,您应该对 TF-IDF,特别是 BM25 有了深入的了解。到目前为止所涉及的主题不仅有助于您理解基于关键字的搜索和向量搜索之间的差异(一个重要的区别是计算搜索的相关性),还将在本书的后续章节中讨论混合搜索时发挥重要作用。

搜索体验的演变

我们现在将看到用户对更好搜索体验的需求如何要求我们考虑除了基于关键字的搜索之外的其他技术。在本节中,我们将讨论基于关键字搜索的局限性,理解向量表示的含义,以及 HNSW 这种元表示如何出现以促进向量的信息检索。

基于关键字搜索的局限性

对于那些对这一主题比较陌生的人,在谈论向量表示之前,我们需要理解为什么行业和基于关键字的搜索体验已经达到了极限,无法完全满足终端用户的需求。

基于关键字的搜索依赖于用户查询与文档中包含的术语之间的精确匹配,如果搜索系统在同义词、缩写、替代措辞等方面不够精细,可能会导致遗漏相关结果。因此,搜索系统需要将给定的单词与同一语义空间中的其他单词关联起来。

由于基于关键字的搜索缺乏对上下文的理解,它们不考虑单词的上下文或意义。因此,澄清单词的上下文很重要。例如,单词“bat”在不同的上下文中有不同的含义——在体育上下文中,“bat”可以指棒球或板球中用来击球的球棒。在动物学上下文中,“bat”指的是一种飞行的哺乳动物。

由于上述限制,单个单词已经很具挑战性,再加上语言依赖、拼写错误和拼写变体。此外,基于关键字的搜索无法捕捉句子的结构或语义。例如,查询中单词的顺序可能对理解其意义很重要。术语之间存在语义关系,这使得使用不同词汇讨论相同主题的文档难以检索。

一个因缺乏语义理解而导致基于关键字搜索局限性的好例子可以在与“全球变暖”主题相关的搜索查询中看到。

假设用户使用术语“global warming”搜索文档,但一些相关文档使用术语“climate change”而不是“global warming”。由于基于关键字的搜索无法捕捉“global warming”和“climate change”之间的语义关系,它可能无法检索使用不同词汇讨论相同主题的相关文档。

需要明确的是,确实存在应对上述限制的技术,但它们很难扩展或维护,并且需要相当多的专业知识。相反,使用模型生成嵌入可以帮助解决许多基于关键字搜索的局限性。

向量表示

正如第1章《向量和嵌入简介》中所解释的,向量表示是一种将复杂数据(如文本)转换为固定大小的数值格式的方法,这些格式可以被机器学习过程轻松处理。在自然语言处理(NLP)中,它有助于捕捉单词、句子和文档的语义意义,使其更容易执行第1章中描述的任务。现在我们将研究将原始数据向量化、提取特征并使用模型将其转换为数字表示的过程。此外,我们将熟悉 HNSW 及其在搜索过程中的作用。

向量化过程

从高层次上看,构建向量的过程包括以下步骤:

首先,对文本进行处理——即清理和预处理原始数据,以消除噪音、纠正拼写错误,并将文本转换为标准化格式。

常见的处理步骤包括小写化、分词、去除停用词、词干提取和词形还原。这非常接近甚至类似于 Elastic 在发送和索引数据时所做的事情,这些任务在索引之前由分析器执行。请注意,这可能会影响嵌入模型,因为这些数据处理步骤可能会移除语义细微差别。

在预处理数据之后,从文本中提取相关特征。这可以使用诸如词袋(Bag-of-Words, BoW)、TF-IDF 或更先进的技术如词嵌入(Word Embeddings)来完成。

词嵌入是密集的向量表示,在连续的向量空间中捕捉单词的语义意义。与稀疏表示(如 BoW 或 TF-IDF,其中大多数值为零)不同,密集向量表示在大多数维度上都有非零值。这意味着词嵌入在较小的空间中存储了更多信息,使其更高效且计算上更易管理。词嵌入将单词映射到连续的多维空间中的点。这意味着空间中每个单词的位置由一组连续的数值(单词的向量)决定,可以使用各种距离度量(如欧几里得距离或余弦距离)来测量单词之间的距离。连续的向量空间允许相关单词之间平滑过渡,使其更容易识别和操作语义关系。

一旦提取了特征,它们就可以转换为数值向量,用作机器学习过程的输入。向量的每个维度对应于一个特定特征,该维度中的值反映了该特征在给定文本中的重要性或相关性。

结合向量表示,可以使用算法在高维空间中执行近似最近邻搜索,这是 HNSW 的目标。

HNSW

您可以在康奈尔大学网站上找到关于 HNSW 的初始论文 arxiv.org/abs/1603.09…,但在这里,我们将其分解为多个基本部分,以便更好地理解它是什么以及它实现了什么。

如前所述,需要一种算法在高维空间中执行最近邻搜索。HNSW 算法可以帮助快速根据其向量表示找到相似的文本。

从高层次上看,HNSW 构建了一个层次图,其中每个节点映射到文本的向量表示。图的构建方式具有小世界特性,允许在高维空间中进行高效搜索,如下图所示:

Elastic 中的向量搜索

然后,近似最近邻搜索会找到与给定查询文本相似的文本。查询首先转换为向量表示,使用与数据集相同的方法,以便在向量空间中找到与查询最近的向量表示。

HNSW 基于“小世界”网络的思想。在小世界网络中,大多数节点不是彼此的邻居,但可以通过少数几跳从任何其他节点到达。

在高维空间中搜索最近邻时,传统方法可能会陷入局部最小值。想象一下,攀登一座山并试图到达最高峰,但你最终只到达了附近的一个较小的峰,因为这是从你站立位置能看到的最高点。这就是局部最小值。

HNSW 通过维护图的多个层次(层)来克服这一问题。搜索时,HNSW 从顶层开始,该层相比于更深的层具有较少的节点,并覆盖更广的区域;因此,它不太可能陷入局部最小值。然后,它逐层向下工作,随着深入逐层细化搜索,直到到达包含详细数据的基层。

当算法找到一个节点,该节点比其邻域中的任何其他节点都更接近查询时,搜索停止。此时,认为它在数据集中找到了最接近(或近似最接近)的匹配项,基于当前搜索的层。

HNSW 受欢迎的原因有很多:

  • 效率:它在准确性和速度之间提供了平衡。尽管它是一种近似方法,但其准确性通常对于许多应用来说已经足够。
  • 内存使用:与一些其他近似最近邻方法相比,它的内存效率更高。
  • 多功能性:它不依赖于特定的距离度量方法。它可以使用欧几里得距离、余弦距离和其他距离度量方法。

这里,每一层都是前一层节点的一个子集。底层包含所有节点,这是文本的向量表示。顶层节点较少,作为搜索过程的入口点。每个节点根据距离度量(如欧几里得距离、点积或余弦距离)连接到其最近的 k 个邻居。

现在你已经理解了 HNSW 背后的概念,让我们看看如何计算距离。

距离度量

由于 Elasticsearch 提供了三种距离评估方法作为选择,以下是它们的计算方式:

欧几里得距离(Euclidean Distance):

d(A,B)=(A1−B1)2+(A2−B2)2+…+(An−Bn)2d(A, B) = \sqrt{(A_1 - B_1)^2 + (A_2 - B_2)^2 + … + (A_n - B_n)^2}d(A,B)=(A1​−B1​)2+(A2​−B2​)2+…+(An​−Bn​)2​

其中,d(A,B)d(A,B)d(A,B) 是欧几里得距离。在二维平面中,它就是连接点 A 和 B 的线段的长度。

点积(Dot Product):

a⋅b=∑i=1naibi=a1b1+a2b2+…+anbna \cdot b = \sum_{i=1}^{n} a_i b_i = a_1 b_1 + a_2 b_2 + … + a_n b_na⋅b=i=1∑n​ai​bi​=a1​b1​+a2​b2​+…+an​bn​

这里,a⋅ba \cdot ba⋅b 是点积。

从几何上讲,如果 aaa 和 bbb 之间有角度 θ\thetaθ,则:

a⋅b=∣a∣∣b∣cos⁡(θ)a \cdot b = |a| |b| \cos(\theta)a⋅b=∣a∣∣b∣cos(θ)

如图所示:

Elastic 中的向量搜索

∣a∣ = 向量 a 的大小(或长度) ∣b∣|b|∣b∣ = 向量 b 的大小(或长度) cos⁡(θ)\cos(\theta)cos(θ) = 向量之间角度的余弦

这里有一个示例,在向量搜索的上下文中,对于两个向量 A 和 B:

A 可以表示一篇关于“机器学习”的文档。 B 表示一篇关于“深度学习”的文档。

鉴于“机器学习”和“深度学习”之间的密切关系,我们期望这些向量在向量空间中彼此接近,但不完全相同。这意味着它们之间的角度相对较小。

余弦相似度(Cosine Similarity):

Sc(A,B)=A⋅B∣∣A∣∣⋅∣∣B∣∣S_c(A, B) = \frac{A \cdot B}{||A|| \cdot ||B||}Sc​(A,B)=∣∣A∣∣⋅∣∣B∣∣A⋅B​

其中,Sc(A,B)S_c(A,B)Sc​(A,B) 是余弦相似度。

Elastic 中的向量搜索

对于值为 A = [1, 2] 和 B = [3, 4] 的向量,它们的距离如下:

  • 欧几里得距离 ≈ 2.83
  • 点积 = 11
  • 余弦相似度 ≈ 0.98

您可能会问什么时候选择其中之一。答案取决于具体的用例、被向量化的文本、领域和向量空间的形状。

例如,当处理具有有意义原点的数据时,使用欧几里得距离。在二维笛卡尔坐标系中,原点指的是零点 (0,0)。有意义的原点在所分析数据的上下文中具有一定的意义或含义。换句话说,有意义的原点是所有特征值都为零且在问题域中具有明确解释的点。一个很好的例子是测量温度时,零摄氏度 (0°C) 代表在正常大气压下水的冰点。这个原点在温度测量的上下文中具有特定的意义。

点积在处理具有正负值的数据时很有趣,并且向量之间的角度不重要。点积可以是正的、负的或零。它不是归一化的,因此向量的大小可以影响结果。此外,如果向量归一化(即它们的大小为 1),则归一化向量的余弦相似度等于点积: cos similarity=A⋅B=dot product\text{cos similarity} = A \cdot B = \text{dot product}cos similarity=A⋅B=dot product

这是有价值的信息,因为在 Elasticsearch 上使用点积进行向量搜索更快。

在表示文本的向量的上下文中,向量可以具有正值和负值。这些值来自训练过程和用于创建嵌入的算法。这些向量中的正负值本身不一定具有特定意义。因此,两个具有高大小但方向非常不同的向量将具有大的点积,即使它们在语义上不相似。

相反,从点积得出的余弦相似度归一化了向量的大小,并关注它们之间的角度,这使其更适合文本数据。它测量向量之间的角度的余弦,捕捉它们的语义相似度。由于它是归一化的,范围从 –1 到 1,余弦相似度对特征的尺度不太敏感,也对大小不太敏感。

注意这里要理解的两个重要组成部分:

  • 由向量之间的角度定义的方向
  • 大小

让我们详细看看这些组件。

方向和大小

我们已经看到,在文本数据的上下文中,向量通常使用词嵌入或文档嵌入创建,这些嵌入是捕捉单词或文档语义意义的密集向量表示。

这些向量的方向和大小与数据集中单词之间的关系有关。文本的向量表示的方向表示文本在高维向量空间中的语义方向。具有相似方向的向量表示语义上相似的文本。它们共享相似的上下文或含义。换句话说,当两个文本在语义上相关时,它们的向量之间的角度很小。

相反,如果角度很大,则表示文本具有不同的含义或上下文。这就是为什么余弦相似度关注向量之间的角度,是测量文本数据语义相似度的流行选择。

假设以下示例使用词嵌入技术转换为向量表示:

  • A:“The cat is playing with a toy.”
  • B:“A kitten is interacting with a plaything.”
  • C:“The chef is cooking a delicious meal.”

A 和 B 之间的角度可能很小;它们有相似的含义。相反,A 和 C 的向量之间的角度肯定很大,因为它们在语义上是不同的。

向量的大小表示文本在向量空间中的权重。在某些情况下,大小可以与文本中单词的频率或文本在数据集中的重要性相关。但是,大小也可能受到文本长度或某些单词存在的影响,这可能表示语义相似度。

另一个例子:

  • D:“Economics is the social science that studies the production, distribution, and consumption of goods and services.”
  • E:“Economics studies goods and services.”

D 较长并提供了经济学的更详细定义。E 较短并提供了更简洁的定义。这种长度差异可能导致表示 D 的向量的大小大于 E。然而,这种差异并不表示语义差异。实际上,D 和 E 具有相似的含义,尽管它们的大小不同,但应被视为语义上相似。

如果目标是捕捉语义相似度,则比较高维空间中的文本时方向通常比它们的大小更重要,因为角度直接表示单词之间的相似度。

以下是您可以在 Google Colab 中运行的代码示例,您可以在本书的 GitHub 仓库的第 2 章文件夹中找到(github.com/PacktPublis…),用来查看我们的文本示例的向量表示的欧几里得距离、余弦相似度和大小。我们将使用 spaCy 库来创建向量表示:

# 安装 spaCy 并下载 'en_core_web_md' 模型
!pip install spacy
!python -m spacy download en_core_web_md

import spacy
import numpy as np
from scipy.spatial.distance import cosine, euclidean

# 加载预训练的词嵌入模型
nlp = spacy.load('en_core_web_md')

# 定义文本
text_a = "The cat is playing with a toy."
text_b = "A kitten is interacting with a plaything."
text_c = "The chef is cooking a delicious meal."
text_d = "Economics is the social science that studies the production, distribution, and consumption of goods and services."
text_e = "Economics studies goods and services."

# 使用 spaCy 模型将文本转换为向量表示
vector_a = nlp(text_a).vector
vector_b = nlp(text_b).vector
vector_c = nlp(text_c).vector
vector_d = nlp(text_d).vector
vector_e = nlp(text_e).vector

# 计算向量之间的余弦相似度
cosine_sim_ab = 1 - cosine(vector_a, vector_b)
cosine_sim_ac = 1 - cosine(vector_a, vector_c)
cosine_sim_de = 1 - cosine(vector_d, vector_e)

print(f"Text A 和 Text B 之间的余弦相似度: {cosine_sim_ab:.2f}")
print(f"Text A 和 Text C 之间的余弦相似度: {cosine_sim_ac:.2f}")
print(f"Text D 和 Text E 之间的余弦相似度: {cosine_sim_de:.2f}")

# 计算向量之间的欧几里得距离
euclidean_dist_ab = euclidean(vector_a, vector_b)
euclidean_dist_ac = euclidean(vector_a, vector_c)
euclidean_dist_de = euclidean(vector_d, vector_e)

print(f"Text A 和 Text B 之间的欧几里得距离: {euclidean_dist_ab:.2f}")
print(f"Text A 和 Text C 之间的欧几里得距离: {euclidean_dist_ac:.2f}")
print(f"Text D 和 Text E 之间的欧几里得距离: {euclidean_dist_de:.2f}")

# 计算向量的大小
magnitude_d = np.linalg.norm(vector_d)
magnitude_e = np.linalg.norm(vector_e)

print(f"Text D 向量的大小: {magnitude_d:.2f}")
print(f"Text E 向量的大小: {magnitude_e:.2f}")

输出将显示距离计算的结果,这将为在这种情况下选择哪种度量提供一些视角。

在这个阶段,您应该对构成文本向量表示的一些基本概念有了良好的理解,并且了解如何确定这些向量之间的相似度,从而了解文本之间的语义相似度。我们现在将探讨 Elastic 并看看如何将其付诸实践。

新的向量数据类型和向量搜索查询 API

在本章的这一部分,您应该对 Elasticsearch 中的相关性排名有了很好的理解,并且知道向量如何扩展搜索能力,以在基于关键字的搜索无法竞争的领域中实现更好的搜索效果。我们还讨论了向量如何组织成 HNSW 图,存储在 Elasticsearch 的内存中,以及评估向量之间距离的方法。现在,我们将把这些知识付诸实践,了解 Elasticsearch 中的密集向量数据类型,设置我们的 Elastic Cloud 环境,最后构建和运行向量搜索查询。

稀疏和密集向量

Elasticsearch 支持一种名为 dense_vector 的新数据类型,它用于在映射中存储数值数组。这些数组是文本语义的向量表示。最终,密集向量在向量搜索和 kNN 搜索的上下文中得到了利用。 关于 dense_vector 类型的文档可以在这里找到:www.elastic.co/guide/en/el…

稀疏向量是指具有少量非零值,大多数维度值为零的向量。这种低维向量空间比密集向量更节省内存和处理速度更快。

例如,考虑一个包含 100,000 个词汇的词汇表和一个包含 100 个词的文档。如果我们使用密集向量表示该文档,我们将需要为这 100,000 个词分配内存,即使其中大多数将是零。相比之下,同样的文档使用稀疏向量只需要为 100 个非零值分配内存,这显著减少了内存使用量。原因在于文档或文本的密集向量表示为词汇表中的每个可能的词分配了一个非零值。

如果您想看一个实际示例,这里有一个笔记本,它构建了文本的稀疏和密集向量表示,并通过热图帮助您直观理解两者之间的差异:

import numpy as np
from scipy.sparse import random
from sklearn.decomposition import TruncatedSVD
import matplotlib.pyplot as plt

# 生成包含 100 个文档的语料库,每个文档包含 1000 个词
vocab_size = 10000
num_docs = 100
doc_len = 1000

# 创建一个包含 10000 个词的词汇表
vocab = [f'word{i}' for i in range(vocab_size)]

# 生成表示每个文档的随机密集向量
dense_vectors = np.zeros((num_docs, vocab_size))
for i in range(num_docs):
    word_indices = np.random.choice(vocab_size, doc_len)
    for j in word_indices:
        dense_vectors[i, j] += 1

# 将密集向量转换为稀疏格式
sparse_vectors = random(num_docs, vocab_size, density=0.01, format='csr')
for i in range(num_docs):
    word_indices = np.random.choice(vocab_size, doc_len)
    for j in word_indices:
        sparse_vectors[i, j] += 1

# 使用 TruncatedSVD 降低密集向量的维度
svd = TruncatedSVD(n_components=2)
dense_vectors_svd = svd.fit_transform(dense_vectors)

# 将 TruncatedSVD 应用于稀疏向量
sparse_vectors_svd = svd.transform(sparse_vectors)

# 在散点图上绘制密集和稀疏向量
fig, ax = plt.subplots(figsize=(10, 8))
ax.scatter(dense_vectors_svd[:, 0], dense_vectors_svd[:, 1], c='b', label='Dense vectors')
ax.scatter(sparse_vectors_svd[:, 0], sparse_vectors_svd[:, 1], c='r', label='Sparse vectors')
ax.set_title('TruncatedSVD 降维后密集和稀疏文档向量的 2D 嵌入')
ax.set_xlabel('维度 1')
ax.set_ylabel('维度 2')
ax.legend()
plt.show()

上面的示例生成了一个包含 100 个文档的语料库,每个文档从 10,000 个词汇表中随机选择 1,000 个词。它创建了表示每个文档的密集和稀疏向量。代码通过 TruncatedSVD 函数使用降维技术,以便可以在 2D 中可视化向量。使用默认的文档数量、词汇表大小和文档长度设置运行代码大约需要两分钟。结果应接近以下所示:

Elastic 中的向量搜索

散点图显示了降维后的文档向量的二维嵌入。密集向量(右侧)分散开来,表明内容多样化,而稀疏向量(左侧)紧密聚集,暗示内容相似。这些差异突显了降维空间中密集和稀疏表示的不同特性。

稀疏向量更节省内存,因为它们只存储非零值,而密集向量为每个值分配内存。后者通常是深度学习模型的首选,因为它们捕捉了文档中单词之间的更复杂关系,因为它们为词汇表中的每个词分配一个非零值。该值基于文档中的频率和上下文分配。稀疏向量的另一个优点是由于它们具有固定的大小和形状,值存储在连续的内存中,使得诸如矩阵乘法等数学运算更容易。

Elastic Cloud 快速入门

从现在开始,您将需要一个沙盒来运行本书中的示例。您需要有一个正在运行的 Elasticsearch 实例和一个 Kibana 实例。最简短和最佳的路径是使用 Elastic Cloud。如果您还没有这样做,请访问 cloud.elastic.co 注册。

完成后,登录并点击“Create Deployment”按钮创建一个新的部署:

  1. 点击“Create Deployment”按钮。
  2. 选择您所需的 Elastic Stack 版本和云提供商。
  3. 配置部署的资源,例如节点数量和规格。
  4. 创建部署并等待其启动完成。

一旦部署完成,您将拥有一个运行中的 Elasticsearch 实例和一个 Kibana 实例,可以用于运行和测试示例。

通过这种方式,您可以轻松设置和管理您的 Elastic Stack 环境,快速开始向量搜索和其他高级功能的实验和开发。

Elastic 中的向量搜索

不要忘记下载您的凭据以连接到您的部署。它们会在部署过程中显示:

Elastic 中的向量搜索

部署创建完成后,返回 cloud.elastic.co/deployments,然后点击您的部署:

Elastic 中的向量搜索

您将被重定向到部署页面,并在此处看到所有有用的端点信息:

Elastic 中的向量搜索

在接下来的练习中,您主要需要 Elasticsearch 端点,但您可能希望访问 Kibana 来运行查询,或在我们构建可视化时使用。

密集向量映射

现在,让我们来看一下如何在 Elasticsearch 中为密集向量构建映射。这是一个简单的练习,下面是一个映射的示例:

{
  "mappings": {
    "properties": {
      "embedding": {
        "type": "dense_vector",
        "dims": 768
      }
    }
  }
}

简单来说,上述示例定义了一个名为 embedding 的字段,其中将存储密集向量。我们将维度设置为 768,因为在以下示例中,我们使用了一个 BERT 模型 bert-base-uncased,它具有 768 个隐藏单元。BERT 模型是一个用于 NLP 任务的预训练深度学习模型,它处理小写文本,并能根据周围的单词理解单词的上下文:

!pip install transformers elasticsearch

import numpy as np
from transformers import AutoTokenizer, AutoModel
from elasticsearch import Elasticsearch
import torch

# 使用凭据定义 Elasticsearch 连接
es = Elasticsearch(
    ['https://hostname:port'],
    http_auth=('username', 'password'),
    verify_certs=False
)

# 定义密集向量字段的映射
mapping = {
    'properties': {
        'embedding': {
            'type': 'dense_vector',
            'dims': 768  # 密集向量的维数
        }
    }
}

# 使用定义的映射创建索引
es.indices.create(index='chapter-2', body={'mappings': mapping})

# 定义一组文档
docs = [
    {
        'title': 'Document 1',
        'text': 'This is the first document.'
    },
    {
        'title': 'Document 2',
        'text': 'This is the second document.'
    },
    {
        'title': 'Document 3',
        'text': 'This is the third document.'
    }
]

# 加载 BERT 分词器和模型
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
model = AutoModel.from_pretrained('bert-base-uncased')

# 使用 BERT 为文档生成嵌入
for doc in docs:
    text = doc['text']
    inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
    with torch.no_grad():
        output = model(**inputs).last_hidden_state.mean(dim=1).squeeze(0).numpy()
    doc['embedding'] = output.tolist()

# 将文档索引到 Elasticsearch 中
for doc in docs:
    es.index(index='chapter-2', body=doc)

您可以在这篇博客文章中了解更多关于隐藏单元的信息:medium.com/computroniu…,但基本上,神经网络中的每个隐藏单元都与在训练过程中学习的一组权重和偏置项相关联。它们决定了隐藏单元如何处理其输入并生成输出。

隐藏层中的隐藏单元数量是网络的超参数,它对网络性能有重大影响。较多的隐藏单元将有利于 NLP 用例,因为它允许网络学习输入数据的更复杂表示。然而,它也使网络在训练和评估时更具计算开销。

上述示例假设您有一个正在运行的 Elasticsearch 实例,正如前一部分中设置的那样。我们使用 BERT 分词器和模型为每个文档生成嵌入,最终将这些嵌入索引到 Elasticsearch 中。

如果您尝试从 Kibana > Management > Dev Tools 中获取文档,您应该会看到如下所示的内容:

Elastic 中的向量搜索

您将看到包含密集向量的 embedding 字段的文档。如果不稍微自定义映射,会有一些注意事项,我们将在下一部分中讨论。

暴力 kNN 搜索

如前所述,上述示例有一个注意事项,即向量字段默认情况下未被索引,这意味着您不能将其与 kNN 端点一起使用。您可以在脚本评分函数中使用这些向量,通过几个现成的相似性函数执行暴力或精确的 kNN 搜索,如此链接所述:www.elastic.co/guide/en/el…。脚本评分查询在您希望避免将评分函数应用于所有文档搜索,而只应用于筛选后的文档集时特别有用。缺点是文档筛选得越多,脚本评分就越昂贵,并且会线性增加。

Elasticsearch 提供了以下现成的相似性函数:

  • CosineSimilarity:计算余弦相似度
  • dotProduct:计算点积
  • l1norm:计算 L1 距离
  • l2norm:计算 L2 距离
  • doc[].vectorValue:返回向量的值作为浮点数数组
  • doc[].magnitude:返回向量的大小

在本章的这一阶段,您已经对上述内容有了很好的理解,并且知道向量的推荐相似性是前四个选项。

一般来说,如果筛选后的文档数量少于 10,000,则不索引并使用其中一个相似性函数应该可以提供良好的性能。

最后两个选项指的是您想直接访问向量的情况。虽然这给用户提供了重要的控制权,但代码的性能将取决于文档集的缩小程度以及多维向量上脚本的质量。

kNN 搜索

在本节中,我们将探讨如何为您的索引激活 kNN 搜索并执行一个示例。

映射

为了使用 kNN 搜索,您需要修改映射以便索引密集向量,如下所示:

{
  "mappings": {
    "properties": {
      "embedding": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "dot_product"
      }
    }
  }
}

注意,您还需要设置相似性函数。相似性的三个选项是 l2_norm、dot_product 和 cosine。我们建议在可能的情况下,在生产中使用 dot_product 进行向量搜索。使用点积可以避免您在每次相似性计算时都必须计算向量大小(因为向量事先归一化,使它们的大小都为 1)。这意味着它可以将搜索和索引速度提高约 2-3 倍。

与基于脚本评分查询的暴力 kNN 搜索不同的是,在这种情况下,HNSW 图被构建并存储在内存中。事实上,它存储在段级别,这就是为什么: 强制合并在索引级别推荐,将索引中的所有段合并为一个段。这不仅会优化搜索性能,还可以避免在任何段中重建 HNSW。要合并所有段,可以调用以下 API:

POST /my-index/_forcemerge?max_num_segments=1

在大规模更新文档(即密集向量)时不推荐,因为这会重新构建 HNSW。

使用 kNN 搜索的示例

提醒一下,kNN 搜索 API 用于找到查询向量的 k 个近似最近邻。查询也是表示文本搜索查询的数字向量。k 个最近邻是向量与查询向量最相似的文档。

以下示例说明了一种有趣的方式来使用 Elastic 构建笑话数据库。在这个示例中,Python 笔记本构建了一个笑话索引,其中 BERT 模型用于将笑话表示为向量。它将查询字符串向量化,然后在 kNN 搜索中使用它来获取相似的笑话。

我们首先导入依赖项并定义与集群的连接:

!pip install transformers elasticsearch

import numpy as np
from transformers import AutoTokenizer, AutoModel
from elasticsearch import Elasticsearch
import torch

es = Elasticsearch(
    ['https://host:port'],
    http_auth=('username', 'password'),
    verify_certs=False
)

然后我们设置索引映射并定义几个代表笑话的文档:

mapping = {
    'properties': {
        'embedding': {
            'type': 'dense_vector',
            'dims': 768,
            'index': 'true',
            "similarity": "cosine"
        }
    }
}

es.indices.create(index='jokes-index', body={'mappings': mapping})

jokes = [
    {
        'text': 'Why do cats make terrible storytellers? Because they only have one tail.',
        'category': 'cat'
    },
    {
        'text': 'Why did the frog call his insurance company? He had a jump in his car!',
        'category': 'puns'
    }
    # ... 其他有趣的笑话 ...
]

我们现在将加载 BERT 模型,生成嵌入,并将其索引到 Elasticsearch 中:

# 加载 BERT 分词器和模型
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
model = AutoModel.from_pretrained('bert-base-uncased')

# 使用 BERT 为笑话生成嵌入
for joke in jokes:
    text = joke['text']
    inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
    with torch.no_grad():
        output = model(**inputs).last_hidden_state.mean(dim=1).squeeze(0).numpy()
    joke['embedding'] = output.tolist()

# 将笑话索引到 Elasticsearch 中
for joke in jokes:
    es.index(index='jokes-index', body=joke)

最后,我们定义一个查询,将其转换为查询向量,并在 Elasticsearch 中运行向量搜索:

query = "What do you get when you cross a snowman and a shark?"
inputs = tokenizer(query, return_tensors='pt', padding=True, truncation=True)
with torch.no_grad():
    output = model(**inputs).last_hidden_state.mean(dim=1).squeeze(0).numpy()
query_vector = output

# 定义 Elasticsearch kNN 搜索
search = {
    "knn": {
        "field": "embedding",
        "query_vector": query_vector.tolist(),
        "k": 3,
        "num_candidates": 100
    },
    "fields": ["text"]
}

# 执行 kNN 搜索并打印结果
response = es.search(index='jokes-index', body=search)
for hit in response['hits']['hits']:
    print(f"Joke: {hit['_source']['text']}")

在这里,我们使用笑话 “What do you get when you cross a snowman and a shark?” 作为查询。该笑话不在数据集中,但与返回的笑话在语义上相似:

  • Joke: What did the cat say when he lost all his money? I am paw.
  • Joke: Why do cats make terrible storytellers? Because they only have one tail.
  • Joke: Why don't cats play poker in the jungle? Too many cheetahs.

另外,注意 kNN 搜索查询的结构:

search = {
    "knn": {
        "field": "embedding",
        "query_vector": query_vector.tolist(),
        "k": 3,
        "num_candidates": 100
    },
    "fields": ["text"]
}

查询需要密集向量字段(这里称为 embedding 的字段)以及文本查询的向量表示、k 和 num_candidates

在 kNN 搜索中,API 首先在每个分片上找到一定数量的近似最近邻候选者,数量为 num_candidates。然后,它计算这些候选者与查询向量之间的相似度,从每个分片中选择最相似的 k 个结果。最后,合并每个分片的结果以获得整个数据集中最接近的 k 个近邻。

总结

在本书的这一阶段,您应该对向量搜索的基本原理,包括向量表示、向量在 HNSW 图中的组织以及计算向量之间相似性的方法,有了很好的理解。此外,我们还了解了如何设置 Elastic Cloud 环境以及 Elasticsearch 映射以运行向量搜索查询并利用 k 近邻算法。

现在,您已经具备了探索后续章节的基础知识。我们将在各种代码示例和领域(如可观察性和安全性)中发现向量搜索的应用领域。

在下一章中,我们将更进一步——不仅学习如何在 Elasticsearch 内部托管模型和生成向量,而不是在外部处理,还将探讨在不同规模上管理它的复杂性以及从资源角度优化部署。

转载自:https://juejin.cn/post/7397339702460071948
评论
请登录