嵌入是支撑 GenAI 的最重要技术之一。因此,理解 GenAI 需要对嵌入形成良好的直觉,因为它们在多个方面都非常有用,从分析 prompt,到决定如何存储文档,都是如此。可以把嵌入看作 LLM 使用的一种特殊数据格式,用于以机器能够理解的方式表示单词和句子的含义。它们是让 LLM 能够在更深层次上理解语言的关键组成部分。
嵌入并不只在 LLM 内部使用;它们也可以帮助分析文档、改进 prompt,并让搜索和检索更加准确。我们可以通过 API、开源工具,以及专门用来存储和搜索嵌入的向量数据库来访问它们。正如前文所说,对于任何从事 GenAI 项目的人来说,充分理解嵌入都是必不可少的。
在本章中,我们将学习什么是嵌入,以及它们如何捕捉单词和句子的含义。我们将探索如何使用余弦相似度以编程方式计算语义相似度,以及如何为你的用例选择合适的嵌入模型。我们还会介绍如何在代码中测试嵌入,理解什么是向量数据库以及它们如何工作,并在摄取文档时做出有效的切分决策。
本章将为你提供启动 GenAI 项目所需的嵌入相关知识。由于 LLM 使用向量嵌入进行“表达”,理解它们的工作方式,对于构建 prompt,以及选择最有助于 LLM 回应你的 prompt 的数据来说,都是至关重要的。
本章将涵盖以下主题:
- 什么是嵌入?
- 以编程方式计算语义相似度
- 选择嵌入模型
- 在代码中测试嵌入
- 什么是向量数据库?
- 切分文档
什么是嵌入?
LLM 非常了不起,它们看起来能够理解我们输入到文本框中的文字,并返回往往正是我们想要的回应。这是如何可能的?语言经过数千年才发展起来,而且所有语言都充满了歧义和习惯表达。比如,我们并不只有一个词来表示某个对象、动作或概念;我们有多个词,每个词都有自己细微的意义色彩,而这些意义又取决于它如何被使用。我们不只是“说话”;我们还会“嘟囔”“咕哝”“商议”。有时候,一个词会有多个完全无关的含义。考虑这两个句子:“I want to go to the park”和“I will park my car”——两句话都使用了 “park” 这个词,但在每个句子中,它承载的含义完全不同。再比如反讽,我们经常会说出与真实意思完全相反的话。尽管存在这些看似难以逾越的障碍,LLM 仍然能够与我们很好地交流,而它们在很大程度上正是通过使用嵌入来实现这一惊人成就。
嵌入不仅包含词本身的表示,也包含词与词之间关系的信息。让我们看看嵌入的创建方式与人类学习语言的方式之间的相似之处。
人类在年幼时学习语言,那时我们的大脑对语言习得高度敏感。随着成长,我们接触到大量句子,并通过这种接触逐渐学习和内化语言的细微差别。这个过程会持续我们的一生。类似地,LLM 通过在来自大型数据集合的数百万、甚至数十亿个句子上训练,学习语言的细微差别。然而,如果接触大规模语料在某种程度上解释了为什么 LLM 能够共享我们对语言细微差别的理解,我们仍然有一些未解的问题:语义含义如何才能在计算机系统中被有效表示?以及,从观察数十亿个句子中学到的知识,如何被压缩到少数几百万个能够装进一块笔记本硬盘的参数里?
正如你可能已经猜到的,这些问题的答案就在向量嵌入这个主题之中。嵌入可以被看作一种存储单词和句子含义的方法。嵌入空间包含一种度量,用于衡量单词之间彼此有多相似。可以把这种距离度量理解为一种归一化的欧氏距离:被认为相似的词,会比不相似的词彼此放得更近。
向量是什么样子的?
嵌入由一个单词或句子生成,并实现为非常大的向量。向量中的每个分量,也就是表 2.1 中“亲和度”列所表示的内容,都会把目标词与源词表中的另一个单词或句子关联起来。每个分量的值都是一个浮点数,范围从接近 0 到接近 1:接近 0 表示最大亲和度,接近 1 表示最小亲和度,用来衡量该向量所表示的词与该分量所关联的词表词之间的关系强度。
数值 0.0001 表示极强的亲和度。像 0.9999 这样非常大的数,则意味着这些词几乎没有关系。表 2.1 展示了单词 “park” 的嵌入向量的一部分,并使用字典形式展示,这样我们就能看到每个亲和度数值对应的是哪个词:
| Word | Affinity |
|---|---|
| Car | 0.6545 |
| Bike | 0.59934 |
| Play | 0.33333 |
| Picnic | 0.2313 |
| Menu | 0.99990 |
| Cup | 0.099999 |
| Dog | 0.460000 |
表 2.1——单词 “park” 的词亲和度
表 2.1 的左列展示的是正在检查其与 “park” 一词亲和度的单词。右列展示的是每种关联的强度。我们可以看到,“Car”和“Bike”具有中等亲和度,这很可能来自有关停放自行车或汽车的句子。我们也看到,“Picnic” 与 “Park” 具有较高亲和度,这与 park 作为一个我们散步、玩耍和野餐的地方有关。借助这张表,我们可以看到 “park” 至少有两种含义;换句话说,它具有多个语义含义。
什么是亲和度?
向量嵌入的数值,是通过遍历包含某个词的数十万、数百万,甚至数十亿个句子创建出来的。在这个过程中,系统会记录出现在该词附近的其他词,同时总是排除 “the”“to”“a” 这类常见功能词。
为什么我们关心词与词共同出现的频率?从直觉上说,当两个词频繁共同出现时,例如 “park” 和 “picnic”,这种反复配对会塑造并细化其中一个或两个词的含义。例如,在无数语境中看到 “park” 和 “picnic” 一起出现,会帮助建立它们之间的语义关联,强化 “park” 的某一种具体含义:一个我们经常野餐的地方。这种效果依赖频率;也就是说,一个词对必须共同出现数十万次,才会留下可衡量的影响。如果这种共同出现非常罕见,那么这种连接就太弱,无法建立被广泛认可的共享含义。因此,词语共同出现的频率,是精细化语义含义的一个良好指标。
举个例子,让我们拆解嵌入如何被用来回答 “how can I go to the park?” 这个问题。我们先考虑 “go” 这个词,它表示运动,因此应该在语义上类似于其他运动词,例如 “ride”“drive”“travel”“move”“run”等。嵌入会自然地根据相似含义,把单词和句子聚集在一起。图 2.1 展示了有关去某个地方的句子如何被聚集到一个组中,而有关吃饭或工作的句子则落入其他组中。
图 2.1——语义岛:含义相似的句子会在嵌入空间中聚集在一起
嵌入的关键属性是:当文本被转换为嵌入表示时,它会落入一组具有相似语义含义的句子之中。由于 “drive a car or ride a bike” 这个可接受的答案包含运动词 “drive” 和 “ride”,它也会落入同一个组。因此,我们至少知道,如果答案存在,它就在这个组里。然而,这个组里也包含一些无用句子,例如 “fly to Mars” 或 “paddle my boat”,这些句子需要被排除。
我们如何进一步细化,只找出那些能很好回答问题的表述?嵌入向量告诉我们一个词与整个词表的相似度,因此还有更多词需要检查。为了排除 “fly to Mars” 和 “paddle my boat”,我们可能会注意到 “park” 与 “boat”“paddle” 或 “Mars” 的亲和度不强,但与 “bike” 和 “car” 亲和度很高。“park” 这个词也属于另一个亲和度组,见图 2.2,该组将包含问题 “how can I go to the park?”。一个嵌入可能具有成千上万个这样的亲和度,每个组都会揭示句子之间的相似之处。
图 2.2——亲和度组:单词和句子会根据共享的语义上下文聚集在一起
在考虑过同时与 “go” 和 “park” 具有强亲和度的词之后,我们可以进一步考虑一个更小的词和句子集合,它们同时与这两个词都具有强亲和度,也就是说,位于两个组交集中的句子:
图 2.3——嵌入组交集:“Traveling”和“Park”两个组之间的重叠区域缩小了答案范围
希望到这里你已经能够看清:我们可以遍历词表,检查亲和度表中的每个条目,以评估关联强度。通过过滤掉低亲和度的词和句子,我们可以得到类似 “you can drive your car or ride your bike to the park” 这样的句子,它能够正确回答这个问题。
在本节中,我们涵盖了几个概念,读起来可能像是从消防水管里喝水一样信息量很大。但你现在应该已经对嵌入形成了良好的直觉,这会在你构建 GenAI 应用时很好地帮助你。
让我们回顾一下到目前为止学到的内容:
- 我们看到,词是能够表示不同含义的符号。我们使用 “park” 这个词作为例子,它既可以指我们喜欢野餐的地方,也可以指停车这个动作。
- 我们讨论了一个词如何通过与另一个词频繁关联,获得细微的意义色彩。
- 我们讨论了 LLM 如何在数十亿个句子和单词上训练,以及它们如何在和我们相同的语境中遇到词;因此,它们会学习到我们所学习到的同样的意义色彩。
- 我们演示了如何使用嵌入向量捕捉句子和词的相似性,其中每个维度都反映了某个词表词出现在目标词附近的可能性。
- 我们展示了嵌入向量的接近性意味着共享语义含义,并利用这一事实,通过迭代地移除低亲和度句子,直到只剩下一小组高度相关的句子,来找到我们问题的答案。
虽然上述讨论对于培养直觉非常有帮助,但很显然,在真实应用中,我们并不想手动检查向量嵌入。那会花费太长时间。我们需要一种更高效的方法。下一节将讨论相似度函数,它们能够非常高效地把一个词与整个词表进行比较。
以编程方式计算语义相似度
在上一节的例子中,我们查看了问题 “How can I go to the park?” 中的两个词,并检查了它们与其他词和短语的亲和度。手动检查亲和度表并不高效。为了让前面描述的过程在实践中有用,我们需要一种高度高效的相似度函数,能够以编程方式比较两个词的嵌入向量。
有几种这样的函数可供选择,但余弦相似度函数可能是使用最广泛的一种。由于词嵌入是一个向量,而所有向量都在空间中定义了一个唯一点,所以我们可以从原点画一条线到该向量在空间中的唯一点,见图 2.4。每一对这样的线都会定义一个角度,两个点之间的角度代表“角距离”。线的长度也是决定距离的另一个因素。一些版本的余弦函数会把向量归一化到相同长度,但即使没有归一化,两个向量之间夹角的余弦值也可以很好地作为相似度函数。
图 2.4——语义相似词之间的夹角:夹角越小,相似度越大
图 2.4 中的图只是二维的,而真实向量会有更多维度。同样的原理也适用于更高维空间。如果你取单词 “Car”,并寻找那些与 “Car” 相比夹角很小的嵌入,你将重新创建一个包含其他交通工具形式的组,例如 “bike”。类似地,取靠近 “dog” 的词,会浮现出其他种类的宠物。余弦函数非常高效:即使有数千个维度,它也能快速计算每个维度上的角度。
像余弦这样的相似度函数,通常会被封装为 API,并在安装嵌入模型后提供。嵌入模型是用于创建向量嵌入的 AI 应用。在下一节中,我们将看看如何为你的应用选择合适的嵌入模型。
选择嵌入模型
幸运的是,软件工程师并不需要从头创建嵌入。现在有大量嵌入模型可供下载,每个模型都提供 API,用于把文本转换成嵌入。不同模型可能具有不同特性,并且可能是在不同数据集上训练出来的。
在启动 GenAI 项目之前,你应该为自己的用例选择最优的嵌入模型。一个很好的起点是 Hugging Face 嵌入模型排行榜:
https://huggingface.co/spaces/mteb/leaderboard
嵌入模型支持 API,可以从 tweet、prompt 和文档等文本创建嵌入向量;这些文本可以是完整文本,也可以是切分后的片段。
在为你的用例决定理想嵌入模型时,有几个因素需要考虑:
最大 token 数: 模型会转换为嵌入的最大文本大小。在我们的示例中,我们从相对较短的句子创建嵌入,但实际情况并不总是如此。正如切分章节中所讨论的,我们可以从文档的不同部分,甚至从整篇文档、单个章节或单个句子创建嵌入。如果你想存储的 chunk 包含许多 token,而一个 token 大约是 3 到 4 个字符,那么它可能会超过模型 API 的限制。在这种情况下,应寻找具有更大 token 限制的模型,或者考虑把文档切分为更小的片段。第 3 章将进一步讨论这一点。
内存需求: 嵌入模型是通过在大量文本上训练而构建出来的,可能有数百万或数十亿个需要加载到内存中的参数。在项目早期就应该考虑配备足够内存的机器所带来的成本;这种成本不仅适用于生产环境,也适用于测试和开发环境。
维度: 维度是向量的大小。更高的维度可以让嵌入向量捕捉更多细微差别和语义层次。
成本: 模型可以下载到本地机器上运行,也可以在云平台上运行,或者通过 ChatGPT 等 LLM 提供商提供的服务来调用。在选择模型时,基础设施成本和 API 使用成本都是重要考虑因素。
训练文档: 一些嵌入模型是在医疗等特定领域的文档上训练的,而另一些则是在通用互联网数据上训练的。
零样本: 零样本嵌入可以在未接受示例训练的情况下,识别一个新词、句子或图像的语义相似度。这非常适合那些 LLM 必须理解独特数据或特定领域数据的用例。
语言: 一些嵌入模型支持多种语言,而另一些可能只支持英语。
兼容性: 用来为查询句子创建嵌入的模型,应该与摄取文档时所使用的模型一致。请检查你的向量数据库文档,确认它对兼容嵌入模型是否有任何限制。
模型大小: 与内存需求一样,选择具有数十亿参数的模型可能会导致显著的硬件成本。
在探索了嵌入理论之后,让我们继续看看如何在代码中使用嵌入。
在代码中测试嵌入
到这里,我们已经覆盖了大部分理论背景,现在将转向使用编程语言寻找嵌入相似性的实践方面,而不是手动检查向量。
嵌入模型可以被集成到你的代码中,用来比较字符串,例如一个 prompt 和一段文本,从而估计该文本在 LLM 生成回应时对它有多大可能是有用的。这种技术对设计和测试 prompt 的开发者很有用,可以帮助他们微调 prompt 并提高回应精度。它也是建立嵌入工作方式直觉的好方法。
在下面的代码片段中,我们展示了一个嵌入模型代码示例,用于比较三个字符串:
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
# Step 1: Define your sentences
sentences = [
"how do I go to the park",
"you can drive to the park",
"I love seafood"
]
# Step 2: Load a pre-trained sentence embedding model
model = SentenceTransformer('all-MiniLM-L6-v2') # small, fast, good quality
# Step 3: Get vector embeddings for each sentence
embeddings = model.encode(sentences)
# Step 4: Compute cosine similarity matrix
similarity_matrix = cosine_similarity(embeddings)
# Step 5: Print the similarity matrix
print("Cosine Similarity Matrix:")
print(similarity_matrix)
# Optional: Interpret pairwise similarities
pairs = [(0, 1), (0, 2), (1, 2)]
for i, j in pairs:
print(f"Similarity between '{sentences[i]}' and '{sentences[j]}': "
f"{similarity_matrix[i][j]:.4f}")
当然,在生产环境中,这段代码并不足够。我们需要非常高效地在可能数千份文档中执行语义搜索,这就需要一个可扩展性和健壮性都强得多的系统。在下一节中,我们将看看一种不同类型的数据库,称为向量数据库。它和关系型数据库、NoSQL 数据库一样,都针对快速查询大量数据进行了优化;但与它们不同的是,向量数据库会基于语义相似度返回结果。
什么是向量数据库?
向量数据库之于非结构化数据,就像 RDBMS 或 NoSQL 数据库之于结构化和半结构化数据。向量数据库非常有用,尤其是在你向 LLM 发送 prompt,并希望获取一些文本片段与 prompt 一起提供给 LLM,让它在生成回应时加以考虑的时候。当你拥有 LLM 并不熟悉的私有数据或可信数据,或者你希望通过明确指定 LLM 应该使用哪些数据来更好地控制其回应时,这一点尤其有用。
在上一节的代码示例中,我们使用一个嵌入数组,返回与一串词在语义上相似的句子。向量数据库做的是同样的事情,只不过它具备一个完整数据库系统应有的各种便利内置功能和效率。
图 2.5 展示了使用向量数据库时你需要熟悉的两个过程。第一个过程把文档摄取到向量数据库中,第二个过程处理实时语义查询。
图 2.5——向量数据库:数据迁移(A)和实时查询(B)流程
在第一个过程中,也就是数据迁移步骤,数据会从其当前存储位置加载出来,这个位置通常可能是 RDBMS、文件服务器、Web 服务或其他存储介质,然后被摄取进向量数据库。被摄取的文档可能采用不同格式,例如 JSON、PDF、CSV 或 TXT。在摄取过程中,数据可能需要被“切分”为多个小文本片段。
一旦数据被摄取进向量数据库,就可以在处理用户请求时,也就是图 2.5 中的实时查询步骤中,被取出并发送给 LLM,用于构建对 prompt 的回应。
向量数据库高度依赖嵌入。嵌入是 GenAI 架构的默认语言,也是贯穿 prompt、文档、向量数据库和 LLM 的共同线索,如图 2.6 所示:
图 2.6——向量嵌入作为贯穿 prompt、向量数据库和 LLM 的共同线索
Prompt 会被转换为向量嵌入,使我们能够查询向量数据库,找到与 prompt 在语义上相似的数据。来自向量数据库的数据随后会与 prompt 结合,并发送给 LLM。LLM 可以使用这些向量触达其知识,而这些知识也以嵌入形式存储,并提取出更多有助于构建回应的信息。我们可以把向量嵌入看作同时存在于三个地方:prompt、向量数据库和 LLM。
到目前为止,我们一直在谈论文本 chunk,但还没有正式定义它们是什么,以及它们如何、何时被创建。下一节我们将完成这件事。
切分文档
在把文档摄取到向量数据库中时,通常建议将它们切分为小段。例如,当文档是 PDF 或 Word 文件时,chunk 可以对应章节、子章节、句子,等等。切分会提高效率。通常你并不希望从向量数据库中检索整篇文档。即使某篇文档处于 LLM 的最大 token 限制之内,你也需要考虑成本,以及加载大型文档相关的问题。这类问题可能会在管理用于缓存对话历史的内存时造成麻烦。切分过程也是你运用数据架构思维,并使用自己对领域和文档语料库的理解,来提高 LLM 回应精度和质量的机会。
图 2.7——将包含 A、B、C 三个部分的文档以重叠方式切分进向量数据库
如图 2.7 所示,一个包含 A、B、C 三个部分的文档被切分进向量数据库。请注意 chunk 之间的重叠;大多数向量数据库支持配置相邻 chunk 之间的重叠百分比。文档如何被切分,会对向量搜索结果产生很大影响。下面我们将详细讨论重叠以及其他切分决策。
切分决策
数据摄取期间做出的决策,对项目成功至关重要。最常见的配置参数是 chunk 大小、切分策略和 chunk 重叠。每个参数都会对返回回应的质量产生重要影响。我们将介绍一些做出这些决策的经验法则,但像大多数流程一样,优化它们的最佳方式是反复调整和测试。第 3 章将讨论测试。
在配置数据库并摄取数据时,有两个重要因素需要考虑:
Chunk 大小和重叠: 每个查询都可以返回多个文本 chunk,这些 chunk 是因为与 prompt 内容在语义上相似而被选中的。它们通常会被嵌入到 prompt 中,并且可以通过在 prompt 中指定其用途,把 LLM 的注意力引向它们。对于某个给定回应而言,最优的 chunk 大小和重叠,是能够返回 LLM 生成最优回应所需最少数据量的组合。关于 chunk 大小如何影响准确性,可以通过考察一个例子来建立直觉。下一节我们将这样做。
切分策略: 有多个用于切分文档的库,每个库可能支持多种文档格式,以及不同的切分执行策略。
切分策略
切分决策关注的是如何把一份文档划分为更小的片段。决策会因文档类型不同而有所变化,但在本章中,我们将考察 PDF 文档的切分,因为 PDF 是你会遇到的最常见、也最复杂的文档类型之一。一旦你理解了切分 PDF 时的权衡,就不会太难切分其他类型的文档。
让我们从一个熟悉的例子开始。假设我们想把一本关于源代码控制系统 Git 的书加入向量数据库。Git 有很多不常用命令;不是所有人都会经常使用 reflog、range-diff、rev-parse 或 --show-toplevel 这样的命令。在这种情况下,用自然语言查询数据库会很有用,例如:“record when the tips of branches and other references were updated”。假设这本书具有图 2.8 所示的章节结构。
图 2.8——Git 书籍:用于说明切分策略的章节结构
根据章节名称,再结合我们对 Git 的了解,大多数读者会推测,每一章的内容与其他章节的内容只有轻微关系。这会让我们倾向于按章节切分,并设置很小的重叠,甚至完全不重叠,如图 2.9 所示。
图 2.9——Git 书籍按章节切分且无重叠
但请仔细思考这一点。假设有人问:“how do I create a branch in git?” 关于创建新分支的相关信息可能位于 Chunk One 中,而 Chunk Two 也会包含有用但关联性不那么直接的内容。例如,关于分支策略的建议,比如何时创建 feature branch,可能位于 Chunk Two 中,而这些内容对正在创建新分支的人同样有价值。
在我们的例子中,“how do I create a branch in git?” 的嵌入向量可能不会匹配 Chunk Two 中的任何文本。这是因为我们的切分策略把所有与查询在语义上相似的数据都隔离在了 Chunk One 中,而 Chunk One 又太小,无法包含所有有用上下文。
我们可以通过让 Chunk One 和 Chunk Two 之间产生 50% 的重叠来修复这个问题:
图 2.10——50% 重叠切分:chunk 之间保持上下文连续性
在这种情况下,查询 “How do I create a branch in git?” 很有可能会同时返回关于分支策略的信息,以及关于创建分支命令的信息。重叠区域很大,两个 chunk 现在共享 50% 的嵌入;Chunk One 下半部分的嵌入,与 Chunk Two 上半部分的嵌入相同。两个 chunk 之间保持了上下文连续性,使嵌入匹配能够在两者上都发挥作用。
这种策略的缺点是,在其他查询中返回冗余信息的概率更高。冗余信息会增加 LLM 调用成本,并给会话记忆增加杂乱内容,使会话管理更加困难。
在第 3 章中,当我们逐步讲解构建 LLM 应用所应遵循的流程时,会重新讨论切分问题。
总结
在本章中,我们学习了嵌入以及它们对 GenAI 的重要性。我们看到,嵌入可以被视为贯穿 prompt、文档、LLM 和向量数据库的共同线索。我们展示了嵌入是如何以一种类似人类学习并意识到语言细微差别的方式被推导出来的;具体来说,单词会通过与其他词频繁共同出现来获得精细化含义。我们介绍了余弦相似度,作为一种高效的编程工具,用于比较嵌入向量。我们讨论了嵌入模型,以及选择嵌入模型时需要考虑的关键因素。最后,我们考察了向量数据库,并讨论了配置参数中的权衡,包括 chunk 大小和 chunk 重叠。
下一章将以更实践的方式,围绕构建和管理 GenAI 项目,更详细地介绍 GenAI 参数配置。主题将包括构建和测试 LLM pipeline、RAG 架构、prompt engineering 策略,以及用于评估和优化 GenAI 系统性能的框架。