7AA7 Blog

embedding 模型使用要点:维度、归一化与相似度度量

Soraya UID:83
2026-09-10 09:44
28 0 1

调用一个 embedding 接口拿到一串浮点数,然后算余弦相似度排序——这条链路看起来简单,但线上效果不达标的检索系统,有相当一部分问题出在这几步的细节上,而不是模型本身的能力。

相似度由任务定义,不由模型定义

通用 embedding 模型的训练目标通常是”语义相关”:在同一批对比学习任务里,相关的句子对靠近,不相关的推远。但业务需要的相似关系未必是这一回事。

  • 问答匹配:用户问题和标准问法要靠近,哪怕用词完全不同。
  • 内容去重:恰好相反——只有字面高度重合才算重复,同义改写不算。语义模型在这里会把两篇措辞不同但观点相同的文章判为高度相似,误杀。
  • 商品召回:要的是”可替代性”。搜索”红色连衣裙 夏”,一件红色夏装连衣裙是正解,而一篇讲连衣裙历史的文章语义也相关,但不可接受。
  • 条款检索:要的是”适用性”和”引用关系”,跟表层语义距离很远。

同一个模型没法同时满足这些定义。所以拿到模型后第一件事不是调参,而是把业务里的”相似”写成若干组明确的判定样例:哪些必须排在前三,哪些绝不能出现在前十。这份样例集既是评测基准,也是后续微调的训练数据来源。

维度是一场存储与表达力的交换

维度不是一个越大越好的指标。

存储开销是线性的。假设用 float32,单条向量的字节数是 $4 times d$。粗算一下:1000 万条 768 维向量,原始存储约 $10^7 times 768 times 4 approx 30$ GB;换成 1536 维就是约 60 GB。这还只是裸向量,没算索引结构(HNSW 的图连接、IVF 的倒排表)和原始文档的开销。内存常驻的向量库对这个数字极其敏感。

检索开销也随维度上升。距离计算的访存量正比于维度,高维下还会让基于聚类和图结构的索引区分度下降——所有点对的距离都趋近,所谓维度灾难。

表达力存在边际递减。从 256 维涨到 768 维,效果提升通常明显;从 768 涨到 1536,在多数通用检索任务上的提升就没那么确定了,很多时候取决于数据和任务。

实操上有几个降低维度的手段值得先试,再考虑直接换大维度模型:

  • 量化:把 float32 转成 int8 或二值,存储直接降到四分之一甚至更低,配合重排能补回大部分精度。
  • 降维:PCA 或随机投影把维度压到目标值。前提是原始分布确实是低秩的,压完要在真实查询上验证,不能只看重建误差。
  • Matryoshka 表示学习:训练时就让前 $k$ 维可独立使用,部署时按需截断,不用重新训练。
维度量级 存储(float32,千万条) 适用场景
256–384 约 10–15 GB 短文本、类别区分度高的任务
768 约 30 GB 通用检索的默认起点
1536 及以上 60 GB 起 长文本、细粒度区分

归一化与三种度量的关系

余弦相似度、内积、欧氏距离在归一化之后是单调等价的。这一点值得推导清楚,因为它决定了你在什么情况下需要纠结选哪个。

设 $a, b$ 是两个向量,余弦相似度定义为:

$$cos(a, b) = frac{a cdot b}{|a| , |b|}$$

欧氏距离的平方展开:

$$|a – b|^2 = |a|^2 + |b|^2 – 2 , a cdot b$$

当 $|a| = |b| = 1$ 时,上式变成 $2 – 2cos(a, b)$,是余弦的严格单调递减函数。而内积此时就是 $a cdot b = cos(a,b)$。于是三种度量给出的排序完全一致。

结论就是:归一化之后,选哪个度量对召回排序没有影响,差别只在数值范围和计算效率。

那什么时候有影响?不归一化的时候。此时内积等于 $cos(a,b) times |a| times |b|$,模长会作为一个额外信号混进去。模长大通常意味着这个词或这条内容与很多东西都相关——在推荐里,这有时反而是有用的流行度信号;但在大多数语义检索任务里,它是纯噪声,会让”万金油”式的高频内容霸占召回列表。

真正容易踩的坑不是选哪个度量,而是训练与检索的度量不一致。模型训练时用了归一化 + 余弦损失,检索时却直接调了内积接口;或者反过来。这种不一致不会报错,只会让效果静默地差一截。查文档确认模型推荐的度量方式,然后在向量库的建索引参数里显式指定,别用默认值。

import numpy as np

def normalize(x: np.ndarray) -> np.ndarray:
    return x / np.clip(np.linalg.norm(x, axis=-1, keepdims=True), 1e-12, None)

def build_index(docs: np.ndarray, queries: np.ndarray, top_k: int = 10):
    # 用与模型训练一致的度量:先归一化,再用内积(等价于余弦)
    D = normalize(docs.astype(np.float32))
    Q = normalize(queries.astype(np.float32))
    return (Q @ D.T).argsort(axis=-1)[:, ::-1][:, :top_k]

def sanity_check(metric_used_in_db: str, model_recommends: str):
    assert metric_used_in_db == model_recommends, (
        f"检索度量 {metric_used_in_db} 与模型推荐 {model_recommends} 不一致"
    )

还有一个边界情况:全零向量。有些模型对超长输入截断后会返回接近零甚至全零的向量,归一化时除零得到 NaN,NaN 混进排序会污染整批结果。上面的 clip 就是防这个的,生产环境里还应该在写入前过滤掉模长异常的记录。

长短文本的不对称

固定长度的句向量本质上是一次有损压缩。压缩 20 个字,损失可控;压缩 5000 字的技术文档,多主题信息被平均池化抹平,得到的向量可能”什么都不太像”。

常见的表现:长文档之间彼此相似度都很高(向量都趋向均值),长文档作为查询时召回质量断崖式下跌,而检索系统里查询短、文档长的不对称结构恰恰是常态。

几种应对方式:

  • 分块:把长文档切成若干语义完整的块,每块一个向量,检索命中块后再映射回原文。关键在切分边界——按固定字数硬切会把一个完整论点劈成两半,按段落或标题切更稳,块之间留一点重叠能缓解边界信息丢失。
  • 多向量索引:文档级向量保证粗召回的完整性,块级向量保证精定位,两级结合。
  • 摘要后编码:先让模型生成文档摘要,再对摘要做 embedding。适合文档结构稳定的场景,代价是引入了额外的生成步骤。
  • 长上下文化的切分:先把整篇文本过一遍编码器拿到全局语境,再在切块时保留这个语境,避免块向量脱离全文语义。

分块大小没有一个通用最优值。经验上从覆盖一个完整语义单元出发(比如 200 到 500 字这个量级),然后在真实查询上做网格搜索,用召回率和答案质量来定,而不是凭感觉。

通用相似不够用时:微调与重排

当业务对”相似”的定义跟模型训练目标错配,两条路可选。

对比学习微调。核心是构造正负例。正例相对好找:点击日志里的查询-文档对、人工标注的 paraphrase、同一标准问题下的不同问法。难例挖掘才是效果的关键——随机负例太容易,模型学不到区分度;真正有用的是”看起来很像但不该召回”的那些:同品类不同型号、同一条款的相邻条目、字面高度重合但意图不同的问句。常见的做法是先用基线模型召回一批,把排名靠前但标注为负的样本当作难负例。

# 简化版的对比学习损失,正例为对角线,其余批内样本为难负例
def contrastive_loss(q, d, temperature: float = 0.05):
    q, d = normalize(q), normalize(d)
    logits = (q @ d.T) / temperature          # [B, B]
    labels = torch.arange(logits.size(0), device=logits.device)
    return F.cross_entropy(logits, labels)

温度参数在这里的作用跟知识蒸馏里类似:温度越低,分布越尖锐,对难负例的惩罚越重。经验上 0.01 到 0.1 这个区间,太小容易训练不稳定。

加一层重排。双塔结构(查询和文档分别编码)为了能在毫秒级检索亿级候选,牺牲了查询和文档之间的交互。重排模型把查询和候选拼在一起过一遍编码器,能捕捉更细的匹配关系,代价是只能处理几十条候选。所以标准做法是召回用双塔、精排用交叉编码。

这两条路的成本差异很大:微调需要标注数据和训练资源,但推理成本不变;重排不需要训练数据(可以直接用现成的重排模型),但每次查询多一次前向。一般的顺序是先加重排,效果还不够再考虑微调。

embedding 用得好不好,很少取决于选了哪个模型,更多取决于有没有把”相似”这件事在业务里定义清楚,以及这条链路上每个环节——切分、归一化、度量、排序——是否前后一致。

877Views
28Likes
0Comments
0Participants
10 minread time

Comments (0)

No comments yet — be the first.