嵌入向量

首先解释这个词。嵌入向量是一个数字列表,代表一段文本并捕捉其含义,使得含义相似的文本转化为相似的数字。本章介绍如何将单词转换为计算机可以比较的东西,这样搜索就可以匹配含义而不是关键词。
文本作为向量
计算机如何知道"狗"和"小狗"是相关的,而"狗"和"订书机"不相关呢?这些词共享不了什么重要的字母。
技巧是将文本转换为捕捉其含义的数字,使得相似的含义最终成为相似的数字。这个数字列表被称为**嵌入向量**,也称为向量。模型读取文本并输出该向量。
你不需要自己解释这些数字。重要的是它们之间的关系。
粗略的心理图景:想象每一段文本都放在空间中的一个点上,含义相似的东西靠近彼此。"狗"和"小狗"在同一个社区里。"订书机"在城镇的另一边。真正的嵌入向量使用数百或数千个维度,而不是你能想象的两三个,但直觉仍然成立:接近意味着相似。
这些数字从何而来,为什么它们与含义相符?嵌入向量模型是来自大语言模型如何工作的预测模型的近亲,在大量文本上进行了训练。为了在其任务中表现良好,它必须学会哪些词和观念出现在相似的语境中,这是一个古老的观察:你通过它周围的词来了解一个词。
"狗"和"小狗"出现在相同类型的句子中,所以模型学会了类似地代表它们。"订书机"与不同的词在一起,所以它坐在其他地方。这数百个数字是学到的含义方向,每一个都捕捉了文本变化的某些轴,没有一个标记为人类会读的方式。你永远不会解码这些数字,你只测量两个向量有多接近。
你想要的搜索能够匹配含义而不是拼写,所以"要回我的钱"能找到关于退款的一行。做法是将每段文本转换为**向量**,一个固定长度的数字列表,其中相似的含义靠近在一起。嵌入向量模型读取文本并发出该向量,整个要点是向量之间的距离代表含义的相似性。
该向量有一个设定的长度,其维度,通常是几百到几千个数字。每个用同一个模型嵌入的文本都产生相同的长度,这就是让你比较任意两个的原因。这些维度是学到的含义方向,而不是人标记的类别,所以你不会将维度47读作"正式性"。你将向量视为一个不透明的点并测量距离。
在你嵌入任何真实数据之前,有一个步骤决定了整个事情是否有效:分块。你很少将整个文档嵌入为一个向量,因为一个向量对于一份20页的手册会将其中的每个话题都模糊到一个与任何东西都不匹配的单一平均点中。相反,你将文档分割成**块**,大约一段到一页的文章,并独立嵌入每个块。嵌入块而不是整个文档,这样每个向量都包含一个集中的观念。
分块大小是一个真实的权衡,你有意设置。太大的块会将几个观念平均在一起,检索松散。太小的块会失去使句子有意义的周围语境。一个常见的起点是每个块几百个标记,块之间有小的重叠,这样在边界处分割的句子在其中之一中仍然完整,然后你根据检索实际返回的内容进行调整。
匹配含义的搜索建立在一个想法上:将文本映射到**向量空间**(一个固定维数坐标空间,其中每个点都是一个数字列表),使得语义相似性成为几何接近性。嵌入向量模型是映射:它读取文本并返回一个向量。
你在它之上构建的一切,索引、检索、排名,都是这些向量之上的几何。弄错映射,下游的任何东西都无法恢复。
第一个设计杠杆是你嵌入什么,那就是分块:在嵌入之前将源文档分割成文章,而不是嵌入整个文件。原因是嵌入向量是其输入到一个点的有损压缩,所以一个块包含的不同观念越多,它们越会平均成一个模糊的质心,无法锐利地检索任何东西。将块调整到用户会提问的单位,通常是几百个标记的文章,有小的重叠,这样跨越边界的事实在至少一个块中存活。
分块策略是检索质量赢或输的地方,比模型选择更多:
- 固定大小窗口便宜,但会在中间截断句子。
- 结构感知分割(在标题、段落或代码块上)保持每个块连贯,倾向于更好的检索,代价是更复杂的管道。
无论你选择什么,都要在向量旁边存储块的源和位置,因为在检索时你需要指回一个事实的来源,而一个裸向量无法告诉你。
还有一个属性要内化:嵌入向量维度不可解释的轴,所以你不能通过读向量来调试检索。你通过检查它们检索什么来调试嵌入向量,而不是通过读数字。当一个查询拉出错误的块时,修复在分块、模型或查询中,通过检索结果进行审查,而不是通过坐标本身。
生成嵌入向量
你要求嵌入向量模型给你向量,很像正常的模型调用,但有不同的端点和不同的模型。
from openai import OpenAI
client = OpenAI()
EMBED_MODEL = "text-embedding-3-small" # 为嵌入向量模型交换端点
def embed(text):
response = client.embeddings.create(model=EMBED_MODEL, input=text)
return response.data[0].embedding # 一个数字列表
vector = embed("一只小的顽皮的狗")
print(len(vector)) # 例如1536个数字结果是一个数字列表。一个规则比其他的都重要:总是比较由**同一个模型**做出的嵌入向量。来自不同模型的向量存在于不同的空间中,不可比较,就像用不同单位的测量。混合模型给出荒唐的分数,每一次。
和每个模型调用一样,你按标记计费,所以嵌入一个大的文档集合有一个值得在你跨越数千个文档之前估算的成本。
调用本身是一个不同的端点,形状与聊天调用相同:你发送文本,你得到一个向量。
from openai import OpenAI
client = OpenAI()
EMBED_MODEL = "text-embedding-3-small"
def embed_batch(texts):
# 在一个请求中发送许多文本,每个文本返回一个向量
response = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [item.embedding for item in response.data]
chunks = ["一只小的顽皮的狗", "一只年轻的狗", "一个办公用品"]
vectors = embed_batch(chunks) # 每个块一个向量,相同的输入顺序形状因提供者而异,但请求和响应的观念在任何地方都是相同的。基本调用之外的两个实际要点。首先,**批量**嵌入:大多数端点在单个请求中接受文本列表并返回每个一个向量,这比每个块一个调用快得多便宜,因为你支付较少的请求开销并较少地受到速率限制。当你索引一个大的语料库时,批处理是保持成本和实际时间理智的东西。
第二,相同模型规则有一个伙伴:模型和维度是一个选择,而不是默认值。更大的嵌入向量模型有更多维度捕捉更精细的区分但计算、存储和比较成本更高。选择最小的模型和维度,仍然能分离你的数据。
一些模型甚至让你请求一个更短的向量来权衡一点准确性以获得更便宜的存储和更快的搜索。无论你选择什么都成为一个契约:稍后改变模型,每个存储的向量都必须重新生成。
API表面很小。你发送文本,你得到一个浮点向量,有趣的决定坐在该调用周围而不是在其中。
from openai import OpenAI
client = OpenAI()
EMBED_MODEL = "text-embedding-3-small"
def embed_batch(texts):
# 一个请求,许多向量返回,以输入顺序
response = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [item.embedding for item in response.data]请求和响应形状因提供者而异,但每个嵌入向量端点都是这个相同的批量输入、向量输出契约。批处理在规模上不是一个好处,它是索引工作完成和整个下午节流之间的区别:每个请求都有固定开销并计入速率限制,所以通过发送大批量摊销两个,然后在大工作会遇到的部分故障上后退和重试。
模型和维度是一个长期的架构承诺。更高维的向量存储更多的区分但在生产中重要的每个轴上成本更高:每向量存储,索引中的内存和每次比较时间,都随维度缩放。一些模型支持**俄罗斯套娃嵌入向量**(你可以截断到更短长度并仍然使用的向量),让你保持完整向量以获得准确性并为便宜的首轮搜索服务一个截断的前缀。当你的语料库足够大,以至于存储和查询延迟,而不是模型质量,是受限因素时,值得一试。
稍后会咬住你的承诺是版本控制。一个存储的嵌入向量永远绑定到一个确切的模型版本。
来自两个模型版本的向量是不可比较的,所以你不能用一个更新的模型嵌入一个新查询并将其与在旧版本上构建的索引进行比较。结果是沉默的:分数看起来合理,排名是错误的。将模型版本视为你的索引的模式的一部分,将其与向量一起存储,并计划重新嵌入作为迁移,在本章后面的新鲜度讨论中涵盖。
测量相似性
要找出两段文本的相关程度,你测量它们的向量有多接近。标准的度量是**余弦相似性**,它返回一个从-1到1的数字:更接近1意味着更相似,接近0意味着不相关。你不需要背后的数学来使用它。将其视为比较两个向量指向的方向。
方向是在这里携带含义的东西,这就是为什么余弦比较角度而不是原始距离。两个关于退款的文本指向相同的方向,无论一个是短短语还是长段落。
import math
def cosine_similarity(a, b):
dot = sum(x * y for x, y in zip(a, b))
mag_a = math.sqrt(sum(x * x for x in a))
mag_b = math.sqrt(sum(x * x for x in b))
return dot / (mag_a * mag_b)
dog = embed("一只小的顽皮的狗")
puppy = embed("一只年轻的狗")
stapler = embed("一个装订纸张的办公用品")
print(cosine_similarity(dog, puppy)) # 更高,两个在含义上接近
print(cosine_similarity(dog, stapler)) # 更低,两个是不相关的这些数字证实了直觉:狗和小狗分数更高,狗和订书机分数更低。将查询的向量与存储的向量进行比较是语义搜索的核心。这一个功能是本章的其余部分所基于的举动。
你有向量,现在你需要一个数字说两个有多接近。标准是**余弦相似性**:它测量两个向量之间的角度,忽略它们的长度,并返回一个从-1到1的值,其中更高意味着更相似。角度,而不是距离,是你想要的,因为一个长段落和一个短短语在同一话题上应该评分为相似,即使它们的向量有不同的幅度。
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
# 如果向量被归一化为长度1,余弦相似性等于它们的点积
score = dot(query_vector, doc_vector) # 不需要除法在规模上回报的举动是:当你存储向量时**归一化**它们为长度1。一旦每个向量有相同的长度,余弦相似性中的除法消失,分数变成一个普通的点积,每个维度一个乘法和加法,跨大的集合运行快得多。在存储时归一化一次,然后与点积比较。许多嵌入向量模型返回已归一化的向量,但不要假设,要检查。
从玩具例子的另一个转变:你很少想要单一的最接近的匹配。你想要**前k**检索,前k个得分最高的结果,通常三到十。一个向量可能是近似未命中,而第二个和第三个一起拥有答案,返回一个小集合给下一个阶段留下工作空间。只返回前一个会扔掉你会希望自己保留的语境。
你需要一个相似性指标,余弦相似性(两个向量之间的角度的余弦,范围-1到1)是默认的,因为它忽略幅度,所以文档长度不会淹没话题。该选择有一个值得围绕设计的性能后果:原始向量之上的余弦成本每次比较一个点积加两个幅度计算,你对索引中的每个向量在每个查询上支付。
import numpy as np
# 在写时归一化,每个向量一次
def normalize(v):
v = np.asarray(v, dtype=np.float32)
return v / np.linalg.norm(v)
# 在查询时,归一化向量之上的余弦是单个矩阵向量乘积
def search(query_vec, matrix):
return matrix @ query_vec # 每行一个分数,向量化优化是在写时**归一化**每个向量为单位长度,使余弦相似性与点积相同,让你将整个语料库评分为单个矩阵向量乘法,而不是一个Python循环。在写时归一化,相似性是一个跨整个索引的点积一次。这是蛮力基线:精确、直接,线性于语料库大小,对于大约低数十万向量很好,上面的东西翻倒了,提示移动到一个近似索引在本章后面涵盖。
两个生产现实玩具指标完全隐藏。首先,你几乎永远不想要argmax,你想要前k,带一个分数阈值,因为第一个和第二个结果之间的差距告诉你匹配有多自信,一个仍然分数低的顶部结果在你的语料库中意味着没有真正回答查询。将其作为"没有好的匹配"返回,而不是交回最坏的行。第二,余弦相似性是相对的,而不是绝对的:0.4的分数仅对你的语料库和模型产生的分数分布有意义,所以根据每个索引的经验校准阈值,而不是从博客文章复制一个幻数。
语义搜索
将这些部分放在一起,你可以按含义搜索而不是按关键词。嵌入你的文档一次并存储向量。当查询进来时,也嵌入它,然后返回向量最接近的文档。
docs = [
"从帐户设置页面重置你的密码。",
"我们的退款政策允许30天内退货。",
"有账单问题请通过电子邮件联系支持。",
]
# 提前嵌入每个文档一次
index = [{"text": text, "vector": embed(text)} for text in docs]
def search(query):
query_vector = embed(query)
scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
return sorted(scored, key=lambda x: x["score"], reverse=True)[0] # 最接近的匹配
print(search("我如何要回我的钱?"))
# 返回退款政策行,即使它与查询没有共享重要词语查询"我如何要回我的钱?"找到退款行,尽管与它没有共享重要词语。语义搜索按含义匹配,而不是按拼写。关键字搜索会完全错过它,因为查询和文档没有重要的词语共同。
形状总是相同的:嵌入一次并保留向量,在请求时嵌入查询,评分、排序、返回最接近的。那是**语义搜索**循环,你将在本手册的其余部分重复使用它。
玩具版本在Python列表中存储向量并在每个查询中评分每一个。那对演示有效,并在你的语料库增长时断裂,因为线性扫描在每个查询上重新运行对每个向量的完整比较,并且你也在每次重启时在内存中重建索引。
修复是一个**向量数据库**:一个为保存向量和快速回答"找到最近的k到这个查询向量"而构建的存储。代替你自己的列表和循环,你一次写入向量,数据库处理搜索、持久性和更新单个条目。
# 形状因向量DB而异,但模式在任何地方都是相同的
index.upsert([
{"id": "doc-1", "vector": embed(chunk), "metadata": {"source": "faq.md", "section": "退款"}}
for chunk in chunks
])
results = index.query(vector=embed(user_question), top_k=5) # 最近5个,不只是1个数据库给你的两件东西列表没有:
- 它返回前k结果在一个调用中,最近的几个而不是单一的最佳,这是你实际上给下一个阶段的东西。
- 它在每个向量旁边存储元数据,源文件、部分和时间戳,所以结果可以指向它来自的地方,你可以按它过滤。
在你有真实数据时存储向量在向量DB中,而不是Python列表。列表是用来学习机制,不是用来运行它。
参考循环,嵌入一次、存储、嵌入查询、评分、排序、返回,是正确的,不能与规模接触存活。蛮力扫描是线性于语料库大小每个查询,所以在一百万向量和任何真实查询速率,你在一个你没有的CPU秒上花费每次搜索。生产搜索中的一切都是关于不与每个向量比较查询。
那是**近似最近邻**搜索为你做的(ANN:通过接受一个小的机会错过真实最近邻来快速找到最接近向量的算法)。两个家族占主导地位:
- HNSW(分层可导航小世界)构建向量的分层图并从粗到精走过它,以高内存为代价给出低查询延迟。
- IVF(倒排文件)聚集向量并仅搜索最近的聚集,使用更少的内存但当数据转变时需要其聚集中心重新训练。
超过几十万向量,ANN不是可选的,它是索引。你调整的权衡是回忆(索引实际返回的真实最近邻的分数)对延迟和内存:推回忆朝向1.0和查询减速,放松它,你无声地错过相关块。
玩具循环完全隐藏的失败模式是新鲜度。索引是一个快照,所以当源文档改变时,其存储向量现在陈旧,并继续作为当前被检索。你需要一个由文档改变键入的重新嵌入管道:检测编辑、重新分块并重新嵌入该文档,并提高新向量同时删除旧的,最好由内容哈希跟踪,所以你仅重新嵌入实际改变的东西,而不是在每次编辑时的整个语料库。陈旧向量是检索系统开始撒谎的安静方式。
最后的差距是精确匹配。向量搜索内置以错过它:一个订单A-10293或一个错误代码的查询嵌入到一个模糊的区域,可能不表面一个包含那个字符串的文档。混合搜索一起运行关键字搜索(哪个钉精确标记)和向量搜索(哪个钉含义),并合并排名,所以标识符、名称和稀有术语不丢失到语义模糊。当你的语料库包含用户会精确键入的东西时伸向它。
嵌入向量不适合什么
嵌入向量是一个相似性工具,而不是思考工具。一些**限制**要记住,以便你为正确的工具伸出手:
- 它们不做精确匹配。要找到精确的订单号或错误代码,普通关键字或数据库搜索更好。
- 它们不推理。它们告诉你两件事是相关的,而不是为什么,也不是一个是否逻辑地跟随另一个。
- 它们变得陈旧。如果你的文档改变,你必须重新嵌入它们,或者你的搜索指向旧内容。
嵌入向量找到什么是相关的,它们不判断什么是真实或当前的。这些差距中的每一个都有一个更好的工具,知道使用哪一个是一半的技能。
有一个工作得这样好的工具的陷阱是在任何地方伸向它。三个限制标记在哪里停止并选择别的。
精确匹配是第一个。嵌入向量将订单ID或错误代码映射到与其他一切相同的模糊语义空间中,所以A-10293靠近其他订单形状的字符串,不精确于自己。对于标识符、代码和任何用户按字面键入的值,关键字或数据库搜索是正确的工具,混合搜索(关键字和向量搜索一起运行)是你一次如何得到两个的。
推理是第二个。相似性不是推理:两个块评分高意味着它们是关于相关的东西,不是一个回答一个问题或逻辑地跟随另一个。推理发生在你给检索的块的模型中,而不是在检索中。检索找候选,模型做思考。
新鲜度是第三个,是无声咬住的一个。你的索引是一个快照从任何时候你嵌入它,所以编辑的文档继续返回它们的旧措辞,直到你重新嵌入。将重新嵌入按改变视为系统的一部分,而不是当有人注意到答案出错时你手工做的东西。
知道嵌入向量在哪里停止是保持检索系统可靠的东西,生产中的三个边界重要。
精确匹配是一个结构盲点,而不是调整问题。嵌入向量压缩输入朝向语义邻域,所以标识符、代码、SKU和稀有专有名词得到涂抹朝向相似看的字符串,而不是在自己上着陆。纯向量搜索会在精确命中之上排列一个近似未命中。修复是混合检索,融合一个词汇索引(关键字或BM25,一个经典术语频率排名)与向量索引,所以精确标记被精确匹配而含义被语义匹配,你不赌注订单查询在几何上。
漂移是无声侵蚀的边界。**嵌入向量漂移**是你在你的索引背后的模型版本改变时得到的不匹配:来自两个版本的向量占据不同的空间,不可比较,所以一个用新模型嵌入对在旧模型上构建的索引的查询返回合理看的无意义。在你的索引模式中固定模型版本,并将任何模型升级视为完整重新嵌入迁移,不是掉进去,因为没有被扔的错误,仅是无声更坏的结果。
评估是人们跳过的边界直到检索已经失败。你不能眼球是否检索好,你测量它:拿出一个设置查询用已知相关块和分数回忆@k(正确块是否着陆在前k)和精度(你返回的多少是相关的)。如果你不测量回忆@k,你不知道检索是否工作。
那个评估集是也告诉你一个新分块策略、模型或混合加权实际帮助的东西,而不是交易一组失败对另一个你还没注意到。检索质量是RAG站立的基础,所以它赚取一个真实测试,而不是一个氛围检查。
实践中
完整流程存活在上面的search函数中:嵌入一个小的常见问题条目集一次,然后通过含义回答查询。模式在任何大小都是相同的,仅存储随着你增长改变。
在下一章中,你取最接近的匹配并给模型喂食,所以它从你的文档而不是从内存回答。那个技术是RAG,它直接内置在你在这里写的东西上。
机制已经清晰:分块、批量嵌入、在向量DB中存储、按相似性检索前k。当你去生产时改变的是周围的管道,在改变上重新嵌入,你过滤的元数据,你固定的模型版本。
检索的块不是结束。在下一章中你给它们给模型作为语境,所以它从你的文档而不是其冻结训练回答。那是RAG,其答案的质量由你在这里构建的检索上限。
一切这里,分块,归一化,ANN索引,混合检索,新鲜度和回忆@k评估,是检索层,它是一个AI系统的部分,无声确定答案是否好。模型在顶可以仅理由超过检索交给它的东西,所以一个自信的错误答案通常是一个检索失败穿着生成服装。
那是到下一章的桥。RAG是这个检索层给模型喂食接地语境,每个失败模式这里,陈旧向量,错过的精确匹配,低回忆,表面那里作为一个错误答案。构建检索被测量和新鲜的,RAG有坚实的站立基础。

