Elastic 中的向量搜索入门指南

123 阅读29分钟

欢迎阅读《Elastic 中的向量搜索入门》。本章将帮助你理解 Elastic 搜索的基本范式,以及向量搜索如何成为实时、上下文感知且精准的信息检索利器。

本章内容包括:

  • 向量搜索引入前的 Elastic 搜索体验
  • 向量和层次可导航小世界图(HNSW)等新型表示的必要性
  • 新增的向量数据类型
  • 配置映射的不同策略及向量存储的挑战,助你优化实现方案
  • 构建查询的方法,包括暴力搜索、k 近邻(kNN)和精确匹配
    无论你是 Elastic 资深用户还是初学者,本章都将为你揭示 Elastic 向量搜索的强大威力。让我们开始吧!

向量引入前的 Elastic 搜索体验

在 Elastic 引入向量搜索之前,主要的相关性模型基于文本搜索和分析能力。Elasticsearch 提供多种数据类型(www.elastic.co/guide/en/el…)和分析器(www.elastic.co/guide/en/el…),支持高效搜索。这里我们先统一理解“向量引入前”的状态。

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

Elasticsearch 中存在多种数据类型,但本节不逐一详述。我们将它们分为两类:直接驱动相关性排名的类型和间接影响相关性的类型,目标是理解它们与相关性模型的关系。

第一类:直接影响相关性排名的数据类型

  • Text(文本) :文本类型是 Elasticsearch 相关性模型中最关键的数据类型,用于存储和搜索文章、产品描述等文本数据。文本会通过内置分析器进行处理,将文本拆分成词元并执行小写转换、词干提取和过滤等操作。
  • Geo(地理坐标) :用于存储和搜索地理坐标,支持基于地理位置的查询,如特定距离范围或边界框内查找文档。虽不属于文本相关性模型,但地理查询有助于缩小搜索结果范围,提高相关性。

第二类:改善相关性的数据类型

  • Keyword(关键字) :存储未分析的文本数据,常用于过滤和聚合,帮助通过过滤或聚合细化搜索结果,提升相关性。
  • Numeric(数值) :包括整数、浮点数、双精度数等,用于存储和搜索数值数据。虽不直接影响文本相关性模型,但可用于过滤或排序,间接影响相关性。
  • Date(日期) :存储和搜索日期时间数据,与数值类型类似,能用于过滤和排序,间接影响整体相关性。
  • Boolean(布尔值) :存储真/假值,不直接贡献相关性模型,但可用于过滤,提升结果相关性。

相关性模型

虽然本书重点是向量搜索,而非 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),这里针对双词组(bi-gram,即连续两个词“vector search”)计算:

  • 文档1:“vector search”出现2次,文档总词数8,TF = 2/8 = 0.25
  • 文档2:“vector search”出现1次,文档总词数9,TF = 1/9 ≈ 0.111
  • 文档3:“vector search”出现1次,文档总词数10,TF = 1/10 = 0.10

接着计算逆文档频率(IDF):

  • 含有“vector search”的文档数 = 3
  • 总文档数 = 3
  • IDF = log(3/3) = log(1) = 0

最后计算 TF-IDF:

  • 文档1:0.25 × 0 = 0
  • 文档2:0.111 × 0 = 0
  • 文档3:0.10 × 0 = 0

在此例中,IDF 为0是因为“vector search”出现在所有文档中,是一个非常常见的词组,因此 TF-IDF 分数均为0。这说明 TF-IDF 算法中的 IDF 组件对文档集合中常见词进行惩罚,降低其对相关性评分的影响。

若将第三篇文档改为:“Elasticsearch is a powerful search engine that supports semantic search.”,针对“semantic search”计算 TF-IDF,排序结果变为:

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

虽然简单直接,TF-IDF 方法会因较长文档通常拥有更高词频而产生偏差。还有两点需要注意:

  • IDF 计算公式为 log(N/df(t)),其中 N 是文档总数,df(t) 是包含词 t 的文档数。IDF 旨在提升稀有词权重,降低常见词权重。
  • TF 无上限,词频越高,相关性评分线性提升。

基于此,我们引入更完善的排名方法。

BM25

BM25 让 Elasticsearch 能更精确地处理数据,改进 TF 和 IDF 组件。比如,BM25 引入了饱和度机制,即当词频达到一定程度后,其对相关性评分的影响趋于平缓,即使词频继续增加,也不会让评分无限上升,避免极高词频主导评分。

下图展示了 TF-IDF 随词频线性增长,而 BM25 对其进行抑制的效果:

image.png

你可以在这篇博客文章中了解更多信息:
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 得分:

TF归一化=f(qi,D)(k1+1)f(qi,D)+k1(1b+bfieldLenavgFieldLen)\text{TF归一化} = \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{\text{fieldLen}}{\text{avgFieldLen}})}

其中,f(qi,D)f(q_i, D) 为词频,k1k_1bb 是调节参数。

假设 k1=1.2b=0.75k_1 = 1.2,b = 0.75,平均文档长度为 avgdl=8+9+103=9\text{avgdl} = \frac{8 + 9 + 10}{3} = 9

  • 文档1:(1.2+1)×21.2×(10.75+0.75×89)+2=1.405\frac{(1.2 + 1) \times 2}{1.2 \times (1 - 0.75 + 0.75 \times \frac{8}{9}) + 2} = 1.405
  • 文档2(1.2+1)×11.2×(10.75+0.75×99)+1=0.952文档2:\frac{(1.2 + 1) \times 1}{1.2 \times (1 - 0.75 + 0.75 \times \frac{9}{9}) + 1} = 0.952
  • 文档3(1.2+1)×21.2×(10.75+0.75×109)+2=1.317文档3:\frac{(1.2 + 1) \times 2}{1.2 \times (1 - 0.75 + 0.75 \times \frac{10}{9}) + 2} = 1.317

BM25 中的逆文档频率(IDF)计算公式为:

IDF=logNdf(t)+0.5df(t)+0.5\text{IDF} = \log \frac{N - df(t) + 0.5}{df(t) + 0.5}

其中,NN 是文档总数,df(t)df(t) 是包含该词的文档数。

本例中,“search”出现于所有 3 篇文档中:

IDF=log33+0.53+0.5=log0.53.51.2528\text{IDF} = \log \frac{3 - 3 + 0.5}{3 + 0.5} = \log \frac{0.5}{3.5} \approx -1.2528

(注:通常使用自然对数。)

计算 BM25 得分:

  • 文档1BM25=IDF×TF归一化=1.2528×1.4051.761文档1:BM25 = IDF × TF归一化 = -1.2528 \times 1.405 \approx -1.761
  • 文档2:BM25=1.2528×0.9521.193BM25 = -1.2528 \times 0.952 \approx -1.193
  • 文档3:BM25=1.2528×1.3171.650BM25 = -1.2528 \times 1.317 \approx -1.650

根据 BM25 得分,对“search”查询的文档排名为:

  • 文档2(BM25 = -1.193)
  • 文档3(BM25 = -1.650)
  • 文档1(BM25 = -1.761)

这个例子展示了 BM25 相较于 TF-IDF 的优势,它能更有效处理文档长度差异。归一化机制减少了对长文档(词频较高)的偏好。

虽然这里未演示 BM25 防止极高词频过度影响相关性评分的特性,但在更复杂的场景中,你会看到图 2.1 中展示的效果——词频极高的词不会获得不合理的高评分。

至此,你应对 TF-IDF,尤其是 BM25 有了扎实理解。前面讲解的内容不仅有助于理解关键词搜索与向量搜索(两者相关性计算的显著差异),也将在后续关于混合搜索的章节中继续发挥作用。

搜索体验的演变

现在,我们将探讨用户对更优搜索体验的需求,为什么这促使我们不得不考虑除关键词搜索之外的其他技术。本节将分析关键词搜索的局限,理解向量表示的内涵,以及元表示方法——层次可导航小世界图(HNSW)是如何应运而生,以便用向量实现高效的信息检索。

关键词搜索的局限

对于刚接触该主题的读者,在讲解向量表示前,我们需要先了解为何行业和关键词搜索体验已经达到了瓶颈,无法完全满足终端用户的需求。

关键词搜索依赖于用户查询词和文档中词项的精确匹配,若搜索系统未能充分处理同义词、缩写、变体等,就可能错过相关结果。因此,搜索系统必须将某些词与处于同一语义空间的其他词关联起来。

关键词搜索缺乏上下文理解,无法考虑词语的语境或含义。例如,“bat”一词在不同语境中意义不同——在体育语境中指棒球或板球棒;在动物学中指飞行的哺乳动物蝙蝠。

单词本身已经存在挑战,语言依赖、拼写错误和变形更增加难度。此外,关键词搜索不捕捉句子结构或语义关系。词序对理解查询意义至关重要。词语间存在语义关联,使得用不同词汇表达同一主题的文档难以被检索。

举例来说,搜索“global warming”(全球变暖)时,如果相关文档使用“climate change”(气候变化)一词,关键词搜索无法捕捉两者间的语义关系,可能遗漏重要文档。

当然,业界存在一些缓解上述问题的技术,但它们难以扩展、维护成本高、且需要大量专业知识。相比之下,利用模型生成的嵌入向量能有效克服许多关键词搜索的不足。

向量表示

如第1章《向量与嵌入简介》所述,向量表示是一种将文本等复杂数据转换为固定尺寸数值格式的方法,便于机器学习处理。在自然语言处理领域,它帮助捕捉词、句、文档的语义,使得执行第1章所述任务变得更容易。

接下来,我们将探讨向量化过程——从原始数据中提取特征,并利用模型转换为数值表示。同时,我们将了解 HNSW 及其在搜索过程中的作用。

向量化过程

从宏观角度看,构建向量的步骤包括:

  1. 文本预处理——清洗和规范化原始数据,去噪声、纠正拼写、统一格式。
    常见处理包括小写化、分词、停用词移除、词干提取和词形还原。这些步骤类似于 Elastic 在索引前用分析器执行的数据处理。但需注意,过度处理可能削弱语义细节,影响嵌入模型效果。
  2. 特征提取——从文本中抽取相关特征,方法有词袋模型(BoW)、TF-IDF,或更先进的词嵌入技术。

词嵌入是密集向量表示,在连续向量空间捕捉词的语义含义。与 BoW 或 TF-IDF 等稀疏表示不同,词嵌入大多数维度非零,信息存储更丰富,计算更高效。词嵌入将词映射为多维空间中的点,每个词的位置由连续数值(词向量)决定,词间距离可用欧氏距离、余弦距离等衡量。连续向量空间支持相关词的平滑过渡,便于捕捉和操作语义关系。

  1. 向量转换——将特征转为数值向量,用作机器学习输入。向量的每个维度对应特定特征,其数值反映该特征在文本中的重要性或相关度。

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

HNSW(层次可导航小世界图)

你可以在康奈尔大学网站(arxiv.org/abs/1603.09…)查阅 HNSW 原始论文,这里我们将拆解为多个基本部分,帮助你理解其原理与作用。

正如前述,高维空间需要算法来执行最近邻搜索。HNSW 算法能够基于文本向量表示快速找到相似文本。

从宏观上看,HNSW 构建一个分层图结构,每个节点对应文本的向量。该图具备小世界网络特性,使得在高维空间中搜索高效。下图展示了该结构示意:

image.png

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

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

在高维空间中寻找最近邻时,传统方法可能陷入局部最小值。想象你在爬山,试图到达最高峰,但因为视野限制,你最终只能登上附近一个较小的山峰,这就是局部最小值。

HNSW 通过维护图的多个层级(层)来克服这一问题。搜索时,HNSW 从顶层开始,该层节点较少,覆盖范围广,因此较少陷入局部最小。然后逐层向下,随着层级加深,搜索逐渐精细,直到抵达包含详细数据的底层。

当算法找到一个节点,其距离查询点比邻近其他节点都近时,搜索停止。此时,基于当前搜索层级,认为已找到数据集中最接近(或近似最接近)的匹配。

HNSW 受欢迎有以下几个原因:

  • 效率:在准确度和速度之间取得平衡。尽管是近似方法,但在许多应用中准确度已足够高。
  • 内存使用:比一些其他近似最近邻方法更节省内存。
  • 通用性:不局限于特定距离度量,可兼容欧氏距离、余弦距离等多种度量方式。

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

了解了 HNSW 的基本概念后,我们接下来看看如何计算距离。

距离度量

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

欧氏距离(Euclidean distance):

d(A,B)=(A1B1)2+(A2B2)2++(AnBn)2d(A, B) = \sqrt{(A_1 - B_1)^2 + (A_2 - B_2)^2 + \dots + (A_n - B_n)^2}

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

点积(Dot product):

ab=i=1naibi=a1b1+a2b2++anbna \cdot b = \sum_{i=1}^n a_i b_i = a_1 b_1 + a_2 b_2 + \dots + a_n b_n

这里的 aba \cdot b 表示向量 a 和 b 的点积。

从几何角度看,若向量 a 和 b 之间的夹角为 θ\theta,则点积满足:

ab=abcos(θ)a \cdot b = |a| \, |b| \cos(\theta)

如图所示:

image.png

  • |a| = 向量 a 的模长(或长度)
  • |b| = 向量 b 的模长(或长度)
  • cos(θ) = 两向量间夹角 θ 的余弦值

以下是在向量搜索场景中,针对两个向量 A 和 B 的示例:

  • A 代表一篇关于“机器学习”的文档
  • B 代表一篇关于“深度学习”的文档

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

余弦相似度计算公式为:

Sc(A,B)=ABA×BS_c(A, B) = \frac{A \cdot B}{\|A\| \times \|B\|}

其中,Sc(A,B)S_c(A, B) 表示 A 和 B 的余弦相似度。

image.png

对于向量 A 和 B,例如:
A = [1, 2]
B = [3, 4]

它们的距离计算结果如下:

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

你可能会问,什么时候选用哪种距离度量?答案取决于具体应用场景、被向量化的文本、领域以及向量空间的形状。

欧氏距离 适用于有“有意义原点”的数据。例如,在二维笛卡尔坐标系中,原点 (0,0) 是零点,具有特定意义。换言之,有意义的原点是所有特征值均为零且在问题领域内有明确解释的点。举例来说,摄氏温度的零度是水的冰点,在温度测量中有明确意义。

点积 适用于数据含正负值且向量间角度不重要的场景。点积可为正、负或零,且不归一化,向量的模长会影响结果。此外,当向量归一化(模长为1)时,归一化向量的余弦相似度等同于点积:

cosine similarity=AB=dot product\text{cosine similarity} = A \cdot B = \text{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,但这不代表语义差异,二者语义相似,应视为语义近似。

在高维空间比较文本时,若目标是捕捉语义相似度,方向通常比模长更重要,因为夹角直接反映词语相似度。

代码示例

你可以在 Google Colab 运行以下示例代码(位于本书 GitHub 第2章文件夹:github.com/PacktPublis…),用 spaCy 库生成文本向量,计算欧氏距离、余弦相似度和向量模长:

# 安装 spaCy 并下载模型
!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."

# 转换为向量表示
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"文本 A 与 B 的余弦相似度: {cosine_sim_ab:.2f}")
print(f"文本 A 与 C 的余弦相似度: {cosine_sim_ac:.2f}")
print(f"文本 D 与 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"文本 A 与 B 的欧氏距离: {euclidean_dist_ab:.2f}")
print(f"文本 A 与 C 的欧氏距离: {euclidean_dist_ac:.2f}")
print(f"文本 D 与 E 的欧氏距离: {euclidean_dist_de:.2f}")

# 计算向量模长
magnitude_d = np.linalg.norm(vector_d)
magnitude_e = np.linalg.norm(vector_e)

print(f"文本 D 向量模长: {magnitude_d:.2f}")
print(f"文本 E 向量模长: {magnitude_e:.2f}")

输出结果将帮助你理解在具体场景中应使用哪种距离度量。

通过以上内容,你应掌握文本向量表示的基本概念,理解如何比较向量相似度,从而推断文本语义的相似程度。接下来,我们将探索 Elastic,并了解如何将这些理论付诸实践。

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

到目前为止,你应该已经对 Elasticsearch 中的相关性排序有了良好的理解,并且明白向量是如何扩展搜索能力,覆盖关键词搜索无法匹敌的领域。我们还介绍了向量如何组织成 HNSW 图,存储在 Elasticsearch 内存中,以及不同的向量距离计算方式。接下来,我们将将这些知识付诸实践,了解 Elasticsearch 中提供的 dense_vector(稠密向量)数据类型,搭建 Elastic Cloud 环境,并最终构建和运行向量搜索查询。

稀疏向量与稠密向量

Elasticsearch 支持一种新的映射数据类型,称为 dense_vector,用于存储数值数组。这些数组是文本语义的向量表示,主要应用于向量搜索和 k 近邻(kNN)搜索。
dense_vector 类型的官方文档请见:www.elastic.co/guide/en/el…

稀疏向量则是只有少数非零值,大部分维度为零的向量。这种向量所在的空间维度较低,因而比稠密向量更节省内存且处理速度更快。

举例说明,假设词汇表有 10 万词,一篇文档包含 100 个词。若用稠密向量表示该文档,则需为 10 万维空间分配内存,其中大多数维度为零;而用稀疏向量表示,则只需为 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 进行维度降维(将稠密向量降至2维)
svd = TruncatedSVD(n_components=2)
dense_vectors_svd = svd.fit_transform(dense_vectors)

# 对稀疏向量应用同样的降维
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 降维后稠密和稀疏文档向量的二维嵌入')
ax.set_xlabel('维度1')
ax.set_ylabel('维度2')
ax.legend()
plt.show()

上述示例生成了一个包含100篇文档的语料库,每篇文档的1000个词从一个包含10000词的词汇表随机选取。它为每篇文档创建了稠密和稀疏向量表示。代码中用 TruncatedSVD 进行了维度降维,使得向量能在二维平面上可视化。默认文档数、词汇大小和文档长度的配置下,运行时间大约两分钟。结果图形应类似如下:

image.png

散点图展示了经过降维处理后的文档向量二维嵌入。右侧的稠密向量分布较为分散,表明内容多样;左侧的稀疏向量则紧密聚集,暗示内容相似。这些差异凸显了降维空间中稠密和稀疏表示的不同特性。

稀疏向量更节省内存,因为它们只存储非零值;而稠密向量则为每个值分配内存。稠密向量通常是深度学习模型的首选,因为它们为词汇表中的每个词赋予非零值,从而能捕捉文档中词语间更复杂的关系。赋值基于词频和文档中的上下文。稀疏向量的另一个优势是其固定的大小和形状,值存储在连续内存中,这使得诸如矩阵乘法等数学运算更加高效。

Elastic Cloud 快速入门

接下来,你需要一个沙箱环境来运行本书中的示例。你需要有一个运行中的 Elasticsearch 实例和 Kibana 实例。最简便且效果最佳的方式是使用 Elastic Cloud。如果你还没有账号,可以访问 cloud.elastic.co 注册。

注册完成后,登录并点击“Create Deployment(创建部署)”按钮,开始创建新的部署:

image.png

别忘了下载你的连接凭据,它们会在部署过程中显示:

image.png

部署创建完成后,返回 cloud.elastic.co/deployments 页面,点击你的部署名称进入:

image.png

你将被重定向到部署详情页,在那里可以看到所有有用的接口地址:

image.png

在接下来的练习中,你主要会用到 Elasticsearch 的接口地址,但你也可能需要访问 Kibana 来执行查询,或者在构建可视化时使用。

稠密向量映射

接下来,我们来看如何在 Elasticsearch 中为稠密向量建立映射(mapping)。这是一个简单的操作,下面是一个映射示例:

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

简单来说,上述示例定义了一个名为 embedding 的字段,用于存储稠密向量。这里设置维度为 768,是因为接下来示例中使用的 BERT 模型(bert-base-uncased)具有 768 个隐藏单元。BERT 是一个预训练的深度学习模型,广泛用于自然语言处理任务,它处理小写文本,并能基于上下文理解词义。

代码示例

!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)

关于隐藏单元(hidden units)的更多信息,可以参考这篇博客:medium.com/computroniu…。简单来说,神经网络中每个隐藏单元都关联一组权重和偏置,这些是在训练过程中学习到的参数,决定了该隐藏单元如何处理输入并输出结果。

隐藏层中隐藏单元的数量是网络的超参数,会显著影响网络性能。更多隐藏单元能够帮助 NLP 任务学习更复杂的输入表示,但同时增加训练和推理的计算成本。

上述示例假设你已按照前面章节设置好运行中的 Elasticsearch 实例。我们用 BERT 分词器和模型生成每篇文档的嵌入向量,最终将它们索引到 Elasticsearch。

如果你从 Kibana 的“Management(管理)”→“Dev Tools(开发工具)”尝试获取文档,应该会看到类似如下的结果:

image.png

你将看到文档中包含 embedding 字段,里面存储了稠密向量。

需要注意的是,如果不稍作自定义映射,可能会遇到一些限制,下一节我们会详细讲解。

逐点暴力 kNN 搜索(Brute-force kNN search)

正如前面提到的示例存在的限制,默认情况下,向量字段是不被索引的,这意味着你无法直接用 kNN 端点查询。你可以通过脚本评分函数(script score function)使用向量,并利用内置的相似度函数执行暴力或精确的 kNN 搜索,详见:www.elastic.co/guide/en/el…

脚本评分查询非常适合你只想对过滤后的文档子集应用评分函数,而非对所有文档执行评分。缺点是过滤后的文档越多,脚本评分的计算开销越大,并且呈线性增长。

Elasticsearch 提供了以下开箱即用的相似度函数:

  • CosineSimilarity:计算余弦相似度
  • dotProduct:计算点积
  • l1norm:计算 L1 距离
  • l2norm:计算 L2 距离
  • doc[<field>].vectorValue:返回向量的浮点数组
  • doc[<field>].magnitude:返回向量的模长

到目前为止,你应当清楚前四项是推荐用于向量相似度计算的函数。

经验法则:如果过滤后的文档数量低于 10,000,则不建立索引,直接用相似度函数,性能表现通常良好。

最后两项则是直接访问向量的场景,给予用户更大控制权,但性能取决于文档集的大小和脚本的优化程度。

kNN 搜索

本节讲解如何为索引启用 kNN 搜索,并通过示例展示使用方法。

映射设置

要使用 kNN 搜索,你需修改映射,使稠密向量被索引,示例如下:

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

注意必须设置相似度函数。相似度函数有三种选项:l2_normdot_productcosine。我们推荐生产环境尽量使用 dot_product,因为它避免了每次计算都需要向量模长(向量在预处理时已归一化为模长1),可提升搜索和索引速度约2-3倍。

与基于脚本评分的暴力 kNN 搜索不同,这种方式会在内存中构建并保存 HNSW 图。实际上,HNSW 图存储在 segment 级别,因此建议:

  • 在索引级别执行强制合并(force merge),将索引内所有 segment 合并为一个 segment。这不仅优化搜索性能,还避免了 HNSW 在多个 segment 上重复构建。
    执行合并的 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

# 连接 Elasticsearch 集群
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 分词器和模型
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
model = AutoModel.from_pretrained('bert-base-uncased')

# 生成笑话的嵌入向量
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)

# 定义查询,向量化并执行向量搜索
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

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

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 搜索先在每个 shard 上寻找一定数量(num_candidates)的近似最近邻候选,再计算它们与查询向量的相似度,选出每个 shard 上最相似的 k 条结果,最后合并所有 shard 的结果,返回整个索引中最接近的 k 个邻居。

总结

到目前为止,你应该已经较好地掌握了向量搜索的基础知识,包括向量表示、向量如何组织成 HNSW 图,以及计算向量相似度的方法。此外,我们还了解了如何搭建 Elastic Cloud 环境、配置 Elasticsearch 映射以运行向量搜索查询,并利用 k 近邻算法。

现在,你已具备了继续探索后续章节的基础知识。我们将通过各种代码示例和领域应用(如可观测性和安全)深入了解向量搜索的应用场景。

在下一章中,我们将更进一步——不仅学习如何在 Elasticsearch 内部托管模型并生成向量(而非外部处理),还将探讨在不同规模下的管理细节,以及如何从资源角度优化部署。