搜索“账号密码忘了该怎么办”,系统却能找到一篇标题叫“通过邮箱重置登录凭证”的文章;两段话没有完全相同的关键词,搜索结果仍然有用。支撑这种能力的一项技术,就是 Embedding,通常译作嵌入或向量表示。
但“把文字变成一串数字”只描述了结果。真正值得追问的是:这些数字最初从哪里来?训练时是谁在修改它们?同一个词在不同句子里为什么会有不同表示?几百个浮点数又怎么用来检索文章?
这篇文章沿着一条完整链路展开:离散符号 → 可训练向量 → 上下文表示 → 句向量 → 相似度 → 检索。我们既看底层矩阵运算,也动手训练一个小模型,最后用真实中文模型跑通检索。读者只需要基本的 Python、向量和矩阵知识;梯度、注意力与对比学习都会在用到时解释。
阅读与复现说明:文中有 8 张原创配图、5 份可下载的完整 Python 示例。示意图中的位置和数值用于解释机制;第十节的相似度图来自实际运行。示例在 Python 3.10.11、PyTorch 2.12.0+cpu、Transformers 4.42.4、Sentence Transformers 5.5.1、NumPy 1.26.4 的现有环境中验证。模型固定到明确 revision,具体浮点数仍可能因计算内核和版本略有差异。
一、先区分:我们说的 Embedding 到底是哪一层?
Embedding 可以表示一个映射过程,也可以表示这个过程产生的向量。在神经网络里,最常见的写法是:
是输入对象的集合, 可以是一个词、一句话、一张图片或一个商品 ID; 是输出向量的维度; 是模型参数。训练的目的是让这个表示保留对目标任务有用的信息。
这里没有承诺“把输入所有信息完整保存”,也没有承诺“不同输入一定对应不同向量”。机器学习语境下的 embedding 不必满足数学中某些严格嵌入定义要求的单射性质。
尤其要区分以下四种常被混用的对象:
| 名称 | 输入 | 输出 | 是否依赖当前上下文 | 主要用途 |
|---|---|---|---|---|
| Token embedding 层 | 一个 token ID | 词表矩阵中的一行 | 单独查表时不依赖 | 为神经网络提供初始表示 |
| 上下文 token 表示 | 一整段 token 序列 | 每个位置一个隐藏向量 | 依赖 | 理解词义、序列标注、生成等 |
| 句子/段落 embedding | 一段文本 | 一个固定维度向量 | 编码时利用整段文本 | 相似度、聚类、检索 |
| 向量索引/数据库 | 已经算好的向量及元数据 | 相近条目及其 ID | 本身不学习文本语义 | 存储、筛选、快速搜索 |
因此,nn.Embedding 通常只是整个文本编码器的一个组件;向量数据库通常也不会因为“收到文章”就凭空知道它的含义。即使某个数据库产品集成了自动向量化,其背后也要调用具体的 embedding 模型。
本文统一采用以下符号,后面每个矩阵维度都可以对照这里检查:
| 符号 | 含义 |
|---|---|
| 词表中的 token 数量 | |
| 一个 batch 中的文本数量 | |
| 当前 batch 补齐后的序列长度 | |
| token 隐藏向量维度 | |
| 最终句向量维度,不一定等于 | |
| 候选文档或文档块数量 |
二、为什么不能直接用编号表示一个词?
2.1 ID 只负责识别,不负责表达关系
假设我们给“猫、狗、飞机”分别编号为 1、2、3。编号 2 并不意味着“狗的语义是猫的两倍”,编号 3 也不意味着飞机比猫更接近狗。编号的排列可以任意交换。
如果直接把这些整数当作连续数值输入一个普通模型,模型可能会接收到人为制造的大小关系与距离关系。我们真正需要的是一种表示,让“哪些性质共享、哪些差异重要”有机会从数据中学出来。
2.2 One-hot 解决了身份问题,却没有学出关系
词表大小为 时,one-hot 用长度为 的向量表示某个 ID,只有对应位置是 1:
任意两个不同的 one-hot 向量内积为 0,欧氏距离为 。所以在这套表示中,“猫与狗”和“猫与飞机”的距离一样大。它准确地区分了身份,却没有表达我们想要的语义结构。
此外,如果 ,每个词的显式 one-hot 就有 50 000 维,大部分还是 0。虽然可以使用稀疏存储,但后续神经网络通常更愿意处理较短、可训练的稠密向量。
2.3 稠密向量让不同对象能够共享特征
我们可以给每个 token 分配一个 维向量,例如 ,其中每个坐标都是可学习的浮点数。不同 token 可以在多个方向上接近或分离。
不要急着把第 1 维叫作“动物程度”、第 2 维叫作“可爱程度”。真实模型的语义通常分散在许多维度和方向的组合中。稠密向量提供了表达关系的能力;训练任务决定这种能力最终用来表达什么。 随机初始化的一张向量表不会自动懂中文。
三、Embedding 层的实现:就是查一张可训练的表
3.1 一行对应一个 token
最基础的 embedding 层包含一个参数矩阵:
第 个 token 的初始向量是 的第 行:
当输入是一批 token ID,形状为 时,查表后的结果就是 。输出多出来的最后一个维度,正是每个 token 的向量坐标。
这个操作还可以写成矩阵乘法。把 token 的 one-hot 写成行向量 ,就有:
因为 除第 个位置外都是 0,乘法结果只留下 的第 行。实际框架直接按 ID 索引,避免真的生成巨大的 one-hot。PyTorch 的 nn.Embedding 就提供这种查表行为。PyTorch Embedding 文档
3.2 先运行一个能看见梯度的例子
如果没有 Python 依赖,可以在独立虚拟环境安装:
1 | python -m venv .venv |
上面的安装命令会取得当前环境可用的版本,不等于复刻文章开头的现有环境。需要严格复现时,应保存自己验证通过的依赖锁文件,并固定模型 revision。
下面文件可以独立运行,也可以点击代码框的 view raw 下载:
关键输出是:
1 | shape: (2, 3, 4) |
这里 num_embeddings=6 表示词表有 6 行;embedding_dim=4 表示每一行有 4 个参数。它不是“输入 6 维、输出 4 维的句子模型”。
ids 中“喜欢”的 ID 3 出现两次,于是它收到两次梯度贡献。“骨头”没有出现,在本例中梯度为 0。ID 0 被设为 padding_idx,作为补齐占位符,不通过这次 embedding 查表反向传播接收梯度。vectors.sum() 只是为了容易看出梯度数值,这个损失本身不会教会模型语义。
3.3 为什么重复的 ID 会累加梯度?
将所有输入位置记为 ,位置 的 token ID 为 。因为多个位置可能读取同一行参数,链式法则给出:
也就是说,参数是共享的;每次使用它产生的梯度,最终都汇总到同一行。示例把所有输出坐标相加,因此每次使用某行,该行的每个坐标都会获得 1 的梯度;使用两次就得到 2。
使用学习率 的普通 SGD 后,ID 3 这一行的每个坐标都减少约 0.2:
注意两个边界:这里讨论的是查表路径的梯度;如果词表还被共享给输出分类器,别的计算路径也可能产生梯度。另外,“某行本步梯度为 0”不保证任何优化器都会让它纹丝不动,动量、权重衰减和历史状态也会影响参数更新。本例特意使用没有这些因素的 SGD。
3.4 参数量到底有多大?
如果 ,,词表矩阵包含:
个参数。按 float32 的每个参数 4 字节计算,单独的权重约为 153.6 MB,即约 146.5 MiB。训练时还要考虑梯度、优化器状态和其他网络层;不要把词表权重的大小当成整个模型的训练内存。
四、训练怎样让向量开始有意义?从 Word2Vec 看起
4.1 “出现在相似上下文中”是一种可利用的信号
如果“猫”和“狗”常出现在“喂养、宠物、喜欢”等相似上下文里,预测上下文的模型有机会学到它们共享的特征。这是分布式词表示的基本出发点。
它不等于“经常相邻的词就是同义词”。“医生”和“医院”经常关联,但不是同义词;“增加”和“减少”可能出现在相似语境里,却有相反含义。词向量反映训练统计关系,不能直接当作逻辑系统。
Word2Vec 的两种经典思路是:CBOW 用上下文预测中心词,Skip-gram 用中心词预测上下文词。下面选后者展开。Word2Vec 原论文
4.2 先把语料变成监督样本
假设句子已经按教学需要分好词:
1 | 猫 喜欢 吃 鱼 |
设置窗口半径为 2,以“喜欢”为中心时,附近的“猫、吃、鱼”可以分别构成一个训练目标:
1 | (中心=喜欢, 上下文=猫) |
监督信号来自语料本身,不需要人工给每一对词写标签。这属于自监督学习的典型思路:由数据构造预测任务,再通过任务学习表示。
4.3 两张表、一次点积、一项损失
Skip-gram 可以使用两张参数表:中心词表 和上下文词表 。这里的 是矩阵名称,与前文表示词表大小的标量 区分开。
从中心词表取出 ,从上下文表取出候选词的 ,点积给出匹配分数:
通过 softmax 得到所有上下文候选的分布:
对真实上下文 ,负对数似然损失是:
如果真实上下文的概率偏低,损失就大。优化器通过梯度改变两张表,让这个上下文相对于其他候选更容易被预测到。
4.4 把“拉近、推远”落实到梯度上
令 ,中心向量的梯度可以写成:
执行梯度下降时, 会增加真实上下文方向的贡献,同时减去当前预测分布的加权平均方向。上下文表也随之调整。
这比“模型觉得猫和狗很像,所以把它们放一起”更接近实际发生的事情:模型先努力降低预测损失,词向量中的结构是在大量更新过程中形成的。 猫和狗不必直接构成正样本;相似的上下文预测需求也可能塑造相似的表示。
还要注意:提高点积并不严格等于缩小欧氏距离,向量的长度也会影响点积。“拉近”是直觉描述,真正受到优化约束的是所使用的分数与损失。
4.5 训练一个最小 Skip-gram
这个例子用空格分词、固定窗口和完整 softmax,省去数据管道的复杂度,让训练过程可以一眼看清:
本机得到的输出为:
1 | vocab=14, pairs=72 |
这说明参数确实通过训练改善了上下文预测;“狗”也成为“猫”的最近邻。但后面的“香蕉”提醒我们:八句话不足以训练可靠的通用语义模型。这个实验验证训练机制,不证明模型掌握了现实世界的知识。
为什么损失没有降到 0?同一个中心词在这份数据里对应多个不同上下文,模型仅看中心词,不可能每次都把某一个上下文预测成概率 1。最优分布还受数据中的条件不确定性影响。检查训练应关注目标是否合理、损失是否改善,以及独立验证集表现,不能单纯追求训练损失归零。
代码中的关键形状是:中心 ID 为 ,其中 是训练词对数;查表后为 ;乘以上下文表转置 后得到 ,恰好对应每个词对对全部上下文词的分类分数。
4.6 大词表为什么需要负采样?
完整 softmax 每个样本都要对整个词表算分。大词表下,可以改为区分真实词对与采样得到的噪声词对。设正上下文为 ,负样本为 ,一项负采样损失是:
是 sigmoid。第一项鼓励真实词对获得较高分数,后面的项抑制采样噪声词对的分数。原始工作研究了按词频的 次幂构造采样分布的方法。负采样论文
负采样改变了训练目标,不能把它直接解释成“精确算出了完整 softmax 的概率”。上面的教学代码也没有使用负采样;词表只有 14 个词,完整计算更便于理解。
五、真实文本进入模型前,还要经过 Tokenizer
5.1 字符、词、token 并不是同一个东西
在刚才的教学代码里,我们用空格把“猫 喜欢 吃 鱼”切成词。真实模型一般有自己固定的 tokenizer,可能使用字符、子词或字节片段。英文单词可能被拆成多个子词,中文词语也可能被拆成单字或更长片段。
因此不能简单认为“一万个汉字就等于一万个 token”,也不能在换模型时保留原模型生成的 token ID。ID 17 对应什么,由特定 tokenizer 的词表决定。
Tokenization 通常包含以下工作:文本规范化与切分、子词编码、映射为整数 ID、添加特殊 token,以及按 batch 做 padding。具体步骤取决于模型配置,不应手动猜测。
5.2 Attention mask 为什么必不可少?
一个 batch 内的句子长度可能不同,但张量往往需要对齐:
1 | 文本 A:[CLS] 猫 喜 欢 鱼 [SEP] |
这里只是补齐机制的示意,不声称某个 tokenizer 一定这样切分。Mask 告诉模型哪些位置是真实输入、哪些只是凑齐张量形状的占位。Padding 不是句子内容,应该在注意力和需要的池化步骤中正确处理。
特殊 token 也不全是 padding。[CLS]、[SEP] 常常具有有效的 attention mask;做平均时是否包含它们,要遵守具体模型的训练约定。
5.3 截断是信息丢失,不能靠高维向量补回来
如果一个模型最多接收 512 个 token,而原文有 2 000 个 token,直接设置 truncation=True 会截掉超过长度限制的部分。被截掉的段落根本没有参与前向计算。
因此,“模型返回了一个向量”不表示“整篇文章都被理解了”。对于长文档,应先设计切块策略,保留标题、段落结构和来源信息,再分别编码;后文会讲如何检查切块是否合理。
六、Transformer 怎样让同一个词获得不同表示?
6.1 查表得到的是起点
“我刚吃了一个苹果”与“苹果发布了新款电脑”里的“苹果”,可能使用相同的 token ID 或相同的 token 片段组合。初始查表向量可以相同,但我们希望最终表示能反映不同语义。
Transformer 通过上下文交互来完成这件事。对于采用绝对位置 embedding 的典型 BERT 式输入,可将初始表示概念性地写为 token、位置以及适用时的 segment 表示之和,再做相应处理:
这不是所有 Transformer 的统一输入公式:有的没有 segment embedding,有的通过相对位置机制或 RoPE 让注意力感知位置。理解经典结构后,仍需以实际模型配置为准。BERT 论文
6.2 自注意力把哪些信息混到一起?
先忽略 batch 维度,设当前层输入 。单个注意力头先做三个线性投影:
表示当前各位置用于匹配的查询, 表示可被匹配的键, 表示实际被聚合的内容。这里的 不是词表大小,也不是 Word2Vec 中的参数表。
注意力计算是:
是 mask 对应的加性矩阵:允许关注的位置通常加 0,禁止的位置加一个极大的负数;softmax 沿每个查询对应的候选位置归一化。对位置 来说,输出就是对各位置 value 的加权求和。
“吃、水果”等上下文会影响第一句里“苹果”相关位置的更新,“发布、电脑”会影响第二句里的更新。多头注意力提供多组不同投影,前馈网络、残差连接和归一化继续变换表示,叠加多层后形成更丰富的上下文特征。Transformer 原论文
所以,model(...).last_hidden_state 通常已经不是简单查表结果。对于编码器,它常见的形状是 ,每个位置的向量都经历了上下文计算。注意力矩阵本身也不是句向量,更不能直接把某个注意力权重当作模型判断的完整解释。
6.3 语言模型训练目标与检索目标还有一段距离
掩码语言模型学习预测被遮住的 token;自回归语言模型学习预测后续 token。这些任务会让隐藏状态包含有用信息,但不自动保证“取出某个隐藏状态后,用余弦相似度就能做好句子检索”。
检索希望两个独立编码结果的相似度能对应任务相关性,而语言建模并不一定直接优化这个量。这也是句向量模型通常需要额外训练设计的原因。Sentence-BERT 论文
七、怎样把多个 token 向量变成一个句向量?
7.1 Pooling 是序列到固定向量的接口
检索系统一般希望每篇短文或每个文档块对应一个向量。编码器却返回 个位置的隐藏状态,因此需要 pooling,也就是把序列表示聚合起来。
常见做法包括:取最终层的 [CLS] 位置;对有效位置做平均;按模型约定取最后一个有效 token;或者使用学习得到的加权聚合。后面还可能有投影层,把 维变成 维。
这些做法并非可以随意替换。模型训练时使用哪个表示参与损失,推理时就应遵守相同的计算约定。 不能因为平均池化“看起来更公平”,就擅自替换一个经过 CLS 池化训练的模型。
7.2 Masked mean 的公式
设 表示位置是否有效,masked mean 为:
分母写上 max(..., 1) 是为了防止零除;它不表示空文本或完全无效输入就有可靠语义。实际系统应该在输入端确定空文本如何处理。
如果两个有效 token 的向量是 、,还有一个 padding 位置为 ,普通平均会得到约 ,正确排除 padding 后则是 。真实 PAD 隐藏状态不一定是 0,不能指望它自己消失。
下面展示池化函数以及稍后要讲的对比损失。前半部分可以独立理解:
7.3 归一化是长度处理,不是重新理解语义
常见的 L2 归一化为:
对于正常的非零向量,归一化后长度为 1,因此点积只反映方向相似性。归一化不能让没有经过合适训练的表示突然变好;它只是与打分方式配套的数学操作。
同样,并不是所有检索模型都应该强制归一化。某些模型的内积训练目标可能利用长度信息。正确做法是按模型说明使用相应的 pooling、归一化和相似度函数。
八、句向量怎样训练?对比学习把目标写得更直接
8.1 先定义什么算正确配对
一个检索训练样本可以包含:
- Query:账号密码忘了怎么办?
- Positive:忘记密码后,通过邮箱验证即可重置。
- Negative:进入个人资料页面上传新头像。
正样本与 query 不一定是同义改写;它也可以是“能够回答这个问题的文档”。因此,对于检索,更准确的说法是“任务相关性”,而不仅仅是字面语义相似度。
其他任务可能用同义句、翻译对、相邻段落或数据增强结果构造正样本。样本关系不同,学到的空间也会不同。SimCSE 等工作展示了对比训练在句向量学习中的应用,但具体的正负样本构造不能混为同一种方法。SimCSE 论文
8.2 一个 batch 就能形成多组候选
假设 batch 有 个配对 。查询和文档分别编码为归一化向量,把第 个 query 与第 个 document 的分数记为:
是温度。对每一行做 softmax,把对角线对应的正确文档作为标签:
这就是一种单向的 batch 内对比目标。它鼓励正确文档在其他候选中获得更高排名。也可以按任务设计反向目标或对称损失,但不是每个检索任务都需要两边完全对称。
下面接着前面池化文件的内容,展示这个损失如何对应到几行 PyTorch。此处直接创建可求导的向量,只验证矩阵和梯度,不包含一个完整 Transformer 训练器:
q @ d.T 得到 的分数矩阵,labels = [0,1,2] 说明第 0 行应选第 0 列,以此类推。cross_entropy 内部完成稳定的 log-softmax 与负对数似然,不应先手动 softmax 再把概率当作 logits 传进去。
真实训练里,q_raw 和 d_raw 应由编码器前向计算产生。损失反传会穿过归一化、pooling、Transformer,最终到达 token embedding 等可训练参数;如果部分层被冻结,就只更新允许训练的部分。
8.3 温度、难负样本与假负样本
温度减小会放大 logits 之间的差异,也改变梯度尺度和训练关注点。它是需要结合任务选择的超参数,不是越小越好。示例里的 0.1 只用于说明计算,与后面 BGE 模型的具体训练温度无关。
容易负样本可能与问题毫不相干,模型很快就能区分。难负样本则看起来很像,例如“修改密码”和“找回密码”的说明,能迫使模型学习更细的差异。
但难负样本也更容易标错:一篇“邮箱验证重设登录凭证”的文章,可能与已有正样本一样能回答问题。如果把它当负样本强行推开,训练目标就自相矛盾。这叫假负样本问题,可以通过去重、人工抽查、多正样本目标或适当 masking 等方式缓解。
正样本、负样本和任务定义都不可靠时,仅仅堆大模型或增加向量维度,并不能自动修复数据问题。
九、向量生成之后,怎么判断两个对象是否接近?
9.1 三种常见打分方式
对非零向量 和 ,余弦相似度定义为:
它衡量方向接近程度,理论取值范围是 。点积则直接计算 ,同时受方向与长度影响。欧氏距离计算 ,值越小越近。
如果两边都归一化到单位长度,就有:
因此,在同一批单位向量上做精确排序时,最大点积、最大余弦和最小欧氏距离给出的顺序一致。若只归一化了一边,或者实际索引使用了量化、近似搜索等手段,就不能直接套用“结果一定完全一致”的结论。
9.2 用一个能手算的例子检查理解
设 ,,,它们的长度都是 1。于是:
与 方向更接近。但如果把 改成 ,未经归一化的点积会因长度改变而剧烈变化。这个例子说明:选什么距离,并非无关紧要的接口参数。
9.3 相似度 0.8 不表示“80% 正确”
一个 embedding 分数通常没有经过概率校准。0.8 可能在某种模型上很高,在另一种模型或另一类文本中却很普通。甚至不相关文本也可能获得明显为正的余弦分数。
检索首先依赖相对排序。如果要设置“低于多少就不回答”的阈值,需要用自己的相关/不相关样本校准,并观察精确率、召回率和误拒绝率。更换模型、指令或文档分块方式后,旧阈值也需要重新评估。
十、完整实战:用中文 BGE 模型做语义搜索
10.1 为什么用这个模型作为教学例子?
这里选用 BAAI/bge-small-zh-v1.5,因为它支持中文、可以在本机 CPU 上运行,并且编码步骤容易展开。这是一个便于复现的教学选择,不是对当前模型排行榜的推荐。
本例固定模型 revision 为 7999e1d3359715c523056ef9478215996d62a620。这个中文 small 模型输出 512 维向量,使用最终层 CLS 池化,输入长度按 512 token 控制;不要与输出 384 维的英文 small 版本混淆。对于短查询检索长文档,官方示例建议给查询添加中文检索指令,文档不添加该指令。BGE 模型说明、模型配置、池化配置
我们准备五篇很短的候选文档:重置密码、修改头像、向量检索、订单物流、梯度更新。用三个表达不同的问题检索,观察系统能否找到正确主题。
10.2 编码、打分、排序和保存,一次走完
第一次运行需要能够访问模型仓库并下载权重;后续可以使用缓存。调用路径完全在本机推理,不需要付费 API Key。如果需要彻底离线运行,应先准备好这个 revision 的完整模型与 tokenizer 文件,再启用离线模式或传入本地模型目录。
把这段代码按职责分解:
- 加载编码器:权重、tokenizer 和 pooling 配置共同决定输入如何变成向量。
- 预先编码文档:
doc_vectors的形状是 ,一篇文档一行。这部分可以离线执行。 - 编码查询:三个问题加上适用的检索指令后,得到 的查询矩阵。
- 矩阵乘法打分:,每一行就是一个问题对全部候选的分数。
- 按行降序排序:取分数最高的三项,返回对应原文。
- 保存向量与元数据:保留模型版本、编码约定和原文顺序,避免以后只拿到一堆无法对应来源的数字。
模型内部的 token 序列长度可以不同,但 pooling 后每篇文档都只有一条固定长度向量。也正因为输出长度固定,长文本中的细节可能被压缩;固定维度不是“无限容量”。
10.3 实际运行结果
本机运行时三个查询的首选结果如下,数值保留四位小数:
| 查询 | 首选文档 | 余弦相似度 |
|---|---|---|
| 账号密码忘了该怎么办? | D1:通过邮箱验证重置密码 | 0.6640 |
| 怎样按语义搜索文章? | D3:把问题与文档编码后按相似度搜索 | 0.5182 |
| 模型参数是怎么学出来的? | D5:反向传播计算梯度,优化器更新参数 | 0.4961 |
这里第三个问题的第二名 D3 得分为 0.4861,与第一名只差约 0.01。这个细节很有价值:第一名正确,并不意味着模型已确信无疑。向量分数很接近时,可能需要更多上下文、更合适的训练数据或额外的 reranker。
这只是五篇候选、三个问题的功能演示,不能用它宣称模型在真实知识库上达到某种准确率。后面讨论的评估集,才负责判断实际质量。
10.4 查询向量和文档向量为什么能放在一起比较?
本例两边使用相同的编码器,并按训练约定给查询添加指令,所以两种输入被映射到兼容的表示空间。查询指令是模型输入的一部分,会经过 tokenizer 与 Transformer 参与计算。
其他检索系统可以使用不同但联合训练的 query encoder 和 document encoder。关键不是“必须同一段代码”,而是两边经过配套训练与配置,输出空间兼容。Sentence Transformers 语义检索文档
如果模型 A 与模型 B 都输出 512 维,并不代表可以把它们的向量直接点积。维度一样仅保证乘法合法,不保证坐标系统和语义约定一致。模型升级后,应重新编码文档并重建或迁移索引,除非有明确且验证过的兼容方案。
10.5 保存向量时,应该一起保存什么?
示例保存了 documents.npy 和 metadata.json。实际工程还应记录每个文档块的稳定 ID、原文版本、来源位置、切块策略与权限范围。至少要能够回答三个问题:这个向量来自哪段文字、由什么模型怎样计算、查询时是否仍满足相同约定。
仅把 float 数组存进数据库不够。原文一旦更新、顺序一旦变化,或者新旧模型结果混在一起,就可能出现“检索分数正常,但返回了错误段落”的隐蔽问题。
十一、拆开封装:一次 encode() 内部究竟发生了什么?
Sentence Transformers 把多项步骤封装到 encode() 中。为了看清机制,下面直接使用 Transformers 完成本例 BGE 的前向计算:
实测张量形状为:
1 | input_ids: (2, 35) |
两个字符串构成 batch,所以第一维是 2;较长输入经 tokenizer 编码后的长度是 35,所以另一个输入补齐到 35;每个位置输出 512 个数。取最后一层位置 0 的向量后,长度维被消去,得到 。
这段代码有几个很容易写错的地方:
encoder.eval()关闭 dropout 等训练行为,不会自动关闭梯度记录。torch.inference_mode()关闭推理时不需要的自动求导记录,不会替代eval()设置。hidden[:, 0, :]取的是经过多层上下文计算后的 CLS 隐藏状态,不是最初词表里[CLS]那一行。- 本例取 CLS 是为了遵守 BGE 的训练与发布配置,不能据此推断所有模型都应取位置 0。
- 示例在 CPU 上运行。如果把模型移动到 GPU,也应把输入张量移到相同设备。
手动路径的 0.6639 与前面封装路径的 0.6640 在显示到四位小数时略有差别。两段代码的 batch 组成、补齐长度以及可能选用的计算路径不同,浮点运算可能产生小差异;不要要求不同批处理方式逐位一致。若差异明显,则应优先核对 tokenizer、模型 revision、pooling、指令与归一化配置。
还要分清两个阶段:训练阶段通过梯度改变模型参数;推理阶段使用已经训练好的参数生成新文本的向量。将自己的文档送入 encode() 并存入数据库,通常是在建立索引,并没有把新知识训练进 embedding 模型的权重。
十二、从五篇短文到真实知识库:切块、索引、精排与 RAG
12.1 文档切块决定模型到底看见了什么
一篇长文包含多个主题时,整篇压成一个向量可能难以突出某个具体细节。更常见的做法是先按章节或段落切成 chunks,再为每个 chunk 生成向量。
切块过小,容易缺失定义、条件和指代关系;切块过大,容易主题混杂或触发截断。重叠可以保留边界信息,但也会增加存储与重复候选。一个可操作的调试起点是:按完整段落聚合,并尝试每块 256~384 token、少量边界重叠,再用真实问题比较效果。这只是针对本文 512 token 示例模型的起始实验范围,不是通用最佳值。 标题、指令和特殊 token 也会占用输入预算。
例如原文说“这种方法在 batch 很小时效果不好”,如果切块后只留下这一句,却丢了上一段对“这种方法”的定义,再好的模型也很难补回确切指代。可以在块前附上文档标题和章节标题,但要避免把所有导航、页脚和广告都混入正文。
12.2 精确检索与近似检索分别解决什么问题?
示例用矩阵乘法扫描所有文档,对一个查询的主要打分成本约为 。小语料上直接精确搜索很容易调试,也适合成为评估近似索引的基准。
当 很大时,可以使用 HNSW、IVF 等近似最近邻方法,在速度、内存和召回之间取舍。这里改变的是查找过程,而不是文本编码模型的语义能力。近似索引可能漏掉本来应该入选的候选,因此应单独测量它相对于精确向量搜索的召回。Faiss 索引说明
再估算一次容量:100 万条 768 维 float32 向量需要 GB,约 2.86 GiB。这仅是原始向量,不包含索引结构、ID、文本、元数据、数据库副本等开销。
12.3 为什么常常还需要关键词检索和 reranker?
Embedding 擅长处理表达方式不同但相关的文本;对精确编号、函数名、错误码、日期或细小的否定关系,单纯的语义向量可能不够。例如 ERR_1042 与 ERR_1043 只差一个字符,含义却可能完全不同。
可以把关键词检索与向量检索组合成混合检索,再通过排名融合或后续精排选择候选。不要直接把 BM25 分数与余弦分数随意相加,它们不在同一量纲上;需要校准、权重实验或基于排名的融合方法。
Reranker 通常把 query 和候选文档一起输入 cross-encoder,让两边 token 直接交互,输出相关性分数。它适合对少量已召回候选重新排序,因为每个 query-document 对都要计算一次,难以像双编码器那样把所有文档向量一次算好反复复用。Retrieve & Re-Rank 文档
12.4 RAG 怎样接上这条链路?
典型流程是:文档预处理与切块,计算并存储文档向量;收到问题后计算查询向量,检索相关块;可选地精排和去重;把检索到的原文内容与来源放入生成模型的上下文,让它依据这些材料作答。
这里传给生成模型的通常是被找回的文本,而不是那一串 512 维浮点数。Embedding 模型负责表示,索引负责找候选,生成模型负责结合问题和材料生成回答。某一个环节出错,最终答案都可能出错。
向量命中也不等于事实核验:一段过期材料可以与问题高度相关,一段错误解释也可能具有很高相似度。检索系统还需要日期、版本、来源和访问权限等元数据,才能把“相似”进一步约束为“适合当前用户使用”。
十三、怎样证明一个 Embedding 系统真的好用?
13.1 准备一小份可信的评估集
先从实际用户问题中抽取一批查询,为每个查询标注哪些文档块能回答问题。包含常见问题,也包含歧义、精确编号、否定表达、超长问题以及知识库没有答案的问题。
评估集不应与训练或调参数据完全重合。比较模型、指令与切块方案时,应尽量固定其他因素,否则很难知道提升来自哪里。
13.2 用指标定位问题所在
| 指标 | 它回答的问题 | 使用时的注意点 |
|---|---|---|
| Recall@k | 标注的相关条目有多少出现在前 k 个结果中? | 需要知道每个 query 的相关集合 |
| Hit@k | 前 k 个结果里是否至少命中一个相关条目? | 不等于一般定义下的 Recall@k |
| MRR | 第一个相关结果排得多靠前? | 无命中查询的倒数排名通常记 0 |
| nDCG@k | 高相关文档是否排在更前面? | 可以利用分级相关性标签 |
| 延迟与吞吐 | 用户等待多久,单位时间处理多少请求? | 分开统计编码、检索、精排耗时 |
对单个 query,若相关集合为 ,返回的前 个条目为 ,则:
有 3 篇相关文档,而前 5 个结果命中其中 2 篇时,Recall@5 为 ,Hit@5 为 1。若每个问题恰好只有一篇相关文档,两者才会对该问题一致。对于没有相关文档的查询,应另行评估拒答与误检,不能直接在这个公式中除以 0。
还要区分两类召回:任务召回比较返回结果与人工标注的相关文档;ANN 索引召回比较近似检索与精确向量搜索的结果。前者检验整个表示与检索方案是否有用,后者检验索引是否忠实地找到向量邻居。
13.3 按症状排查,比盲目换模型更有效
| 症状 | 优先检查 |
|---|---|
| 同一段话重复编码差异很大 | 是否 eval();模型版本、输入预处理、精度与设备是否一致 |
| 短句能搜,长文细节搜不到 | 是否截断;切块是否丢失上下文;标题有没有保留 |
| 返回的文档与分数对不上 | 向量行号、chunk ID 与原文映射是否错位 |
| 换模型后效果突然下降 | 查询和文档是否使用不同模型/不同维度;旧索引是否重建 |
| 否定句、型号、错误码总混淆 | 是否需要关键词约束、混合检索或精排 |
| 几乎所有分数都很高 | 阈值是否照搬;输入是否重复;模型分数分布是否本来如此 |
| 近似检索比精确检索差很多 | 距离类型、归一化与索引参数是否匹配 |
| 小样本好、上线后差 | 评估集是否覆盖真实问题,语料是否存在领域差异与过期内容 |
十四、几个值得反复澄清的问题
14.1 每个维度能不能直接解释成一个概念?
通常不能。一个有用的思考实验是:若将所有向量乘以同一个正交矩阵 ,满足 ,则点积和长度保持不变,相似度排序也保持不变。但每个坐标轴已经旋转了。
既然坐标旋转后功能不变,就没有理由断言某个固定编号的维度必然对应唯一的人类概念。可以研究方向、子空间与探针,但那需要额外实验,不能凭看几组数字命名坐标。
14.2 维度越高,一定越好吗?
不一定。维度增大可能提高表示容量,也增加存储、传输和搜索成本。训练数据、目标、领域适配和输入处理都可能比维度更重要。
同样,不能把一个 768 维向量随手切成前 256 维,就宣称获得等价的小向量模型。只有模型明确支持可变维度输出,或者使用了经过训练与验证的降维方案,才应按相应方法操作。
14.3 二维散点图能不能证明模型理解了语义?
不能。PCA、t-SNE、UMAP 等把高维结果压到二维时,都会改变或舍弃一部分结构。可视化适合帮助发现模式和异常,但“图上分成几团”不等于检索质量好,也不应把二维图里的距离直接当作原空间距离。
本文前面的点位图因此明确标成示意图;实战部分直接展示真实高维向量计算得到的分数矩阵,避免把手绘点位当作模型实验。
14.4 Embedding 是压缩、加密还是摘要?
它是为任务学习的表示,可能丢失大量原文细节,通常不能像无损压缩那样按固定算法恢复原文。它也不是自然语言摘要,向量中的每个数不对应摘要里的一句话。
更不能把它当作安全加密。不要因为数据库里存的是浮点数,就忽略访问控制或认为数据可以随意公开。这里最实用的判断是:向量属于从原始数据计算出的派生数据,应结合原文的使用边界来管理。
14.5 能不能用聊天模型的隐藏状态直接做检索?
技术上可以提取隐藏状态,但必须确定取哪一层、哪个位置、怎样 pooling,以及查询与文档如何输入。生成效果好不代表这些向量已经针对检索校准,质量应以实际评估为准。
如果一个模型专门提供 embedding 能力,就应使用它定义的编码流程;不能把任意接口返回的 token logits、注意力权重或 KV cache 当成可互换的句向量。
14.6 从零训练、微调、调用现成模型,分别适合什么时候?
学习底层机制时,从零训练小模型很有价值;搭建一般检索原型时,先使用任务合适的现成编码器,能更快暴露数据与切块问题;当领域术语、问题表达或业务相关性与预训练任务差异明显时,再考虑用可靠配对数据微调。
微调前先保存基线评估结果。否则训练损失下降了,却无法判断原本能处理的问题是否退化,也无法知道性能提升是否值得额外维护成本。
十五、把完整过程串起来
现在重新看“账号密码忘了该怎么办?”这句话,系统可以依次完成这些实际操作:
- 按模型要求补上查询指令,交给配套 tokenizer。
- 获得整数 ID、特殊 token、padding 与 attention mask。
- 通过可训练词表查出初始 token 向量,并按架构加入位置信息。
- 经过多层 Transformer,让各位置结合上下文更新隐藏状态。
- 按训练约定取 CLS、masked mean 或其他聚合结果,得到固定维度向量。
- 按模型约定投影或归一化,用匹配的打分方式与文档向量比较。
- 取回高分条目的原文,必要时精排,再用于搜索展示或 RAG 回答。
而这整条链之所以可能有用,是因为更早的训练阶段已经用语料、配对关系、损失函数和梯度更新,塑造了编码器的参数。数字的计算过程由网络结构决定,数字所保留的关系由训练任务与数据塑造,最终有没有用则要由真实任务检验。
如果只做三次练习,建议先运行 01_lookup.py 看梯度如何回到词表,再修改 02_skipgram.py 的语料观察邻居变化,最后在 04_search.py 里加入自己博客的段落和真实查询。把输出形状、分数与原文逐项对上,Embedding 就会从一个名词变成可检查的计算过程。
代码下载与参考资料
五份代码均为本文教学示例,可以独立保存运行;03_pooling_loss.py 在正文中分成两段解释,下载文件包含完整内容。
- 01_lookup.py:查表、one-hot 等价与梯度
- 02_skipgram.py:从零训练最小 Skip-gram
- 03_pooling_loss.py:masked mean 与对比损失
- 04_search.py:中文 BGE 检索与向量保存
- 05_manual_encoder.py:展开 tokenizer、编码器与 CLS 池化
原始论文与官方说明,按学习顺序排列:
- PyTorch:Embedding — 查表层的形状、参数及 padding 行行为。
- Efficient Estimation of Word Representations in Vector Space — Word2Vec 的经典建模思路。
- Distributed Representations of Words and Phrases and their Compositionality — Skip-gram 与负采样。
- Attention Is All You Need — Transformer 和缩放点积注意力。
- BERT — 双向上下文表示与预训练。
- Sentence-BERT — 通过配套训练得到可用于相似度比较的句向量。
- SimCSE — 句向量对比学习。
- BAAI/bge-small-zh-v1.5 模型卡 — 本文实战模型的指令与编码约定。
- Sentence Transformers:Semantic Search — 查询/文档编码与语义检索。
- Sentence Transformers:Retrieve & Re-Rank — 召回与精排的组合。
- Faiss:Indexes — 索引类型与存储、搜索权衡。
