阶段一 · NLP 基础与文本表示

Embedding 与语义空间

一句话总结

Embedding 把词映射到一个连续的向量空间中,语义相近的词在空间中距离更近.
理解这个"空间"的几何意义--距离,方向,聚类--是后续理解模型为什么能工作的关键直觉.

从查找表到语义空间

上一篇我们知道了 Embedding 是一张查找表:token ID → 向量. 但如果只把它理解为"查表",会错过最重要的东西--这些向量在空间中的分布是有结构的.

想象一个二维坐标系(实际是 768 维,但思路相同),每个词是空间中的一个点:

quadrantChart title 词向量语义空间(简化为2D示意) x-axis "中性/日常" --> "攻击性/负面" y-axis "口语化" --> "正式" quadrant-1 "正式负面" quadrant-2 "正式中性" quadrant-3 "口语中性" quadrant-4 "口语负面" "废物": [0.82, 0.3] "垃圾": [0.78, 0.35] "白痴": [0.85, 0.25] "天气": [0.15, 0.55] "阳光": [0.12, 0.6] "今天": [0.2, 0.5] "滚": [0.9, 0.15] "讨论": [0.1, 0.7]

训练出来的向量不是随机散落的,而是自动按语义关系聚集--辱骂词扎堆在右下区域,日常词聚在左上区域. 这不是人为设计的,是训练算法从大量文本的词语共现模式中"涌现"出来的.

Embedding 空间 = Go 中 net/http 的路由表. 注册 handler 时只给了路径和函数的映射关系,但 ServeMux 内部会按前缀树组织--结构是从数据中自动产生的. Embedding 空间也一样:只给了共现数据,空间结构自动形成.

距离 = 语义相似度

在 Embedding 空间中,两个向量之间的距离直接反映语义关系. 最常用的距离度量是余弦相似度(Cosine Similarity):

余弦相似度 = cos(θ) = (A·B) / (|A| × |B|)

范围: [-1, 1]
1 = 方向完全相同(语义极相似)
0 = 正交(语义无关)
-1 = 方向完全相反(语义相反)

为什么用余弦而不是欧氏距离? 因为余弦只看方向,不看长度. 两个向量即使数值大小不同(比如一个词出现频率高导致向量被"拉长"),只要方向一致就判为相似.

类比两个 API 的请求模式:一个日均 1000 QPS,另一个日均 10 QPS,但它们的流量曲线形状完全相同(都是早高峰+午低谷+晚高峰). 余弦相似度关注的是"形状"(方向),不是"量级"(长度).

import numpy as np

def cosine_similarity(a, b):
"""计算两个向量的余弦相似度"""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# 假设从 Word2Vec 中取出的向量
vec_waste = np.array([0.23, -0.45, 0.12, 0.67]) # "废物"
vec_trash = np.array([0.21, -0.42, 0.15, 0.63]) # "垃圾"
vec_weather = np.array([-0.56, 0.31, -0.78, 0.02]) # "天气"

print(cosine_similarity(vec_waste, vec_trash)) # → 0.99(极相似)
print(cosine_similarity(vec_waste, vec_weather)) # → -0.68(不相关甚至相反)

维度意味着什么

BERT 的 Embedding 是 768 维--这 768 个数字各自代表什么?

诚实的回答:没有人确切知道. 每一维不像数据库字段那样有明确定义(不存在"第 42 维 = 情感正负"这种对应关系).

但从宏观上可以这样理解:

  • 某些维度的组合涌现出语义属性(情感倾向,正式程度,领域归属等)
  • 维度越多,能编码的语义细节越丰富(就像描述一个人:3 个特征不够,300 个特征能区分大多数人)
  • 768 维是效果和计算成本的平衡点--更多维度效果提升递减,但计算量线性增长
模型 维度 能编码的信息量 推理速度
Word2Vec 小模型 50-100 基本语义关系 极快
Word2Vec / FastText 300 丰富的语义+部分语法信息 很快
BERT-base 768 深层语义+上下文+语法+世界知识
BERT-large / GPT 1024-4096 更精细的语义 较慢

对工程决策的影响

维度直接影响推理延迟和内存占用. 768 维向量做一次余弦相似度需要 768 次乘法+加法. 如果对骂检测要求 <5ms 延迟,每秒处理上万条消息,可能需要考虑用 128 或 256 维的蒸馏模型(阶段三会讲).

从词向量到句子向量

到目前为止讨论的都是词级的向量. 但分类任务(对骂检测)需要的是整个句子的向量--一句话 → 一个向量 → 一个分类标签.

怎么从词向量得到句子向量? 三种主流方式:

方法一:平均池化(最简单)

把一句话中所有词的向量逐维取平均:

# "你 脑子 有 问题" 的句子向量 = 各词向量的平均
sentence_vec = (vec_你 + vec_脑子 + vec_有 + vec_问题) / 4

优点:零成本,不需要训练. 缺点:丢失词序信息("我打你"和"你打我"结果相同).

方法二:TF-IDF 加权平均

用 TF-IDF 权重决定每个词对句子向量的贡献度:

# "问题"的TF-IDF权重高(区分度大),"有"的权重低(太常见)
sentence_vec = w_你*vec_你 + w_脑子*vec_脑子 + w_有*vec_有 + w_问题*vec_问题
# 其中 w = 对应词的 TF-IDF 值

比纯平均好--重要的词("废物""垃圾")贡献更多,虚词("的""了")贡献更少.

方法三:BERT 的 [CLS] 向量(最强)

先回顾:[CLS] 是 BERT 在句子开头自动加的特殊标记(Classification 的缩写). 它本身没有含义--不代表任何实际的字或词. 那它的向量是怎么"变成"句子向量的?

关键在于理解 Transformer 层做了什么. 分步展开:

第一步:Embedding 查表(所有位置平等)

输入的每个 token(包括 [CLS])都去 Embedding 表查出自己的初始向量. 此时 [CLS] 的向量只是表里第 101 行的固定值,和句子内容无关.

输入 ID:    [101,    872,   5554,  2094,  3300,  4567,  1416,  102]
CLS 你 脑 子 有 病 吧 SEP

Embedding
查表后: [v_cls] [v_你] [v_脑] [v_子] [v_有] [v_病] [v_吧] [v_sep]

此时只是初始值,和句子内容无关

第二步:Transformer 层的自注意力(核心魔法)

进入 Transformer 层后,每个位置都会"查看"其他所有位置,并把看到的信息融合进自己的向量中. 这个过程叫自注意力(Self-Attention).

第 1 层 Transformer 中,[CLS] 位置发生了什么:

[CLS] 向同一句话中所有其他位置"提问":
→ 看了"你" → 吸收了一部分 "你" 的信息
→ 看了"脑" → 吸收了一部分 "脑" 的信息
→ 看了"子" → 吸收了一部分 "子" 的信息
→ 看了"有" → 吸收了一部分 "有" 的信息
→ 看了"病" → 吸收了一部分 "病" 的信息
→ 看了"吧" → 吸收了一部分 "吧" 的信息

经过加权融合后,[CLS] 的向量被更新了--
它现在携带了整个句子中所有字的一部分信息.

同时,其他位置也在互相看:
"病" 看了 "脑""子" → 知道了自己和"脑子"搭配,是贬义方向
"吧" 看了前面所有字 → 编码了语气(反问/轻蔑)

第三步:12 层叠加(信息层层融合)

这个过程重复 12 次(BERT-base 有 12 层). 每一层 [CLS] 都会再次看所有位置--但此时其他位置的向量已经被上一层更新过了,包含了更丰富的信息.

第 1 层后: [CLS] 知道了句子中有哪些字
第 3 层后: [CLS] 知道了"脑子有病"是一个贬义短语
第 6 层后: [CLS] 知道了整句是一个反问句式的辱骂
第 12层后: [CLS] 融合了完整的句意--辱骂,反问语气,针对对方智力

最终 [CLS] 向量 = 整句话的"语义摘要"(768维)

[CLS] 经过 Transformer = Go 中 HTTP 请求经过 middleware 链. 一个请求经过 12 层中间件,每层都往 context 里注入一些信息(认证状态,用户身份,权限,限流标记...). 到最后一层时,context 已经聚合了所有中间件的输出. [CLS] 就是这个 context--经过 12 层 Transformer "中间件",每层都从句子中其他字那里收集信息,最终成为整句话的浓缩表示.

为什么是 \[CLS\] 而不是其他位置

[CLS] 天生适合做句子汇总,因为它没有自己的语义负担. "你"的向量既要表达"你"本身的含义,又要参与上下文计算;而 [CLS] 不代表任何字,它唯一的任务就是当"信息收集器".

BERT 预训练时就是让 [CLS] 承担句子级任务(判断两句话是否连续),所以训练完成后这个位置天然擅长汇总全句语义.

最终的使用方式很简单--取 [CLS] 对应位置的输出向量,作为整个句子的表示:

# outputs.last_hidden_state 形状: [1, 8, 768]
# ↑ ↑ ↑
# batch 序列长度 向量维度
#
# [CLS] 在位置 0,直接取第 0 个位置的向量
sentence_vector = outputs.last_hidden_state[0, 0, :] # → 768维

这是效果最好的方式--因为 [CLS] 的向量经过 12 层注意力计算,已经融合了整句话的上下文信息,包括词序,语气和语义关系.

方法 考虑词序 考虑上下文 需要训练 效果
平均池化 够用做 baseline
TF-IDF 加权平均 比纯平均好
BERT [CLS] 是(微调) 最好

语义空间中的聚类现象

一个重要的直觉:如果把大量文本的句子向量画在空间中,同类文本会自然聚成簇.

分类器做的事情就是在这个空间中画边界线--把不同簇分开. Embedding 质量越好(同类越聚,异类越散),分类器的工作就越简单.

阴阳怪气:分类的难点

"你可真行啊"和"你真行! 太厉害了"在静态词向量空间中可能很近(用词相似),但语义相反. 这就是为什么需要上下文感知的 BERT--它能通过上下文判断"真行"是褒还是贬,把它们映射到空间中不同的位置.

预训练向量 vs 微调向量

预训练的 Embedding(如 Word2Vec 通用模型)是从通用语料学来的,它的语义空间是"通用"的. 但业务场景有特定的语义结构:

  • 通用空间:"nmsl" 可能不在词表中,或者和 "nms" 等无关缩写混在一起
  • 业务场景:"nmsl" 应该紧靠"废物""垃圾"等辱骂词

微调(Fine-tuning)就是用自己的标注数据对 Embedding 空间做调整--让模型的语义空间适配业务定义.

预训练空间(通用语料学来的):
"nmsl" 的位置不确定,可能在角落
"有意思" 偏正面

微调(用群聊对骂数据继续训练)


微调后空间(业务语义):
"nmsl" 被拉到辱骂簇附近
"有意思" 在对骂语境中偏向讽刺簇

预训练 = 招了一个通用型工程师(什么都懂一点).
微调 = 让他在项目上工作几周后(熟悉了业务术语和上下文), 他对 "MR""SOP""上线" 这些词的理解从泛化变成了和团队一致.

全流程串联

把阶段一四篇的知识点放回完整的推理流程中:

flowchart TD A["原始群聊消息"] --> B["文本预处理  
(02篇)"] B --> C["Tokenizer 分词+编码
(02篇)"] C --> D["Embedding 查表
ID → 初始向量
(03-04篇)"] D --> E["Transformer 上下文计算
初始向量 → 上下文向量
(阶段三详讲)"] E --> F["取 [CLS] 句子向量
(04篇)"] F --> G["分类层
句子向量 → 类别概率
(阶段二详讲)"] G --> H["输出: 对骂 0.92"] style D fill:#dbeafe,stroke:#3b82f6 style E fill:#dbeafe,stroke:#3b82f6 style F fill:#dbeafe,stroke:#3b82f6

蓝色部分是本篇的核心--从 ID 变成向量,向量在空间中有结构,最终取句子向量做分类. 阶段一到此完成概念建设,接下来阶段二开始实际训练分类器.

阶段一的闭环:向量有了,但还不会分类

到这里可能会有一个疑问:向量空间里同类文本是聚集了,但模型怎么知道哪个簇是"骂人",哪个簇是"正常"?

答案是:模型不知道. "哪个是骂人"这件事,是通过标注数据告诉它的.

阶段一做的事情,是把文本变成向量空间中的点--画了一张地图. 但地图上还没有国界线. 阶段二要做的事情,就是用标注数据告诉模型怎么画边界:

阶段一完成的(本篇到此为止):

● ● ○ ○
● ● ● ○ ○ ○ ← 向量空间中,同类自然聚集
● ○ 但模型不知道 ● 是什么,○ 是什么

阶段二要做的(标注 + 训练):

● ● ╱ ○ ○
● ● ● ╱ ○ ○ ○ ← 标注数据告诉模型:● = 对骂,○ = 正常
● ╱ ○ 模型学出一条分界线(超平面)

新文本进来 → 变成向量 → 看落在哪边 → 输出分类

这个"学分界线"的过程就是训练. 分类层做的事极其简单--就是一个线性方程:

score_对骂 = W[0] · sentence_vector + b[0]
score_正常 = W[1] · sentence_vector + b[1]

谁的 score 大 → 判为哪类

其中 W 和 b 是通过标注数据训练出来的参数. 本质上就是在 768 维空间中画了一条分界线.

标注数据是真正的瓶颈

模型能不能分对,完全取决于标注数据有没有清晰地定义"什么算对骂". 如果标注不一致(同一句话有人标对骂有人标正常),就相当于在地图上画了一条歪歪扭扭的国界线--模型照着学,结果也是歪的.

这就是为什么阶段二会花大篇幅讲标注规范设计.

总结阶段一到阶段二的衔接:

阶段 做什么 类比 输出
阶段一(已完成) 文本 → 向量 画地图:把城市标在坐标上 每条文本对应一个向量坐标
阶段二(即将开始) 标注 + 训练分类器 画国界线:告诉模型哪些点属于哪国 一个能对新文本做分类的模型
阶段三 BERT 微调 重新画地图:让同国城市更紧,异国更远 边界线更好画,分类更准

快速回顾

  • 语义空间:Embedding 向量不是随机的,语义相近的词/句自动在空间中聚集
  • 距离 = 相似度:余弦相似度衡量两个向量的方向一致性,是最常用的语义相似度度量
  • 维度:越多越精细但越慢,768 维是效果和成本的平衡点
  • 句子向量:平均池化(最简) → TF-IDF 加权(稍好) → BERT [CLS](最强)
  • 微调的意义:让通用的语义空间适配业务场景--"nmsl" 该在辱骂簇里,不是随机角落

动手练习

  1. 获取 CLS 向量:用 BERT Tokenizer + Model 获取种子数据集中每条文本的 [CLS] 向量(768 维),保存为 numpy 数组
  2. 对比类间距离:计算对骂组和正常组的 [CLS] 向量平均余弦相似度,与上一篇 TF-IDF 实验的结果对比--看 BERT 是否把两类分得更开
  3. 可视化聚类:用 sklearn 的 PCA 把 768 维降到 2 维,画散点图观察两类文本是否聚成不同簇