OnlyDecoder做嵌入模型:原理、实战与面试要点
最近准备嵌入模型方向的面试好几个朋友不约而同被问到同一个问题OnlyDecoder这种自回归结构凭什么拿来当嵌入模型乍一听确实有点反直觉——过去做语义向量第一反应都是Encoder模型双向注意力能看到完整上下文特征更均衡而Decoder是一个字一个字往后猜的看起来天生不是干这个活的。但现实是不少团队确实在用Decoder做Embedding甚至把检索和生成塞进同一个底座里。我前阵子为了准备这个方向把一个开源Decoder基座改造成句向量模型跑了完整的对比学习训练也踩了不少坑。这篇文章就从面试官视角和技术实操两条线来拆面试官到底在考什么正确的回答框架是什么以及你自己动手改模型时要注意哪些细节。目标人群是准备NLP算法岗面试的同学还有想在检索管线上统一模型的人。1. 为什么面试官会问OnlyDecoder做嵌入模型1.1 传统Encoder嵌入模型是怎么回事要理解Decoder做嵌入的来龙去脉先得回头看Encoder那套方案为什么能成立。以掩码语言模型为代表的Encoder类架构训练目标是随机遮住一部分token然后预测被遮住的内容本质上是在学一个双向的上下文表征。每个位置的输出都同时聚合了左右两侧的信息所以任意位置的hidden state都携带比较完整、比较均衡的语义信息。在实际做嵌入模型Embedding Model的时候普通做法是在预训练Encoder之上加一个池化层最常见的是取[CLS]位置的向量或者对整段token的hidden state做Mean Pooling再通过一个投射层压缩到目标维度最后做L2归一化。这还没完想要让向量真正具备语义检索能力通常还要做对比学习把同一句话的正样本对拉近把不相关样本推远配合in-batch负样本机制才能把通用预训练模型变成真正的语义向量模型。这条技术路径非常成熟在短文本匹配、文本分类、聚类、去重、召回这些场景里都验证过。面试官当然知道这套方案他们特意问Decoder不是想听你背一遍Encoder的优点而是想看你能不能跳出惯性思考理解另一条技术路线存在的合理性。1.2 解码器做嵌入究竟动了哪根筋Decoder类模型的注意力是因果注意力用矩阵表示就是一个下三角掩码每个token只能看到自己和前序token看不到未来信息。那为什么还能拿来做嵌入关键就在最后一个token的hidden state上。你想最后一个token在计算它的表示时被允许注意整个序列所有前序token。经过多层Transformer的逐层加工这个位置的输出其实已经凝聚了整个序列的“单向摘要”。你完全可以把这个向量当作整段文本的序列级表示它没有看到后面的词但它看到的内容足够覆盖全局语义。打个比方让一个学生听一整堂课只允许他边听边写阶段性总结不允许他回头修改。下课铃响的那一瞬间他在草稿纸上留下的最后一笔就是这堂课在他脑子里形成的全部理解。这笔记录不完美会漏重点但它确实是一个合法且自洽的全局表示。所以把Decoder用于嵌入模型不要求它像Encoder那样做双向信息融合而是利用自回归模型强大的语言建模能力在单向累积的过程中生成一个隐式的文档状态。实际落地的时候一般取序列最后一个token的hidden state过一层投射层先做归一化再做相似度计算。这套流程在检索、召回、聚类任务上都能跑并不比Encoder方案差太多。1.3 哪类场景真正需要解码器嵌入这里必须说清楚不是所有场景都适合Decoder做嵌入。传统Encoder嵌入在短文本、句子级匹配、分类聚类上依然非常有竞争力。真正让团队转向Decoder嵌入的是下面这几类场景第一类是生成与检索的统一。如果一个底座既能做RNN式的续写生成又能产出语义向量那么整个系统只需要维护一个模型、一套部署框架省掉一个独立的Encoder向量模型。第二类是长文档建模。Decoder在处理长序列时天然具备更强的高阶语义建模能力因为它经过大规模自回归语料训练对长距离依赖的建模能力往往比中小规模Encoder模型更稳健。虽然理论上双向注意力更充分但实际长文本任务里Decoder这种逐步累积的状态更新方式反而更接近我们阅读长文档的实际体验看到一个位置你脑子里有一个基于前文的整体理解。第三类是对齐任务比如在检索增强生成管线里希望检索阶段和生成阶段共享同一套语义空间。用Decoder做嵌入查询向量和文档向量都来自同一个模型语义空间生成阶段再做融合时语义一致性会更好。还有一个不那么显性但很重要的原因现在开源社区里优秀的Decoder基座越来越多训练数据质量、模型容量、上下文长度都远超很多中小规模Encoder模型。比起另起炉灶去预训练一个Encoder直接在现成Decoder基座上做嵌入适配成本低得多。2. 面试回答的骨架抓牢这几个考察点2.1 因果注意力的位置偏差与信息瓶颈面试官第一层考察点是你对注意力机制的底层理解够不够扎实。你不能只说“Decoder有因果掩码所以自己做嵌入”你得说清楚因果注意力在形成表征过程中的信息流动特点。因果注意力意味着每个位置的token只能attend前缀信息流动是单向的。最后一个位置的表示虽然看到了整个序列但它看到的只是经过前面每层压缩后的“残差信息”不是原始token的直接暴击。在深层Transformer中如果某一段文本的关键信息出现在序列中段那么随着层数加深这些信息要一路传递到末尾期间还会和其他token的信息叠加混合最终到达最后位置时可能已经被稀释。这就是位置偏差序列末尾信号强序列头部信号经过长距离传递后容易被覆盖。还有一个信息瓶颈的问题。最后一个token的hidden state维度就是模型隐藏层维度比如768或者1024。它要把整个序列的全部摘要塞进这个固定维度的向量里信息量必然有损。相比之下Mean Pooling可以在一定程度上分散信息分布因为它把所有token的位置都平等地聚合起来不把宝全部押在一个位置上。所以面试官追问“为什么取最后一位”时最好的回答不是武断地说“因为能看到全序列”而是承认这是主流做法但基于位置偏差和信息瓶颈它不总是最优。然后补一句实际工程里我会做层扫描和池化方式对比比如比较Last Token、Mean Pooling在不同任务上的效果。这句话一出来面试官就知道你不是只看过博客而是真跑过实验。2.2 对比学习如何弥补自回归训练的空档第二层考察点是训练目标设计。Decoder预训练的目标是Next Token Prediction也就是预测下一个词这个目标优化的是生成能力它只关心每一步的条件概率并不保证最终的hidden state在余弦相似度空间里具备良好的语义区分度。如果你想拿它做Embedding必须在预训练之后接一轮对比学习微调。对比学习的核心是InfoNCE损失公式长这样[ \mathcal{L} -\log \frac{\exp(\text{sim}(q, p) / \tau)}{\sum_{i1}^{N} \exp(\text{sim}(q, n_i) / \tau)} ]其中(q)是查询向量(p)是正样本向量(n_i)是负样本向量(\tau)是温度系数(N)是batch内负样本数量加一。本质上这是一个多分类问题在给定查询的情况下模型要把正样本从一组候选里选出来。这里有一个非常容易被忽略的点Decoder在预训练阶段几乎没有见过“把整个序列压缩成一个固定向量”的任务所以它的hidden state空间和生成任务的语义空间并不是天然对齐的。对比学习起了两个作用一是重新排列hidden state的分布让相似文本在向量空间里更接近二是定义一个显式的相似度度量习惯让模型学会在目标空间里做区分。如果面试官继续追问“为什么用in-batch负样本”你可以说构建显式负样本成本高而batch内其他样本天然就是当前查询的负样本虽然不一定都是难负样本但胜在免费。只要batch足够大负样本数量足够多对比学习的信号就足够强。这是工业级Embedding训练最常用的方案。2.3 三层递进式回答模板面试中遇到“讲讲OnlyDecoder用于嵌入模型”这种开放题最忌讳的是想到哪说到哪。我建议用三层递进的结构组织答案每一层都有明确的目的。第一层是结构层。先把模型结构讲清楚这是一个标准自回归Decoder基座因果注意力输入是一段文本取序列最后一个token的hidden state通过一个线性投射层把它映射到Embedding维度做L2归一化得到句子向量。到这里你展示了基础理解。第二层是训练层。说明只靠预训练不够必须做对比学习微调。具体手段包括in-batch负样本、温度系数、相似度函数选余弦相似度、可选加入难负样本挖掘。还可以提一个细节训练时query和document可能走不同的指令前缀比如query端加“给定一个搜索查询”document端加“给定一个文档”这种不对称prompt在很多实验里能带来明显提升。第三层是实战层。到这里就要开始讲你自己的观察。比如我会说我第一次实验时直接取最后一层Last Token向量效果比Mean Pooling差了将近两个点后来分析发现是位置偏差导致末尾token对关键信息捕捉不稳定改成倒数第二层加Mean Pooling才追回来。这个例子既能证明你懂原理又能证明你有排查能力比背任何结论都管用。3. 实操环节把OnlyDecoder嵌入模型真正落地3.1 基座选型与结构最小改动实战第一步是选基座。核心考量有四个参数量、上下文长度、词表覆盖、训练数据的领域匹配度。如果做通用检索任务选一个中文英文覆盖均衡、上下文长度至少为2048的开源Decoder基座就够用。如果做垂直领域比如金融或医疗那就优先挑这个领域语料占比更高的基座而不是盲目追大模型。结构上的改动其实很小不需要动Decoder主干只在输出端加一个投射层。实现起来大概是这样的思路import torch import torch.nn as nn import torch.nn.functional as F class DecoderEmbeddingModel(nn.Module): def __init__(self, base_model, hidden_dim, embed_dim): super().__init__() self.backbone base_model self.projection nn.Linear(hidden_dim, embed_dim) def forward(self, input_ids, attention_mask): outputs self.backbone(input_idsinput_ids, attention_maskattention_mask) hidden outputs.last_hidden_state # 取最后一个非padding位置的hidden state last_token_index attention_mask.sum(dim1) - 1 last_hidden hidden[torch.arange(hidden.size(0)), last_token_index] vec self.projection(last_hidden) vec F.normalize(vec, p2, dim-1) return vec注意这里取最后一个token时不能直接取序列最末位。因为文本经过padding后最后一个位置往往是padding符号所以我先根据attention mask算出来每个样本最后一个有效token的位置再去取hidden state。这是很容易出错的一个工程细节。投射层的维度不建议设太高。常见做法是把hidden dim投射到128到512之间。维度太低会损失语义容量太高会导致存储和检索成本上升。比如base模型hidden dim是1024你投射到256用来做百万级规模召回存储成本就很可观。实际项目里我一般先试256如果指标不够再往上加。3.2 池化策略与归一化细节池化策略是整个Decoder嵌入模型里最微妙的地方。按照论文和工程经验常见的有三种第一种是Last Token Pooling也就是直接取最后一个有效token的hidden state。这种方式在规模较大的Decoder基座上表现普遍不错因为大模型经过充分的生成预训练后最后位置确实凝聚了很强的序列级摘要能力。第二种是Mean Pooling对所有token的hidden state取平均。这种策略对中小规模Decoder基座更友好因为它平摊了位置偏差不会因为一个位置表现不好就导致整个向量崩掉。但缺点也很明显如果序列里有很长的无关内容平摊操作会稀释关键词信号。第三种是Weighted Pooling按注意力权重或者位置权重对token做加权平均。这种方式理论上最灵活但实现成本高需要额外计算注意力矩阵而且多了一个超参数要调收益不一定稳定。我的建议是第一轮实验把Last Token和Mean Pooling都跑一遍不要凭感觉选。拿同一个模型、同一份训练数据、同一个评测集把两种池化方式的指标列在一起对比。我自己实测的经验是参数量超过十亿的基座用Last Token效果更好小模型反而Mean Pooling更稳。归一化这块也有讲究。一般做法是先通过projection层再做L2归一化。注意归一化应该在投影之后不能在投影之前做。因为线性层没有归一化不变性如果你先把hidden state归一化再投影等于是先压缩了一部分尺度信息再映射信息损失可能更大。反过来先投影再归一化能让线性层在更大的数值范围内去调节各维度的重要性。3.3 训练超参温度系数、batch size与梯度缓存训练Decoder嵌入模型超参选择和预训练语言模型完全不同。首当其冲的是温度系数。温度系数(\tau)直接控制对比损失对难负样本的惩罚强度。温度越低模型越关注那些和查询很像的负样本也就是难负样本温度越高所有负样本的梯度贡献越平均模型倾向于把正样本拉近即可区分度会下降。常用的温度区间是0.02到0.2。但这里有个经验值不要一上来就调温度先把batch size和负样本数量稳定住把温度固定为0.05跑第一轮实验观察训练集上的相似度分布。如果正负样本相似度区分不明显再把温度调低到0.03附近如果训练不稳定就会回调到0.1。batch size是另一个关键因子。对比学习的负样本主要来自batch内部的其它样本所以batch size越大每个查询看到的负样本就越多训练信号越强。但Decoder不像Encoder那么省显存一个大batch很可能直接让显存爆掉。这时候可以用梯度缓存技术。思路很简单把一个大batch拆成多个micro-batch分别过Encoder拿到特征向量用特征向量计算对比损失然后再把梯度合并反传。只对特征向量这一层保留完整全图的反传链路就不需要同时把几十个样本的完整Decoder计算图都保留在显存里。实现上是把模型切分成两块backbone计算特征对比头计算损失中间用缓存将特征保留下来。# 训练循环中使用梯度缓存 large_batch 512 micro_batch 32 features [] for start in range(0, large_batch, micro_batch): sub_batch data[start:start micro_batch] with torch.no_grad(): sub_feat embedding_model.encode(sub_batch) features.append(sub_feat) features torch.cat(features) loss contrastive_loss(features, temperature) loss.backward()当然这里有个细节梯度缓存时backbone的输出不计算梯度所以需要把最后一步encode改成no_grad模式让投影层以外的部分不保留计算图。这个技术能让你在同样显存下把有效batch扩大好几倍代价是整体训练时间变长但对指标提升很有帮助。3.4 评测指标怎么设才不会自欺欺人训练过程中最容易踩的坑是只看loss下降趋势认为模型在变好。对比学习的loss下降非常正常但如果向量空间出现坍缩所有Embedding都挤到一个很小的区域里loss照样可能降得很漂亮。所以必须设置独立的评测指标最好同时看多个维度。第一个维度是检索召回指标。准备一份带标注的query-doc配对集计算Recall1、Recall5、MRR这些指标。这是最接近真实业务的评估方式。很多团队进大厂面试时会被问召回率怎么算公式为[ \text{Recallk} \frac{1}{|Q|} \sum_{i1}^{|Q|} \mathbb{1}[\text{正样本在top-k结果中}] ]第二个维度是向量分布检查。随机抽几千个Embedding计算它们的平均余弦相似度以及各维度的方差。如果平均相似度非常高超过0.8甚至0.9就要警惕表示退化问题。理想情况下随机样本之间的平均余弦相似度应该明显低于0.5且各维度方差比较均匀。第三个维度是相似度排序的感知质量。人工看几十组查询检查排序结果是否符合直觉。这个环节看着不硬核但很多bad case都是在这个环节发现的比如某些领域词莫名其妙聚在一起或者语义相反的两个句子相似度很高。4. 排坑实录我在这条路上踩过的三个坑4.1 坑一最后一层向量并不总是最优我第一次做Decoder嵌入实验直接按主流论文的写法取基座最后一层的Last Token向量接投影层然后傻乎乎地期待效果爆炸。结果在检索评测集上表现很平淡甚至比不过同参数量的Encoder基线。后来我做了层扫描实验把模型每一层的hidden state都抽出来各自过一个投影层分别测试检索指标。结果发现倒数第二层的效果比最后一层要高出将近三个点。原因也不难理解最后一层hidden state和softmax预测头直接相连它的表示被调校得特别适合按词表分配概率细节上可能更偏向局部语言模式而不是全局语义。倒数第二层反而保留了更均衡的语义信息。这次之后我养成了习惯不管用什么基座第一件事就是做层扫描。不要无条件信任最后一层输出尤其是当你是从生成模型拿向量的时候。4.2 坑二温度系数的玄学区间有段时间训练loss下降得很快但检索指标在某个节点之后开始波动甚至下滑。我一开始怀疑是学习率策略问题排查了很久才发现是温度系数选得太低。我最初把温度设为0.02这是很多论文里推荐的默认值。但我的训练数据里负样本质量参差不齐有些batch里几乎没有难负样本低温会让模型过度惩罚那些本来就不像的负样本导致正样本之间的区分度被压缩。后来我把温度从0.02调回0.05再把难负样本挖掘功能打开指标立刻稳定下来。温度系数这个超参真的得按自己的数据情况来调。它不是越大越好或者越小越好而是要看正负样本相似度的整体分布。一个实用的方法训练开始时先冻结模型单独抽取一部分特征向量计算相似度分布如果正样本和负样本相似度很接近温度就得调低一些如果本身区分度就很高温度可以适当调高防止训练震荡。4.3 坑三显存没炸语义却丢了第三个坑是数据配比问题。增大batch size之后召回指标非但没有提升某些类别上的效果反而明显变差。分析之后发现问题出在in-batch负样本的构成上。in-batch负样本都是从同一个batch里随机抽出来的它们和查询的相关性普遍很低属于easy negative。如果训练语料里大量存在这些简单负样本模型学到的是一个很粗糙的区分边界根本不需要单独理解那些长尾语义差异。真正的语义边界恰恰要靠那些和查询很像、但实际上不是正样本的hard negative来刻画。所以后来我的训练数据里专门加了一路hard negative用上一轮模型对候选池做召回把每个查询排在Top10但没被标注为正样本的文档挖出来作为显式负样本加入训练。每一轮迭代之后更新一次负样本池模型检索效果提升得非常明显。这是做Decoder嵌入训练性价比最高的操作之一没有之一。5. 面试话术与实战细节补充5.1 面试官追问时怎么稳住面试现场的高频追问差不多就那几个提前准备好就没什么可慌的。第一个高频追问“为什么不用Encoder非要用Decoder做嵌入”千万不要把Decoder吹成万能解。合理的回答是先承认Encoder在短文本、句子匹配上依然有优势然后给出团队选Decoder的工程理由统一底座、长文本建模、生成检索共用同一语义空间。最后补一句“如果纯粹看检索指标Encoder不一定差但要在生产环境同时做生成和检索Decoder的成本优势就很明显了。”第二个高频追问“最后一个token真的能代表整个序列吗”你可以回答不能完全代表但它是一个合理的信息汇聚点。实际考虑的时候我会对比Last Token和Mean Pooling根据任务选择而不是无脑取最后一位。然后把位置偏差和信息瓶颈说出来面试官会觉得你真的理解边界。第三个高频追问“对比学习里的负样本从哪里来”你可以分两层讲第一层是in-batch负样本免费但质量参差第二层是显式hard negative通过上一轮模型挖掘Top10候选构成成本高但效果显著。能把这两层的取舍讲清楚面试官基本就满意了。第四个高频追问“训练的时候温度系数怎么设置”把我们在4.2里的经验讲一遍。先固定batch和模型观察相似度分布再决定温度是往低调还是往高调。不要背数字要讲调节逻辑。5.2 少走弯路的三个心得最后分享三个体检心得每个都是用实际调参煮出来的。第一个心得先在小模型上把全链路跑通再上大基座。很多人一上来就用巨大的开源Decoder做嵌入微调结果一个实验跑一整天最后发现是池化策略的问题白白浪费时间。小模型十几分钟就能验证一个方案先把池化、温度、数据配比这些关键环节的实验矩阵在小模型上跑完再带到大模型上确认即可。第二个心得记录每一份实验配置哪怕是你觉得无关紧要的改动。温度、batch、负样本比例、是否加指令前缀这些因素之间存在强交互作用。你只调温度可能会发现问题但如果你同时改了温度和数据配比出了问题就不知道是谁导致的。我自己的习惯是拿一个表格记录每次实验的完整参数和指标任何一个数字都对应一条记录。第三个心得在面试里主动展示你踩过的坑比背标准答案有用得多。当面试官问失败经历时把4.1里那个“最后一层向量不好用”的故事讲出来然后说你是怎么做层扫描找到倒数第二层的。这种具体到可以复现的细节比任何华丽的总结都更能证明你的实战能力。我个人实际操作到最后的体会是OnlyDecoder做嵌入这条路真正拉开差距的往往不是模型结构而是训练细节。同一个基座池化策略不同温度系数不同负样本配比不同结果能差出好几个点。面试时能把这种细节讲透就已经赢了大部分人。