从一副面孔到千人千面:上下文词向量
前言
接着上一篇的坑往下填。Word2Vec训练完,"苹果"在库里就只有一副面孔的向量,可是"苹果发布了新手机"和"下午吃了个苹果"里的苹果分明是两个物种。这个一词多义的问题,静态词向量从原理上就无解,于是有了这一篇的上下文词向量。
静态词向量的三宗罪
把上一代方案的局限摆清楚,才知道后面的人都在补什么:
- 一词多义无解:一个词一个向量,多义词只能妥协出一个不伦不类的平均值,"苹果"既不像水果也不像手机
- 新词没有向量:训练时没见过的词(人名、新梗、错别字)直接抓瞎,OOV(out of vocabulary)问题
- 词序信息丢失:把一句话的词向量平均一下当句向量,"狗咬人"和"人咬狗"算出来是一样的
中间有个补丁式的改进是Facebook的fastText,把词再拆成字符级n-gram,"哈哈哈"没见过但"哈哈"见过就能拼出个大概,缓解了新词问题。但一词多义还是治不了,病根在于静态这两个字。
上下文向量:向量跟着句子走
真正的解法思路其实很直接:词的向量不该在训练完就焊死,而应该在读到具体句子时现场计算,上文是什么,向量就是什么。
2018年是个分水岭,前后脚两件大事:
- ELMo:用双向LSTM读句子,每个词的向量由整句计算得出,"苹果"在手机句里就被手机这个词拉向科技区,在水果句里被拉向食物区
- BERT:换成Transformer结构,用完形填空式的掩码语言模型在几十亿词的语料上预训练,效果直接屠榜
从此"预训练+微调"成了NLP的标准范式,词向量也升级成了动态的上下文表示。更妙的是这套表示顺手把句子级任务也统一了,取特殊位置(如句首标记)的向量或做池化,就得到整句话的向量。
拿句向量算个相似度
现在这玩法已经封装得非常顺手,相似度就用余弦,$\mathrm{sim}(a, b) = \dfrac{a \cdot b}{\lVert a \rVert \, \lVert b \rVert}$,sentence-transformers几行:
from sentence_transformers import SentenceTransformer
import numpy as np
# 多语言模型,中英都能算
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
sentences = [
"显卡最近降价了,可以入手",
"GPU价格回落,正是购买好时机",
"今天午饭吃了火锅",
]
emb = model.encode(sentences) # 每句话一个384维向量
def cos(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
print(cos(emb[0], emb[1])) # 前两句意思相近,得分明显高
print(cos(emb[0], emb[2])) # 和火锅句,得分明显低同义不同词、词序变化都能hold住,这就是上下文表示的功力。而这个小小的余弦相似度,正是如今一大票系统的心脏。
结束语
上面那三行cos代码放大到生产环境,就是现在火热的RAG(检索增强生成):文档切块算向量入库(各种向量数据库,Milvus、Qdrant这些),用户提问也转成向量,捞回最相近的几段塞给大模型当参考资料。大模型再能聊,接不上你的私有数据也白搭,而这座桥的桥墩子,就是十年前那些想让"爬虫"和"蜘蛛"挨得近一点的努力。技术这条线的伏笔有时候埋得很远,回头看挺有意思。