前几天讲的都是 decoder(GPT 这类生成模型)。今天换主线:encoder(编码器)——它不生成文字,只把文字压成向量,是 RAG 检索、语义搜索、分类的底座。四个概念串成进化线:BERT 奠基 → RoBERTa 优化配方 → Sentence-BERT 让向量可比 → ColBERT 平衡精度与速度。
GPT 像流式日志处理——只能从左往右一条条读,预测下一条时看不到未来。BERT 反过来:它像一次性把整张表加载进内存做全表扫描,每个 token 都能同时看到左边和右边的全部上下文。代价是 BERT 不能用来生成(它没"下一个词"这个任务),但理解一句话的能力更强——因为"理解"本来就需要前后文一起看。
2018 年之前,理解类任务(分类、命名实体识别、问答)大多用单向语言模型或浅层词向量。痛点:单向看不全。"苹果发布新机" vs "苹果削了皮"——"苹果"的含义取决于后文,左到右模型在读到"苹果"时还看不到后面。BERT(Devlin et al. 2018)的解法是 encoder-only 架构 + 两个自监督预训练任务:
预训练完,BERT 学到了通用的语言表示;下游只需在它上面接一个小分类头微调(fine-tune),就能在分类 / NER / 抽取式问答上刷出 SOTA。这就是"预训练 + 微调"范式的开端。
from transformers import pipeline # BERT 最自然的演示就是它的预训练任务:完形填空 fill = pipeline("fill-mask", model="bert-base-uncased") # [MASK] 处让 BERT 用双向上下文补全 for r in fill("The capital of France is [MASK].")[:3]: print(f"{r['token_str']:>8} {r['score']:.3f}") # paris 0.42 / lyon 0.05 ... ——它靠前后文推断,不是"续写" # 取句子的隐藏向量(下游分类/检索的原料) from transformers import AutoModel, AutoTokenizer tok = AutoTokenizer.from_pretrained("bert-base-uncased") mdl = AutoModel.from_pretrained("bert-base-uncased") out = mdl(**tok("hello world", return_tensors="pt")) print(out.last_hidden_state.shape) # [1, 序列长, 768] 每个token一个向量
RoBERTa(Liu et al. 2019)和 BERT 架构一模一样——没换 CPU、没改 schema。它只是把训练超参和数据配方重新调了一遍,就大幅超越原版。就像你拿到一台跑得很慢的数据库,没换硬件,只是调大了 buffer pool、关掉了一个没用的后台任务、喂了 10 倍数据,性能就翻番——结论是:原来 BERT 被严重训练不足(undertrained)了。
BERT 出来后,大家默认"想更强就得改架构"。RoBERTa 是一篇严谨的消融研究(ablation),它证明:很多收益不来自新结构,而来自把旧配方做对。核心改动:
没有一个改动是"新架构",但叠加起来全面刷新了 GLUE / SQuAD 等基准。RoBERTa 的真正贡献是方法论:在宣称"我发明了更好的架构"之前,先确认你把baseline 训练充分了——否则你比较的是"训练不足的旧模型 vs 训练充分的新模型",结论不可信。
from transformers import pipeline # RoBERTa 接口与 BERT 完全一致——架构没变,换权重即可 # 注意:RoBERTa 没有 NSP,掩码符号是 <mask> 不是 [MASK] fill = pipeline("fill-mask", model="roberta-base") for r in fill("Better data beats a fancier <mask>.")[:3]: print(f"{r['token_str']:>12} {r['score']:.3f}") # 真实工程里,RoBERTa 常作为"理解类"任务的微调基座 # 例如情感分类、自然语言推理(NLI)——同样的 fine-tune 流程 clf = pipeline("sentiment-analysis", model="cardiffnlp/twitter-roberta-base-sentiment-latest") print(clf("调配方就把分数刷上去了,太优雅了"))
原版 BERT 比较两句话相似度,必须把两句拼在一起喂进去算一次——像每次查询都做一次全表 JOIN。1 万句话两两比较 = 约 5000 万次 BERT 推理,几十小时。Sentence-BERT 的思路是建索引:把每句话独立编码成一个固定向量、提前算好存起来,比较时只算向量余弦相似度——从"JOIN 时现算"变成"预计算 + 查表",几十小时降到几秒。
痛点有两层。一是速度:上面说的两两比较组合爆炸。二是质量:很多人以为"BERT 输出的向量直接拿来算余弦就行"——大错。Sentence-BERT 论文(Reimers & Gurevych 2019)实测,直接平均 BERT 的 token 向量、或取 [CLS] 向量,做句子相似度还不如平均 GloVe 词向量。原因:BERT 的预训练目标(完形填空)从没要求"语义相近的句子向量也相近",所以它的向量空间不是为余弦比较设计的。
解法是 Siamese(孪生)网络 + 对比式微调:
这就是今天所有 embedding 模型 / RAG 检索的直接祖先,也是 sentence-transformers 这个库的来历。Day 22 讲的 dense retrieval、Day 4 的 RAG embedding,底座都是它。
from sentence_transformers import SentenceTransformer, util # 一个轻量句向量模型(384维),专为余弦比较训练过 model = SentenceTransformer("all-MiniLM-L6-v2") docs = ["分布式系统的一致性权衡", "如何给孩子讲量子力学", "CAP 定理与最终一致性"] # 1) 离线:把文档全部编码成向量,存进向量库(此处简化为内存) doc_emb = model.encode(docs, convert_to_tensor=True) # 2) 在线:query 编码后,只做一次余弦相似度查表 q_emb = model.encode("一致性和分区容忍怎么取舍", convert_to_tensor=True) hits = util.cos_sim(q_emb, doc_emb)[0] for i in hits.argsort(descending=True)[:2]: print(f"{hits[i]:.3f} {docs[i]}") # 命中"CAP定理"——它和query没有共同词,纯靠语义匹配
上面两种方案是两个极端:Bi-encoder 快但糙(整句压成一个向量,细节丢失,像把一整行数据 hash 成一个值),Cross-encoder 准但慢(每个 query-doc 对都要现算,无法预计算)。ColBERT 走中间:它给文档每个 token 都存一个向量(像建了一个细粒度的列索引),查询时做一次轻量的 token 级匹配。既保留了 token 级细节,又能把文档侧的重活提前算好。
单向量 bi-encoder 有个根本瓶颈:把一整段话压成一个 768 维向量,长文档或多主题文档的细节必然被平均掉——query 里某个关键词精确命中文档某句,这个信号在"全局平均"里被冲淡了。Cross-encoder 没这问题(它让 query 和 doc 的每个 token 充分交互),但代价是无法预计算:文档向量依赖当前 query,来一个 query 就得重跑整个库。
ColBERT(Khattab & Zaharia 2020)的关键词是"延迟交互"(late interaction):把"交互"这一步推迟到最后,且做得很轻。流程:
MaxSim 公式:S(q,d) = Σi∈q maxj∈d (Eq,i · Ed,j)。直觉:i 遍历 query 的每个 token,maxj 是"这个 query token 在文档里最匹配的地方有多匹配",外层 Σ 把每个 query token 的最佳匹配累加。它本质是"软性的关键词匹配"——既有 BM25 那种精确 term 命中的优点,又是在语义向量空间里做的(同义词也能匹配)。文档 token 向量能预存,交互只是查表+取max,因此比 cross-encoder 快约两个数量级,精度却接近。
# 用 MaxSim 直观演示 ColBERT 的打分逻辑(简化版) import torch from sentence_transformers import SentenceTransformer enc = SentenceTransformer("all-MiniLM-L6-v2") def token_vecs(text): # 取每个token的向量并归一化(真ColBERT用专训模型,此处仅示意) feats = enc.tokenize([text]) out = enc[0].auto_model(**{k: v for k, v in feats.items()}) return torch.nn.functional.normalize(out.last_hidden_state[0], dim=-1) def maxsim(q, d): Q, D = token_vecs(q), token_vecs(d) sim = Q @ D.T # [query_tok, doc_tok] 所有token两两相似度 return sim.max(dim=1).values.sum().item() # 每个q取max再求和 q = "how to keep data consistent" print(maxsim(q, "eventual consistency in distributed databases")) print(maxsim(q, "a recipe for chocolate cake")) # 明显更低