本章主要讲解检索增强生成(RAG)中的检索部分(R)。具体而言,我们将讨论与相似度搜索相关的四个领域:索引、距离度量、相似度算法和向量搜索服务。基于此,本章将覆盖以下内容:
- 距离度量、相似度算法与向量搜索的区别
- 向量空间
- 代码实验 8.1 - 语义距离度量
- 不同的搜索范式:稀疏、密集和混合
- 代码实验 8.2 - 使用自定义函数进行混合搜索
- 代码实验 8.3 - 使用LangChain的EnsembleRetriever进行混合搜索
- 语义搜索算法,如k-NN和ANN
- 提升ANN搜索效率的索引技术
- 向量搜索选项
到本章结束时,你应该对基于向量的相似度搜索有全面的理解,并明白它在RAG系统中作为检索组件的重要性。
技术要求
本章的代码存放在以下GitHub仓库中:github.com/PacktPublis…
每个代码实验的文件名会在相应部分中提到。
距离度量、相似度算法与向量搜索的区别
首先,让我们区分距离度量、相似度算法和向量搜索之间的差异。相似度算法可以使用不同的距离度量,而向量搜索则可以使用不同的相似度算法。它们是不同的概念,最终构成了你RAG系统中的检索组件。如果你想理解如何正确地实现和优化你的检索解决方案,理解这些概念的不同作用至关重要。你可以将其视为一个层级结构,如图8.1所示:
在图8.1中,我们仅展示了每个概念的两种选项,其中每个向量搜索有两种不同的相似度算法选项,而每个相似度算法又有两种不同的距离度量选项。然而,实际上,每个层次上都有更多的选项。
这里的关键点是,这些术语常常被交替使用或一起使用,仿佛它们是相同的概念,但它们是整体相似度搜索机制中的不同部分。如果你混淆了它们,理解相似度搜索背后的整体概念将变得更加困难。
现在我们已经澄清了这一点,我们将讨论另一个有助于理解相似度搜索原理的概念:向量空间。
向量空间
向量空间的概念与向量相似度搜索密切相关,因为搜索是在由向量表示的向量空间中进行的。从技术上讲,向量空间是一个数学构造,表示高维空间中向量的集合。向量空间的维度对应于与每个向量相关的特征或属性的数量。在这个空间中,最相似的文本向量具有相似的嵌入,因此它们在空间中相互靠近。当你谈论相似度搜索时,你会经常听到向量空间这一概念。这个空间的其他常见名称包括嵌入空间或潜在空间。
向量空间的概念有助于可视化寻找与用户查询嵌入最接近向量的距离算法是如何工作的。忽略这些向量有时可能是成千上万维的,我们可以在二维空间中将它们想象出来,其外部边界由其中的向量定义,数据点(可以在免费PDF版本中看到的小点)代表每个向量(见图8.2)。在不同位置,有一些小的点簇表示不同数据点之间的语义相似性。当搜索发生时,一个新的查询(X)根据用户查询向量的维度出现在这个想象的空间中,而与该查询(X)最接近的数据点(小点)将成为我们检索器协调的相似度搜索结果。我们将所有要检索的数据点(小点)转化为查询结果(大点):
让我们来分析一下这里的情况。查询结果有四个(大点)。从我们的角度来看,在这个二维空间中,看起来有一些数据点(小点)比查询结果(大点)更接近查询(X)。为什么会这样呢?你可能还记得,这些点最初是在一个1,536维的空间中。所以,如果你想象只增加一个维度(高度),让这些点从页面向你扩展出来,那么这些查询结果(大点)可能实际上会更接近,因为它们的高度比看起来更接近的那些数据点(小点)要高得多。从上方直接看,某些数据点(小点)可能看起来更接近,但从数学上来说,考虑到所有维度后,查询结果(大点)是更接近的。将空间扩展到所有1,536维后,这种情况变得更加可能。
语义搜索与关键词搜索
正如我们之前多次提到的,向量通过数学表示捕捉了我们数据背后的含义。为了找到与用户查询语义相似的数据点,我们可以在一个向量空间中搜索并检索最接近的对象,就像我们刚刚展示的那样。这就是所谓的语义搜索或向量搜索。与关键词匹配不同,语义搜索是寻找具有相似语义的文档,而不仅仅是相同的词语。作为人类,我们可以用许多不同的方式说出相同或相似的事情!语义搜索能够捕捉到这一语言特征,因为它会为相似的概念分配相似的数学值,而关键词搜索则专注于特定词语的匹配,往往会部分或完全忽略相似的语义。
从技术角度来看,语义搜索利用我们已向量化文档的含义,这些含义以数学方式嵌入在表示它的向量中。对于数学爱好者来说,使用数学解决语言学挑战的方式是多么美妙!
让我们通过一个示例来说明语义搜索是如何工作的。
语义搜索示例
想象一个简单的语义相似性示例,比如一个在线毛毯产品的评论,其中一位顾客说:
“这条毛毯能很好地保持舒适的温暖感!”
另一位顾客则说:
“使用这条毛毯让我感觉暖和又舒适!”
虽然他们在语义上说的事情相对相似,但关键词搜索可能并不会认为它们像语义搜索那样相似。接下来,我们引入一个第三个句子,代表一个随机的在线评论以做比较:
“泰勒·斯威夫特在2024年34岁。”
这个随机在线评论的语义显然与前两个句子大不相同。但是,不要只听我说,让我们在笔记本中做个数学计算!在接下来的代码中,我们将回顾一些作为语义搜索基础元素的最常见的距离度量方法。
代码实验 8.1 – 语义距离度量
你需要从 GitHub 仓库中访问的文件名为 CHAPTER8-1_DISTANCEMETRICS.ipynb。
本章的第一个代码实验将专注于如何计算向量之间的距离,为你提供一个直观的视角,了解不同方法之间的差异。我们将使用一个名为 CHAPTER8-1_DISTANCEMETRICS.ipynb 的全新笔记本,这个笔记本的代码与之前使用的代码不同。我们将安装并导入所需的包,创建我们讨论过的句子的嵌入表示,然后逐步讲解三种在自然语言处理(NLP)、生成式AI和RAG系统中非常常见的距离度量公式。
首先,我们安装开源的 sentence_transformers 库,这个库将设置我们的嵌入算法:
%pip install sentence_transformers -q --user
sentence_transformers 包提供了一种简单的方法来计算句子和段落的稠密向量表示。接下来,我们导入一些选择的包,帮助我们衡量距离:
import numpy as np
from sentence_transformers import SentenceTransformer
在这里,我们添加了流行的 NumPy 库,它提供了执行距离分析所需的数学操作。正如之前所提到的,sentence_transformers 被导入,是为了我们可以为文本创建稠密的向量表示。这将使我们能够创建预训练嵌入模型的实例。
在接下来的代码中,我们定义了要使用的 Transformer 模型:
model = SentenceTransformer('paraphrase-MiniLM-L6-v2')
这个 paraphrase-MiniLM-L6-v2 模型是该包中较小的模型之一,希望它能更好地兼容你可能使用的计算环境。如果你想要更强大的模型,可以尝试 all-mpnet-base-v2 模型,语义搜索性能大约比 paraphrase-MiniLM-L6-v2 高50%。
接下来,我们将之前提到的句子添加到一个列表中,以便在代码中引用:
sentence = ['This blanket has such a cozy temperature for me!',
'I am so much warmer and snug using this spread!',
'Taylor Swift was 34 years old in 2024.']
然后,我们使用 SentenceTransformer 模型对句子进行编码:
embedding = model.encode(sentence)
print(embedding)
embedding.shape
model.encode 函数将一个字符串列表转换为一个嵌入列表。输出显示了我们的句子对应的数学表示(向量):
[[-0.5129604 0.6386722 0.3116684 ... -0.5178649 -0.3977838 0.2960762 ]
[-0.07027415 0.23834501 0.44659805 ... -0.38965416 0.20492953 0.4301296 ]
[ 0.5601178 -0.96016043 0.48343912 ... -0.36059788 1.0021329 -0.5214774 ]]
(3, 384)
你会注意到 embedding.shape 输出了 (3, 384),这意味着我们有三个向量,每个向量是 384 维的。所以现在我们知道这个特定的 SentenceTransformer 模型提供的是 384 维的向量!
有趣的小知识
你可能会想知道是否可以使用 sentence_transformers 库为你的 RAG 向量存储生成嵌入,正如我们使用 OpenAI 的嵌入 API 所做的那样。答案是肯定的!这是一个免费的替代方案,尤其是当你从较大的 all-mpnet-base-v2 模型生成嵌入时。你可以使用“大型文本嵌入基准”(MTEB)排名来大致比较这个模型的嵌入质量,目前它在303个模型中排名第94位。OpenAI 的 ada 模型排名第65,而它们的“最佳”模型 text-embedding-3-large 排名第14。你还可以使用你自己的数据微调这些模型,可能会使它比任何付费的 API 嵌入服务在 RAG 系统中更有效。最后,对于任何 API 服务,你都依赖于它的可用性,但它并非总是可用。使用本地的 sentence_transformers 模型使其始终可用,且100%可靠。你可以查看 MTEB 来找到更好的模型,下载并以类似方式使用它们。
好吧,我们现在有了一个可以开始探索距离度量的环境。
计算向量之间的距离有很多方法。欧几里得距离(L2)、点积和余弦距离是 NLP 中最常见的距离度量。
让我们从欧几里得距离(L2)开始。
欧几里得距离(L2)
欧几里得距离计算的是两个向量之间的最短距离。在使用此度量来计算距离时,请记住,我们是在寻找“更近”的东西,因此较低的值表示较高的相似性(即更小的距离)。我们来计算两个向量之间的欧几里得距离:
def euclidean_distance(vec1, vec2):
return np.linalg.norm(vec1 - vec2)
在这个函数中,我们计算了 vec1 和 vec2 两个向量之间的欧几里得距离。首先,我们对这两个向量进行逐元素的减法运算,然后使用 NumPy 的 linalg.norm() 函数来计算该向量的欧几里得范数(也称为 L2 范数)。这个函数会取向量元素平方和的平方根。结合这些操作,我们就得到了两个向量之间的欧几里得距离。
我们在这里调用这个函数,分别对每一对嵌入进行计算:
print("欧几里得距离:评论 1 vs 评论 2:", euclidean_distance(embedding[0], embedding[1]))
print("欧几里得距离:评论 1 vs 随机评论:", euclidean_distance(embedding[0], embedding[2]))
print("欧几里得距离:评论 2 vs 随机评论:", euclidean_distance(embedding[1], embedding[2]))
运行这个代码,我们得到以下输出:
欧几里得距离:评论 1 vs 评论 2: 4.6202903
欧几里得距离:评论 1 vs 随机评论: 7.313547
欧几里得距离:评论 2 vs 随机评论: 6.3389034
稍微停顿一下,看看你身边离你最近的物体,再看看距离较远的物体。离你最近的物体距离值较小。1 英尺比 2 英尺近,因此在这种情况下,当你希望它更接近时,1 是比 2 更好的得分。在语义搜索中,越近表示越相似。所以,在这些结果中,我们希望看到一个更低的分数表示更相似。评论 1 和评论 2 的欧几里得距离是 4.6202903,它们比随机评论要明显远。这表明了数学是如何用来判断这些文本在语义上是相似还是不同的。不过,正如在数据科学中常见的那样,我们有多种方式来计算这些距离。接下来,我们看看另一种方法:点积。
点积(也叫内积)
点积严格来说不是一种距离度量,它衡量的是一个向量在另一个向量上的投影的大小,这表示的是相似性而非距离。然而,它是与其他度量类似目的的一个指标。由于我们讨论的是大小而非接近度,因此更大的正点积值表示更高的相似性。所以,当点积值变小,甚至变为负值时,这表示相似性降低。我们来打印出每对文本字符串的点积:
print("点积:评论 1 vs 评论 2:", np.dot(embedding[0], embedding[1]))
print("点积:评论 1 vs 随机评论:", np.dot(embedding[0], embedding[2]))
print("点积:评论 2 vs 随机评论:", np.dot(embedding[1], embedding[2]))
在这个代码中,我们使用了 NumPy 函数来完成所有点积计算。输出如下:
点积:评论 1 vs 评论 2: 12.270497
点积:评论 1 vs 随机评论: -0.7654616
点积:评论 2 vs 随机评论: 0.95240986
在第一次比较评论 1 和评论 2 时,得分是 12.270497。点积的正值(12.270497)表明评论 1 和评论 2 之间的相似性相对较高。当我们将评论 1 与随机评论进行比较时,得分为 -0.7654616,评论 2 与随机评论的点积为 0.95240986。低值和负值表明向量之间有不相似或不对齐的情况。得分告诉我们评论 1 和评论 2 比它们与随机评论的相似性要更高。
接下来,我们来看最后一个距离度量:余弦距离。
余弦距离
余弦距离衡量的是向量之间方向上的差异。由于这是另一种距离度量,我们认为较低的值表示更接近,更相似的向量。首先,我们设立一个函数来计算两个向量之间的余弦距离:
def cosine_distance(vec1, vec2):
cosine = 1 - abs((np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))))
return cosine
注意,余弦距离的公式包含了我们前面两个度量的元素。首先,我们使用 np.dot(vec1, vec2) 来计算两个向量的点积。然后,我们将结果除以这两个向量的范数的乘积,使用与欧几里得距离相同的 NumPy 函数来计算欧几里得范数。在这里,我们计算的是每个向量的欧几里得范数(而不是像欧几里得距离那样计算向量间的差异),然后将它们相乘。结合起来,我们得到了余弦相似度,它再被从 1 中减去,得到余弦距离。我们调用这个函数如下:
print("余弦距离:评论 1 vs 评论 2:", cosine_distance(embedding[0], embedding[1]))
print("余弦距离:评论 1 vs 随机评论:", cosine_distance(embedding[0], embedding[2]))
print("余弦距离:评论 2 vs 随机评论:", cosine_distance(embedding[1], embedding[2]))
输出结果如下:
余弦距离:评论 1 vs 评论 2: 0.4523802399635315
余弦距离:评论 1 vs 随机评论: 0.970455639064312
余弦距离:评论 2 vs 随机评论: 0.9542623348534107
就像欧几里得距离一样,较低的距离值表示更接近,更相似。所以,这两个评论之间的距离值表明它们在语义上要比任何一个评论和随机评论更为接近和相似。然而,需要注意的是,0.4523802399635315 表示评论 1 和评论 2 之间的相似度是中等的。但另外两个得分,1.0295443572103977 和 0.9542623348534107,则表明向量之间具有较大的不相似性。
对不起,泰勒·斯威夫特,从数学角度来看,我们有充足的证据证明你并不是一条温暖的毛毯的语义等价物!
记住,你可以使用许多其他的距离度量和相似度分数来处理文本嵌入,包括 Lin 相似度、Jaccard 相似度、汉明距离、曼哈顿距离和 Levenshtein 距离。然而,之前提到的三种度量是 NLP 中最常用的,它们应该能帮助你理解这些度量是如何计算的。
到目前为止,我们讨论了稠密向量,它们代表语义意义,但并非所有模型都代表语义意义。有些模型只是单纯地计算我们提供给它的数据中的词频。这些向量被称为稀疏向量。接下来,让我们讨论这两种类型的向量之间的差异,以及如何利用这些差异来优化 RAG。
不同的搜索范式——稀疏、密集和混合
在本讨论中,理解不同类型的向量非常重要,因为不同类型的向量搜索需要使用不同的搜索方式。让我们深入探讨这些向量类型之间的差异。
密集搜索
密集搜索(语义搜索)使用向量嵌入表示数据来执行搜索。正如我们之前提到的,这种类型的搜索可以捕捉并返回语义上相似的对象。它依赖于数据的意义来执行查询。从理论上讲,这听起来很棒,但也有一些局限性。如果我们使用的模型是基于完全不同领域的数据训练的,那么查询的准确性可能会很差。密集搜索非常依赖于它所训练的数据。
此外,当搜索数据中包含某些引用(如序列号、代码、ID,甚至人的名字)时,也会得到很差的结果。这是因为这种类型的文本没有太多的意义,因此在嵌入中也不会捕获任何意义,无法用来比较嵌入。当搜索这类特定引用时,字符串或单词匹配更为有效。我们称这种类型的搜索为关键词搜索或稀疏搜索,接下来我们将讨论这个话题。
稀疏搜索
稀疏搜索允许你通过在所有内容中进行关键词匹配来执行搜索。之所以称为稀疏嵌入,是因为文本通过统计在查询和存储的句子中每个词汇表中唯一单词的出现次数来嵌入到向量中。这个向量大部分是零,因为一个句子包含你词汇表中每个单词的可能性较低。用数学术语来说,如果一个嵌入向量大多是零,那么它被认为是稀疏的。
一个例子是使用词袋模型(Bag of Words)。词袋模型的思路是统计查询和数据向量中每个单词出现的次数,然后返回具有最高匹配词频的对象。这是进行关键词匹配的最简单方法。
关键词基础算法的一个典型例子是BM25算法。这个非常流行的模型在处理大量关键词搜索时表现得非常好。BM25的基本思想是:统计你传入的短语中出现的单词数量,出现频率高的词会在匹配时被赋予较低的权重,而稀有的词则得分更高。这个概念是不是听起来很熟悉?它使用了TF-IDF模型,这是我们上一章讨论过的内容之一!
混合搜索
拥有这两种搜索方式后,会引发一个具有挑战性的问题:我们该选择哪一种?如果我们既需要语义匹配又需要关键词匹配怎么办?好消息是,我们无需选择;我们可以将两者结合起来使用,这就是混合搜索!接下来我们将介绍这个概念。
混合搜索允许你同时使用密集搜索和稀疏搜索技术,并将它们的返回结果结合在一起。在混合搜索中,你会同时进行向量/密集搜索和关键词/稀疏搜索,然后将结果合并。
这种组合可以基于一个评分系统来完成,评分系统衡量每个对象使用密集和稀疏搜索匹配查询的效果。为了更好地说明这种方法是如何工作的,我们将通过代码示例来进行演示。在下一节中,我们将介绍如何使用BM25进行关键词/稀疏搜索,然后将其与我们现有的检索器结合,形成混合搜索。
代码实验 8.2 - 使用自定义函数的混合搜索
你需要从 GitHub 仓库访问的文件名为 CHAPTER8-2_HYBRID_CUSTOM.ipynb。
在这个代码实验中,我们将从第5章的笔记本开始:CHAPTER5-3_BLUE_TEAM_DEFENDS.ipynb。需要注意的是,我们不再使用第6章或第7章的代码,因为它们包含很多我们在接下来的工作中不会使用的杂项代码。不过,在这个代码实验中有一个额外的奖励:我们将介绍一些新的元素,这些元素将贯穿接下来的几章,包括用于处理PDF文档的新类型文档加载器、新的大型文档以供搜索,以及新的文本拆分器。我们还会清理掉由于这些变化而不再需要的代码。
一旦我们更新了代码以适应这些变化,我们就可以专注于当前任务:使用 BM25 来生成稀疏向量,将这些向量与我们已经使用的密集向量结合,形成一个混合搜索方法。我们将使用之前的向量化器生成我们的密集向量。然后,我们将使用这两组向量进行搜索,对在两个检索中都出现的文档重新排序,并提供最终的混合结果。BM25 已经存在了几十年,但它仍然是一个非常有效的基于 TF-IDF 的词袋算法(我们在上一章回顾过)。它也非常快速。
一个有趣的方面是,结合来自两个检索器的结果引出了一个问题:它如何对来自两个相对不同搜索机制的结果进行排序?我们的密集向量搜索使用余弦相似度并提供相似度评分,而我们的稀疏向量基于 TF-IDF,使用 TF 和 IDF 分数(我们在上一章回顾过)。这些是不可比的分数。事实证明,我们可以使用许多算法在这两个检索器之间进行排序。我们将使用的算法叫做 倒排排序融合(Reciprocal Rank Fusion,RRF) 。本实验主要集中在构建一个模拟 RRF 排序方法的函数,以便你可以自己走一遍并理解这些计算。
首先,我们不再需要专注于解析网页的包,因为我们将从处理网页切换到解析 PDF。让我们从移除该代码开始:
%pip install beautifulsoup4
我们需要安装一个新的包来解析PDF,因为我们需要一个新包,让我们可以将 BM25 模型与 LangChain 配合使用,生成稀疏嵌入:
%pip install PyPDF2 -q --user
%pip install rank_bm25
这将把这两个包加载到我们的环境中。记得在安装后重新启动内核!
接下来,移除以下导入代码:
from langchain_community.document_loaders import WebBaseLoader
import bs4
from langchain_experimental.text_splitter import SemanticChunker
如前所述,我们不再需要用于解析网页的代码。我们也将移除文本拆分器并用一个新的替代它。
将以下代码添加到导入部分:
from PyPDF2 import PdfReader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document
from langchain_community.retrievers import BM25Retriever
在这里,我们添加了 PdfReader 用于 PDF 提取。添加了 RecursiveCharacterTextSplitter 文本拆分器,它将替代 SemanticChunker。我们还添加了一个新的类,帮助我们在处理 LangChain 时管理和处理文档。最后,我们添加了 BM25Retriever 加载器,作为 LangChain 的检索器。
接下来,移除网页解析的代码:
loader = WebBaseLoader(
web_paths=("https://kbourne.github.io/chapter1.html",),
bs_kwargs=dict(
parse_only=bs4.SoupStrainer(
class_=("post-content", "post-title",
"post-header")
)
),
)
docs = loader.load()
我们将扩展我们定义 OpenAI 变量的单元,并在该单元底部定义我们在代码中使用的所有变量:
pdf_path = "google-2023-environmental-report.pdf"
collection_name = "google_environmental_report"
str_output_parser = StrOutputParser()
这设置了一些变量,我们将在后续代码中进一步讨论。现在,添加处理 PDF 的代码:
pdf_reader = PdfReader(pdf_path)
text = ""
for page in pdf_reader.pages:
text += page.extract_text()
我们将在第11章详细讨论 LangChain 文档加载,但现在我们希望为你介绍一种不同于仅加载网页的替代方案。考虑到 PDF 文件的普及,未来你很可能会遇到类似的情况。关键是,你需要确保 google-2023-environmental-report.pdf 文件与笔记本处于同一目录下。你可以从本书中访问的相同仓库下载该文件。此代码将加载该文件,并提取每一页的文本,将文本合并回一个大字符串,以确保跨页的文本不会丢失。
到目前为止,我们已经有了一个非常大的字符串,代表 PDF 中的所有文本。接下来,我们需要使用拆分器将文本拆分为可管理的块。这时我们将从 SemanticChunker 切换到 RecursiveCharacterTextSplitter。这也给你一个机会,尝试使用不同的 LangChain 拆分器,这是我们将在第11章进一步扩展的话题。首先,移除以下代码:
text_splitter = SemanticChunker(OpenAIEmbeddings())
splits = text_splitter.split_documents(docs)
然后,添加以下代码:
character_splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", ". ", " ", ""],
chunk_size=1000,
chunk_overlap=200
)
splits = character_splitter.split_text(text)
RecursiveCharacterTextSplitter 是一个常用的拆分器,它也避免了使用 OpenAIembeddings API 与 SemanticChunker 拆分器对象相关的成本。结合我们现在上传的较大 PDF 文档,这个拆分器将为我们提供更多的块来处理,方便我们在下一章中处理向量空间和检索图。
有了新的数据和新的拆分器,我们还需要更新与检索器相关的代码。首先,准备好文档:
documents = [Document(page_content=text, metadata={
"id": str(i)}) for i, text in enumerate(splits)]
接下来,需要移除检索器相关代码:
vectorstore = Chroma.from_documents(
documents=splits, embedding=OpenAIEmbeddings())
retriever = vectorstore.as_retriever()
替换为以下代码:
chroma_client = chromadb.Client()
vectorstore = Chroma.from_documents(
documents=documents,
embedding=embedding_function,
collection_name=collection_name,
client=chroma_client
)
dense_retriever = vectorstore.as_retriever(
search_kwargs={"k": 10})
sparse_retriever = BM25Retriever.from_documents(
documents, k=10)
这段代码需要一些时间加载,但一旦完成,我们就设置好了我们的 Chroma DB 向量存储,更好地管理从 PDF 获取的文档,并添加了 ID 元数据。原始的检索器现在被称为 dense_retriever,这是一个更具描述性和准确性的名称,因为它与密集嵌入交互。新的检索器 sparse_retriever 基于 BM25,并且通过 LangChain 提供了检索器功能,给我们提供了与其他 LangChain 检索器类似的功能。在这两个实例中,我们确保通过设置 k=10 获取 10 个结果。还需要注意,vectorstore 对象使用了我们在代码前面定义的 collection_name 字符串。
有趣的事实
需要注意的是,我们并没有像处理密集型嵌入一样,将稀疏嵌入存储在 Chroma DB 向量库中。相反,我们直接将文档拉入检索器,检索器会将它们保存在内存中,以便在代码中使用。在更复杂的应用中,我们可能会希望更彻底地处理这一过程,并将嵌入存储在一个更加持久的向量库中,以便将来检索。即便是我们的 Chroma DB 在这段代码中也是临时的,这意味着如果我们关闭笔记本内核,数据就会丢失。你可以通过使用 vectorstore.persist() 来改进这种情况,它会将 Chroma DB 数据库本地存储在一个 sqlite 文件中。这些是一些高级技巧,对于本次代码实验并不是必须的,但如果你想为你的 RAG 流水线构建一个更稳健的向量存储环境,可以查阅相关资料!
接下来,我们将介绍一个执行混合搜索的函数,让你可以一步步地了解发生了什么。在我们回顾它之前,先讨论一下如何处理它。请记住,这只是一个快速的尝试,目的是模仿 LangChain 在混合搜索机制中使用的排名算法。这里的思路是,通过这个函数,你可以了解在使用 LangChain 执行混合搜索时,背后发生了什么。LangChain 实际上提供了一个机制,可以在一行代码中完成所有这些工作!那就是 EnsembleRetriever。EnsembleRetriever 执行混合搜索的方式与我们的函数相同,但它采用了一种被称为 RRF 算法的复杂排名算法。这个算法负责做繁重的工作,决定如何对所有结果进行排名,类似于我们刚才讨论的函数操作。
我们将一步步分析接下来的函数,讨论每个部分,并解释它如何与 LangChain 使用的 RRF 算法相对应。到目前为止,这是我们使用过的最大函数,但它值得我们去深入理解!请记住,这只是一个函数,你可以在代码中看到它的全部内容。让我们从函数定义开始:
def hybrid_search(query, k=10, dense_weight=0.5,
sparse_weight=0.5):
首先,我们将分别处理密集型和稀疏型结果的权重。这与 LangChain 的 EnsembleRetriever 权重参数相匹配,我们稍后会详细回顾。这一设置使得这个函数的行为与该类型的检索器完全一致。我们还设置了一个 k 值,表示我们希望函数返回的结果总数。k 的默认值与检索器初始化时返回的结果数量相匹配。
在函数的第一步,我们集中精力从两种检索器中分别检索出前 k 个文档:
dense_docs = dense_retriever.get_relevant_documents(query)[:k]
dense_doc_ids = [doc.metadata['id'] for doc in dense_docs]
print("\nCompare IDs:")
print("dense IDs: ", dense_doc_ids)
sparse_docs = sparse_retriever.get_relevant_documents(query)[:k]
sparse_doc_ids = [doc.metadata['id'] for doc in sparse_docs]
print("sparse IDs: ", sparse_doc_ids)
all_doc_ids = list(set(dense_doc_ids + sparse_doc_ids))
dense_reciprocal_ranks = {doc_id: 0.0 for doc_id in all_doc_ids}
sparse_reciprocal_ranks = {doc_id: 0.0 for doc_id in all_doc_ids}
我们首先通过密集型和稀疏型搜索分别检索前 k 个文档。就像 RRF 算法一样,我们从密集型搜索和稀疏型搜索中检索前 k 个文档,基于它们各自的评分机制。我们还希望为文档分配 ID,以便可以比较不同检索器的结果,去除重复项(通过将其转换为集合来删除重复项),然后创建两个字典来存储每个文档的倒排排名。
接下来,我们将计算每个文档的倒排排名:
for i, doc_id in enumerate(dense_doc_ids):
dense_reciprocal_ranks[doc_id] = 1.0 / (i + 1)
for i, doc_id in enumerate(sparse_doc_ids):
sparse_reciprocal_ranks[doc_id] = 1.0 / (i + 1)
这段代码会计算每个文档在密集型和稀疏型搜索结果中的倒排排名,并将其存储在我们刚才创建的字典中。对于每个文档,我们计算它在每个排序列表中的倒排排名。倒排排名是文档在排序列表中的位置的倒数(例如,1/排名)。倒排排名计算为 1.0 除以文档在相应搜索结果中的位置(基于 1 的索引)。注意,这个计算不涉及相似度分数。正如你从之前的讨论中记得的那样,我们的语义搜索是基于距离排名的,而 BM25 是基于相关性排名的。但 RRF 不需要这些分数,这意味着我们不需要担心将不同检索方法的分数归一化,使它们在相同尺度上直接可比。使用 RRF,它依赖的是排名位置,这使得将不同评分机制的结果结合起来更加容易。重要的是要注意,这种方式对你的搜索会产生影响。你可能会遇到一种情况,即从语义角度看,语义搜索中的某些分数(基于距离)非常接近,但关键词搜索中的最高排名结果却不太相似。使用相同权重的 RRF 会使这些结果拥有相同的排名,因此从排名角度来看,它们的价值是相等的,尽管你可能希望语义结果具有更大的权重。你可以使用 dense_weight 和 sparse_weight 参数来调整这一点,但如果你遇到反向情况怎么办呢?这是使用 RRF 和混合搜索的一个缺点,这也是为什么你需要测试一下,以确保它是最适合你特定需求的解决方案。
在这里,我们对每个文档在密集型搜索和稀疏型搜索中的倒排排名进行加总:
combined_reciprocal_ranks = {doc_id: 0.0 for doc_id in all_doc_ids}
for doc_id in all_doc_ids:
combined_reciprocal_ranks[doc_id] = dense_weight * dense_reciprocal_ranks[doc_id] + sparse_weight * sparse_reciprocal_ranks[doc_id]
RRF 方法的核心思想是,两个检索方法都排名靠前的文档更有可能与查询相关。通过使用倒排排名,RRF 给排名靠前的文档赋予更高的权重。注意,我们在这里加权求和,使用了我们在参数中收集的权重。这意味着,在这里我们可以让某一类型的嵌入(密集型或稀疏型)在搜索结果中更具影响力。
接下来的这一行根据文档的综合倒排排名分数对文档 ID 进行降序排序:
sorted_doc_ids = sorted(all_doc_ids, key=lambda doc_id: combined_reciprocal_ranks[doc_id], reverse=True)
降序排序通过 reverse=True 指定。它使用 sorted() 函数和一个键函数来获取每个文档 ID 的综合倒排排名。
我们的下一步是遍历排序后的文档 ID,并从密集型和稀疏型搜索结果中检索相应的文档:
sorted_docs = []
all_docs = dense_docs + sparse_docs
for doc_id in sorted_doc_ids:
matching_docs = [doc for doc in all_docs if doc.metadata['id'] == doc_id]
if matching_docs:
doc = matching_docs[0]
doc.metadata['score'] = combined_reciprocal_ranks[doc_id]
doc.metadata['rank'] = sorted_doc_ids.index(doc_id) + 1
if len(matching_docs) > 1:
doc.metadata['retriever'] = 'both'
elif doc in dense_docs:
doc.metadata['retriever'] = 'dense'
else:
doc.metadata['retriever'] = 'sparse'
sorted_docs.append(doc)
我们用这个来指示文档的来源检索器,从而更好地了解每个检索器对结果的影响。根据排序后的文档 ID 检索文档。最终得到的排名列表即为混合搜索的结果,其中在密集型和稀疏型搜索排名中都排在前面的文档,将具有更高的综合排名分数。
最终,我们返回结果:
return sorted_docs[:k]
需要注意的是,k 在两个检索器中都被使用,这意味着我们得到的结果是我们要求的数量的两倍。因此,我们对这些结果进行切割,只返回前 k 个结果。实际上,这样做的效果是,如果在这两个检索器的结果中有些排在后半部分(比如排名第 8 的结果),但它们在两个检索器中都存在,那么这些结果很可能会被推到前 k 名之内。
接下来,我们需要将这个新的检索器机制考虑到 LangChain 的链中。我们更新 rag_chain_with_source 链,使用 hybrid_search 函数来返回上下文,如下所示:
rag_chain_with_source = RunnableParallel(
{"context": hybrid_search,
"question": RunnablePassthrough()}
).assign(answer=rag_chain_from_docs)
这完成了 RAG 流水线代码的更改,以便使用混合搜索。但我们添加了所有这些额外的元数据,我们希望在输出和分析中展示它们。构建这个函数的额外好处是,它允许我们打印出一些通常使用 LangChain 的 EnsembleRetriever 时无法看到的输出。让我们利用这一点,替换我们在 RAG 流水线中调用的代码。与之前代码实验中的最终代码不同,在处理我们的 RAG 流水线时,使用以下代码:
user_query = "What are Google's environmental initiatives?"
result = rag_chain_with_source.invoke(user_query)
relevance_score = result['answer']['relevance_score']
final_answer = result['answer']['final_answer']
retrieved_docs = result['context']
print(f"\nOriginal Question: {user_query}\n")
print(f"Relevance Score: {relevance_score}\n")
print(f"Final Answer:\n{final_answer}\n\n")
print("Retrieved Documents:")
for i, doc in enumerate(retrieved_docs, start=1):
doc_id = doc.metadata['id']
doc_score = doc.metadata.get('score', 'N/A')
doc_rank = doc.metadata.get('rank', 'N/A')
doc_retriever = doc.metadata.get('retriever', 'N/A')
print(f"Document {i}: Document ID: {doc_id}\nScore: {doc_score} Rank: {doc_rank}\nRetriever: {doc_retriever}\n")
print(f"Content:\n{doc.page_content}\n")
这段代码继承了我们在之前章节中使用的内容,比如我们在安全响应中使用的相关性评分。我们添加了对每个检索器结果的打印,以及我们收集的元数据。以下是前几个结果的示例输出:
Compare IDs:
dense IDs: ['451', '12', '311', '344', '13', '115', '67', '346', '66', '262']
sparse IDs: ['150', '309', '298', '311', '328', '415', '139', '432', '91', '22']
Original Question: What are Google's environmental initiatives?
Relevance Score: 5
Final Answer:
Google's environmental initiatives include partnering with suppliers to reduce energy consumption and GHG emissions, engaging with suppliers to report and manage emissions, empowering individuals to take action through sustainability features in products, working together with partners and customers to reduce carbon emissions, operating sustainably at their campuses, focusing on net-zero carbon energy, water stewardship, circular economy practices, and supporting various environmental projects and initiatives such as the iMasons Climate Accord, ReFED, and The Nature Conservancy. They also work on sustainable consumption of public goods and engage with coalitions and sustainability initiatives to promote environmental sustainability.
Retrieved Documents:
Document 1: Document ID: 150 Score: 0.5 Rank: 1 Retriever: sparse
Content: sustainability, and we're partnering with them…
Document 2: Document ID: 451 Score: 0.5 Rank: 2 Retriever: dense
Content: Empowering individuals: A parking lot full of electric vehicles lined up outside a Google office…
Document 3: Document ID: 311 Score: 0.29166666666666663 Rank: 3 Retriever: both
Content: In 2022, we audited a subset of our suppliers to verify compliance for the following environmental…
在检索文档时,我们打印出文档的 ID,以便看到它们的重叠情况。然后,对于每个结果,我们打印文档的 ID、排名分数、排名以及是哪个检索器产生了该结果(包括两个检索器都检索到的情况)。注意,我在这里截断了完整的内容,只显示了前 3 个结果中的 10 个,因为输出中内容要长得多。如果你在笔记本中运行这个代码,可以看到完整的输出。
如果查看这 10 个结果,源检索器包括:稀疏(sparse)、密集(dense)、两者(both)、稀疏、密集、稀疏、密集、密集、稀疏和稀疏。这是一个相对均匀分布的结果,涵盖了不同的搜索机制,包括一个来自两者的结果,使其排名更高。排名分数分别为:0.5、0.5、0.29、0.25、0.25、0.17、0.125、0.1、0.1 和 0.83。
这是我们只使用密集嵌入时的响应:
Google's environmental initiatives include empowering individuals to take action, working together with partners and customers, operating sustainably, achieving net-zero carbon emissions, focusing on water stewardship, and promoting a circular economy. They have reached a goal to help 1 billion people make more sustainable choices through their products and aim to collectively reduce 1 gigaton of carbon equivalent emissions annually by 2030. Google also audits suppliers for compliance with environmental criteria and is involved in public policy and advocacy efforts. Additionally, Google is a founding member of the iMasons Climate Accord, provided funding for the ReFED Catalytic Grant Fund to address food waste, and supported projects with The Nature Conservancy to promote reforestation and stop deforestation.
此时,判断哪种版本更好是有些主观的,但我们将在第 9 章中讨论一个更客观的方法,评估 RAG 的效果。在此之前,我们先看看几个突出的方面。我们的混合搜索版本似乎涵盖了不同的倡议,内容更广泛。
这是混合搜索的结果:
Google's environmental initiatives include partnering with suppliers to reduce energy consumption and GHG emissions, engaging with suppliers to report and manage emissions, empowering individuals to take action through sustainability features in products, working together with partners and customers to reduce carbon emissions, operating sustainably at their campuses, focusing on net-zero carbon energy, water stewardship, circular economy practices, and supporting various environmental projects and initiatives such as the iMasons Climate Accord, ReFED, and The Nature Conservancy.
这是密集搜索的结果:
Google's environmental initiatives include empowering individuals to take action, working together with partners and customers, operating sustainably, achieving net-zero carbon emissions, focusing on water stewardship, and promoting a circular economy.
你可能会说,密集搜索方法关注更精确的细节,但这是否是好事还是坏事,依然是主观的。例如,在混合搜索中,你看不到关于 10 亿人目标的内容,而在密集搜索中却可以看到:
They have reached a goal to help 1 billion people make more sustainable choices through their products and aim to collectively reduce 1 gigaton of carbon equivalent emissions annually by 2030.
混合搜索采取了更广泛的方式,提到了以下内容:
They also work on sustainable consumption of public goods and engage with coalitions and sustainability initiatives to promote environmental sustainability.
你可以用其他问题运行这段代码,看看它们在不同搜索方法下的比较。
好吧,我们为设置这个函数做了很多工作,但现在我们要看看 LangChain 提供的功能,并完全替换我们的函数。
代码实验 8.3 – 使用 LangChain 的 EnsembleRetriever 进行混合搜索,替换我们自定义的函数
您需要从 GitHub 仓库访问的文件名是 CHAPTER8-3_HYBRID-ENSEMBLE.ipynb。
我们将从上一个实验的代码开始,使用 CHAPTER8-2_HYBRID-CUSTOM.ipynb 文件。本实验的完整代码为 CHAPTER8-3_HYBRID-ENSEMBLE.ipynb。首先,我们需要从 LangChain 导入检索器,添加如下导入语句:
from langchain.retrievers import EnsembleRetriever
这会从 LangChain 导入 EnsembleRetriever,它将作为第三个检索器,结合其他两个检索器。需要注意的是,在 8.2 代码实验中,我们为每个检索器都添加了 k=10,确保获得足够多的响应,以便与其他响应类似。
过去,我们只有一组文档,定义为 documents,但在这里,我们希望将这些文档的名称改为 dense_documents,然后再添加一组名为 sparse_documents 的文档:
dense_documents = [Document(page_content=text,
metadata={"id": str(i), "source": "dense"}) for i, text in enumerate(splits)]
sparse_documents = [Document(page_content=text,
metadata={"id": str(i), "source": "sparse"}) for i, text in enumerate(splits)]
这允许我在元数据中标记密集文档为 "dense",并将稀疏文档标记为 "sparse"。我们将这些文档传递到最终的结果中,并可以用它们显示每个文档的来源。然而,这种方式不如我们自定义函数中的方式有效,因为当内容来自两个来源时,它并没有指示两个来源。这突显了自定义函数的一个优点。
接下来,我们要添加新的检索器类型 EnsembleRetriever,并将其添加到我们定义其他两个检索器的单元格的底部:
ensemble_retriever = EnsembleRetriever(retrievers=[
dense_retriever, sparse_retriever], weights=[0.5, 0.5],
c=0)
EnsembleRetriever 接受两个检索器、它们的权重以及一个 c 值。c 值是描述为添加到排名中的常量,控制高排名项的重要性与低排名项的考虑之间的平衡。默认值为 60,但我将其设置为 0。如果您希望更多 ID 从底部浮上来,可以调整这个 c 参数。我们的函数中没有 c 参数,因此这使得比较结果变得困难!不过,如果需要更多浮动结果,这个参数会很有用。
您可以完全删除我们之前的 hybrid_search 函数。删除以下代码开始的整个单元格:
def hybrid_search(query, k=10, dense_weight=0.5,
sparse_weight=0.5):
接下来,我们使用新的检索器更新 rag_chain_with_source 的 "context" 输入:
rag_chain_with_source = RunnableParallel(
{"context": ensemble_retriever,
"question": RunnablePassthrough()}
).assign(answer=rag_chain_from_docs)
现在,我们的输出代码也需要改变,因为我们不再有通过自定义函数添加的所有元数据:
user_query = "What are Google's environmental initiatives?"
result = rag_chain_with_source.invoke(user_query)
relevance_score = result['answer']['relevance_score']
final_answer = result['answer']['final_answer']
retrieved_docs = result['context']
print(f"Original Question: {user_query}\n")
print(f"Relevance Score: {relevance_score}\n")
print(f"Final Answer:\n{final_answer}\n\n")
print("Retrieved Documents:")
for i, doc in enumerate(retrieved_docs, start=1):
print(f"Document {i}: Document ID: {doc.metadata['id']}
source: {doc.metadata['source']}")
print(f"Content:\n{doc.page_content}\n")
输出将如下所示:
Original Question: What are Google's environmental initiatives?
Relevance Score: 5
Final Answer:
Google's environmental initiatives include being a founding member of the iMasons Climate Accord, providing funding for the ReFED Catalytic Grant Fund to address food waste, supporting projects with The Nature Conservancy for reforestation and deforestation prevention, engaging with suppliers to reduce energy consumption and emissions, auditing suppliers for environmental compliance, addressing climate-related risks, advocating for sustainable consumption of public goods, engaging with coalitions like the RE-Source Platform, and working on improving data center efficiency.
Retrieved Documents:
Document 1: Document ID: 344 source: dense
Content:
iMasons Climate AccordGoogle is a founding member and part…
Document 2: Document ID: 150 source: sparse
Content:
sustainability, and we're partnering with them to develop decarbonization roadmaps…
Document 3: Document ID: 309 source: dense
Content:
that enable us to ensure that those we partner with are responsible environmental stewards…
结果几乎与我们的函数完全相同,但在顺序上,密集搜索的结果优先于稀疏搜索的结果(在我们的自定义函数中,稀疏结果优先),这只是一个小差异,但您可以通过调整权重轻松解决。记得那个 c 值吗?如果你调整它,结果会有很大变化。若有更多时间,我们可以回去给我们的函数添加 c 值,但我暂且不说这个!
构建我们自己的函数无疑为我们提供了更多的灵活性,并且让我们能够看到并修改函数的内部工作原理。使用 LangChain 的 EnsembleRetriever,我们无法更改搜索或排名中的任何步骤,以更好地满足我们的需求,而且还存在一些小问题,比如 "source" 元数据的问题,我们无法判断它是否以及何时来自两个来源。从这个小例子来看,很难判断哪种方法更好。实际上,您所做的每一步都需要考虑,并且您必须根据自己的情况决定什么是最适合的。
如果混合搜索很重要,您可能会考虑使用向量数据库或向量搜索服务,这些服务提供了更多的功能和灵活性,帮助您定义混合搜索。LangChain 提供了权重,可以让您在搜索机制之间进行加权,但目前为止,您只能使用 RRF 中内置的排名机制。例如,Weaviate 允许您从两种不同的排名算法中进行选择。这也是在决定在您的 RAG 管道中使用什么基础设施时需要考虑的一个因素。
接下来,让我们讨论使用这些距离的算法。
语义搜索算法
我们已经深入讨论了语义搜索的概念。接下来的步骤是走访我们可以采取的不同方法来执行语义搜索。这些实际的搜索算法使用一些距离度量(如我们已经讨论过的欧氏距离、点积和余弦相似度)来进行稠密嵌入的搜索。我们从 k 最近邻(k-NN)算法开始。
k-NN
找到相似向量的一种方法是通过暴力搜索。通过暴力搜索,您计算查询向量与所有数据向量之间的距离,然后根据距离从近到远进行排序,返回一定数量的结果。您可以根据阈值截断结果,也可以定义返回的结果数目,例如 5。这个设置的数量称为 k,所以您会说 k=5。这就是经典机器学习中的 k-NN 算法。这是一个简单直接的算法,但它的性能会随着数据集的增大而下降。这个算法的计算成本是线性的,取决于查询数据的大小。它的时间复杂度是 O(n * d),其中 n 是训练数据集中的实例数量,d 是数据的维度。这意味着如果数据量翻倍,查询时间也会翻倍。对于达到数百万甚至数十亿数据点的大型数据集,暴力比较每一对项目可能会变得在计算上不可行。
如果您的数据集相对较小,考虑使用 k-NN 可能是值得的,因为它被认为比我们接下来讨论的下一种方法更准确。什么是“小”的数据集,可能依赖于您的数据和嵌入维度,但我在处理 25,000 到 30,000 个嵌入和 256 维度的项目时,成功地使用了 k-NN。我在检索评估指标中看到了 2-6% 的改进,这足以抵消计算成本的轻微增加。
但是,刚才讨论的所有距离度量怎么办?它们在 k-NN 中的作用是什么?k-NN 可以使用任何这些距离度量来确定查询向量与数据集中向量之间的相似度。k-NN 中最常用的距离度量是欧氏距离。根据数据的性质和问题的需求,其他距离度量,如曼哈顿距离(也叫城市块距离)或余弦相似度,也可以使用。距离度量的选择会显著影响 k-NN 算法的性能。一旦计算出查询向量与数据集中的所有向量之间的距离,k-NN 会对这些距离进行排序,并根据选择的距离度量选择 k 个最近邻。
如果您发现数据集已经超出了 k-NN 的能力范围,还有许多其他算法可以更高效地找到最近的向量。通常我们称之为近似最近邻(ANN),接下来我们将讨论它。
ANN
ANN 是一类旨在解决 k-NN 可扩展性限制的算法,同时仍能提供令人满意的结果。ANN 算法旨在以更高效的方式找到与查询向量最相似的向量,牺牲一些准确性以换取更好的性能。
与 k-NN 通过计算查询向量与所有数据向量之间的距离进行穷尽性搜索不同,ANN 算法采用各种技术来减少搜索空间并加快检索过程。这些技术包括索引、分区和近似方法,使得 ANN 算法能够专注于可能是最近邻的数据点子集。
k-NN 和 ANN 之间的一个关键区别是准确性与效率之间的权衡。虽然 k-NN 保证找到确切的 k 个最近邻,但随着数据集的增长,它变得在计算上非常昂贵。另一方面,ANN 算法优先考虑效率,通过近似最近邻来实现,接受丢失一些真实最近邻的可能性,以换取更快的检索时间。
ANN 算法通常利用索引结构,如层次树(例如 KD 树、Ball 树)、哈希技术(例如局部敏感哈希(LSH))或基于图的方法(例如层次可导航小世界(HNSW))来组织数据点,以便高效地进行搜索。这些索引结构使得 ANN 算法能够快速缩小搜索空间,并在不对每个数据点进行全面比较的情况下识别候选邻居。我们将在接下来的章节中更深入地讨论 ANN 的索引方法。
ANN 算法的时间复杂度取决于使用的具体算法和索引技术。然而,通常情况下,ANN 算法旨在实现亚线性搜索时间,意味着查询时间比数据集的大小增长得更慢。这使得 ANN 算法更适合用于大规模数据集,在这些数据集上,k-NN 的计算成本变得不可承受。
那么,距离度量怎么处理呢?像 k-NN 一样,ANN 算法依赖于距离度量来衡量查询向量与数据集中向量的相似性。距离度量的选择取决于数据的性质和问题的需求。常见的 ANN 距离度量包括欧氏距离、曼哈顿距离和余弦相似度。然而,与 k-NN 不同,k-NN 计算查询向量与所有数据向量之间的距离,而 ANN 算法通过使用索引结构和近似技术来减少距离计算的次数。这些技术使得 ANN 算法能够快速识别出可能接近查询向量的候选邻居,然后在这个子集上应用距离度量来确定近似的最近邻,而不是对整个数据集进行距离计算。通过减少距离计算的次数,ANN 算法能够显著加快检索过程,同时仍提供令人满意的结果。
需要注意的是,选择 k-NN 还是 ANN 取决于应用程序的具体要求。如果精确的最近邻非常关键,并且数据集相对较小,k-NN 可能仍然是一个可行的选择。然而,在处理大规模数据集或需要近实时检索时,ANN 算法通过在准确性和效率之间找到平衡,提供了一个实用的解决方案。
总之,ANN 算法可以为在大规模数据集中寻找相似向量提供比 k-NN 更具可扩展性和高效性的替代方案。通过采用索引技术和近似方法,ANN 算法能够显著减少搜索空间和检索时间,使其适用于需要快速和可扩展相似性搜索的应用。
尽管理解 ANN 很重要,但同样重要的是了解其真正的优势在于可以通过多种方式进行增强。接下来,让我们回顾一些这些增强技术。
通过索引技术提升搜索性能
近似最近邻(ANN)和k-最近邻(k-NN)搜索是计算机科学和机器学习中的基础解决方案,广泛应用于图像检索、推荐系统和相似性搜索等多个领域。尽管搜索算法在ANN和k-NN中起着至关重要的作用,但索引技术和数据结构同样在提高这些算法的效率和性能方面扮演着重要角色。
这些索引技术通过减少搜索过程中需要比较的向量数量,优化了搜索过程。它们帮助快速识别出一个较小的候选向量子集,这些向量可能与查询向量相似。然后,搜索算法(如k-NN、ANN或其他相似性搜索算法)可以在这一缩小的候选向量集上操作,从而找到实际的最近邻或相似向量。
所有这些技术的目标都是通过减少搜索空间并加速相关向量的检索,从而提高相似性搜索的效率和可扩展性。然而,每种技术在索引时间、搜索时间、内存使用和准确性方面都有各自的优缺点。技术选择取决于应用的具体需求,例如向量的维度、所需的准确性水平和可用的计算资源。
在实际应用中,这些技术可以单独使用,也可以组合使用,以实现给定向量搜索任务的最佳性能。一些库和框架,如Facebook的AI相似性搜索(FAISS)和pgvector,提供了多种索引技术的实现,包括产品量化(PQ)、HNSW和局部敏感哈希(LSH),允许用户根据特定用例选择最合适的技术。
在深入讨论之前,我们先回顾一下我们目前的进展。搜索算法使用了距离/相似性度量(例如余弦相似度、欧几里得距离和点积)。这些搜索算法包括k-NN、ANN等。搜索算法可以使用索引技术,如LSH、KD树、Ball树、PQ和HNSW,以提高其效率和可扩展性。
好,大家都清楚了吗?太好了!接下来我们来讨论几种与搜索算法互补并提高ANN搜索整体效率的索引技术:
局部敏感哈希(LSH)
LSH是一种索引技术,通过将相似的向量映射到同一个哈希桶中来实现高概率的相似性搜索。LSH的目标是通过减少搜索空间,快速识别潜在的候选向量。它通过使用哈希函数将向量空间划分为多个区域,在这些区域内,相似的项更有可能被哈希到同一个桶中。
LSH在准确性和效率之间提供了一种折中。通过将LSH作为预处理步骤,搜索算法需要检查的向量集合可以大大缩小。这减少了计算开销,提高了整体搜索性能。
基于树的索引
基于树的索引技术通过根据向量的空间属性将向量组织成层次结构。两种流行的基于树的索引技术是KD树和Ball树。
- KD树 是一种用于组织k维空间中点的二叉空间划分树。它通过递归地根据向量的维度将空间划分为子区域。在搜索过程中,KD树通过修剪不相关的树枝来实现高效的最近邻搜索。
- Ball树 则将数据点划分为嵌套的超球体。树中的每个节点表示一个超球体,包含数据点的子集。Ball树在高维空间的最近邻搜索中特别有效。
KD树和Ball树都提供了一种有效导航候选向量的方法,从而加速了搜索过程。
产品量化(PQ)
PQ是一种压缩和索引技术,它将向量量化为一组子向量,并使用码本表示它们。你还记得之前讨论的向量量化概念吗?我们在这里使用的正是这些概念。PQ通过近似计算查询向量与量化向量之间的距离,允许高效的距离计算并实现紧凑的存储。
PQ在高维向量中特别有效,并已广泛应用于图像检索和推荐系统等应用中。通过压缩向量并近似计算距离,PQ减少了存储需求和计算成本。
分层小世界图(HNSW)
HNSW是一种基于图的索引技术,它构建了一个互联节点的分层结构,从而实现快速的近似最近邻搜索。HNSW创建了一个多层图,每一层代表一个不同的抽象级别,允许高效地遍历和检索近似最近邻。
HNSW具有高度的可扩展性,解决了暴力KNN搜索的运行时复杂性问题。它已被最先进的向量数据库所采用,并因其高性能和可扩展性,尤其在高维数据中的表现,获得了广泛关注。
HNSW的NSW部分通过寻找与其他向量相比,在许多向量中位置较好的向量(就接近度而言)来工作。这些向量成为搜索的起点。可以定义节点之间连接的数量,从而选择最佳位置的节点连接到大量其他节点。
在查询过程中,搜索算法从一个随机的入口节点开始,朝着查询向量的最近邻节点移动。每次接近一个节点时,都会重新计算查询节点到当前节点的距离,然后选择当前节点网络连接中距离最近的节点。这个过程穿越节点,跳过数据的较大部分,从而显著提高搜索速度。
HNSW的分层(H)部分在每一层上叠加多个可导航的小世界。这可以想象为通过飞机到达最近的机场,然后换乘火车到小镇,最后在一个更小的节点集内查找目标位置。
有趣的事实
HNSW的灵感来自于人类社交网络中观察到的现象,即每个人之间都紧密相连,这就像“六度分隔”理论一样。六度分隔理论认为,任何两个个体之间平均最多通过六个熟人链接相隔。这个概念最早源自弗里吉耶斯·卡林西(Frigyes Karinthy)1929年发表的短篇小说,小说中描述了一群人玩一个游戏,试图通过五个其他人的链条将世界上的任何人和自己连接起来。理论上,通过最多六步,这个连接链条可以将世界上任何两个人连接起来。这也被称为“六次握手规则”。
所有这些索引技术在提高ANN搜索算法的效率和性能方面发挥着至关重要的作用。局部敏感哈希(LSH)、基于树的索引、产品量化(PQ)和HNSW是与搜索算法结合使用的一些显著索引技术。通过利用这些索引技术,可以减少搜索空间,修剪不相关的候选项,并加速整体搜索过程。索引技术提供了一种组织和结构化数据的方式,从而实现高维空间中高效的检索和相似性搜索。
现在我们已经将索引技术加入到我们的工具库中,在开始实际实现这些功能之前,还有一个重要方面需要讨论。ANN和k-NN并不是你可以直接注册的服务;它们是服务和软件包使用的搜索算法方法。因此,接下来我们需要了解这些软件包是什么,才能真正将它们付诸实践。让我们来谈谈向量搜索吧!
向量搜索选项
简单来说,向量搜索是从向量库中找到与查询向量最相似的向量的过程。快速识别相关向量的能力对于系统的整体性能至关重要,因为它决定了LLM用于生成响应的信息片段。这一组件弥合了原始用户查询和高质量生成所需的数据输入之间的鸿沟。市场上有许多不同的向量搜索服务和选项,您可以根据项目需求选择最合适的服务。服务在快速发展,每天都有新的初创公司出现,因此在决定选项之前,做深入的调研非常值得。接下来,我们将介绍一些可以帮助您了解市场上可用的向量搜索选项。
pgvector
pgvector是一个开源的向量相似性搜索扩展,针对流行的关系型数据库PostgreSQL。它允许您直接在PostgreSQL中存储和搜索向量,利用其强大的功能和生态系统。pgvector支持多种距离度量和索引算法,包括L2距离和余弦距离。pgvector是为数不多的支持精确k-NN搜索和使用ANN算法的近似k-NN搜索的服务之一。索引选项包括倒排文件索引(IVF)和HNSW,用以加速搜索过程。pgvector可以通过指定所需的最近邻数量,并选择精确搜索或近似搜索来执行k-NN搜索。我们已经详细讨论了HNSW,而IVF是一种常与向量存储结合使用的索引技术。IVF通过减少搜索过程中的距离计算量,高效地识别与给定查询向量相似的向量子集。IVF可以与HNSW结合使用,进一步提高效率和速度。如果您已经在使用PostgreSQL,并且想在不引入单独系统的情况下增加向量搜索功能,pgvector非常适合。它得益于PostgreSQL的可靠性、可扩展性和事务支持。
Elasticsearch
Elasticsearch是一个流行的开源搜索和分析引擎,支持向量相似性搜索。它广泛应用,并且有一个庞大的插件和集成生态系统。Elasticsearch使用ANN算法,特别是HNSW,进行高效的向量相似性搜索。它并没有明确使用k-NN,但其相似性搜索功能可以用于查找k个最近邻。Elasticsearch提供了高级搜索功能,包括全文搜索、聚合和地理空间搜索,并具有分布式架构,支持高可扩展性和容错性。它与LangChain集成良好,提供了强大的可扩展性、分布式架构和丰富的功能。如果您已经有使用经验,或者需要它的高级搜索和分析功能,Elasticsearch是一个不错的选择。不过,它可能需要比其他向量存储更复杂的配置。
FAISS
FAISS是由Facebook开发的一个高效相似性搜索和密集向量聚类库,以其卓越的性能和处理亿级向量数据集的能力而著名。FAISS heavily relies on ANN algorithms for efficient similarity search offering a wide range of ANN indexing and search algorithms, including IVF, PQ, and HNSW. FAISS可以用于执行k-NN搜索,通过检索与查询向量最相似的k个向量。FAISS提供多种索引和搜索算法,包括基于量化的方法,用于压缩向量表示,并提供GPU支持,加速相似性搜索。它可以作为向量存储,并与LangChain集成。FAISS适合那些对性能有高要求且能够熟练使用底层库的用户。不过,它可能需要比托管服务更多的手动设置和管理。
Google Vertex AI Vector Search
Google Vertex AI Vector Search是Google Cloud Platform(GCP)提供的全托管向量相似性搜索服务,包括向量存储和搜索能力。它使用ANN算法支持快速且可扩展的向量相似性搜索,虽然具体的ANN算法没有披露,但很可能采用基于Google ScaNN(可扩展、通道感知最近邻)的最新技术。它可以通过指定所需的最近邻数量执行k-NN搜索。使用Vertex AI Vector Search时,向量存储在托管服务内。它与BigQuery和Dataflow等Google Cloud服务无缝集成,支持高效的数据处理管道。Vertex AI Vector Search提供在线更新功能,允许您逐步添加或删除向量,而无需重建整个索引。它与LangChain集成,并提供可扩展性、高可用性以及与其他Google Cloud服务的易集成。如果您已经在使用Google Cloud并希望得到一个低配置的托管解决方案,Vertex AI Vector Search是一个不错的选择,但它可能比自托管的解决方案更贵。
Azure AI Search
Azure AI Search是由微软Azure提供的全托管搜索服务,支持向量相似性搜索及传统的基于关键词的搜索。Azure AI Search利用ANN算法进行高效的向量相似性搜索,尽管使用的具体ANN算法没有明确说明,但它通过先进的索引技术实现快速检索相似向量。它可以通过查询k个最近邻来执行k-NN搜索。Azure AI Search提供了同义词、自动完成功能和分面导航等功能,并与Azure Machine Learning无缝集成,便于机器学习模型的部署。如果您已经在使用Azure服务,并希望得到一个具有先进搜索功能的托管解决方案,Azure AI Search是一个不错的选择,但它相比一些其他选项可能具有更高的学习曲线。
Approximate Nearest Neighbors Oh Yeah
Approximate Nearest Neighbors Oh Yeah(ANNOY)是Spotify开发的一个开源ANN搜索库。它以快速的索引和查询速度而著名,并能高效处理高维向量。ANNOY通过结合随机投影和二进制空间划分,构建一个树的森林,支持快速的相似性搜索。ANNOY可以通过检索k个近似最近邻来执行k-NN搜索。ANNOY有一个简单直观的API,易于集成到现有项目中。如果您注重速度并且数据集较小,ANNOY非常适合。但是,它可能不如一些其他选项适用于超大数据集。
Pinecone
Pinecone是一个完全托管的向量数据库,专门为机器学习应用设计。它提供高性能的向量相似性搜索,支持稠密和稀疏向量表示。Pinecone采用ANN算法实现其高性能的向量相似性搜索,支持多种ANN索引算法,包括HNSW,以便高效地检索相似向量。Pinecone可以通过查询k个最近邻来执行k-NN搜索。Pinecone还提供实时更新、水平扩展和多区域复制等功能,确保高可用性。它具有简单的API,并能与多种机器学习框架和库无缝集成。如果您需要一个专注于机器学习应用的专用向量数据库,Pinecone是一个不错的选择。然而,与一些开源或自托管解决方案相比,Pinecone可能更昂贵。
Weaviate
Weaviate是一个开源的向量搜索引擎,能够高效地进行相似性搜索和数据探索。它支持多种向量索引算法,包括HNSW,并提供基于GraphQL的API,便于集成。Weaviate利用ANN算法,特别是HNSW,实现高效的向量相似性搜索,利用HNSW的NSW结构来快速检索相似向量。Weaviate可以通过指定所需的最近邻数量来执行k-NN搜索。它还提供了架构管理、数据验证和实时更新等功能。Weaviate既可以自托管,也可以作为托管服务使用。如果您倾向于使用开源解决方案,且重视数据探索和图形查询功能,Weaviate是一个不错的选择。然而,它可能需要比全托管服务更多的设置和配置。
Chroma
Chroma是本书中大部分代码中使用的向量搜索工具。Chroma是一个开源嵌入式向量数据库,旨在与现有工具和框架轻松集成。Chroma支持包括HNSW在内的ANN算法,用于快速高效的向量相似性搜索。它可以通过检索与查询向量最相似的k个向量来执行k-NN搜索。Chroma提供简单直观的Python API,便于存储和搜索向量,非常适合机器学习和数据科学工作流。这也是我们选择它在本书代码中展示的主要原因。Chroma支持多种索引算法,包括HNSW,并提供动态过滤和元数据存储等功能。它可以作为内存数据库使用,也可以将数据持久化到磁盘以便长期存储。如果您需要一个轻量级、易于使用的向量数据库,并希望直接嵌入到Python应用程序中,Chroma是一个不错的选择。然而,它可能没有一些更成熟的向量搜索解决方案那样具备同等的可扩展性和高级功能。
总结
在本章中,我们涵盖了与向量相似性搜索相关的广泛主题,这是RAG系统的关键组成部分。我们探讨了向量空间的概念,讨论了语义搜索与关键词搜索的区别,并介绍了用于比较嵌入相似度的各种距离度量方法,并提供了代码示例来演示它们的计算。
我们回顾了实现混合搜索的代码,使用BM25算法进行稀疏搜索和使用密集检索器进行语义搜索,展示了如何结合并重新排序结果。我们还讨论了语义搜索算法,重点讲解了k-NN和ANN,并介绍了提高ANN搜索效率的索引技术,如LSH、基于树的索引、PQ和HNSW。
最后,我们概述了市场上几种可用的向量搜索选项,讨论了它们的关键特性、优点和考虑因素,帮助您在为特定项目需求选择向量搜索解决方案时做出明智决策。
在下一章中,我们将深入探讨如何可视化和评估您的RAG管道。