Embeddings(嵌入)


先说说这个词。embedding(嵌入) 是一串数字,用来代表一段文本并捕捉它的含义,让含义相近的文本转换出相近的数字。这一章要讲的就是如何把词语变成计算机可以比较的东西,也正是这一点让搜索能够匹配含义而不是关键词。
把文本变成向量
计算机怎么知道"dog"(狗)和"puppy"(小狗)是相关的,而"dog"和"stapler"(订书机)不是?这些单词并没有共享什么关键的字母。
这里的诀窍是把文本转换成能捕捉其含义的数字,让含义相近的文本得到相近的数字。这串数字就叫**embedding(嵌入)**,也叫向量(vector)。模型读取文本,输出这个向量。
你自己不需要去解读这些数字。重要的是它们之间的关系。
一个大致的画面:想象每一段文本都是空间中的一个点,含义相近的东西彼此靠近。"dog"和"puppy"落在同一片区域,"stapler"则在很远的另一边。真正的嵌入用的是成百上千个维度,而不是你能想象出来的两三个维度,但这个直觉是成立的:越近就越相似。
这些数字从哪儿来的?为什么它们会和含义对齐?嵌入模型是大语言模型的工作原理里那种预测模型的近亲,是用海量文本训练出来的。为了把任务做好,它必须学会哪些词语和概念会出现在相似的语境中——这正是那句老话"观其伴而知其词"的意思。
"dog"和"puppy"经常出现在相同类型的句子里,所以模型学会了把它们表示得相似。"stapler"混的圈子不一样,所以它落在别处。这几百个数字是习得的"含义方向",每一个都捕捉文本变化的某个维度,没有一个是人类能直接读懂的标签。你从来不需要去解码这些数字,只需要衡量两个向量有多接近。
你想要的是能匹配含义而不是拼写的搜索,这样"帮我把钱退回来"就能找到一条关于退款的内容。做法是把每一段文本转换成一个**向量(vector)**,一个固定长度的数字列表,含义相近的文本会落在相近的位置。嵌入模型读取文本并输出这个向量,整件事的关键就在于:向量之间的距离代表着含义上的相似度。
向量有固定的长度,也就是它的维度,通常是几百到几千个数字。用同一个模型嵌入的每一段文本,输出的长度都是一样的,这也是你能拿任意两个向量做比较的前提。这些维度是习得的"含义方向",不是人为标注的类别,所以你不能把第47维读成"正式程度"之类的东西。你要把整个向量当作一个不透明的点,只去衡量距离。
在真正开始嵌入任何东西之前,有一步会决定整套方案是否奏效:分块(chunking)。你几乎不会把一整篇文档嵌入成一个向量,因为一篇20页的手册用一个向量表示,会把里面所有的主题都模糊平均成一个谁都不像的点。正确的做法是把文档切分成**chunk(块)**——大致一段到一页长的段落,然后分别为每个块生成嵌入。嵌入的应该是块,不是整篇文档,这样每个向量才能承载一个聚焦的想法。
块的大小是一个需要你刻意权衡的取舍。块太大,会把好几个想法平均混在一起,检索时就会不够精准。块太小,又会丢失让一句话有意义的上下文。一个常见的起点是每块几百个 token,相邻块之间留一点重叠,这样一句话即便正好被切分点截断,也能在其中一个块里完整出现,之后再根据检索的实际效果做调整。
按含义匹配的搜索建立在一个核心想法之上:把文本映射进一个**向量空间(vector space)**(一个固定维度的坐标空间,其中每个点都是一串数字),让语义上的相似变成几何上的接近。嵌入模型就是这张映射:它读取文本,返回一个向量。
在这之上你构建的一切——索引、检索、排序——本质上都是在这些向量上做几何运算。如果映射本身出了问题,后面的任何一步都无法挽回。
第一个可以设计的关键点是"你嵌入的是什么",这就是分块(chunking):先把源文档切分成段落,再进行嵌入,而不是把整份文件直接嵌入。原因在于,嵌入本质上是把输入有损压缩成一个点,一个块里包含的想法越多,它们平均出来的结果就越是一个模糊的中心点,检索时也就越难精准命中。要把块的大小调整到用户实际会问的那个粒度,通常是几百个 token 的一段文字,并留一点重叠,让跨越边界的事实至少能在一个块里完整保留。
分块策略往往比模型选择更能决定检索质量的成败:
- 固定长度窗口成本低,但会把句子从中间切断,打断思路。
- 基于结构的切分(按标题、段落或代码块切分)能让每个块保持连贯,通常检索效果更好,代价是流程更复杂一些。
不管你选哪种方式,都要把块的来源和位置和它的向量一起存起来,因为在检索的时候你需要能指回某个事实出自哪里,而单独一个向量是说不出这个信息的。
还有一点要牢记:嵌入的维度不是可解释的坐标轴,所以你没法通过读向量来调试检索问题。调试嵌入的方式是去看它检索出了什么结果,而不是去读那些数字。当某次查询拉出了错误的块时,该修的是分块方式、模型或查询本身,而修正的依据是检索出来的结果,不是坐标本身。
生成嵌入
你向嵌入模型请求向量,过程很像普通的模型调用,只是换了一个端点和一个模型。
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 个数字结果是一串数字。有一条规则比其他都重要:一定要比较由**同一个模型**生成的嵌入。不同模型生成的向量属于不同的空间,不能互相比较,就像用不同的单位去做测量一样。混用不同模型的结果,得到的分数每次都毫无意义。
而且和其他模型调用一样,你是按token计费的,所以给一个大型文档集合做嵌入是有成本的,值得在真正跑几千篇文档之前先估算一下。
调用本身是一个不同的端点,但结构和聊天调用差不多:你发送文本,拿到一个向量。
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]请求和响应的具体形态因服务商而异,但每个嵌入端点都遵循同样的"批量输入、批量返回向量"的约定。在大规模场景下,批量处理不是可有可无的优化,它是索引任务能顺利跑完还是整个下午都被限流的分水岭:每次请求都有固定开销,还会占用速率限额,所以要通过发送大批量请求来分摊这些成本,再针对大任务必然会遇到的部分失败做退避重试。
模型和维度是一项影响深远的架构决策。维度更高的向量能存储更多的区分度,但在生产环境中每一个重要指标上都要付出更高成本:每个向量的存储、索引占用的内存、每次比较所需的时间,都随维度线性增长。有些模型支持**俄罗斯套娃嵌入(Matryoshka embeddings)**(可以截断成更短长度、依然可用的向量),让你既能保留完整向量用于精确匹配,又能用截断后的前缀做便宜的初筛。当你的语料库大到存储和查询延迟(而不是模型质量)成为瓶颈时,这一招就很值得用。
真正在后期会让人吃苦头的是版本管理。一个存下来的嵌入,永远绑定着某个确切的模型版本。
来自两个不同模型版本的向量是不可比的,所以你不能用更新的模型嵌入一个新查询,再拿它去和用旧模型建的索引比较。而且这个错误是悄无声息的:分数看起来很合理,排名却是错的。要把模型版本当作索引 schema 的一部分,和向量一起存下来,并把重新嵌入当作一次有计划的迁移来对待——这一点会在本章后面讨论"新鲜度"的部分详细展开。
衡量相似度
要判断两段文本有多相关,你需要衡量它们的向量有多接近。标准的衡量方式是**余弦相似度(cosine similarity)**,它返回一个 -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)) # 更低,两者不相关这些数字印证了直觉:dog 和 puppy 的分数更高,dog 和 stapler 的分数更低。拿一个查询的向量去和已存储的向量做比较,这正是语义搜索的核心。这一个函数,正是本章后面内容要不断构建在上面的基础动作。
有了向量之后,你还需要一个数字来表示两个向量有多接近。标准做法是**余弦相似度(cosine similarity)**:它衡量两个向量之间的夹角,忽略它们的长度,返回一个 -1 到 1 的值,数值越高越相似。你想要的是角度,不是距离,因为同一主题下的一段长文字和一句短语,尽管向量的长度不同,理应得到相似的分数。
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
# 如果向量都归一化为长度 1,余弦相似度就等于它们的点积
score = dot(query_vector, doc_vector) # 不再需要除法有一个技巧在大规模场景下非常划算:把向量存储时就**归一化(normalize)**成长度为 1。一旦所有向量的长度都一样,余弦相似度里的除法就消失了,分数变成了一个普通的点积——每个维度只需要一次乘加运算,在大批量数据上跑起来要快得多。存储时归一化一次,之后用点积来比较。很多嵌入模型返回的向量本身就已经归一化了,但不要想当然,要检查一下。
和玩具示例相比的另一个变化是:你几乎不会只想要最接近的那一个结果。你想要的是**top-k**检索,也就是分数最高的 k 个结果,通常是三到十个。有时候排第一的向量差一点,而排第二、第三的加在一起才真正包含答案,返回一小批结果能给下一个环节留出发挥的空间。只返回排名第一的结果,会丢掉你之后会后悔没保留的上下文。
你需要一个相似度度量,余弦相似度(cosine similarity)(两个向量夹角的余弦值,范围 -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 # 每行一个分数,向量化计算这里的优化手段是在写入时把每个向量**归一化(normalize)**成单位长度,这样余弦相似度就等同于点积,让你可以用一次矩阵-向量乘法给整个语料库打分,而不需要用 Python 循环逐个计算。写入时归一化,相似度就变成了一次覆盖整个索引的点积运算。这是暴力(brute-force)检索的基线方案:精确、直接,而且和语料库大小呈线性关系,大致在几十万向量以内还能撑得住,超过这个量级就会顶不住,这也是需要转向近似索引的信号,后面会讲到。
玩具版度量方式掩盖了两个生产环境中的实际问题。第一,你几乎从来不想要 argmax(单个最高分),而是想要带分数阈值的 top-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 列表里,每次查询都给所有向量打一遍分。这对演示来说没问题,但语料库一旦变大就会崩:线性扫描要为每个查询重新对每一个向量做完整比较,而且每次重启,你还得在内存里重建整个索引。
解决办法是用一个**向量数据库(vector database)**:一种专门用来存储向量、并能快速从磁盘上回答"找出离这个查询向量最近的 k 个"这类问题的存储系统。你不再自己维护列表和循环,而是把向量写进去一次,搜索、持久化和单条记录的更新都交给数据库处理。
# 具体形态因向量数据库而异,但模式到处都一样
index.upsert([
{"id": "doc-1", "vector": embed(chunk), "metadata": {"source": "faq.md", "section": "refunds"}}
for chunk in chunks
])
results = index.query(vector=embed(user_question), top_k=5) # 最近的5个,不只是1个数据库能提供两个列表做不到的东西:
- 一次调用就返回top-k结果,是最接近的若干个而不是单个最优结果,而这正是你要真正喂给下一个环节的内容。
- 在每个向量旁边存储metadata(元数据)——来源文件、章节、时间戳——这样一个结果就能指回它的出处,你也可以据此过滤。
一旦你手上有了真实数据,就把向量存进向量数据库,而不是 Python 列表。列表是用来学习这套机制的,不是用来真正运行它的。
那套参考流程——嵌入一次、存储、嵌入查询、打分、排序、返回——是正确的,但一旦碰到真实规模就撑不住。暴力扫描的开销和语料库大小呈线性关系,一旦到了百万级向量,加上任何实际的查询频率,每次搜索都要花掉你负担不起的一整个 CPU 秒。生产级搜索的核心思路就是尽量避免把查询和每一个向量都比较一遍。
这正是**近似最近邻(approximate nearest neighbor)**搜索能带来的好处(简称 ANN:一类通过接受"偶尔漏掉真正最近邻"这一小概率,来换取快速找到最接近向量的算法)。主要有两大流派:
- HNSW(层次化可导航小世界图)为向量构建一个分层图,从粗到细地在图上遍历,查询延迟低,但内存开销大。
- IVF(倒排文件)对向量做聚类,只在最近的几个簇里搜索,占用内存更少,但需要随着数据变化不断重新训练聚类中心。
超过几十万向量的规模,ANN 就不是可选项了,而是索引本身的必需品。你要调的取舍是召回率(recall)(索引实际返回的真实最近邻占比)相对延迟和内存的权衡:把召回率往 1.0 推,查询就会变慢;放松召回率,你就会悄无声息地漏掉相关的块。
玩具版流程完全掩盖的一个失效模式是新鲜度问题。索引是一份快照,所以当源文档发生变化时,它存下的向量就已经过时,却还会继续像最新内容一样被检索出来。你需要一套以文档变更为触发点的重新嵌入流水线:检测编辑动作、对该文档重新分块并重新嵌入,然后写入新向量、删除旧向量,理想情况下用内容哈希来追踪,这样你只需要重新嵌入真正变化过的部分,而不是每次改动都重新嵌入整个语料库。过时的向量,正是检索系统悄悄开始"说谎"的方式。
最后一个缺口是精确匹配。向量搜索天生就容易漏掉这一点:查询一个订单号 A-10293 或一个错误代码,嵌入出来会落在一片模糊的区域里,可能根本浮不出那份真正包含这个字符串的文档。**混合搜索(hybrid search)**把关键词搜索(精确命中确切字符串)和向量搜索(精确命中含义)一起跑,并把两边的排序结果融合起来,这样标识符、名称和罕见词就不会被语义上的模糊给"吞掉"。只要你的语料库里有用户会原样输入的东西,就该考虑用它。
嵌入不适合做什么
嵌入是一种相似度工具,不是一种思考工具。记住几个**局限**,才能用对工具:
- 它做不了精确匹配。要找一个准确的订单号或错误代码,普通的关键词搜索或数据库搜索会更好用。
- 它不会推理。它只能告诉你两件事是相关的,不能告诉你为什么相关,也判断不出一件事是否能从另一件事逻辑推出。
- 它会过时。如果你的文档发生了变化,你必须重新嵌入它们,否则搜索结果指向的还是旧内容。
嵌入能找出相关的东西,但判断不出什么是真的、什么是最新的。每一个短板都有更合适的工具去补,知道该用哪个工具正是这门技能的一半。
一个工具用得太顺手,陷阱就是什么都想拿它来用。有三个局限,标出了该在哪儿停下来、换个别的工具。
第一个是精确匹配。嵌入会把一个订单号或错误代码,和其他所有东西一样,映射进同一片模糊的语义空间,所以 A-10293 会落在其他"看起来像订单号"的字符串附近,而不是精确地落在自己身上。对于标识符、代码,以及任何用户会原样输入的值,关键词搜索或数据库搜索才是正确的工具,而**混合搜索(hybrid search)**(把关键词搜索和向量搜索一起跑)能让你两者兼得。
第二个是推理。相似不等于推断:两个块的分数很高,只说明它们讨论的是相关的东西,不代表其中一个能回答某个问题,也不代表一个能从另一个逻辑推出。真正的推理发生在你把检索出的块喂给的那个模型里,不发生在检索这一步。检索找出的是候选内容,思考的活儿归模型来干。
第三个是新鲜度,它是那种悄无声息咬你一口的问题。你的索引是你嵌入时那一刻的快照,所以文档被编辑之后,搜索结果还会一直返回旧的措辞,直到你重新嵌入为止。要把"变更时重新嵌入"当作系统的一部分来设计,而不是等有人发现答案不对了才手动去补。
知道嵌入的边界在哪里,才能让一套检索系统保持可靠,生产环境中有三条边界特别重要。
精确匹配是一个结构性的盲区,不是靠调参能解决的问题。嵌入会把输入压向语义上的"邻域",所以标识符、代码、SKU 和罕见的专有名词,都会被拉向看起来相似的字符串,而不是落在它们自己身上。纯向量搜索会把一个"差不多"的结果排在真正精确命中的结果前面。解决办法是混合检索,把一个词法索引(关键词或 BM25,一种经典的基于词频的排序方法)和向量索引融合起来,这样精确的词元能被精确匹配,含义能被语义匹配,你也不用把一次订单查询的成败赌在几何距离上。
漂移(drift)是那种会悄悄侵蚀系统的边界。**嵌入漂移(embedding drift)**指的是当你索引背后的模型版本发生变化时产生的错位:来自两个不同版本的向量处在不同的空间里,不可比较,所以用新模型嵌入的查询,拿去和用旧模型建的索引比较,会返回看起来很合理、实际上却是错的结果。要把模型版本固定写进索引的 schema,并把任何模型升级都当作一次完整的重新嵌入迁移,而不是简单替换,因为这里不会抛出任何错误,只会悄悄地让结果变差。
评估是那种大家会一直忽略,直到检索已经失效才想起来的边界。你无法靠肉眼判断检索好不好,必须去衡量它:留出一批已知相关块的查询作为测试集,计算recall@k(正确的块是否落在前 k 个结果里)和精确率(你返回的结果里有多少是真正相关的)。如果你没有在衡量 recall@k,你就根本不知道检索到底有没有在起作用。
这个评估集还能告诉你,一种新的分块策略、新的模型,或者新的混合权重配置,到底是真的带来了改善,还是只是用一批失败换掉了另一批你还没发现的失败。检索质量是RAG赖以立足的地基,值得用一套真正的测试来把关,而不是靠感觉。
实践中的应用
完整的流程就在上面那个 search 函数里:一次性嵌入一小批 FAQ 条目,然后按含义回答查询。这个模式在任何规模下都是一样的,变化的只是存储方式。
在下一章里,你会拿最接近的匹配结果去喂给一个模型,让它基于你的文档来回答,而不是凭记忆回答。这个技巧就是RAG,它正是直接建立在你在这里写的这套东西之上。
这套机制已经很清楚了:分块、批量嵌入、存进向量数据库、按相似度检索 top-k。等真正上生产环境,变化的是周边的配套工作——变更时的重新嵌入、你用来过滤的元数据、你固定下来的模型版本。
检索出的块并不是终点。在下一章里,你会把它们作为上下文喂给一个模型,让它基于你的文档来回答,而不是依赖它那已经冻结的训练知识。这就是RAG,它的回答质量,上限就是你在这里搭建的检索能力。
这里讲到的一切——分块、归一化、ANN 索引、混合检索、新鲜度,以及 recall@k 评估——共同构成了检索层,它正是一套 AI 系统里悄悄决定答案质量好坏的那一部分。模型只能基于检索交给它的内容去推理,所以一个自信满满却错误的答案,往往是检索出了问题、只是披着"生成错误"的外衣。
这正是通向下一章的桥梁。RAG就是这套检索层,把有据可查的上下文喂给一个模型,而这里的每一种失效模式——过时的向量、漏掉的精确匹配、低召回率——都会在那里表现为一个错误的答案。把检索建得可衡量、够新鲜,RAG 才有坚实的地基可以立足。

