从词袋到上下文向量:NLP文本表示选型与工程实践

发布时间:2026/10/29 16:13:22
从词袋到上下文向量:NLP文本表示选型与工程实践

文本表示这四个字,听起来像是教科书第二章的内容,但我做了这么多年NLP项目,踩坑最多的恰恰就在这一章。同样一批文本,换一种表示方式,下游的分类准确率能差出一倍,检索的召回能差出三十个点,甚至在线上跑起来的内存占用能差出两个数量级。很多人拿到任务第一反应是"上大模型",结果基线没跑通、维度没对齐、归一化忘了做,最后模型再大也白搭。这篇就把文本表示这条线从最土的词袋一路捋到上下文相关的稠密向量,中间夹着公式推导、参数取值、矩阵规模估算和我自己在工程里踩过的坑。不管你是刚接触NLP自然语言处理的新手,还是已经在做检索、分类、聚类的老手,都能从里面找到能直接抄走的东西——尤其是那些文档里不会写、但线上一定会撞上的细节。

1. 文本表示到底在解决什么问题

1.1 从字符串到向量的本质跨越

计算机不认识"猫"这个字,它只认识一串码点。文本表示要干的事情,就是把一段没有数学结构的符号序列,映射成有数学结构的数值向量,让"相似""相关""属于某类"这些人类概念,变成可以算距离、算内积、算梯度的操作。这件事听起来简单,难在"相似"这个词本身就有歧义:字形相似、语义相似、用法相似、在某个具体任务里相似,是完全不同的四件事。

举个很实际的例子。"这家餐厅的菜很难吃"和"这家餐厅的菜不难吃",从字符重叠角度看相似度极高,词袋模型给出的相似度接近1,但从情感判断看,两者是完全相反的。反过来,"手机没电了"和"充电宝在哪",字面几乎没有共现词,但在一段真实的客服对话里,它们高度相关。这说明一件事:没有一种文本表示是万能的,每一种表示都隐式地定义了"什么叫相似"。你选了一种表示,就等于选了一个相似性假设。

我在项目里的习惯是先问三个问题:任务是要判类别、算相似、还是要做生成?语料规模是几万条还是几千万条?线上对延迟和内存的预算是多少?这三个问题的答案基本就能把候选方案砍到两三个。分类任务在中等语料上,TF-IDF加线性模型经常能打到BERT的九成效果,而推理成本只有后者的千分之一;检索任务里,纯稠密向量在长尾专有名词上经常翻车,反而需要和稀疏的BM25做融合;只有在语义理解要求高、标注数据又充足的场景里,上下文相关的编码器才真正划算。

还有一个常被忽略的点:文本表示不只是模型输入层的事。分词粒度、是否去停用词、是否做词形还原、是否保留标点和数字,这些预处理决策本身就是表示的一部分,而且它们的收益有时候比换模型更大。我见过一个工单分类项目,把工单号、时间戳这类高频但无意义的模式统一替换成占位符之后,准确率直接涨了六个点,模型一行没改。

1.2 三种表示范式的边界与选型对照

把主流方案归归类,其实就是三层:离散稀疏表示、静态稠密表示、上下文相关表示。它们不是替代关系,而是不同预算和不同精度要求下的不同档位。

范式典型方案维度量级是否含语义训练成本典型适用场景
离散稀疏One-hot、词袋、TF-IDF、BM25词表规模(1万到百万)仅词面共现几乎为零短文本分类、关键词检索、冷启动基线
静态稠密共现矩阵分解、词向量、子词向量50到300有,但一词一向量小时级到天级聚类、近义词挖掘、轻量语义召回
上下文相关双向编码器、句向量模型384到1024强,随语境变化需要预训练或微调语义匹配、问答、复杂分类

这张表最右边的场景不是绝对的。我做过一个百万级的工单去重任务,本来打算上句向量,结果发现工单标题极短、专有名词极多,稠密向量把不同型号的产品混在一起,最后用字符级n-gram的TF-IDF加余弦阈值做到了92%的准确率,成本几乎为零。所以选型的正确顺序是:先用最便宜的方案把基线跑出来,量化它的失败样本长什么样,再决定要不要加钱换更重的表示。上来就选最贵的方案,往往连"贵在哪里"都说不清楚。

2. 离散稀疏表示:别小看词袋和TF-IDF

2.1 One-hot与词袋的维度陷阱

One-hot是最直观的表示:词表里第i个词,就是一个只有第i位是1、其余全0的向量。它的致命问题不是"稀疏"两个字,而是维度和内存的连锁反应。假设词表10万,每条文本平均50个词,用稠密数组存一条样本就是10万个数,float32下是400KB。一万条样本就是4GB。这就是为什么实际工程里从来不会真的去构造稠密one-hot,而是用稀疏矩阵或者直接把"文档到词的索引列表"交给下游。

真正容易出事的是词袋的统计口径。我在一个评论情感项目里遇到过这样的问题:长评论动辄上百字,短评论只有几个字,直接用词频做特征,模型完全被长文本主导,因为长文本的向量模长天然更大。解决办法有两个,一是做L1或L2归一化,二是用词频的对数做压缩。这两个操作看着是细节,实际效果差异很大,我在同一批数据上测过,不做归一化时F1是0.71,做完L2归一化直接到0.78,模型和特征都没换。

另一个陷阱是词表截断。词表不是越大越好,低频词带来的噪声往往超过信息。常见的做法是按文档频率过滤,把出现文档数少于2到5次的词直接丢掉,词表通常能从几十万压到几万,而效果基本不掉甚至更好。我一般的取值是:语料在十万条以下用min_df=2,百万级用min_df=5,同时用max_df=0.9把那些几乎每篇都出现的词也剔掉——这一步等价于半自动去停用词,比手写停用词表稳得多。

2.2 TF-IDF的公式拆解与参数实测

TF-IDF的逻辑很朴素:一个词在这篇文档里出现得越多越重要,在所有文档里出现得越普遍越不重要。但真到实现层面,各种变体的差别不小。

词频部分常见的有三种口径:原始计数、除以文档长度、以及1+log(tf)的次线性缩放。我基本只用第三种,原因是它把"出现10次"和"出现20次"的差异压缩了,避免单个高频词把整条向量带偏。逆文档频率部分是 log(N/df),但直接用会在df=N时变成0,甚至在某些实现里取对数出负数,所以主流做法都加了平滑:

idf(t) = log((1 + N) / (1 + df(t))) + 1

其中N是文档总数,df(t)是包含词t的文档数。加1是为了保证即使某个词在所有文档都出现,它的权重也不会被彻底抹成0,避免整条向量退化为零向量。

手算一个例子更清楚。假设语料有1000篇文档,词"向量"在其中10篇出现过;某篇文档共100个词,其中"向量"出现3次。那么:

  • tf = 1 + log(3) ≈ 2.099
  • idf = log((1 + 1000) / (1 + 10)) + 1 ≈ log(90.99) + 1 ≈ 4.511 + 1 = 5.511
  • 未归一化权重 ≈ 2.099 × 5.511 ≈ 11.57

如果这篇文档还有其他词,最终会得到一个稀疏向量,再做L2归一化:每个分量除以向量模长。这一步做完,任意两条文本的余弦相似度就等于它们的归一化向量点积,计算上非常友好,这也是为什么"TF-IDF + 线性SVM/逻辑回归"这条老路子到今天仍然是文本分类的第一基线。我自己的项目流程里,新任务一律先跑这个组合,得到一个能解释、能复盘的下界,后面的复杂模型必须显著超过它才值得上线。

关于n-gram的窗口,中文任务我一般用1到2,也就是单字加二元组。纯单字会丢失"不高""不太"这类否定结构,而三元组以上会让特征数爆炸、稀疏度急剧上升。实测在短文本分类上,1到2 gram比纯1 gram通常能涨两到三个点,再往上收益迅速衰减。

2.3 哈希技巧与内存控制的现实取舍

词表这东西在离线实验里不是问题,一上线就是问题。用户输入里永远有没见过的词、拼错的词、表情符号、随机字符串,词表会无限膨胀。哈希技巧就是拿来对付这个的:不维护词表,直接对词的哈希值取模,映射到固定数量的桶里。

代价是哈希冲突——两个不同的词可能落进同一个桶,信息被混合。但实践中只要桶的数量足够大,冲突带来的损失远小于维护词表的工程复杂度。我的经验取值是桶数取2的整数次幂,方便位运算取模,小规模任务用2^18(约26万),中大规模用2^20(约105万)。同时一定要开备用特征(alternate sign)那个开关,它会给哈希后的特征随机分配正负号,让冲突的期望影响相互抵消,实测能稳住一个点左右的损失。

需要提醒的是,哈希技巧只适合"特征维度可以牺牲一点精度换稳定"的场景,比如在线学习的点击率模型、流式文本过滤。如果你需要事后解释"哪个词贡献最大",那就老老实实维护词表,因为哈希之后你根本不知道那个桶对应的是什么词,可解释性和可调试性会一起丢掉。这个取舍我在两个项目里做过相反的决策,教训是:面向人的功能(比如给运营看的关键词归因)用词表,面向机器的功能(比如实时过滤)用哈希。

3. 从共现统计到静态词向量

3.1 共现矩阵与矩阵分解的直觉

TF-IDF把词当成独立的符号,忽略了词与词之间的关系。共现矩阵的思路是:统计每个词在一定窗口内和其他词一起出现的次数,那么语义相近的词,它们的"上下文分布"应该相似。这个假设被称为分布式假设,是整个稠密表示的地基。

具体做法是构造一个词×词的矩阵,行是目标词,列是上下文词,值可以用窗口内的共现次数、也可以再用一次PPMI(正点互信息)做加权。PPMI的作用是压制"的、是、了"这类高频词——它们和谁都共现,原始计数很高,但信息量极低。PPMI的公式是 max(0, log(P(w,c) / (P(w)P(c)))),负值直接截断为0,这也是为什么叫"正"点互信息。

然后对这个稀疏的大矩阵做截断奇异值分解,取前k个奇异值对应的向量,就得到了每个词的k维稠密表示。这一步的意义不只是降维,更重要的是它把"词和上下文"的关系统一到同一个向量空间里,实现了某种程度上的语义泛化。k的取值通常在100到300之间,我一般用200起步,语料小的时候降到100,避免过拟合。

这套方法的优点是确定性、可解释、小语料也能跑;缺点是计算复杂度高,词表10万的话矩阵是10^10量级,虽然稀疏,但分解依然吃内存,而且新词没法增量加入——加一个新词就得重算整个矩阵。所以在今天的工程里,它更多是作为理解词向量原理的教学工具,或者用在小规模、需要完全可控的专有领域语料上。

3.2 词向量训练:两种架构的取舍

把共现矩阵那一套换成用神经网络做预测任务,就是大家熟悉的词向量训练。两种主流架构:一种是用上下文预测中心词,另一种是用中心词预测上下文。前者的特点是把上下文向量做平均,训练快,对高频词更友好,适合小语料;后者对每个上下文词单独预测,低频词和罕见搭配学得更细,但训练慢,语料小的时候容易欠拟合。

我自己的选择标准很直接:语料在千万词以下,用前者;上亿词并且想挖掘罕见搭配,用后者。窗口大小也要跟着任务走,窗口小(2到5)学到的向量偏语法和词性,窗口大(8到15)偏主题和语义。做情感分析这类任务我一般窗口取5,做主题聚类取10。

参数方面,一个能直接用的起点是:维度200,窗口5,最小词频5,负采样数5到15,初始学习率0.025并随训练词数线性衰减,子采样阈值取1e-5。子采样这一步容易被忽略,它的作用是按词频的平方根倒数随机丢弃高频词,等价于把"的、是"这类词的出现概率压下来,让训练更聚焦在信息量大的词上。不开子采样,高频词会主导整个训练过程,向量空间被它们撑得变形。

训练量级可以粗算一下:语料1亿词,窗口5,每个中心词产生约10个正样本对,再配5个负样本,就是约60亿次参数更新;每次更新涉及两个200维向量的加减,约几百次浮点运算,总量在10^12到10^13量级,单卡跑几个小时到一天。这个量级对个人项目完全可接受,属于"一顿饭的功夫能看到结果"的范围。

3.3 负采样里的采样分布与常见误用

负采样解决的问题是:输出层如果做全词表softmax,词表50万的话每次更新要算50万次,根本训不动。负采样的做法是把"从50万个词里选一个"简化为"从真实上下文里选一个正例,再从噪声分布里抽几个负例",把一个多分类问题拆成若干个二分类。

关键在于噪声分布的选取。用词频的0.75次方,也就是 P(w) ∝ f(w)^0.75,这个指数的意义是把高频词的采样概率压下去、低频词抬上来。如果直接用原始词频,负样本几乎全是"的、是、在"这类词,模型学到的判别边界非常粗糙;如果用均匀分布,又会让极低频的生僻词承担过多负样本压力。0.75这个值是在这两者之间取的折中,实践中很稳,不建议随便改。

还有一个容易踩的坑:负样本数量和向量维度的配合。负样本太少(比如2个),梯度噪声大,收敛慢;太多(比如50个),虽然理论上更准,但每个batch的计算量翻倍,性价比不高。我的经验区间是5到15,小语料取小值,大语料取大值。维度小于100的时候,负样本也不宜多,否则模型容量不足以拟合这么细的判别边界。

训练完之后一定要做的一件事是看近邻。随便挑几十个词,打印它们余弦相似度最高的10个词,人工扫一遍。如果"价格"的近邻里出现"成本、售价、便宜",说明向量空间是有意义的;如果近邻里全是功能词或者完全不相关的词,那八成是语料太杂、预处理没做干净、或者训练轮数不够。这个检查花不了五分钟,但能省掉后面几天的排查。

4. 上下文相关表示与句子级编码

4.1 一词多义带来的表示升级

静态词向量最大的问题是"一词一向量"。"苹果"在"吃了一个苹果"和"苹果发布了新系统"里是同一个向量,模型没法区分。上下文相关的表示就是来解决这个的:同一个词在不同句子里,输出不同的向量。

技术上的核心是自注意力机制——每个位置的表示都会根据句子里其他位置的信息做加权聚合,权重由内容决定。这就让"苹果"的向量能被"吃"和"系统"这两个上下文拉向不同的方向。工程上通常取编码器最后几层(或倒数第二层)的隐藏状态作为词的上下文表示,然后在句子级别做池化。

关于取哪一层,我的经验是:做词级别的任务(如实体识别),取最后四层做拼接或平均通常比只取最后一层好;做句子级别的任务,取倒数第二层往往比最后一层略好,因为最后一层更偏向预训练时的具体目标,泛化性稍弱。这个规律不是铁律,不同模型差异明显,所以我会在验证集上把最后四层逐层跑一遍,选最优的,成本不过多跑几次推理。

4.2 句向量的各向异性问题与池化策略

直接把所有词向量平均,得到的句向量效果经常出奇地差。原因有两个,一是平均会把词序信息完全抹掉,"狗咬人"和"人咬狗"变成一个向量;二是各向异性——预训练出来的向量空间并不是各向同性的,所有向量都挤在空间的一个狭窄锥形区域里,导致任意两句的余弦相似度都偏高(实测常见在0.7以上),区分度很低。

针对第一个问题,有两条路:一是用带位置编码的编码器,并训练一个专门的池化层;二是直接训练句向量模型,用对比学习的方式让语义相近的句子在空间里靠拢、不相近的推远。后者是目前的主流做法,训练目标通常是批内负采样加温度系数,温度一般取0.05左右。这个方法的威力在于它直接优化了"句向量之间的距离"这个下游真正用到的东西,而不是间接地从词级目标里凑出来。

针对第二个问题,简单的补救办法是对向量做白化:减去均值、除以协方差矩阵的平方根。这一步不需要训练,纯用一批无标注语料估计统计量就能做,实测能把随机句对的相似度从0.7压到0.1以下,下游的检索和聚类效果明显改善。如果不想引入额外计算,另一个办法是只用句首特殊标记的输出来做句向量,但它在语义匹配任务上一般不如训练过的句向量模型。

我一般会做一张简单的对照表,在同一批验证数据上比较几种池化方式:

池化方式语义相似任务短文本分类备注
词向量均值偏弱中等快,但对词序不敏感
句首标记输出中等中等依赖预训练目标,不稳定
最后四层均值加注意力池化较强较强计算量增加约三成
对比学习专门训练的句向量最强较强需要额外训练,但可离线完成

真实取舍还要看延迟预算。句向量模型通常在几十毫秒到一两百毫秒一条(批量时摊销下来更低),如果要给几百万条历史数据建索引,一次性编码的成本是几小时到几天,这部分可以离线做;线上只需要编码查询侧,成本就小得多。这个"离线编码文档、线上只编码查询"的架构,是语义检索能落地的关键,别一上来就想实时编码全库。

4.3 微调与不微调的决策依据

预训练编码器拿来做文本表示,绕不开一个问题:到底要不要在自己的数据上继续训练。判断依据其实很清晰:如果你的任务和预训练目标差异大、且有几千条以上标注数据,微调几乎一定有效;如果标注数据只有几百条,微调很容易过拟合,反而不如直接把预训练模型当特征提取器,再在向量上接一个轻量分类器。

还有一个中间路线叫冻结主干只训练池化层,参数量小、不易过拟合、训练快,在小样本场景下性价比很高。我在一个只有八百条标注的工单分类任务上试过三种做法:全量微调、冻结主干、纯TF-IDF基线,F1分别是0.79、0.83、0.76。也就是说在小数据上,"冻结主干+轻量分类头"反而赢了全量微调。这个结论不一定普适,但它提醒我一件事:微调不是默认正确选项,数据和任务的匹配度才是。

5. 一条可复现的文本表示流水线

5.1 数据清洗与分词的实际操作

真正决定表示质量的往往是最脏的那一步。我会按固定顺序做这几件事,顺序错了效果会打折:

  1. 统一全半角、大小写、繁简,去掉不可见字符和控制符。
  2. 把数字、日期、编号、链接这类"结构相同但语义不同"的模式替换成占位符,这是减少噪声最有效的一步。
  3. 中英混排时用统一的分词策略,中文按词切,英文按空格切并做小写化,数字保留。
  4. 停用词的取舍按任务定:检索任务通常保留,因为"如何""什么"这些词在问句里有信号;主题分类可以去掉。
  5. 建立词表并统计文档频率,按min_df和max_df过滤。

第2步值得单独说一句。我在一个售后工单项目里,把订单号、手机号、时间戳统一替换成三个占位符之后,词表从四十多万降到十万以内,模型训练时间缩短到原来的四分之一,准确率还涨了。原因是这些模式在原始文本里每个都是唯一的,构成了大量只出现一次的特征,既占内存又不提供可泛化的信息。

分词粒度上,中文我一般用词级做稀疏表示,用字级或子词做稠密表示。原因是稠密表示对未登录词更敏感,字级或子词级能覆盖住分词错误;而稀疏表示依赖精确匹配,词级更符合人工判断的直觉,也方便做关键词解释。

5.2 基线与稠密方案的对照实验记录

下面这套流程我在多个项目里复用,基本可以直接套。先跑基线,再逐步升级,每一步都记录指标和耗时,这样能明确回答"多花的成本买到了什么"。

第一步,TF-IDF加线性模型。特征用1到2 gram,min_df=2,max_df=0.9,次线性tf,L2归一化。这一步通常能在半天内完成,得到第一个可交付的下界。

第二步,换哈希特征版本。桶数2^20,开备用特征,其余不变。这一步的意义是验证"词表维护成本"值不值得,通常精度会掉零点几个点,但工程复杂度大幅下降。

第三步,加静态词向量做特征。把句中所有词的向量做加权平均(用idf做权重),拼接到TF-IDF特征后面,或者单独训一个模型再和基线做分数融合。这一步在语料中等、语义鸿沟明显的任务上通常能涨两到四个点。

第四步,上上下文编码器。小样本用冻结主干加线性头,数据够了再全量微调。这一步的成本跳升最明显:训练时间从分钟级到小时级,推理延迟从亚毫秒到几十毫秒,显存从几百MB到几GB。

第五步,做分数融合。把稀疏模型的分数和稠密模型的分数按排名做融合,常用的是倒数排名融合,公式是 score = Σ 1/(k + rank_i),k取60。这个做法不需要调权重,对两个分数量纲不一致的情况特别稳。我在一个检索项目里,单用BM25的Recall@10是0.61,单用稠密向量是0.68,融合之后是0.79,收益非常明显。

阶段方案单条推理延迟冷启动数据需求典型收益
一TF-IDF + 线性模型亚毫秒几十条基线
二哈希特征 + 线性模型亚毫秒几十条略降,换来工程简化
三加静态词向量毫秒级千条以上涨2到4点
四上下文编码器数十毫秒千条以上视任务,涨5到15点
五稀疏稠密融合两者之和—检索场景常涨10点以上

5.3 显存与吞吐的粗算方法

上线前一定要算这笔账,不然演示很美好,部署就翻车。

以768维、12层的编码器为例,单条512长度序列的前向计算量大约在50 GFLOPs量级(注意力部分是序列长度的平方,长度减半计算量约降到四分之一)。批量32条时约1.5 TFLOPs,在一张消费级显卡上按有效算力10 TFLOPS估,单次前向约0.15秒,折合每条约5毫秒,加上分词和前后处理,线上单条延迟按20到50毫秒规划比较稳妥。

向量索引的内存也要算。768维float32每条向量是3072字节,一百万条就是约3GB,这还没算索引结构的额外开销。解决办法有两个:一是降到半精度或量化到int8,内存直接砍到1/2或1/4,实测检索效果掉得很少;二是降维,用主成分分析把768维压到256维,配合归一化,通常只掉一两个点的召回,但内存和计算都省三分之二。我一般先量化再降维,因为量化几乎无损,而降维要看数据分布。

索引规模上,暴力检索在十万条以内完全够用(一次全量点积,几十毫秒),上了百万条就得上近似最近邻索引。这里有个常见误区:索引不是越复杂越好,图索引建得慢、内存占用高,但在千万级以下的数据量优势并不明显,反而增加运维负担。先用暴力检索把效果验证清楚,再考虑换索引,是更稳的路径。

6. 常见故障与排查速查

6.1 典型现象与对应排查路径

下面这张表是我这些年反复遇到的,基本覆盖了八成的问题:

现象最可能的原因排查动作
所有句子两两相似度都在0.8以上各向异性,未做白化或归一化统计随机句对的相似度分布,做中心化或白化
训练集很好,线上效果崩词表或分词器在线上遇到未登录词统计线上未登录词比例,改用子词或哈希
长文本分类总被少数长样本带偏未做向量归一化加L2归一化或次线性tf
检索时专有名词召回极差稠密表示丢失了精确匹配信息引入BM25做混合,或加字符级n-gram
词向量近邻全是功能词未开子采样或语料过杂开子采样,提高min_count,清洗语料
微调后验证集指标不如冻结主干标注数据太少,过拟合冻结底层,加正则,减小学习率
推理延迟抖动大序列长度未截断,长短样本混批按长度分桶,设置最大长度上限

6.2 几条只有踩过才知道的经验

第一条,永远先看错误样本的原始文本。我遇到过准确率卡在0.72上不去的情况,换了三种模型都没动,最后把错分样本打印出来一看,是同一批工单里混进了另一个产品的编号体系,标注本身就有噪声。清洗完标注,指标直接到0.86。模型救不了脏数据,这句话在文本任务上尤其成立。

第二条,相似度阈值不要拍脑袋。余弦相似度0.7在某些模型里算"很相关",在另一些模型里算"几乎无关",因为不同模型输出的相似度分布完全不同。正确做法是在验证集上画一下正负样本的相似度分布,找两条曲线的交点,或者按目标精确率反推阈值。我一般会给出三个阈值:高精度(误召回少)、均衡、高召回,让业务方自己选。

第三条,向量库里的旧向量一定要有版本号和失效机制。换了一版编码器,新老向量混在同一个索引里做相似度比较,结果是灾难性的——它们在完全不同的空间里,算出来的相似度毫无意义。我们的做法是在索引名前缀里带上模型版本,切换时双写并行一段时间,灰度验证后再摘掉旧索引。

第四条,不要迷信维度越高越好。我做过一个对比,同一个任务上256维和768维的句向量,检索的Recall@10分别是0.71和0.73,差距只有两个点,但内存和延迟差了三倍。如果业务对延迟敏感,降维是完全值得的。维度这件事,够用就行,多出来的部分往往是在拟合噪声。

最后分享一个我在实际项目里养成的小习惯:每上一个新表示方案,先在两百条样本上人工看一眼近邻结果和错分案例,确认"模型眼中的相似"和"业务眼中的相似"对得上,再投入全量训练。这一步花不到半小时,但能提前发现数据分布和任务定义之间的错配,比跑完三天训练再发现方向错了要划算得多。文本表示这层地基,最怕的不是不够先进,而是和你的任务根本不在同一个坐标系里。