为什么距离度量错配是一类静默故障
Embedding 检索链路通常由四部分组成:Embedding 模型、向量预处理、索引度量和业务阈值。这四部分只要有一个环节发生变化,系统可能依然能够正常写入、正常查询,也不会抛出异常,但 Top-K 排序、召回边界和阈值通过率已经改变。
常见故障包括:
- 建库时使用 Cosine,查询时按 Dot Product 理解分数;
- 只对 Query 做 L2 Normalize,库内历史向量没有归一化;
- 从一个返回”越大越相似”的系统迁移到返回”越小越相似”的系统,却继续使用旧阈值;
- Embedding 模型升级后,维度没有变化,但向量范数分布和语义空间已经变化;
- 从精确检索切换到 HNSW、IVF 或量化索引后,把近似误差误判为模型质量退化。
这类问题危险之处在于:接口成功率、延迟和错误率可能全部正常,只有召回结果在悄悄漂移。
三种常见度量到底有什么区别
设查询向量为 q,文档向量为 x。
Cosine Similarity:只比较方向
cos(q, x) = (q · x) / (||q|| × ||x||)
Cosine 关注两个向量的方向是否一致,弱化向量长度的影响。它常用于文本语义检索,但前提是模型训练方式和线上实现与该度量匹配。
Dot Product:方向和长度共同参与
dot(q, x) = q · x
Dot Product 不会自动消除范数影响。若向量长度携带置信度、流行度或其他训练信号,Dot Product 可能有意义;若模型和系统原本假设单位向量,未归一化的 Dot Product 则可能让大范数文档获得不合理优势。
L2 Distance:比较空间中的绝对距离
L2(q, x) = ||q - x||
L2 越小表示越接近。部分系统返回 L2,部分返回平方 L2;两者排序相同,但阈值数值不能混用。
当 q 和 x 都是单位向量时:
||q - x||² = 2 - 2 × (q · x)
因此,在单位归一化前提下,Cosine、Inner Product 和 L2 可以得到等价排序。但 “排序等价”不代表”分数相同”,更不代表旧阈值可以原样迁移。
把 Embedding 当成有版本的检索契约
生产系统不应只记录 model_name。建议为每套向量建立不可变的 Embedding Contract:
embedding_contract:
model_id: text-embedding-model
model_revision: 2026-07-01
dimension: 1024
dtype: float32
normalization: l2_unit
metric: cosine
score_semantics: higher_is_better
index_type: hnsw
index_build_version: v17
preprocessing_version: text-clean-v4
至少需要固化以下字段:
| 字段 | 说明 |
|---|---|
| 模型与修订版本 | 不能只写一个会漂移的别名 |
| 向量维度和数据类型 | 用于阻止不同空间误写 |
| 归一化策略 | none、L2 Unit、模型内置归一化需要明确区分 |
| 距离度量 | Cosine、Dot/IP、L2 |
| 分数语义 | Higher Is Better 还是 Lower Is Better |
| 预处理版本 | 大小写、Unicode、切块、前缀模板都会改变向量 |
| 索引与量化配置 | 用于解释近似检索误差 |
写入端、查询端和离线评测端必须引用同一个 Contract ID。发现 Contract 不一致时,应拒绝请求,而不是自动猜测。
建立 Metric Matrix,统一不同引擎的分数语义
不同向量引擎对同一概念的命名和返回值并不一致。例如:
| 业务语义 | 常见实现 | 排序方向 | 注意事项 |
|---|---|---|---|
| Cosine Similarity | cosine 或归一化后 IP | 越大越相似 | 有的系统返回 1 - cosine |
| Inner Product | IP、Dot | 越大越相似 | pgvector 的排序操作符使用负内积 |
| Euclidean Distance | L2、Euclid | 越小越相似 | 有的实现返回平方 L2 |
建议在业务层建立统一分数对象,而不是直接透传数据库的 score:
from dataclasses import dataclass
from enum import Enum
class ScoreDirection(str, Enum):
HIGHER_IS_BETTER = "higher_is_better"
LOWER_IS_BETTER = "lower_is_better"
@dataclass(frozen=True)
class RetrievalScore:
raw_score: float
engine: str
metric: str
direction: ScoreDirection
contract_id: str
def passes(self, threshold: float) -> bool:
if self.direction is ScoreDirection.HIGHER_IS_BETTER:
return self.raw_score >= threshold
return self.raw_score <= threshold
更稳妥的做法是:业务规则始终基于明确的 Contract 和 Score Direction 判断,不把来自不同模型、不同索引或不同引擎的裸分数直接比较。
用黄金向量回放定位到底是哪一层变了
上线前应准备一套 Golden Vector Replay Set。它不是只有”查询—相关文档”标签,还应包含固定向量对和人工计算结果。
每条回放记录至少包括:
- 原始 Query 和 Document 文本;
- 固定版本的 Query Vector 和 Document Vector;
- 两侧向量范数;
- 手工计算的 Cosine、Dot 和 L2;
- 精确 Top-K 基线;
- 业务阈值判断结果;
- ANN 索引返回的 Top-K 和原始分数。
建议分三层执行回放。
第一层:数学探针
直接对少量固定向量手工计算三种度量,检查数据库返回值是否符合预期:
import numpy as np
def metric_probe(query: np.ndarray, target: np.ndarray) -> dict[str, float]:
if query.ndim != 1 or target.ndim != 1:
raise ValueError("vectors must be one-dimensional")
if query.shape != target.shape:
raise ValueError("vector dimensions do not match")
if not np.isfinite(query).all() or not np.isfinite(target).all():
raise ValueError("vectors contain NaN or Infinity")
q_norm = np.linalg.norm(query)
x_norm = np.linalg.norm(target)
if q_norm == 0 or x_norm == 0:
raise ValueError("zero vector cannot be used for cosine similarity")
dot = float(np.dot(query, target))
cosine = float(dot / (q_norm * x_norm))
l2 = float(np.linalg.norm(query - target))
return {
"query_norm": float(q_norm),
"target_norm": float(x_norm),
"dot": dot,
"cosine": cosine,
"l2": l2,
}
这一层可以快速发现 Score 取反、平方距离、只归一化一侧等基础错误。
第二层:精确检索与 ANN 对照
使用 Flat/Brute-Force 索引生成精确 Top-K,再与 HNSW、IVF、PQ 或量化索引对比。重点观察:
| 指标 | 说明 |
|---|---|
| Recall@K | ANN 召回精确 Top-K 的比例 |
| Top-K 交集率 | 精确与近似检索的结果重合度 |
| 排名相关性 | 排序顺序是否一致 |
| 阈值附近翻转率 | 边界样本是否因近似误差而误判 |
| 分桶差异 | 不同 Query 长度、语言和领域的差异 |
这样可以把”度量契约错误”和”ANN 参数不足”分开。
第三层:业务查询回放
对黄金查询集运行完整链路,记录:
- 首阶段召回文档;
- 进入 Reranker 的候选集;
- 最终答案引用文档;
- 无结果率和阈值拒绝率;
- 不同业务分桶的 Recall 与延迟。
不要只看总体平均值。距离度量错配往往先影响长尾、短文本、专有名词或阈值边缘样本。
模型升级和索引迁移如何安全发布
1. 新旧空间必须物理隔离
不同模型版本、不同归一化规则或不同度量不应写入同一个 Vector Field。建议使用:
embedding_v1_cosine
embedding_v2_cosine
embedding_v2_dot
或者直接使用独立 Collection/Index。不要依赖一列 model_version 在查询时临时过滤来维持空间兼容性。
2. 双写并进行影子查询
迁移期间同时生成旧向量和新向量,线上请求继续使用旧索引,新索引执行 Shadow Query。比较:
- Top-K Overlap;
- 新增与丢失文档;
- 分数分布和范数分布;
- 原阈值与新阈值的通过率;
- 业务黄金集的 Recall 和错误案例。
3. 阈值必须重新校准
即使排序基本一致,Score 数值也可能改变。应在标注集上重新选择阈值,并区分:
| 阈值用途 | 说明 |
|---|---|
| 是否进入 Reranker | 粗筛阶段的召回边界 |
| 是否判定”没有相关文档” | 触发拒答或降级策略 |
| 是否触发人工复核 | 置信度不足时引入人工 |
| 是否允许直接回答 | 高置信度自动应答边界 |
阈值应绑定 embedding_contract_id + index_version,不能作为全局常量。
4. 灰度切换必须可回滚
灰度维度可以是租户、业务域、语言或 Query Hash。回滚时必须同时恢复:
- Embedding 模型;
- 归一化规则;
- 索引别名;
- 分数适配器;
- 阈值版本。
只回滚模型而保留新阈值,同样会造成召回异常。
适用场景
这套治理方法尤其适合:
- 从 Faiss 迁移到 Milvus、Qdrant、pgvector 等系统;
- 更换 Embedding 模型或输出维度;
- 从 Cosine 切换到 Dot Product;
- 引入向量量化、HNSW 或 IVF;
- 多供应商 Embedding 共存;
- 检索阈值直接影响拒答、自动化操作或合规判断的系统。
常见误区
误区一:Cosine 和 Dot Product 永远等价
只有两侧向量都单位归一化时才等价。模型是否已内置归一化,也必须通过实际向量范数验证,不能靠名称猜测。
误区二:维度一样就能混用
两个模型都输出 1024 维,不代表它们处于同一个语义空间。跨模型直接比较向量通常没有意义。
误区三:迁移后 Top-1 看起来相同就算成功
Top-1 相同可能掩盖候选集变化。Reranker、引用生成和阈值判断依赖整个 Top-K,应检查排序和边界样本。
误区四:Reranker 会修复所有召回问题
Reranker 只能重排已经召回的候选。如果度量错配导致相关文档没有进入候选集,后续模型无法补救。
误区五:把数据库 Score 直接暴露给业务
不同引擎对 Score 的定义可能相反。必须经过 Score Adapter,并附带 Metric、Direction 和 Contract Version。
上线检查清单
- 模型 ID、Revision、维度、Dtype 已固化
- Query 与 Document 使用相同的预处理和归一化规则
- Index Metric 与 Query Metric 完全一致
- 明确 Score 是 Similarity 还是 Distance
- 手工向量探针通过
- 精确检索与 ANN Recall 达标
- 黄金查询集完成 Top-K 与阈值回放
- 新旧向量范数分布已比较
- 新阈值绑定新 Contract 和 Index Version
- 双写、影子查询、灰度和回滚路径可用
- 监控包含 Contract Mismatch、Zero Vector、NaN/Inf 和 Dimension Error
- 禁止不同 Embedding Space 混写同一索引