Transformer 编码器如何实现向量"吸收":一句"句内 RAG"讲透
上一篇《向量数据库原理》的 §12,我用”库存不足”带你走了单头注意力的数字:算出来”库存”的新向量里,混进了约 52% 的”不足”。你当时追问的那句——“Transformer 编码器,是怎么实现向量’吸收’的?”——那一节为了不打断主线,把很多零件一句话带过了。
这篇就是它的放大镜:把”吸收”这个动作,拆到每一个零件、每一行代码。
先立一句话贯穿全文,后面所有内容都在给这句话做注脚:
吸收 = 每个词对整句话做一次”句内 RAG”:用 Q 去查 K 定权重,按权重把 V 加权求和、加到自己身上(残差),再用 FFN 消化;堆十几层,让信息像涟漪一样扩散到整句。
你可能会觉得”又是 Q/K/V、又是注意力”老一套。放心,这篇不重复 §12 的数字,而是回答 §12 当时”一句话带过”的四件事:多头是什么、残差为什么不能省、FFN 在干嘛、为什么堆十几层就能看见整句。
一、先定一个锚:吸收 = 句内 RAG
你已经在《企业级记忆知识库》里把 RAG 摸透了,那我们就直接拿它当坐标。RAG 的流程你闭着眼睛都能背:
外部向量库里的文档 → 切成块 → embedding 成点 → 存进向量库
用户提问 → embedding 成 Q → 和向量库里的 K 算相似度 → softmax 得分
→ 按得分加权取回文档 V → 拼进上下文
注意力干的事,就是这套流程,只是把”外部向量库”换成了”这句话本身”:
这句话里的每个词,都是一个"点"(1536 维向量)
对词"它":
"它"的 Q → 和这句话里每个词的 K 点积打分 → softmax 成权重
→ 按权重把每个词的 V 加权求和 → 加到自己身上
一张表把两个世界对上:
| RAG 里的角色 | 注意力里的角色 | 含义 |
|---|---|---|
| 外部文档库 | 这句话里的所有词 | 检索对象 |
| 用户提问 embedding | 当前词的 Q | “我想找什么” |
| 向量库里的索引向量 | 每个词的 K | “我身上有什么标签” |
| 检索回来的文档 | 每个词的 V | “我真正传递的内容” |
| 相似度打分 + softmax | Q·K 点积 + softmax | 决定”吸谁、吸多少” |
| 把文档拼进上下文 | 把 V 加权求和加到自己身上 | “吸收”这个动作本身 |
记住这一条就够了:注意力就是”把外部检索搬到句子内部”,检索回来的不是文字,而是别的词的向量。 这就是”吸收”的全部秘密。
二、”吸收”这个动作,发生在哪一行代码
先回答最实的问题:如果把编码器写成 Java,“吸收”到底是哪一行?
答案:是把 V 按权重加权求和的那一行。Q 和 K 忙活半天,只是为了算出一张”权重表”;真正改变这个词向量内容的,只有这一行:
// attended[i] = Σ_j weights[i][j] * V[j]
// 词 i 的新信息 = 所有词 j 的 V,按"词 i 对词 j 的注意力权重"加权平均
float[][] attended = matMul(weights, V);
这一行之前,V 还是”每个词各自的内容”;这一行之后,每个词都变成了”所有词内容的加权混合物”。吸收发生在 matMul(weights, V) 这一句,别处都是铺垫。
后面你会看到,这一行再配上”残差”,就是完整答案。
三、Q/K/V:三个角色,其实只有 V 被搬运
§12 已经给了 Q/K/V 的定义,这里用一句话帮你钉死记忆:
Q 和 K 是用来”配对”的,V 才是真正被”搬运”的东西。
拿招聘类比(这是最贴的一个):
| 角色 | 招聘里的对应 | 说明 |
|---|---|---|
| Q | 岗位要求 / 我想要的 | 决定”我要找什么样的人” |
| K | 简历关键词 / 岗位标签 | 决定”这人符不符合” |
| V | 这个人本身 | 最后进公司干活、把能力带进来的,是这个人 |
注意一个反直觉的点:Q 和 K 配对(点积)只决定”这个人要不要录用、权重多高”,但它们自己不会进公司。 最后进公司、真正带来新能力的是 V——也就是”这个人本身”。
翻译回注意力:score = Q·K 算出来的是”词 i 该从词 j 身上抄多少”,但真正被抄走的、被加权求和的,是 V[j]。所以:
- Q、K 是”检索条件”;
- V 是”检索结果”;
- “吸收” = 按检索得分,把检索结果加权融合进自己。
四、完整走一遍:一次五步的”加权求和”
用一句稍微长点的话,专门看”它”这个字怎么吸收”苹果”:
句子:
他 递给 我 一个 苹果 , 它 很 红任务:编码”它”,让”它”的向量”知道”自己指代”苹果”。
第 1 步:每个词都乘三个矩阵,得到各自的 Q、K、V。(纯计算,Q=x·Wq、K=x·Wk、V=x·Wv。这里的 Wq/Wk/Wv 就是第 2 篇 §10 讲的”训练出来的数字矩阵”。)
第 2 步:拿”它”的 Q,去和每个词的 K 做点积打分:
score(苹果) = Q(它) · K(苹果) → 很大(训练让"它"和它指代的名词相关性高)
score(他) = Q(它) · K(他) → 较小
score(很) = Q(它) · K(很) → 较小
score(,) = Q(它) · K(,) → 很小
第 3 步:softmax 把分数压成”加起来等于 1”的权重:
苹果: 0.82 它: 0.07 很: 0.05 他: 0.03 其余: 0.03 (合计 = 1.00)
第 4 步:按权重把 V 加权求和——这就是第二节说的那一行”吸收”代码:
新向量(它) = 0.82·V(苹果) + 0.07·V(它) + 0.05·V(很) + 0.03·V(他) + ...
看到了吗?新向量里 82% 是”苹果”的干货。“它”这个向量的坐标,被”苹果”的内容大幅改写,于是这个向量现在”知道”:它 = 苹果。
第 5 步:叠加残差(关键,下一节单独讲):
最终(它) = 原向量(它) + 新向量(它)
到这里,一个”词吸收别的词”的完整动作就做完了。每一步都是矩阵乘法,没有魔法——这也正是第 2 篇 §10 说的”一坨矩阵乘法”。
五、为什么是”吸收”而不是”覆盖”——残差连接
这一节必须单独讲,因为“吸收”这个词能成立,全靠残差。
如果第 4 步写成 vec = attended(直接覆盖),那”它”自己原来的信息就被抹掉了,变成纯粹的”苹果”。这有两个问题:
- 原词信息丢了——”它”是个代词这件事、它的指代关系,全没了。一个词不能只剩别人。
- 深层网络训不动——信息被反复覆盖重写,梯度传不回去(就是《模型怎么跑起来》里讲的梯度那套),模型越深越难学。
所以真正的写法是:
// 不是 vec = attended(覆盖)
// 而是 vec = vec + attended(叠加增量)
float[][] out = add(x, attended);
语义上就是一句话:“保留我自己,再吸收别人。”
一个你熟的类比:
不是
git checkout把文件整个覆盖,而是git commit打一个增量补丁——历史还在,只是在上一次提交的基础上叠加了新东西。
每一层编码器都只是”打一个补丁”。十几层下来,向量里既保留了自己最原始的意思,又层层叠叠吸收了整句的上下文。这,才是”吸收”而不是”替换”。
六、多头:把一次”开会”拆成 12 个小组并行开
§12 只在最后补了一句”通常拆 12 个头各算一份再拼起来”,没展开。这里讲透。
为什么要拆多头?因为一个 Q/K/V 三件套,只能捕捉一种”相关关系”。而词与词之间的相关,是多种的:
- “它”和”苹果”相关,是指代关系;
- “递给”和”我”相关,是动宾关系;
- “苹果”和”红”相关,是主谓/修饰关系。
如果只有一套 Q/K/V,模型就不得不把这些关系”挤”进同一组权重里,互相打架。多头的思路很朴素:开 12 个并行的小组,每个小组有自己的一套 Wq/Wk/Wv,各自从不同角度去”配对”。
头1:可能学会了关注"指代关系"(它 → 苹果)
头2:可能学会了关注"动宾关系"(递给 → 我)
头3:可能学会了关注"修饰关系"(苹果 → 红)
...
头12:谁知道它学会了什么(这正是深度学习的魅力/黑盒)
最后把这 12 个小组算出的结果拼起来,再乘一个 W_O 合成一份:
// 12 个头,各算各的 attended,然后拼一起
float[][] multiHead(float[][] x) {
float[][] concat = new float[seqLen][headDim * 12];
for (int h = 0; h < 12; h++) {
float[][] one = attentionHead(x, h); // 用第 h 套 Wq/Wk/Wv
// 把 one 塞进 concat 的第 h 段
}
return matMul(concat, Wo); // Wo 把 12 份合成一份
}
Java 后端一眼懂:这就是”拆成 12 个线程各算一块,最后 join 汇总”的套路,只不过这里”线程”是并行的矩阵运算,”汇总”是乘一个 Wo 再合成一个向量。
顺带说一句:多头的”头”越多,每一头的维度越小(1536 ÷ 12 = 128),总计算量不变,但表达力更强。所以它不是”多算 12 遍更慢”,而是”把 1536 维拆成 12 个 128 维分头看”。
七、为什么点积后要除 √d——一个小细节,别跳过
第 2 步算完 score = Q·K,真实代码里还会除以 √d(d 是每个头的维度),再进 softmax:
scores = scale(scores, 1.0f / Math.sqrt(headDim));
为什么?因为点积是”维度越多,数值天然越大”:d=128 维时,点积是 128 项相加,容易变成一个很大的数。而 softmax 是 e^x,x 一大,e^x 会爆炸式拉开差距,导致权重几乎全给了一个词(饱和),梯度接近 0,训不动。
除一个 √d,本质是把分数拉到合理范围,让 softmax 别饱和。
Java 的直觉类比:防止整数溢出,先除一下再累加。目的不一样,但”数值太大要压一压”的手感是相通的。
八、吸收完要”消化”——FFN
注意力只负责”吸收”(把别的词的信息混进来)。混进来之后还是”生料”,编码器后面会再跟一个 FFN(前馈网络):
FFN(x) = W2 · ReLU(W1 · x + b1) + b2
拆开看,就三步:
W1 · x + b1:把 1536 维先放大,比如放大到 6144 维;ReLU:把负数砍成 0(”掰弯”,非线性,第 2 篇 §10 讲过);W2 · (…) + b2:再缩回 1536 维。
为什么要”放大再缩回”?类比一下:注意力是”收集资料”,FFN 是”关起门来消化整理”。资料混进来了,需要在一个更大的空间里重新组合、提炼,把有用的留下、没用的丢掉,最后浓缩回原来的维度。
注意一个分工上的关键区别:
| 组件 | 作用对象 | 方向 | 干的活 |
|---|---|---|---|
| 注意力 | 词与词之间 | 横向 | 收集、吸收别的词 |
| FFN | 每个词内部 | 纵向 | 消化、重组、提炼 |
两者交替堆叠,就是”上下文调整”的完整配方。第 2 篇 §10 那句”Transformer 就是一堆矩阵乘法”,在这里看得最清楚:注意力负责”收集信息”,FFN 负责”加工信息”,而两者都只是矩阵乘法。
九、LayerNorm:让每层输出”标准化”
§12 的流程里还有一步 LayerNorm,一句话带过就够,但要知道它为什么在:
一层层叠加下来,向量数值会越来越大、越来越飘。LayerNorm 就是把每个词向量重新归一化到”均值 0、方差 1”,让下一层从一个稳定的数值范围开始。
它不参与”吸收”,它是让”吸收”能稳定重复十几层的辅助件。类比:流水线每道工序之间,把半成品”校准”一下再交给下一道工序。
十、为什么堆十几层就能看见整句——涟漪扩散
一层的注意力,每个词只能”直接”吸收它关注的那些词(一般是邻近的、强相关的)。那”句首”怎么知道”句尾”?
答案是堆层。信息像水波涟漪,一层扩散一圈:
第1层:'它' 直接吸收到 '苹果'(一步到位的强关联)
第2层:'红' 吸收 '它' → 而'它'已含'苹果',所以'红'间接沾到'苹果'
第3层:更远的词又吸收到'红'……
第4层:……
每一层,都在上一层的”半成品”上继续互相吸收。十几层之后,哪怕句首和句尾隔着几十个字,信息也互相渗透了。
所以输出时,每一个词的向量,都等于”吸饱了整句上下文”——不再是孤立的”字典义”,而是”在这句话里的具体义”。这就是第 2 篇 §12.2 说的”库存”从”通用库存”变成”和’不足’在一起的那个库存”。
十一、接回 embedding:吸饱了之后,怎么变成一个点
你现在应该能回头看穿第 2 篇 §8 的那句话了:“一句话会变成一个 1536 维的点”。
那这个”点”是怎么来的?就是编码器输出的这一堆”吸饱了上下文”的词向量,再压一次:
float[] sentenceEmbedding(float[][] tokens) {
float[][] out = encoder(tokens); // 吸饱上下文:[seqLen][1536]
return meanPooling(out); // 每个维度取平均 → [1536]
}
有两种常见压法:
[CLS]向量:句子开头专门放一个特殊 token,编码器最后就取它(它已经通过注意力吸饱了全句);- 平均池化:把每个词的向量,按维度求平均,得到一个点。
无论哪种,这个点之所以准,是因为每个词先在内部互相”吸收”过一遍——不是把孤立词向量简单平均,而是把”上下文调整完”的向量平均。所以你现在能看穿整条链路:
词 → 嵌入表 → 1536 维向量 → 编码器反复"吸收上下文" → 池化 → 一个 1536 维的点
这个点,就是你扔进向量数据库、去做相似度检索的那个东西。
十二、Java 骨架代码串一遍
把前面所有零件拼成一段 Java(伪代码,只为讲清结构,非生产实现):
// 输入:一句话的词向量,seqLen 个词,每个 1536 维
float[][] selfAttention(float[][] x) {
// ① 三个矩阵乘出 Q/K/V(每头各一套,这里简化成单头)
float[][] Q = matMul(x, Wq); // [seqLen][d]
float[][] K = matMul(x, Wk); // [seqLen][d]
float[][] V = matMul(x, Wv); // [seqLen][d]
// ② 每个词的 Q 和所有词的 K 点积打分 → [seqLen][seqLen]
float[][] scores = matMul(Q, transpose(K)); // scores[i][j] = 词i对词j的相关性
scores = scale(scores, 1f / (float) Math.sqrt(d)); // 除以 √d,防 softmax 饱和
// ③ softmax 归一化,每行变成"权重",加起来 = 1
float[][] weights = softmaxRow(scores);
// ④ 按权重对 V 加权求和 —— ★ 这就是"吸收"的那一行
float[][] attended = matMul(weights, V); // attended[i] = Σ_j weights[i][j]·V[j]
// ⑤ 残差:保留原样 + 增量(不是覆盖,是吸收)
return add(x, attended);
}
// 一层完整的编码器
float[][] encoderLayer(float[][] x) {
float[][] attended = multiHead(x); // 12 个头各算一遍,拼起来乘 Wo
float[][] y = layerNorm(add(x, attended)); // 残差 + 归一化
float[][] z = ffn(y); // 消化:W2·ReLU(W1·y+b1)+b2
return layerNorm(add(y, z)); // 再残差 + 归一化
}
// 最后池化成一个点,拿去当 embedding
float[] sentenceEmbedding(float[][] tokens) {
float[][] out = encoder(tokens); // 堆 12~24 层 encoderLayer
return meanPooling(out); // → [1536]
}
对照着看,整篇就三句话:
matMul(weights, V)是”吸收”;add(x, attended)是”保留自己 + 吸收别人”(残差);ffn(...)是”消化”。
剩下的多头、LayerNorm、除以 √d,全是让这三步能稳定地、反复地做十几层的零件。
十三、和 RAG 逐条对照(收束前的总表)
既然开篇拿 RAG 当锚,结尾就再对一次账,让你彻底不晕:
| 步骤 | 外部 RAG | 编码器里的注意力 |
|---|---|---|
| 检索对象 | 外部文档切块后的向量 | 这句话里的每个词向量 |
| 查询 | 用户提问 embedding = Q | 当前词的 Q |
| 索引 | 文档的 K | 每个词的 K |
| 结果 | 文档的 V(原文) | 每个词的 V(词义) |
| 打分 | Q·K 相似度 | Q·K 点积 |
| 归一化 | softmax → 取 topK | softmax → 全句加权 |
| 融合 | 把文档拼进提示词 | 把 V 加权求和 + 残差加到自己身上 |
| 消化 | 让 LLM 理解拼进来的文档 | FFN 加工 |
一句话总结这张表:
RAG 是”把外部知识检索进来,喂给模型”;注意力是”把句内信息检索进来,融进每个词”。一个对外,一个对内,同一个套路。
十四、一句话收束
回答你最初的问题——”Transformer 编码器是怎么实现向量’吸收’的”:
它让每个词,都对整句话做一次”句内 RAG”:用 Q 查 K 定权重,按权重把每个词的 V 加权求和、用残差加到自己身上(保留自己、吸收别人),再用 FFN 消化;把这件事堆十几层,让上下文像涟漪一样扩散到整句。最后池化成一个点,就是那个能进向量库做语义检索的 embedding。
没有任何一步超出”矩阵乘法 + 归一化 + 残差”的范畴。你之所以之前觉得”吸收”玄乎,是因为教科书把它叫”自注意力机制”、用了一堆术语;拆到代码级,它就是一个带权重的加权平均 + 一个加法。
和《企业级记忆知识库》、《向量数据库原理》合成一条完整链路:知识怎么存、怎么检索、检索前那个 embedding 是怎么被”上下文吸收”出来的——三篇连起来读,就是”从一段文字到一个能命中你问题的点”的全过程。