用 Embeddings 做情感分析:语义空间比对与工程落地
情感分析这件事,很多人第一反应是拉一堆正负面词表做匹配,或者干脆调一个分类接口拿个标签回来。我最早做一批商品评论的情绪打标时就是这套路,结果碰上"不是不好用,就是有点贵"这种句子直接翻车——词表里"不好用"是负面词,"贵"也是负面词,可人家真正想说的是东西还行、价格劝退,是一种典型的混合情绪。后来我把思路换了一下,不再让模型去"判断"情绪,而是让它把句子压成向量,再看这个向量离"正面语义区"近还是离"负面语义区"近。这就是文本情感分析借助 embeddings 接口做语义空间比对的思路,也是这个系列第三篇要展开的东西。
这篇适合两类人看:一类是手上有一批评论、工单、问卷开放题,想快速做出情绪打分能力,又不想标注几千条数据训模型的;另一类是想搞明白 embedding 到底怎么回事,而不只是会贴一段调用代码的。全文会从原理讲到可复现的脚本,包括阈值怎么定、反向案例怎么兜、钱花在哪,参数和坑我都会写清楚。
1. 为什么情感分析要绕到 embeddings 上
1.1 词表匹配和通用分类接口都踩过哪些坑
先说词表匹配。它的优点是零成本、可解释,出问题你能立刻指着某个词说"是它干的"。但缺点一样明显:第一,覆盖率靠人堆,网络用语、缩写、谐音一天一个样,词表永远追不上;第二,它完全没有语序和上下文概念,"服务没得说"和"服务没话说"在词表眼里都是"服务+没",结果一个褒一个贬;第三,程度和否定处理极麻烦,"还行""凑合""勉强能接受"到底算几分,只能靠拍脑袋定权重。
再说通用情感分类接口。省事,一行调用出标签,但它给的是离散结果,正面/负面/中性三档,粒度粗得让人难受。你要做的是排序,比如从一万条评论里挑出最不满的一百条优先处理,这时候"负面"这个标签就帮不上忙了,因为三千条都是负面,谁更负面?我还遇到过一个更现实的问题:通用分类模型训练语料多为通用领域公开数据,套到垂直场景(比如工业设备报修描述、医疗咨询转述)上准确率会掉得很难看,而你又没法微调。
绕到 embeddings 上,本质上是把"分类"这件事换成了"距离测量"。模型不告诉你这是正面还是负面,它只负责把每句话映射到同一个高维空间里,正面的句子会聚在一团,负面的聚在另一团,剩下的活儿是你在空间里做几何题。好处是连续打分、可排序、可调阈值,而且不用训练,只维护一小撮"锚点句"。
1.2 三条技术路线的横向对比
把这几条路摆在一起看会更清楚。下面这张表是我按实际项目经验整理的,成本数字取的是量级,具体以你调用时的官方定价为准:
| 维度 | 词表/规则匹配 | 通用情感分类接口 | embeddings 语义比对 |
|---|---|---|---|
| 冷启动成本 | 低,但要人工堆词 | 极低,直接调用 | 低,需要写几十条锚点句 |
| 输出粒度 | 可加权,但权重靠拍 | 离散标签,通常三档 | 连续分数,可排序 |
| 上下文理解 | 基本没有 | 有,但受训练域限制 | 有,且随锚点可迁移 |
| 领域迁移 | 换场景就要重写词表 | 效果通常明显下降 | 换锚点句即可迁移 |
| 可解释性 | 强,能定位到词 | 弱 | 中等,能看相似度拆解 |
| 单位成本 | 几乎为零 | 按 token 或按次计费 | 按 token 计费,比生成类接口便宜一到两个数量级 |
| 维护成本 | 词表持续膨胀 | 依赖服务方迭代 | 锚点集需要定期复盘 |
选 embeddings 的核心逻辑在于:情感判断这件事,人类自己也没有一个绝对标尺,更多是"相对而言"。你说一句评论是 0.8 分负面还是 0.6 分负面,很难讲清;但你说它比另一句更负面,这个判断很稳。而距离度量天然适合做相对比较。这也是为什么做优先级排序、做舆情趋势曲线,向量方案比标签方案好用得多——它能出连续值,画出来的折线才有形状。
1.3 这套方案的适用边界:什么场景别硬上
不是所有场景都该上向量。有几种情况我会直接劝退或者换方案。
第一种是要求极强可解释性、需要给出判定依据的合规场景。向量相似度只能告诉你"它离负面锚点更近",说不出具体因为哪个词。这种场景老实做规则加人工复核更稳。
第二种是类别极多且边界模糊的任务。情感只有正负两极,向量空间里方向很清晰;但如果你要做二十个细分类目的意图识别,质心之间互相挤在一起,区分度会掉得厉害,这时候用少量标注数据训一个小分类器反而更划算。
第三种是对延迟极端敏感的在线链路。embedding 调用再快也有网络往返,如果你的接口 P99 要求几十毫秒,那最好把向量计算挪到异步批处理,线上只查缓存的分数。
第四种,文本超长且信息分散。比如一份三千字的长报告,整篇算一个向量,正负情绪会被平均掉,出来永远接近中性。这种情况必须切块,后面第 2.4 节细说。
2. embeddings 接口到底返回了什么
2.1 把一句话压成一串浮点数
接口本身很朴素:你传一段文本进去,它返回一个浮点数数组。以主流的第三代小模型为例,默认是 1536 个浮点数;大模型是 3072 个。这串数字就是这句话在语义空间里的坐标。
打个比方,想象一个三维空间,X 轴大致表示"积极程度",Y 轴表示"正式程度",Z 轴表示"是否涉及金钱"。一句"这个价格真划算"会落在 X 正、Z 正的位置;"客服态度差,退款还慢"落在 X 负的位置。真实模型的空间是 1536 维,人类没法直接可视化,但道理一样:语义相近的句子,坐标就挨得近。
这里有个关键认知需要摆正——单个维度没有独立含义。你不能指着第 237 维说"这是情绪维度"。语义是被分布式编码在整组数字里的。所以别想着去解读单维,要做的是整体比较两个向量的夹角。
调用形态上,接口支持单条字符串,也支持字符串数组批量传入(单次请求有输入条数上限,通常是两千条量级,实际使用时按你的文本长度保守估计)。返回体里除了向量,还有usage字段告诉你消耗了多少 token,这个数字要盯紧,它是你成本核算的唯一依据。
2.2 余弦相似度的算法细节与手算示例
判断两个向量近不近,最常用的是余弦相似度,衡量的是方向夹角而非绝对距离。公式很朴素:
cos(A, B) = (A · B) / (|A| × |B|)A · B 是逐元素相乘再求和,|A| 是各元素平方和开根号。取值区间 -1 到 1,1 表示方向完全一致,0 表示正交(语义无关),-1 表示完全相反。
举个能手算的小例子。假设只有二维,正面锚点方向是 A = (0.8, 0.6),某条待测文本向量是 B = (0.6, 0.8)。点积 = 0.48 + 0.48 = 0.96;|A| = 1,|B| = 1(这组数刚好是单位向量);余弦 = 0.96,非常接近。
实际工程里有个省事的点:第三代 embedding 模型返回的向量通常已经是归一化的,也就是模长接近 1。这种情况下余弦相似度就等于点积,省掉两次开方,批量算的时候能快不少。不过我个人的习惯是拿到向量后自己再做一次归一化,一是防止某些维度裁剪场景下模长有微小偏差,二是代码里统一处理,读起来不容易出岔子。
还有一个容易忽略的细节:相似度不是概率。算出来 0.73,不代表"73% 的把握是正面"。它只是一个几何量,绝对值大小跟锚点集的质量强相关。锚点句写得散,整体相似度都会偏低;锚点句高度同质化,相似度又会虚高。所以跨批次比较时,锚点集必须冻结,不能中途改。
2.3 模型选型:小模型够不够,维度要不要裁
主流的 embedding 模型有三代同堂的情况,选型主要看三件事:维度、单价、多语言能力。下面这组对比是按公开资料整理的量级参考:
| 模型 | 默认维度 | 相对单价 | 适用判断 |
|---|---|---|---|
| 第二代 ada 类 | 1536 | 基准的 5 倍左右 | 老项目兼容,新项目不建议 |
| 第三代 small | 1536 | 基准 | 绝大多数情感分析场景的首选 |
| 第三代 large | 3072 | 基准的 6 倍以上 | 语义极细腻、短文本歧义多的场景 |
我的实测结论比较明确:中文短评论的情感极性判断,small 完全够用。正负两极的方向差异是语义空间里最粗的一层结构,不需要那么高的分辨率。什么时候才考虑 large?我的经验是当你要区分的是细粒度情绪(比如"失望"和"愤怒"、"惊喜"和"满足"),或者文本极短(三五个字的弹幕、标签),信息量少导致 small 容易挤在一起,这时候大模型的边际收益才明显。
第三代模型还支持dimensions参数,可以把输出维度裁到更低(比如 512、256)。这有什么用?存储和检索。一百万条评论,1536 维 float32 就是约 6GB,裁到 256 维直接降到 1GB,内存里做暴力检索可行性大增。裁剪的原理是取前 N 维再重新归一化,精度损失在情感这种粗粒度任务上通常可以接受,但我建议做法是:先用全维度跑通、验证效果,再逐步下调维度做 A/B,看分数排序的 Spearman 相关性掉多少,掉得可接受再定稿,别一上来就裁。
2.4 token 预算与截断策略
第三代模型的单条输入 token 上限在 8000 出头。绝大多数评论、工单、弹幕远远够用。但长文本一定会遇到,比如用户反馈里粘贴了一大段日志,或者问卷开放题写了小作文。
这里最重要的原则是:别直接截断。原因很直白——长文本的情绪往往是分段落的,前面骂问题、后面夸解决得快,你截掉后半段,结论就全反了。合理做法是分块:
- 按句子或段落切,每块控制在 200 到 500 字;
- 每块单独求向量、单独打分;
- 汇总时不要简单平均,用"最长块优先"或者"绝对值最大的块优先",因为情绪最强烈的片段通常才是用户真正想表达的。
顺带说成本。token 消耗按输入字符估算,中文大致一个字接近一个 token(不同分词器有差异,量级参考即可)。一万条评论、平均 40 字,就是 40 万 token 量级,按 small 的单价算,成本是几毛钱到一块钱这个级别。真正的成本大头不在算,在重复调用——同一条文本被算了好几遍。这个后面第 3.4 节讲缓存。
3. 一套可复现的情感打分流程
3.1 环境准备与第一次调用
先把最小可运行单元跑通。依赖很轻,装官方 SDK 和 numpy 就够:
pip install openai numpy环境变量里放好密钥,别写死在代码里:
export OPENAI_API_KEY="你的密钥"第一次调用,确认接口通、向量形状对:
import os import numpy as np from openai import OpenAI client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) def embed(texts, model="text-embedding-3-small"): resp = client.embeddings.create(model=model, input=texts) vecs = np.array([d.embedding for d in resp.data], dtype=np.float32) return vecs, resp.usage.prompt_tokens vecs, used = embed(["这东西真好用,下次还买"]) print(vecs.shape) # (1, 1536) print(used) # 消耗的 token 数几个实操要点。第一,input传列表比循环单条传快得多,网络往返次数直接决定总耗时。第二,返回的data顺序与你传入的顺序一致,但稳妥起见还是按index字段排序后再用,避免某些批处理实现打乱顺序。第三,空字符串要提前过滤,空白输入会浪费请求甚至触发参数错误,做一层strip()判断,空文本直接给中性分,不浪费一次调用。
3.2 造锚点句库:质心怎么算才稳
这是整套方案里最需要手工投入、也最决定成败的一步。锚点句就是你的"标尺刻度"。做法是准备两组句子,一组明确正面,一组明确负面,各自求向量平均,得到两个质心。
锚点句的选择有四条经验:
- 数量:每组 30 到 50 条起步,少于 20 条质心会抖;
- 多样性:覆盖不同句式(陈述、感叹、反问)、不同长度(短语和整句都要有)、不同表达强度("还行"到"太好了");
- 业务贴合:做外卖评论就别用影评锚点,我吃过这个亏,跨域锚点会让分数整体偏移;
- 排除模糊句:像"说不上好也说不上坏"这种,坚决不能进锚点集,它会把质心往原点拽。
代码上就是一次批量调用加一次平均:
POS_ANCHORS = [ "质量很好,用起来很顺手", "非常满意,会推荐给朋友", "超出预期,性价比高", "服务态度好,响应也快", # ... 补足到 30 条以上,覆盖不同强度和句式 ] NEG_ANCHORS = [ "质量太差,用两天就坏了", "很失望,不会再买了", "客服推诿,问题一直没解决", "价格虚高,不值这个钱", # ... 同上 ] def normalize(m): return m / np.linalg.norm(m, axis=-1, keepdims=True) pos_vecs, _ = embed(POS_ANCHORS) neg_vecs, _ = embed(NEG_ANCHORS) pos_centroid = normalize(pos_vecs.mean(axis=0)) neg_centroid = normalize(neg_vecs.mean(axis=0))这里有个细节值得强调:先平均再归一化,顺序不能反。反过来先归一化再平均,等价于把每条锚点权重拉平,效果差别不大但会让质心略微偏离"语义中心"。另外,锚点集一定要版本化管理,落到配置或数据库里并打上版本号,因为一旦你中途改了锚点,历史分数就不可比了,趋势图上会出现莫名其妙的断崖。
3.3 把相似度变成 -1 到 1 的情感分
有了质心,打分就简单了。最基础的写法是差值:
def score_basic(vecs, pos_c, neg_c): v = normalize(np.atleast_2d(vecs)) sim_pos = v @ pos_c sim_neg = v @ neg_c return sim_pos - sim_neg差值法的输出范围大致在 -1 到 1 之间,正数偏正面、负数偏负面,直观好用。但它有个毛病:中间区域分辨率低。大量中性文本挤在 0 附近,你看不出谁稍微好一点。
所以我更常用的是归一化对比:
def score_norm(vecs, pos_c, neg_c, eps=1e-8): v = normalize(np.atleast_2d(vecs)) sim_pos = v @ pos_c sim_neg = v @ neg_c return (sim_pos - sim_neg) / (sim_pos + sim_neg + eps)这个式子的意思是"正面相似度在总相似度里占多大比例",输出区间更均匀,中性区也能拉开差距。代价是对极端值更敏感,遇到 sim_pos 和 sim_neg 同时接近 0 的奇怪文本(比如纯符号、乱码)会放大噪声,所以我加了 eps,并且事后对结果做截断。
实际项目里我的做法是先用差值法定大方向,再用归一化法定排序。两个分数都存下来,排查问题时对照着看,能很快判断是锚点偏了还是文本本身异常。
3.4 批量、缓存、重试三件套
单条调用能跑通,工程化还差三件事。
批量:控制单次请求的条数。我一般按 64 到 128 条一批,同时估算总 token,别让单批超过模型输入上限。分批的好处是失败重试成本低,坏一批只重跑一批。
缓存:这是省钱的关键。用一个键值存储,键是文本内容的哈希,值是向量。我通常用 SQLite 就够,几百万条以内性能完全能接受。
import hashlib, sqlite3, json def text_key(t: str) -> str: return hashlib.sha256(t.strip().encode("utf-8")).hexdigest() def embed_cached(texts, conn, model="text-embedding-3-small", batch=128): keys = [text_key(t) for t in texts] cur = conn.execute( f"SELECT key, vec FROM emb_cache WHERE key IN ({','.join('?'*len(keys))})", keys, ) hit = {k: np.array(json.loads(v), dtype=np.float32) for k, v in cur.fetchall()} miss_idx = [i for i, k in enumerate(keys) if k not in hit] out = [hit.get(k) for k in keys] for s in range(0, len(miss_idx), batch): chunk = miss_idx[s:s + batch] vecs, _ = embed([texts[i] for i in chunk], model=model) for i, v in zip(chunk, vecs): out[i] = v conn.execute( "INSERT OR REPLACE INTO emb_cache(key, vec) VALUES (?, ?)", (keys[i], json.dumps(v.tolist())), ) conn.commit() return np.array(out, dtype=np.float32)缓存表记得建索引,key做主键即可。这一步在重复跑历史数据、调阈值重算分数的时候,能把调用量压到接近零。我做过一个统计:同一批两万条评论反复调试了十几轮,没缓存的话调用费是缓存方案的十倍以上。
重试:网络抖动和限流是常态。用指数退避,别用固定间隔:
import time, random def embed_with_retry(texts, max_retry=5, **kw): for attempt in range(max_retry): try: return embed(texts, **kw) except Exception as e: if attempt == max_retry - 1: raise wait = min(2 ** attempt + random.random(), 30) time.sleep(wait)退避的上限我一般卡在 30 秒,再长不如直接放弃这批、记录下来稍后补跑。另外要单独处理一类错误——输入本身有问题(比如某个字段是None),这种重试一万次也没用,得在进队列前就清洗干净。
3.5 阈值校准:拿 100 条人工标注定生死
分数算出来了,-0.42到底算不算负面?这个阈值不能拍脑袋。我的做法是抽 100 到 200 条真实数据,人工标成正面/中性/负面三档,然后看分数分布。
具体操作:按分数排序,把人工标"负面"的样本的分数分布画出来(或者直接看分位数),取能让准确率和召回率平衡的切点。经验上,如果人工标"负面"的样本 90% 分位数落在 -0.25,那 -0.25 就可以作为负面线的候选。
更严谨一点的做法是算几个候选阈值下的 F1,选最高的。下面这个思路不用画图也能跑:
def pick_threshold(scores, labels, step=0.02): # labels: 1 表示负面,0 表示非负面 best_t, best_f1 = None, -1 t = min(scores) while t <= max(scores): pred = [1 if s <= t else 0 for s in scores] tp = sum(1 for p, y in zip(pred, labels) if p == 1 and y == 1) fp = sum(1 for p, y in zip(pred, labels) if p == 1 and y == 0) fn = sum(1 for p, y in zip(pred, labels) if p == 0 and y == 1) if tp: prec, rec = tp / (tp + fp), tp / (tp + fn) f1 = 2 * prec * rec / (prec + rec) if f1 > best_f1: best_t, best_f1 = t, f1 t += step return best_t, best_f1要提醒一点:阈值和锚点集是绑定的。你换了锚点、换了模型、换了维度,阈值全部要重新校。所以我把阈值、锚点版本、模型名一起写进配置文件,任何一项变动都触发重新校准,这个纪律能省掉后面无数次的"为什么今天分数全变了"。
4. 效果验证与踩坑记录
4.1 常见问题速查表
下面这张表是我这两年遇到过的、以及同行问过我的高频问题,按出现频率排:
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 所有分数都挤在 0 附近 | 锚点句太温和,正负区分度不足 | 锚点里加入更强烈的表达,扩大两极 |
| 分数整体偏向正面 | 锚点数量不平衡,正面句明显更多 | 两组锚点数量对齐,或做均值权重校正 |
| 同一文本两次调用向量不同 | 用了不同模型或不同 dimensions | 锁定模型名和维度参数,写进配置 |
| 短文本(两三个字)分数乱跳 | 语义信息太少,向量不稳定 | 拼上上下文再算,或对短文本降权 |
| 长文本永远判中性 | 整篇一个向量,两极被平均 | 切块分别打分,取极值块 |
| 特定品类误判严重 | 锚点集与该品类表达风格不匹配 | 为该品类补 10 到 20 条专属锚点 |
| 调用偶发报错 | 触发限流或网络抖动 | 指数退避重试,控制批量大小 |
| 分数在同一批内可比、跨批不可比 | 锚点集或模型版本变了 | 锚点集版本化管理,冻结后不轻改 |
4.2 否定、反讽、混合情感这三个老大难
否定是最容易踩的。早期我拿"服务好"做正面锚点,结果"服务不好"的分数也是偏正面的,因为 embedding 模型对否定词的处理不像人类那么敏感——它抓到的主要是"服务"这个主题。解决办法有两个,我一般双管齐下:一是在正负锚点里都塞进成对的否定表达("服务好"和"服务不好"分别进两组),让质心把这个方向学进去;二是在预处理阶段对含否定词的句子加一点惩罚项,幅度不用大,0.05 到 0.1 的量级就够,主要起纠偏作用。
反讽更难。像"真棒,三天就坏了,厉害"这种,字面词全是褒义,实际是强烈讽刺。纯语义向量的做法是:在负面锚点里专门补几条反讽句式,让质心知道"褒义词+负面事实"这类组合该往哪边靠。同时可以在流程上加一条规则——如果一句话里褒义词和负面事实词(比如故障、退货、投诉)同时出现,触发人工复核。这类样本占比通常不高,抽查成本可以接受。
混合情感是最常见也最该被正视的。用户真实反馈里,绝大多数不是纯正或纯负,而是"东西不错但物流太慢"。我的处理策略是不做单一标签判定,输出双分数:分别算这条文本到正面质心和负面质心的绝对相似度,两个都高就标记为"混合情绪",在业务侧单独走一条处理路径。这个改动听着小,实际价值很大,因为混合情绪里往往藏着最有价值的产品改进线索。
4.3 成本、延迟与方案收尾
先把数字摆出来,都是我这边的实测量级。延迟方面,单批 64 条短文本(平均 40 字),端到端往返一般在几百毫秒到一秒出头,其中网络占了大部分;批内条数翻倍,耗时增长并不线性,所以适当加大批量是划算的。成本方面,按 small 模型的公开单价,一万条 40 字评论的总消耗在几十万 token 量级,费用是个位数人民币级别——这套方案真正的成本从来不是算力费,是人工:写锚点、标 100 条校准样本、复盘误判,加起来可能是一两天。
所以在落地上我给的建议是:先用 200 条数据做一次快速验证,看看分数分布能不能把明显负面和明显正面分开;能分开就继续推,分不开就先检查锚点集,别急着换大模型——我试过,换 large 在这种粗粒度任务上带来的提升,远不如把锚点句改好来得明显。
还有个小技巧分享下。这套流程除了做情感分,还能顺手干两件事:一是用同一批向量做主题聚类,把负面评论按问题类型分组,比单看分数有用得多;二是做去重,相似度高于某个阈值的评论视为同一诉求,客服侧的重复工单能压掉一大半。向量算一次,多个用途复利,这才是 embeddings 接口最实惠的用法。
我个人的体会是,embedding 这类接口的价值不在于它多聪明,而在于它把一个模糊的判断问题,转成了明确的几何计算问题。判断会被情绪、语境、个人偏好干扰,几何不会。你只需要保证标尺本身是准的,剩下的事情交给点积就行。