"帮我重置一下密码"和"我密码忘了怎么办",字面上只共享"密码"两个字,向量检索却能判为高度相似;反过来,"这个方案很稳"和"这个方案很稳吗",字面几乎一致,语义方向相反。这两件事都说明:embedding 比的不是字,是在一个高维坐标里离得近不近。这篇只讲清三件事——向量是什么、相似怎么算、什么时候它一定会出错。
1. 向量是什么:一句话的坐标
模型把一段文本压成一个定长浮点数组,比如 768 或 1024 个数字。可以把它想成"语义空间里的一次定位":
| 概念 | 直观理解 | 工程含义 |
|---|---|---|
| 维度 | 坐标轴的数量 | 由模型固定,不能改 |
| 向量值 | 在这根轴上的偏移 | 单个数没有含义,只有整体有意义 |
| 距离 | 两个点的远近 | 近≈语义相关,远≈无关 |
| 归一化 | 把向量缩到长度 1 | 只保留方向,丢掉长度 |
关键假设只有一条:训练目标让语义相近的文本在空间里靠近。这个"相近"由训练数据定义——它决定模型擅长什么、不擅长什么,也决定了后面所有的失败模式。
2. 三种相似度,先记住一个等式
import numpy as np
def cos(a, b): # 余弦:只看夹角
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
def dot(a, b): # 点积:夹角 + 长度
return float(a @ b)
def l2(a, b): # 欧氏距离:越小越近
return float(np.linalg.norm(a - b))
| 算法 | 取值含义 | 受向量长度影响 | 常用场景 |
|---|---|---|---|
| 余弦相似度 | -1~1,越大越像 | 否 | 通用语义检索 |
| 点积 | 无固定上界 | 是 | 归一化向量、部分模型训练目标 |
| 欧氏距离 | 0~∞,越小越近 | 是 | 图像、聚类 |
归一化之后余弦与点积等价:两个长度都是 1 的向量,a·b == cos(a,b)。而且此时欧氏距离与余弦单调对应(‖a-b‖² = 2 - 2cos),三者排序一致。所以工程上常见做法是:入库前统一 L2 归一化,索引里用点积,省一次除法。
a = a / np.linalg.norm(a); b = b / np.linalg.norm(b)
assert abs(dot(a, b) - cos(a, b)) < 1e-6
前提是真的归一化了。混着存归一化和未归一化的向量,点积排序会被长度污染,表现为"长文本永远排前面"。
3. 维度:不是越高越好
| 维度量级 | 常见取舍 | 代价 |
|---|---|---|
| 384 | 快、省内存,适合短句分类 | 细粒度语义容易糊 |
| 768 / 1024 | 通用检索的主力区间 | 平衡 |
| 1536 / 3072 及以上 | 长文本、细粒度区分 | 存储与计算线性上升 |
维度是模型训练时定死的,不能事后裁剪(除非模型本身支持 Matryoshka 式的截断维度)。同一语义在不同维度下表达力不同,但"更高维=更准"不成立:维度要和训练目标匹配,一个 1024 维但只在小语料上训过的模型,可能不如 384 维的通用模型。
存储估算很简单:条数 × 维度 × 4 字节(float32)。100 万条 × 768 维 ≈ 3 GB,这只是原始向量,不含索引结构。
4. 模型之间的差异面
| 差异面 | 影响 | 核对方式 |
|---|---|---|
| 语言支持 | 中文模型对中文语义更敏感 | 用中文同义句试 |
| 最大长度 | 超长被截断,尾部信息丢失 | 查模型卡(model card)的 max tokens |
| 是否归一化 | 影响能否直接用点积 | 看输出范数是否≈1 |
| 训练目标 | 对称(句子对)vs 非对称(查询-文档) | 看是否区分 query/passage |
| 指令前缀 | 有些模型要求加 query: 之类前缀 |
查用法示例 |
最后一行的坑很隐蔽:给"查询-文档"型模型(如部分 BGE/E5 系列)不加前缀,召回会明显变差,而你不会收到任何报错。
5. 文本怎么变成向量
三步:分词 → 截断 → 池化。
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("MODEL_NAME")
v = m.encode(["第一句", "第二句"], normalize_embeddings=True)
print(v.shape, (v ** 2).sum(axis=1)) # (2, D),第二项应≈1
| 步骤 | 发生了什么 | 常见误解 |
|---|---|---|
| 分词 | 子词切分,中文常按字/词片段 | 以为按"词"切,其实多是子词 |
| 截断 | 超过最大长度直接切掉 | 以为会自动摘要 |
| 池化 | token 向量合成一个句向量(均值/CLS) | 以为是"取第一个 token" |
截断是最常见的静默失败:一段 3000 字的文档,模型最大长度 512 token,后面的内容根本没进向量。检索时"文档里明明写了"却召不回,多半是这里。
6. 长文本必须分块
| 策略 | 做法 | 风险 |
|---|---|---|
| 按段落 | 空行/标题切 | 段落过长仍会截断 |
| 按长度 | 固定 token 数切 | 句中切断,语义断裂 |
| 重叠窗口 | 相邻块重叠 10%~20% | 存储与召回重复上升 |
| 语义切分 | 按句子边界聚合到阈值 | 实现复杂,效果依文本而定 |
def chunk(text, size=400, overlap=80):
step = size - overlap
return [text[i:i + size] for i in range(0, len(text), step)]
切坏的后果很具体:把"步骤如下"和列表切开,前一块的向量指向"步骤说明",后一块指向"具体操作",两块都不完整。检索命中任一块都只能回答一半。所以块要自带一点上下文(比如每块前面拼上标题),而不是纯按字数硬切。
7. "相似"不等于"相关"
| 反例 | 向量表现 | 为什么错 |
|---|---|---|
| 同一句式不同主题 | "如何配置 A" vs "如何配置 B" 很近 | 句式主导了相似度 |
| 否定句 | "支持" vs "不支持" 常很近 | 否定词只改一个方向 |
| 反义词对 | "高" vs "低" 在部分模型里很近 | 共现多,方向差异小 |
| 数字与专名 | "3 天" vs "30 天" 几乎无差别 | 数值不是语义主成分 |
这几类不是模型"坏了",而是训练目标不包含这些区分。对策是别把相似度当答案:让它做召回,把判断交给后续环节(重排、规则、关键词过滤)。否定句尤其要用关键词或规则兜底。
8. 跨模型不能混算
| 混算方式 | 结果 | 原因 |
|---|---|---|
| A 模型存库,B 模型查询 | 排序接近随机 | 语义空间不重合 |
| 不同维度直接比 | 报错或错位 | 维度不同,坐标轴不对应 |
| 同家族不同版本 | 通常也不兼容 | 权重变了,空间就变了 |
模型升级必须全量重建索引,不能只换查询侧。常见事故:升级了查询模型,忘了重建库,表现为"上线后检索质量突然崩了",而日志里一切正常。
索引元数据至少要记:model_name、model_version、dim、normalized、prefix 规则。
有了这五项,跨模型混算能在入库时就被拦下来,而不是等检索结果莫名其妙。
9. 最小示例:三句话的相似度
import numpy as np
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("MODEL_NAME")
sents = ["如何重置密码", "我密码忘了怎么办", "如何重置邮箱"]
v = m.encode(sents, normalize_embeddings=True)
print(np.round(v @ v.T, 3)) # 3x3 相似度矩阵
典型结果(具体数值按模型核对):第 1、2 句明显高于第 1、3 句——尽管第 1、3 句字面更像。这就是语义向量的价值。
再观察失败:
neg = ["这个方案支持回滚", "这个方案不支持回滚"]
v = m.encode(neg, normalize_embeddings=True)
print(float(v[0] @ v[1])) # 常见在 0.8 以上:否定几乎没被区分
换模型再算一遍,数值与排序都可能变,但否定句这个弱点在很多通用模型上仍然存在。结论不是"换个模型就好了",而是"相似度之后必须有一层判断"。
10. 常见坑
| 坑 | 表现 | 修正 |
|---|---|---|
| 拿不同模型的向量硬比 | 结果随机,时好时坏 | 固定模型版本,写进索引元数据 |
| 长文本直接截断 | 文档后半段永远召不回 | 分块 + 每块带标题 |
| 以为 0.9 就很准 | 阈值换模型就失效 | 阈值按模型和语料实测标定 |
| 忽略语言差异 | 中文查询配英文库,召回差 | 选多语模型或统一语言 |
| 忘了归一化 | 长文本总排前面 | 入库前统一 L2 |
| 把相似度当相关性 | 否定句、反义词误判 | 加关键词/规则/重排兜底 |
11. 边界
| 场景 | 向量能做 | 该换别的 |
|---|---|---|
| 找同义改写 | 是 | — |
| 精确匹配编号/错误码 | 不可靠 | BM25、精确索引 |
| 否定与条件判断 | 不可靠 | 规则、小模型分类 |
| 多语言检索 | 取决于模型 | 多语模型或翻译后检索 |
| 排序精排 | 粗排可用 | 交叉编码器重排 |
12. 自测
- 为什么归一化之后点积和余弦等价?这个结论在什么情况下失效?
- 一个 3000 字的文档用最大长度 512 的模型编码,会丢掉什么?
- 分块时为什么要在每块前面拼标题?
- 否定句为什么容易被判为高相似?怎么兜底?
- 模型升级后只换查询侧会发生什么?
- 相似度 0.85 能不能直接当"相关"的判据?为什么?