一、比字形,还是比意思
第 25 讲结尾,关键词检索犯了一个典型的错误。员工问"我每年能休多少天带薪假?",病假制度因为字面上恰好有"带薪"两个字,得分最高,被检索了出来。
这个错误的根源是:关键词比较的是字形,问题问的是意思。 “带薪假"和"年假"字面不同,意思相关;“年假"和"病假"共享一个"假"字,意思却是两回事。
第 3 讲讲过一件事:“意思相近"在数学上可以被定义为"向量方向接近”,用余弦相似度衡量。第 8 讲把每个 token 变成了一个向量,并且预告过:这个思想可以"从一个 token 延伸到一整段文本”。
这一讲就做这件事:把每段文字变成一个向量,检索就变成找离问题最近的那几个向量。
二、打个比方:意思的地图
第 3 讲用过一个比方:一座城市的位置可以用经纬度两个数字表示,北京和天津坐标接近,悉尼离得很远。
现在把每一段文字都当成一座"城市”,放在一张意思的地图上:讲年假的几段挨在一起,讲病假的在不远处,讲出差报销的在另一个方向。用户的问题也被放上这张地图,落在某个位置。检索,就是在地图上找离问题最近的几座城市。
这张地图不是按字形画的。“带薪假"和"年假"字面不同,但如果它们意思相近,就会被放在相邻的位置。
要画出这张地图,需要解决两个问题:怎么把一段文字变成一个坐标;怎么保证意思相近的文字,坐标真的相近。
三、从一段文字到一个向量
做法:让 Transformer 读一遍,再汇总
第 8 讲的词嵌入给每个 token 一个静态向量,不管它出现在哪句话里都一样。第 9、10 讲的 Transformer 读完一整段话之后,最后一层的每个位置都是一个结合了上下文的向量——“苹果公司"里的"苹果"和"吃苹果"里的"苹果”,到这里已经不同了。
要得到整段文字的向量,常见的做法是把最后一层所有位置的向量汇总成一个,比如直接求平均,或者在开头放一个特殊 token,训练模型把整段的意思集中到这个 token 的向量上。
用来做这件事的模型,常常是第 11 讲讲的 Encoder:那一讲说过,Encoder 每个位置能看到完整的上下文,适合"不需要逐词生成、只需要给整段输入生成一个理解表示"的任务。把一段文字变成一个向量,正是这样的任务。
文本嵌入(text embedding):用一个模型把一整段文字映射成一个固定维度的向量,使得意思相近的文字,向量的余弦相似度也高。做这件事的模型叫嵌入模型。
训练:拉近该近的,推远该远的
一个随机初始化的 Transformer 算出来的向量,和意思毫无关系。第 3 讲结尾说过,向量里"意思"不是人为设计进去的,是训练出来的。嵌入模型用的训练方法叫对比学习(contrastive learning)。
训练数据是一批相关的文本对:一个问题和能回答它的段落、一篇文章的标题和正文、论坛里的提问和被采纳的答案。每次训练取一个问题,配上它的正确段落(正样本),再配上几个无关段落(负样本),要求:
问题和正样本的余弦相似度,要明显高于问题和每一个负样本的余弦相似度。
写成损失函数,是一个你已经见过好几次的形状:
P(选中正样本) = exp(s₊ / τ) / Σ exp(sᵢ / τ) (对所有候选段落求和)
损失 = −log P(选中正样本)
sᵢ 是问题和第 i 个候选段落的余弦相似度。这就是第 5 讲的 softmax,候选项换成了几个段落,和第 17 讲"两个回答里选一个"是同一个结构。τ 是温度,和第 21 讲的温度作用相同:余弦相似度只在 −1 到 1 之间,差距太小,除以一个较小的 τ(比如 0.1)把差距放大,训练信号才够强。
手算一个例子:一个正样本、两个负样本,τ = 0.1。
模型已经排对了:正样本相似度 0.8,负样本 0.5 和 0.3。
exp(0.8/0.1) = e⁸ ≈ 2981
exp(0.5/0.1) = e⁵ ≈ 148.4
exp(0.3/0.1) = e³ ≈ 20.1
P(选中正样本) = 2981 / (2981 + 148.4 + 20.1) ≈ 0.947
损失 = −ln(0.947) ≈ 0.055
模型排错了:正样本相似度只有 0.5,一个负样本反而是 0.8。
P(选中正样本) = 148.4 / (148.4 + 2981 + 20.1) ≈ 0.047
损失 = −ln(0.047) ≈ 3.06
排错时损失大了五十多倍。梯度会推动模型调整参数:把问题和正样本的向量拉近,把问题和那个相似度 0.8 的负样本推远。
⭐ 训练跑过几千万、几亿个这样的文本对,模型逐渐学会:会在同一类问答里配对出现的文字,放在相近的位置。 这张"意思的地图"不是按任何人设计的规则画出来的,而是被海量的"这两段是一对"推出来的——和第 1 讲"猜词逼出理解"是同一种逻辑。
负样本从哪来?一个便宜的办法是用同一批里其他问题的正样本充当负样本:一批取 1000 对,每个问题就自动有了 999 个负样本,不需要额外准备。更有效的是困难负样本:那些和问题很像、但并不回答它的段落。比如"满五年 15 天年假"之于"满一年有几天年假”。这类负样本逼模型去分辨细微的差别。
四、手算:第 25 讲的知识库,换成向量检索
回到第 25 讲的四段制度。为了能手算,假设一个只有 3 维的嵌入模型,并且为了方便理解,把三个维度粗略看成"休假"“病与医疗"“钱与报销”。
⚠️ 这只是为了讲解。第 3 讲说过,真实的嵌入模型有几百到几千维,单独一个维度通常没有可读的含义,下面的数字也是示意的。
块 1(满一年 10 天年假): [0.90, 0.10, 0.10]
块 2(满五年 15 天年假): [0.88, 0.12, 0.10]
块 3(5 天带薪病假): [0.40, 0.90, 0.10]
块 4(住宿不超过 500 元): [0.05, 0.05, 0.95]
问题 “我每年能休多少天带薪假?” 被同一个模型编码成:
问题:[0.85, 0.25, 0.10]
它主要落在"休假"方向上,“带薪"带来了一点点其他成分。算余弦相似度(第 3 讲):
| 块 | 余弦相似度 | 排名 |
|---|---|---|
| 块 2 | 0.989 | 1 |
| 块 1 | 0.985 | 2 |
| 块 3 | 0.651 | 3 |
| 块 4 | 0.177 | 4 |
⭐ 两条年假规定排在前两位,病假制度落到第三。第 25 讲里因为字面上的"带薪"而胜出的块 3,在这里得分明显更低:这次比较的是问题在意思地图上的位置,不是它包含哪些字。
换一个真正问病假的问题,“生病请假需要提供什么材料?”,编码成 [0.20, 0.90, 0.05],它和块 3 的相似度是 0.979,和两条年假规定只有 0.33 左右。地图能把这两类问题分开。
但地图也有看不清的地方
再算一个数:块 1 和块 2 之间的余弦相似度是 0.9997,几乎完全相同。
这不是示意数字凑出来的巧合。两段话的结构、用词、主题几乎一模一样,只有"满一年"和"满五年”、“10 天"和"15 天"不同,而员工问题的答案,恰恰取决于这几个字。
第 3 讲留过一句话:“余弦相似度高,只能说明两个向量在训练数据里表现出的统计模式相近,不能保证这种相近对应人类理解意义上的’意思像’。“这里就是一个具体的例子:在地图上,两段话几乎挤在同一个点上,而它们给出的答案不同。 公开的研究和实践中还反复观察到类似的现象:条件、数字、否定(“可以报销"和"不可以报销”)这类细节,在嵌入向量里常常只留下很微弱的差别。
在这个例子里,这不是问题:两块都被检索到了,第 25 讲说过,分辨"六年适用哪一条"是模型读完资料之后的事。但如果只取第一名,检索系统在两块之间几乎是在掷硬币。
五、一千万段文字:大规模检索的账
四段文字可以逐一比较。一个真实的知识库,可能有一千万段。
逐一比较有多贵
用一组示意数字:一千万段文字,每段一个 768 维的向量,每个数字用 32 位(4 字节)存储:
存储: 10,000,000 × 768 × 4 字节 ≈ 30.7 GB
每次查询:问题向量要和一千万个向量各算一次点积
10,000,000 × 768 ≈ 77 亿次乘加
每来一个问题都做 77 亿次乘加,而且 30 GB 的向量都要从内存里读一遍——第 22 讲讲过,读数据常常比算还慢。
近似最近邻:只在附近找
一个直接的思路:先把一千万个向量分成若干个区域,查询时只在离问题最近的几个区域里找。
离线:把一千万个向量聚成 1000 个簇,每个簇有一个中心点
在线:① 问题向量和 1000 个中心点比较,挑出最近的 10 个簇
② 只在这 10 个簇里(约 10 万个向量)逐一比较
计算量:1000 × 768 + 100,000 × 768 ≈ 7800 万次乘加
从 77 亿次降到 7800 万次,大约快了 100 倍。
⭐ 代价是:如果真正最近的那个向量,恰好落在没被检查的簇里,它就会被错过。 所以这类方法叫近似最近邻(approximate nearest neighbor,ANN) 检索:用"偶尔找不到真正的第一名”,换来快得多的速度。检查的簇越多,越不容易错过,也越慢——又是一个可以调节的权衡。
⚠️ 关于时效性:除了分簇,还有一类常用的方法把向量连成一张"近邻图”,查询时从某个点出发,沿着图一步步走向离问题更近的点。具体的算法和工程实现有很多,还在不断改进;但它们都在做同一件事:不逐一比较,只检查一小部分可能最近的候选,并且都要在速度和"找全"之间取舍。
量化:把 30 GB 压下来
第 22 讲讲过量化:用更少的位数存数字。它在这里同样适用。
逐个数字量化:把 32 位压成 8 位,存储从 30.7 GB 降到约 7.7 GB,读得也更快。
分段量化,压得更狠:把 768 维切成 96 段、每段 8 维。对每一段,事先准备一本"码本”,里面收录 256 个有代表性的 8 维小向量。每个向量的每一段,都用码本里最接近的那一个代替,只存它的编号——256 个编号,恰好 1 个字节:
原来每个向量:768 × 4 字节 = 3072 字节
现在每个向量:96 段 × 1 字节 = 96 字节 ← 压缩到 1/32
一千万个向量:约 30.7 GB → 约 0.96 GB
代价和第 22 讲一样:近似带来误差。压缩后算出的相似度只是估计值,排名可能出错。常见的做法是先用压缩向量快速找出几百个候选,再用精确的原始向量给这几百个重新计算、重新排序。
六、实际系统:多种办法组合
关键词检索并没有被淘汰
向量检索擅长换了说法的同一个意思。但有些问题,恰恰需要字形的精确匹配:产品型号"XJ-2031”、一个人的名字、一段报错信息、某个条款编号。第四节已经看到,嵌入向量对这类细节可能不敏感,一个型号和另一个相似的型号,在意思的地图上几乎挨在一起。
所以实际系统常常同时运行两种检索,把结果合并:关键词检索负责精确匹配,向量检索负责意思相近。
两阶段:先快后准
嵌入模型把问题和段落分别编码成向量,再比较。这样做很快:段落的向量可以提前算好存起来,查询时只需编码问题。但问题和段落在编码时从未见过对方,没法细致地比较"这段话是不是恰好回答了这个问题"。
另一种模型叫重排序模型(reranker):把问题和一段文字拼在一起输入,让第 9 讲的注意力在两者之间直接交互,输出一个"这段话回答了这个问题的程度"。它准确得多,但每一对都要单独跑一遍模型,没法提前算好。
⭐ 所以常见的做法是两阶段:先用向量检索(加上关键词检索)从一千万段里快速捞出一百段,再用重排序模型在这一百段里仔细挑出最好的五段,交给第 25 讲的流程。便宜的方法负责不遗漏,昂贵的方法负责挑准。 这个结构在很多系统里反复出现。
七、代价
⚠️ 相近不等于有用。向量检索找到的是"意思相近"的段落,而不是"回答了这个问题"的段落。一个问题的正确答案,可能在一段和它措辞、主题都不太像的文字里;而和问题最像的,可能只是另一段把同一个问题重新问了一遍的文字。
⚠️ 条件、数字、否定容易被抹平。第四节算出,两条条件不同的规定相似度高达 0.9997。检索依赖的是一张"大致正确"的地图,细节要靠后面的重排序和模型自己阅读来分辨。
⚠️ 嵌入模型有它自己的知识边界。它的"意思地图"来自训练时见过的文本对。一家公司内部的术语、缩写、业务概念,如果从没出现在它的训练数据里,它可能完全不知道这些词之间的关系。
⚠️ 换嵌入模型,就要重建整个索引。不同的嵌入模型画出的是不同的地图,坐标之间没有可比性。升级嵌入模型,就意味着把一千万段文字全部重新编码一遍。
⚠️ 近似检索和量化,都会偶尔漏掉正确答案。而第 25 讲说过,检索漏掉的东西,模型永远看不到,也不会提醒你。
⚠️ 地图可以被刻意利用。如果有人能往知识库里写入文档,他可以精心构造一段文字,让它在地图上靠近大量常见问题的位置,从而被频繁检索出来——再在里面夹带指令。这是第 30 讲要讲的攻击的一种入口。
八、和后面课程的关系
- 第 27 讲(Function calling):到目前为止,检索是每次提问前固定执行的一步。下一讲会让模型自己决定什么时候检索、检索什么。
- 第 28 讲(多步推理与规划):第 25 讲那个需要两步查找的问题,要靠模型根据第一次的检索结果,生成第二次的检索问题。
- 第 30 讲(Prompt injection):第七节最后一条,把恶意内容放进知识库、让它被检索出来。
- 第 31 讲(可解释性):嵌入向量的每一维说不出含义,这张"意思的地图"为什么这样画,也很难解释清楚。
九、本讲小结
- 关键词比字形,向量检索比意思:把第 3 讲"意思相近即向量接近"从一个词推广到一整段文字。
- 怎么得到一段文字的向量:让 Transformer(常用第 11 讲的 Encoder)读一遍,把最后一层的上下文向量汇总成一个。
- ⭐ 意思是训练出来的:对比学习要求问题和正确段落的相似度高于和负样本的相似度,损失是一个带温度的 softmax——第 5、17、21 讲的老结构。手算:排对时损失 0.055,排错时 3.06。
- ⭐ 手算第 25 讲的知识库:两条年假规定排在前两位(0.989、0.985),病假降到 0.651,情况二被纠正。但两条年假规定之间相似度 0.9997——条件和数字在地图上几乎被抹平,这是第 3 讲"相似只是代理"的具体例子。
- 大规模检索:一千万个 768 维向量,逐一比较要 77 亿次乘加、30.7 GB;分簇只查附近,快约 100 倍,但可能错过;分段量化把每个向量从 3072 字节压到 96 字节,一千万个约 1 GB。
- ⭐ 实际系统组合使用:关键词检索负责精确匹配,向量检索负责意思相近;先用便宜的方法捞候选,再用重排序模型挑准。
- ⚠️ 代价:相近不等于有用;细节容易被抹平;嵌入模型有知识边界;换模型要重建索引;近似和量化会漏;地图可以被恶意利用。
思考题
- 用第四节的示意向量,算一算问题
[0.10, 0.10, 0.90](比如"出差住宿能报多少")和四块的余弦相似度,看看排第一的是哪一块。 - 第三节手算对比学习时用了
τ = 0.1。如果把τ改成 1,“排对了"那种情况下正样本被选中的概率是多少?损失是多少?结合第 21 讲,解释为什么对比学习通常用较小的温度。 - 第四节说块 1 和块 2 相似度 0.9997。如果员工问"我入职两年,有几天年假”,只取第一名的检索结果可能是哪一块?你会怎么设计系统,避免它因此答错?
- 用第五节的分簇方法,如果把 1000 个簇改成 10,000 个簇、仍然只检查最近的 10 个,每次查询的计算量大约是多少?和 1000 个簇相比,更快还是更慢?错过正确答案的风险会怎么变?
- 一个客服知识库里有大量产品型号(比如"XJ-2031"“XJ-2013”)。请说明为什么纯向量检索在这里可能出问题,以及你会怎样结合第六节的方法设计检索流程。