3、Query、Key、Value的本质:信息检索系统的类比与线性变换

好,咱们接着聊。上一章我们把注意力机制的核心公式拆开了,这一章我重点讲讲 Query、Key、Value 这三个东西到底是个啥。

说实话,我第一次接触 QKV 的时候,也觉得这名字起得挺玄乎。什么查询、键、值,听着像数据库。后来做项目做多了才发现,这个类比其实特别贴切。你想想看,注意力机制本质上就是在做一件事:从一堆信息里,找到跟你当前需求最匹配的那部分

3.1 信息检索系统的类比

咱们先抛开数学,用生活场景来理解。

假设你现在在图书馆,想找一本关于「Transformer 原理」的书。你会怎么做?

  1. 你脑子里有个查询(Query):我要找「Transformer 原理」相关的资料。
  2. 你走到书架前,每本书都有个标签(Key):比如「深度学习」、「自然语言处理」、「计算机视觉」。
  3. 你拿着你的 Query,跟每个书的 Key 做匹配。匹配度高的,你就抽出来看看。
  4. 抽出来的书,里面的内容(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

你看,整个流程其实就五步:

  1. Q 和 K 做矩阵乘法,得到相似度分数
  2. 除以 sqrt(d_k) 做缩放
  3. 加 mask(如果需要)
  4. Softmax 归一化成概率
  5. 用概率加权求和 V

说白了,Q 决定了「我要看哪里」,K 决定了「我有什么可被看」,V 决定了「被看到后给出什么信息」。三者缺一不可。

总结一下

  • Query:当前时刻的「需求」
  • Key:每个位置的「标签」
  • Value:每个位置的「内容」
  • 三个线性变换:把原始向量映射到不同的语义空间
  • 维度选择:d_k * num_heads = d_model 是常见配置

下一章,咱们会深入讲讲多头注意力,看看为什么「多个头」比「一个头」更好用。到时候我会分享一个我在 BERT 预训练项目里踩过的坑,跟多头注意力的头数选择有关。嗯,到时候细聊。