3、Query、Key、Value的本质:信息检索系统的类比与线性变换
好,咱们接着聊。上一章我们把注意力机制的核心公式拆开了,这一章我重点讲讲 Query、Key、Value 这三个东西到底是个啥。
说实话,我第一次接触 QKV 的时候,也觉得这名字起得挺玄乎。什么查询、键、值,听着像数据库。后来做项目做多了才发现,这个类比其实特别贴切。你想想看,注意力机制本质上就是在做一件事:从一堆信息里,找到跟你当前需求最匹配的那部分。
3.1 信息检索系统的类比
咱们先抛开数学,用生活场景来理解。
假设你现在在图书馆,想找一本关于「Transformer 原理」的书。你会怎么做?
- 你脑子里有个查询(Query):我要找「Transformer 原理」相关的资料。
- 你走到书架前,每本书都有个标签(Key):比如「深度学习」、「自然语言处理」、「计算机视觉」。
- 你拿着你的 Query,跟每个书的 Key 做匹配。匹配度高的,你就抽出来看看。
- 抽出来的书,里面的内容(Value)才是你真正要读的东西。
你看,这个流程跟注意力机制一模一样:
- Query:当前时刻,你想关注什么?
- Key:每个输入位置,它「是什么」?
- Value:每个输入位置,它「有什么内容」?
核心思想:注意力机制就是让 Query 去跟所有 Key 做相似度计算,然后用这个相似度作为权重,去加权求和对应的 Value。
我在做机器翻译项目的时候,遇到过一个问题:模型在长句子上表现很差。后来发现,其实就是 Query 和 Key 的匹配出了问题。模型不知道该关注源语言的哪个词。嗯,这个类比帮了大忙。
3.2 从类比到数学:线性变换
好,类比说完了。现在咱们看看代码里是怎么实现的。
你可能会问:输入不是已经有一个向量了吗?为什么还要搞出 Q、K、V 三个东西?
答案很简单:为了学习不同的表示空间。
原始输入向量 x,它既承载了「我是谁」的信息(Key),也承载了「我有什么内容」的信息(Value),还要承载「我想找什么」的信息(Query)。如果都用同一个向量,那这三个角色就混在一起了,模型学不清楚。
所以,我们需要用三个不同的线性变换,把输入映射到三个不同的空间:
import torch
import torch.nn as nn
class QKVProjection(nn.Module):
def __init__(self, d_model, d_k, d_v):
super().__init__()
# 三个独立的线性层
self.W_q = nn.Linear(d_model, d_k)
self.W_k = nn.Linear(d_model, d_k)
self.W_v = nn.Linear(d_model, d_v)
def forward(self, x):
# x shape: (batch_size, seq_len, d_model)
Q = self.W_q(x) # (batch_size, seq_len, d_k)
K = self.W_k(x) # (batch_size, seq_len, d_k)
V = self.W_v(x) # (batch_size, seq_len, d_v)
return Q, K, V
你看,就是这么简单。三个 nn.Linear,每个都学自己的权重矩阵。
我个人习惯:d_k 和 d_v 通常设成跟 d_model 一样大,或者更小。比如 d_model=512,d_k=64,d_v=64。这样每个头只关注一个子空间,多个头合起来覆盖整个特征空间。
3.3 为什么需要三个不同的矩阵?
这个问题我当年也纠结过。为什么不直接用同一个矩阵?
咱们做个实验:
# 假设我们用同一个矩阵
W = nn.Linear(512, 64)
Q = W(x)
K = W(x)
V = W(x)
# 那么 Q 和 K 永远是一样的
# 注意力分数 = Q @ K.T = Q @ Q.T
# 这会导致什么问题?
# 每个位置对自己的注意力永远最大!
说白了,如果 Q 和 K 来自同一个变换,那模型就学不会「跨位置的信息交互」。每个 token 只会关注自己,这不就退化成了逐点操作吗?
我曾经在一个文本分类项目里踩过这个坑。当时为了省参数量,让 Q 和 K 共享权重。结果模型在长文本上完全学不会上下文依赖,准确率掉了 5 个点。从那以后,我再也不敢偷这个懒了。
3.4 维度选择与计算效率
咱们来看看实际项目中怎么选维度。这里我列个表,方便你参考:
| 参数 | 常见取值 | 说明 |
|---|---|---|
| d_model | 512, 768, 1024 | 输入输出的维度 |
| d_k | 64, 128 | Query/Key 的维度,影响注意力计算复杂度 |
| d_v | 64, 128 | Value 的维度,影响输出信息量 |
| num_heads | 8, 12, 16 | 头的数量,d_k * num_heads = d_model |
注意看最后一行:d_k * num_heads = d_model。这不是巧合,是为了保证多头注意力的输出能拼回原来的维度。
避坑指南:我曾经在调参时把 d_k 设得太大,比如 d_k=256,num_heads=8。结果 d_model 变成了 2048,模型直接炸显存。记住,d_k 和 num_heads 的乘积必须等于 d_model,除非你后面接了额外的投影层。
3.5 完整的 QKV 计算流程
最后,咱们把整个流程串起来,看看一个完整的注意力层是怎么工作的:
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q: (batch, seq_len_q, d_k)
K: (batch, seq_len_k, d_k)
V: (batch, seq_len_v, d_v) # 注意 seq_len_v = seq_len_k
"""
# 1. 计算注意力分数
scores = torch.matmul(Q, K.transpose(-2, -1)) # (batch, seq_len_q, seq_len_k)
# 2. 缩放,防止梯度消失
d_k = Q.size(-1)
scores = scores / torch.sqrt(torch.tensor(d_k, dtype=torch.float32))
# 3. 可选:mask 掉不需要的位置
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 4. Softmax 得到注意力权重
attn_weights = torch.softmax(scores, dim=-1)
# 5. 加权求和得到输出
output = torch.matmul(attn_weights, V) # (batch, seq_len_q, d_v)
return output, attn_weights
你看,整个流程其实就五步:
- Q 和 K 做矩阵乘法,得到相似度分数
- 除以 sqrt(d_k) 做缩放
- 加 mask(如果需要)
- Softmax 归一化成概率
- 用概率加权求和 V
说白了,Q 决定了「我要看哪里」,K 决定了「我有什么可被看」,V 决定了「被看到后给出什么信息」。三者缺一不可。
总结一下:
- Query:当前时刻的「需求」
- Key:每个位置的「标签」
- Value:每个位置的「内容」
- 三个线性变换:把原始向量映射到不同的语义空间
- 维度选择:d_k * num_heads = d_model 是常见配置
下一章,咱们会深入讲讲多头注意力,看看为什么「多个头」比「一个头」更好用。到时候我会分享一个我在 BERT 预训练项目里踩过的坑,跟多头注意力的头数选择有关。嗯,到时候细聊。