资讯详情

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

📅 2026/10/29 16:13:27 | 华诺云谱 👁 阅读
从词袋到上下文向量: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-IDF2.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_df2百万级用min_df5同时用max_df0.9把那些几乎每篇都出现的词也剔掉——这一步等价于半自动去停用词比手写停用词表稳得多。2.2 TF-IDF的公式拆解与参数实测TF-IDF的逻辑很朴素一个词在这篇文档里出现得越多越重要在所有文档里出现得越普遍越不重要。但真到实现层面各种变体的差别不小。词频部分常见的有三种口径原始计数、除以文档长度、以及1log(tf)的次线性缩放。我基本只用第三种原因是它把出现10次和出现20次的差异压缩了避免单个高频词把整条向量带偏。逆文档频率部分是 log(N/df)但直接用会在dfN时变成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.099idf 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 数据清洗与分词的实际操作真正决定表示质量的往往是最脏的那一步。我会按固定顺序做这几件事顺序错了效果会打折统一全半角、大小写、繁简去掉不可见字符和控制符。把数字、日期、编号、链接这类结构相同但语义不同的模式替换成占位符这是减少噪声最有效的一步。中英混排时用统一的分词策略中文按词切英文按空格切并做小写化数字保留。停用词的取舍按任务定检索任务通常保留因为如何什么这些词在问句里有信号主题分类可以去掉。建立词表并统计文档频率按min_df和max_df过滤。第2步值得单独说一句。我在一个售后工单项目里把订单号、手机号、时间戳统一替换成三个占位符之后词表从四十多万降到十万以内模型训练时间缩短到原来的四分之一准确率还涨了。原因是这些模式在原始文本里每个都是唯一的构成了大量只出现一次的特征既占内存又不提供可泛化的信息。分词粒度上中文我一般用词级做稀疏表示用字级或子词做稠密表示。原因是稠密表示对未登录词更敏感字级或子词级能覆盖住分词错误而稀疏表示依赖精确匹配词级更符合人工判断的直觉也方便做关键词解释。5.2 基线与稠密方案的对照实验记录下面这套流程我在多个项目里复用基本可以直接套。先跑基线再逐步升级每一步都记录指标和耗时这样能明确回答多花的成本买到了什么。第一步TF-IDF加线性模型。特征用1到2 grammin_df2max_df0.9次线性tfL2归一化。这一步通常能在半天内完成得到第一个可交付的下界。第二步换哈希特征版本。桶数2^20开备用特征其余不变。这一步的意义是验证词表维护成本值不值得通常精度会掉零点几个点但工程复杂度大幅下降。第三步加静态词向量做特征。把句中所有词的向量做加权平均用idf做权重拼接到TF-IDF特征后面或者单独训一个模型再和基线做分数融合。这一步在语料中等、语义鸿沟明显的任务上通常能涨两到四个点。第四步上上下文编码器。小样本用冻结主干加线性头数据够了再全量微调。这一步的成本跳升最明显训练时间从分钟级到小时级推理延迟从亚毫秒到几十毫秒显存从几百MB到几GB。第五步做分数融合。把稀疏模型的分数和稠密模型的分数按排名做融合常用的是倒数排名融合公式是 score Σ 1/(k rank_i)k取60。这个做法不需要调权重对两个分数量纲不一致的情况特别稳。我在一个检索项目里单用BM25的Recall10是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维的句向量检索的Recall10分别是0.71和0.73差距只有两个点但内存和延迟差了三倍。如果业务对延迟敏感降维是完全值得的。维度这件事够用就行多出来的部分往往是在拟合噪声。最后分享一个我在实际项目里养成的小习惯每上一个新表示方案先在两百条样本上人工看一眼近邻结果和错分案例确认模型眼中的相似和业务眼中的相似对得上再投入全量训练。这一步花不到半小时但能提前发现数据分布和任务定义之间的错配比跑完三天训练再发现方向错了要划算得多。文本表示这层地基最怕的不是不够先进而是和你的任务根本不在同一个坐标系里。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑