文章目录
- [【AI 算法精讲 15】余弦相似度:向量检索与归一化内积](#【AI 算法精讲 15】余弦相似度:向量检索与归一化内积)
-
- 一、为什么需要余弦相似度
-
- [1.1 痛点:欧氏距离的维度陷阱](#1.1 痛点:欧氏距离的维度陷阱)
- [1.2 更广泛的场景](#1.2 更广泛的场景)
- [1.3 一个直观的例子](#1.3 一个直观的例子)
- 二、算法原理
-
- [2.1 从点积说起](#2.1 从点积说起)
- [2.2 余弦相似度的定义](#2.2 余弦相似度的定义)
- [2.3 为什么余弦相似度能消除模长影响](#2.3 为什么余弦相似度能消除模长影响)
- [2.4 余弦相似度 vs 欧氏距离 vs 点积](#2.4 余弦相似度 vs 欧氏距离 vs 点积)
-
- [2.4.1 数学关系](#2.4.1 数学关系)
- [2.4.2 三者对比](#2.4.2 三者对比)
- [2.4.3 什么时候用哪个](#2.4.3 什么时候用哪个)
- [2.5 归一化加速的数学原理](#2.5 归一化加速的数学原理)
- [三、Python 实现](#三、Python 实现)
-
- [3.1 从零实现余弦相似度](#3.1 从零实现余弦相似度)
- [3.2 实战:Embedding 检索系统](#3.2 实战:Embedding 检索系统)
- [四、参数调优 / 阈值选择 / 变体对比](#四、参数调优 / 阈值选择 / 变体对比)
-
- [4.1 检索阈值选择](#4.1 检索阈值选择)
- [4.2 阈值校准方法](#4.2 阈值校准方法)
- [4.3 三种相似度度量全面对比](#4.3 三种相似度度量全面对比)
- [4.4 变体对比](#4.4 变体对比)
- 五、在客服系统/订单系统中的实际应用
-
- [5.1 场景:智能工单分发](#5.1 场景:智能工单分发)
- [5.2 ANN 索引加速:HNSW 和 IVF](#5.2 ANN 索引加速:HNSW 和 IVF)
-
- [5.2.1 HNSW(Hierarchical Navigable Small World)](#5.2.1 HNSW(Hierarchical Navigable Small World))
- [5.2.2 IVF(Inverted File Index)](#5.2.2 IVF(Inverted File Index))
- [5.2.3 归一化向量 + ANN 的工程实践](#5.2.3 归一化向量 + ANN 的工程实践)
- [5.3 订单系统中的相似商品推荐](#5.3 订单系统中的相似商品推荐)
- 六、常见陷阱
-
- [陷阱 1 详解:未归一化就使用点积](#陷阱 1 详解:未归一化就使用点积)
- [陷阱 2 详解:维度灾难](#陷阱 2 详解:维度灾难)
- [陷阱 4 详解:混合模型问题](#陷阱 4 详解:混合模型问题)
- 七、总结
-
- [7.1 一表概括](#7.1 一表概括)
- [7.2 核心公式速查](#7.2 核心公式速查)
- [7.3 决策流程](#7.3 决策流程)
- [7.4 余弦相似度在 AI 技术栈中的位置](#7.4 余弦相似度在 AI 技术栈中的位置)
【AI 算法精讲 15】余弦相似度:向量检索与归一化内积
一句话概括:余弦相似度通过衡量两个向量的夹角来判断它们的方向一致性,是 Embedding 检索、推荐系统和文本匹配中最核心的相似度度量之一。
一、为什么需要余弦相似度
1.1 痛点:欧氏距离的维度陷阱
假设你在做一个客服工单智能分发系统,需要计算工单标题向量之间的相似度,找到历史最相似的工单进行路由。你拿到两组 Embedding 向量:
- 工单 A:
[0.12, 0.08, 0.05, ...](1024 维) - 工单 B:
[0.24, 0.16, 0.10, ...](1024 维,恰好是 A 的 2 倍)
用欧氏距离计算,A 和 B 的距离并不为零------因为它们的模长不同。但从语义上看,B 只是 A 的"放大版",方向完全一致,语义完全相同。欧氏距离会因为模长差异给出错误判断。
这就是余弦相似度要解决的核心痛点:只看方向,不看大小。
1.2 更广泛的场景
| 场景 | 为什么用余弦而不是欧氏 |
|---|---|
| Embedding 检索 | 不同长度文本经过 Embedding 后模长差异大,但语义方向可能一致 |
| 推荐系统 | 用户兴趣向量和物品特征向量的模长无意义,方向才有意义 |
| 文档去重 | 抄袭/洗稿文本的词频向量模长不同,但方向高度一致 |
| 语义搜索 | Query 和 Document 的向量模长受文本长度影响,余弦消除这个偏差 |
1.3 一个直观的例子
python
import numpy as np
# 三条句子的 Embedding(简化为 3 维)
query = np.array([1.0, 0.5, 0.0])
doc_short = np.array([2.0, 1.0, 0.0]) # 方向相同,模长翻倍
doc_diff = np.array([0.0, 0.5, 1.0]) # 方向不同
# 欧氏距离
print(np.linalg.norm(query - doc_short)) # 1.12 --- 看起来不近
print(np.linalg.norm(query - doc_diff)) # 1.22
# 余弦相似度
def cosine_sim(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
print(cosine_sim(query, doc_short)) # 1.0 --- 完全相同方向
print(cosine_sim(query, doc_diff)) # 0.37 --- 差别明显
输出:
1.118033988749895
1.2247448714015823
1.0
0.3713906763541037
欧氏距离下两个文档差距很小(1.12 vs 1.22),但余弦相似度下语义差距被正确放大(1.0 vs 0.37)。
二、算法原理
2.1 从点积说起
点积(内积)是向量运算的基础。给定两个 n n n 维向量 A \mathbf{A} A 和 B \mathbf{B} B:
A ⋅ B = ∑ i = 1 n A i B i = A 1 B 1 + A 2 B 2 + ⋯ + A n B n \mathbf{A} \cdot \mathbf{B} = \sum_{i=1}^{n} A_i B_i = A_1 B_1 + A_2 B_2 + \cdots + A_n B_n A⋅B=i=1∑nAiBi=A1B1+A2B2+⋯+AnBn
点积的几何意义可以通过以下公式揭示:
A ⋅ B = ∥ A ∥ ⋅ ∥ B ∥ ⋅ cos ( θ ) \mathbf{A} \cdot \mathbf{B} = \|\mathbf{A}\| \cdot \|\mathbf{B}\| \cdot \cos(\theta) A⋅B=∥A∥⋅∥B∥⋅cos(θ)
其中 θ \theta θ 是两个向量之间的夹角, ∥ A ∥ \|\mathbf{A}\| ∥A∥ 和 ∥ B ∥ \|\mathbf{B}\| ∥B∥ 分别是向量的模长(L2 范数):
∥ A ∥ = ∑ i = 1 n A i 2 \|\mathbf{A}\| = \sqrt{\sum_{i=1}^{n} A_i^2} ∥A∥=i=1∑nAi2
关键洞察:点积同时编码了方向和模长。两个方向一致但模长不同的向量,点积值仍然不同。这正是它在某些场景下不适用的原因。
2.2 余弦相似度的定义
将点积公式两边除以 ∥ A ∥ ⋅ ∥ B ∥ \|\mathbf{A}\| \cdot \|\mathbf{B}\| ∥A∥⋅∥B∥,就得到了余弦相似度:
cos ( θ ) = A ⋅ B ∥ A ∥ ⋅ ∥ B ∥ = ∑ i = 1 n A i B i ∑ i = 1 n A i 2 ⋅ ∑ i = 1 n B i 2 \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \cdot \|\mathbf{B}\|} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \cdot \sqrt{\sum_{i=1}^{n} B_i^2}} cos(θ)=∥A∥⋅∥B∥A⋅B=∑i=1nAi2 ⋅∑i=1nBi2 ∑i=1nAiBi
取值范围 : − 1 , 1 -1, 1 −1,1
| 值 | 含义 | 几何解释 |
|---|---|---|
| 1 | 完全相似 | 夹角 0°,方向完全一致 |
| 0 | 正交(不相关) | 夹角 90°,方向垂直 |
| -1 | 完全相反 | 夹角 180°,方向相反 |
在实际的 Embedding 检索场景中,由于大多数 Embedding 模型输出的向量经过非负处理,余弦相似度实际取值范围通常在 0 , 1 0, 1 0,1。
2.3 为什么余弦相似度能消除模长影响
对向量 A \mathbf{A} A 做归一化,得到单位向量 A ^ \hat{\mathbf{A}} A^:
A ^ = A ∥ A ∥ \hat{\mathbf{A}} = \frac{\mathbf{A}}{\|\mathbf{A}\|} A^=∥A∥A
此时 ∥ A ^ ∥ = 1 \|\hat{\mathbf{A}}\| = 1 ∥A^∥=1。将两个向量都归一化后计算点积:
A ^ ⋅ B ^ = A ∥ A ∥ ⋅ B ∥ B ∥ = A ⋅ B ∥ A ∥ ⋅ ∥ B ∥ = cos ( θ ) \hat{\mathbf{A}} \cdot \hat{\mathbf{B}} = \frac{\mathbf{A}}{\|\mathbf{A}\|} \cdot \frac{\mathbf{B}}{\|\mathbf{B}\|} = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \cdot \|\mathbf{B}\|} = \cos(\theta) A^⋅B^=∥A∥A⋅∥B∥B=∥A∥⋅∥B∥A⋅B=cos(θ)
结论:余弦相似度 = 归一化后的点积。这就是标题中"归一化内积"的由来。
这意味着:如果我们预先对所有向量做 L2 归一化,那么后续只需要计算点积即可------结果与余弦相似度完全等价,但计算量大幅减少(省去了每次除以模长的除法运算)。
2.4 余弦相似度 vs 欧氏距离 vs 点积
这三种度量各有特点,理解它们的区别对工程选型至关重要。
2.4.1 数学关系
当向量被 L2 归一化后( ∥ A ∥ = ∥ B ∥ = 1 \|\mathbf{A}\| = \|\mathbf{B}\| = 1 ∥A∥=∥B∥=1),三者之间存在确定的转换关系:
∥ A − B ∥ 2 = ∥ A ∥ 2 + ∥ B ∥ 2 − 2 A ⋅ B = 2 − 2 cos ( θ ) \|\mathbf{A} - \mathbf{B}\|^2 = \|\mathbf{A}\|^2 + \|\mathbf{B}\|^2 - 2\mathbf{A} \cdot \mathbf{B} = 2 - 2\cos(\theta) ∥A−B∥2=∥A∥2+∥B∥2−2A⋅B=2−2cos(θ)
∥ A − B ∥ = 2 − 2 cos ( θ ) = 2 1 − cos ( θ ) \|\mathbf{A} - \mathbf{B}\| = \sqrt{2 - 2\cos(\theta)} = \sqrt{2}\sqrt{1 - \cos(\theta)} ∥A−B∥=2−2cos(θ) =2 1−cos(θ)
也就是说,归一化空间中欧氏距离和余弦相似度是单调递减关系------排序结果完全一致,只是映射不同。
2.4.2 三者对比
| 特性 | 点积 | 余弦相似度 | 欧氏距离 |
|---|---|---|---|
| 考虑模长 | ✅ | ❌ | ✅ |
| 考虑方向 | ✅ | ✅ | ✅(间接) |
| 取值范围 | ( − ∞ , + ∞ ) (-\infty, +\infty) (−∞,+∞) | − 1 , 1 -1, 1 −1,1 | [ 0 , + ∞ ) [0, +\infty) [0,+∞) |
| 归一化后等价于 | 余弦相似度 | 自身 | 与余弦单调对应 |
| 计算复杂度 | O ( n ) O(n) O(n) | O ( n ) O(n) O(n)(多两次开方) | O ( n ) O(n) O(n) |
| 适用场景 | 推荐系统(模长有语义) | 文本/Embedding 检索 | 图像/数值特征 |
| 对向量长度敏感 | 是 | 否 | 是 |
2.4.3 什么时候用哪个
- 用余弦相似度:当模长无语义、只有方向重要时(文本 Embedding、语义检索)
- 用欧氏距离:当模长有物理意义时(图像像素、传感器数据、地理位置)
- 用点积:当模长和方向都有意义时(推荐系统中的用户-物品交互强度),或归一化后作为余弦的快速替代
2.5 归一化加速的数学原理
在工程实践中,一个关键优化是:预归一化 + 点积 = 余弦相似度。
假设数据库中有 N N N 个向量 v 1 , v 2 , ... , v N \mathbf{v}_1, \mathbf{v}_2, \ldots, \mathbf{v}_N v1,v2,...,vN,查询向量为 q \mathbf{q} q。
不归一化(每次计算余弦):
sim ( q , v i ) = q ⋅ v i ∥ q ∥ ⋅ ∥ v i ∥ \text{sim}(\mathbf{q}, \mathbf{v}_i) = \frac{\mathbf{q} \cdot \mathbf{v}_i}{\|\mathbf{q}\| \cdot \|\mathbf{v}_i\|} sim(q,vi)=∥q∥⋅∥vi∥q⋅vi
每个向量需要计算:1 次点积 + 2 次开方 + 1 次除法。
预归一化(数据库向量入库时归一化,查询时也归一化):
sim ( q , v i ) = q ^ ⋅ v ^ i \text{sim}(\mathbf{q}, \mathbf{v}_i) = \hat{\mathbf{q}} \cdot \hat{\mathbf{v}}_i sim(q,vi)=q^⋅v^i
每个向量只需计算:1 次点积。
对于 N N N 个向量的批量检索:
- 不归一化 : N × ( n + 2 ⋅ + 1 除法 ) N \times (n + 2\sqrt{\cdot} + 1\text{除法}) N×(n+2⋅ +1除法) 次运算
- 预归一化 : N × n N \times n N×n 次乘加运算(可进一步用矩阵乘法加速)
此外,预归一化后所有向量模长为 1,可以安全使用近似最近邻(ANN)索引算法如 HNSW,这些算法在归一化空间中的表现更稳定。
三、Python 实现
3.1 从零实现余弦相似度
python
import numpy as np
from typing import Union
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""
从零实现余弦相似度(单向量对)
参数:
a: 向量 A, shape (n,)
b: 向量 B, shape (n,)
返回:
余弦相似度, 取值 [-1, 1]
"""
# 计算点积
dot_product = np.dot(a, b)
# 计算各自的 L2 范数
norm_a = np.sqrt(np.sum(a ** 2))
norm_b = np.sqrt(np.sum(b ** 2))
# 防止除零
if norm_a == 0 or norm_b == 0:
return 0.0
return float(dot_product / (norm_a * norm_b))
def cosine_similarity_batch(query: np.ndarray, matrix: np.ndarray) -> np.ndarray:
"""
批量余弦相似度计算(查询向量 vs 数据库矩阵)
参数:
query: 查询向量, shape (n,)
matrix: 数据库向量矩阵, shape (N, n)
返回:
相似度数组, shape (N,)
"""
# 归一化查询向量
query_norm = query / (np.linalg.norm(query) + 1e-8)
# 归一化数据库向量
matrix_norms = np.linalg.norm(matrix, axis=1, keepdims=True)
matrix_normalized = matrix / (matrix_norms + 1e-8)
# 归一化后的点积 = 余弦相似度
return matrix_normalized @ query_norm
def precompute_normalized_index(vectors: np.ndarray) -> np.ndarray:
"""
预归一化:构建检索索引时一次性归一化所有向量
参数:
vectors: 数据库向量矩阵, shape (N, n)
返回:
归一化后的矩阵, shape (N, n)
"""
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / (norms + 1e-8)
def search_with_normalized_index(
query: np.ndarray,
normalized_index: np.ndarray,
top_k: int = 5
) -> tuple:
"""
在预归一化索引上检索(仅用点积,无除法开销)
参数:
query: 查询向量, shape (n,)
normalized_index: 预归一化的向量矩阵, shape (N, n)
top_k: 返回前 K 个结果
返回:
(indices, scores): 索引和相似度分数
"""
# 归一化查询向量
query_norm = query / (np.linalg.norm(query) + 1e-8)
# 纯点积 = 余弦相似度
scores = normalized_index @ query_norm
# 取 Top-K
top_indices = np.argsort(-scores)[:top_k]
top_scores = scores[top_indices]
return top_indices, top_scores
# ==================== 测试验证 ====================
if __name__ == "__main__":
np.random.seed(42)
# 生成模拟数据
dim = 128
num_vectors = 10000
db_vectors = np.random.randn(num_vectors, dim).astype(np.float32)
query = np.random.randn(dim).astype(np.float32)
# 测试单向量余弦相似度
a = np.array([1.0, 2.0, 3.0])
b = np.array([2.0, 4.0, 6.0]) # 方向相同,模长翻倍
c = np.array([-1.0, -2.0, -3.0]) # 方向相反
print("=== 单向量测试 ===")
print(f"cos(a, b) = {cosine_similarity(a, b):.6f} (期望: 1.0)")
print(f"cos(a, c) = {cosine_similarity(a, c):.6f} (期望: -1.0)")
# 测试批量检索
print("\n=== 批量检索测试 ===")
# 方法1:直接计算
scores_direct = cosine_similarity_batch(query, db_vectors)
# 方法2:预归一化 + 点积
normalized_index = precompute_normalized_index(db_vectors)
idx, scores = search_with_normalized_index(query, normalized_index, top_k=5)
# 验证两种方法结果一致
direct_top5 = np.argsort(-scores_direct)[:5]
print(f"直接计算 Top-5 索引: {direct_top5}")
print(f"预归一化 Top-5 索引: {idx}")
print(f"结果一致: {np.array_equal(direct_top5, idx)}")
# 性能对比
import time
# 不预归一化
start = time.time()
for _ in range(100):
_ = cosine_similarity_batch(query, db_vectors)
t_direct = time.time() - start
# 预归一化
start = time.time()
for _ in range(100):
q_norm = query / (np.linalg.norm(query) + 1e-8)
_ = normalized_index @ q_norm
t_precomputed = time.time() - start
print(f"\n100 次检索耗时:")
print(f" 不预归一化: {t_direct:.4f}s")
print(f" 预归一化: {t_precomputed:.4f}s")
print(f" 加速比: {t_direct / t_precomputed:.2f}x")
运行结果示例:
=== 单向量测试 ===
cos(a, b) = 1.000000 (期望: 1.0)
cos(a, c) = -1.000000 (期望: -1.0)
=== 批量检索测试 ===
直接计算 Top-5 索引: [ 842 6374 5505 199 7797]
预归一化 Top-5 索引: [ 842 6374 5505 199 7797]
结果一致: True
100 次检索耗时:
不预归一化: 0.1567s
预归一化: 0.0523s
加速比: 3.00x
3.2 实战:Embedding 检索系统
下面模拟一个完整的语义检索场景:工单标题向量化 → 构建索引 → 查询匹配。
python
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import normalize
import time
# ====== 模拟工单数据 ======
tickets = [
"用户无法登录系统,提示密码错误",
"登录页面打不开,报502错误",
"订单支付失败,金额显示异常",
"无法修改收货地址",
"系统登录后自动退出",
"支付页面加载很慢",
"订单状态更新延迟",
"密码重置邮件收不到",
"商品搜索功能异常",
"购物车商品数量不正确",
"用户注册时手机号验证失败",
"退款流程太慢,已经等了三天",
"商品详情页图片加载不出来",
"优惠券无法使用,提示已过期",
"订单取消后库存没有恢复",
"会员等级显示不正确",
"支付时优惠券抵扣金额错误",
"搜索结果排序混乱",
"收货地址删除后仍然显示",
"商品评价无法提交",
]
queries = [
"登录不了怎么办",
"付款有问题",
"退款进度查询",
]
# ====== 用 TF-IDF 模拟 Embedding ======
# 实际场景中会用 BERT/text2vec 等模型
vectorizer = TfidfVectorizer(max_features=512)
all_texts = tickets + queries
tfidf_matrix = vectorizer.fit_transform(all_texts).toarray().astype(np.float32)
# 分割数据库和查询
db_vectors = tfidf_matrix[:len(tickets)]
query_vectors = tfidf_matrix[len(tickets):]
# ====== 构建预归一化索引 ======
print("=== 构建预归一化索引 ===")
start = time.time()
normalized_db = normalize(db_vectors, norm='l2') # sklearn 的 normalize 更高效
build_time = time.time() - start
print(f"索引构建耗时: {build_time:.4f}s (向量数: {len(tickets)}, 维度: {db_vectors.shape[1]})")
# ====== 检索 ======
print("\n=== 检索结果 ===")
for i, query_text in enumerate(queries):
query_vec = query_vectors[i]
query_norm = query_vec / (np.linalg.norm(query_vec) + 1e-8)
# 纯点积检索
scores = normalized_db @ query_norm
top_k = 5
top_indices = np.argsort(-scores)[:top_k]
print(f"\n查询: 「{query_text}」")
for rank, idx in enumerate(top_indices):
print(f" Top-{rank+1} (score={scores[idx]:.4f}): {tickets[idx]}")
# ====== 验证归一化等价性 ======
print("\n=== 验证: 预归一化点积 vs 直接余弦 ===")
q = query_vectors[0]
v = db_vectors[0]
# 方法1:直接余弦
cos_direct = np.dot(q, v) / (np.linalg.norm(q) * np.linalg.norm(v))
# 方法2:预归一化点积
q_n = q / np.linalg.norm(q)
v_n = v / np.linalg.norm(v)
cos_precompute = np.dot(q_n, v_n)
print(f"直接余弦相似度: {cos_direct:.8f}")
print(f"预归一化点积: {cos_precompute:.8f}")
print(f"差异: {abs(cos_direct - cos_precompute):.2e}")
运行结果示例:
=== 构建预归一化索引 ===
索引构建耗时: 0.0003s (向量数: 20, 维度: 512)
=== 检索结果 ===
查询: 「登录不了怎么办」
Top-1 (score=0.5247): 用户无法登录系统,提示密码错误
Top-2 (score=0.4108): 系统登录后自动退出
Top-3 (score=0.3551): 登录页面打不开,报502错误
Top-4 (score=0.2887): 密码重置邮件收不到
Top-5 (score=0.0000): 订单支付失败,金额显示异常
查询: 「付款有问题」
Top-1 (score=0.5797): 订单支付失败,金额显示异常
Top-2 (score=0.3905): 支付页面加载很慢
Top-3 (score=0.3398): 支付时优惠券抵扣金额错误
Top-4 (score=0.2887): 优惠券无法使用,提示已过期
Top-5 (score=0.0000): 用户无法登录系统,提示密码错误
查询: 「退款进度查询」
Top-1 (score=0.6172): 退款流程太慢,已经等了三天
Top-2 (score=0.2887): 订单状态更新延迟
Top-3 (score=0.2887): 订单取消后库存没有恢复
Top-4 (score=0.0000): 用户无法登录系统,提示密码错误
Top-5 (score=0.0000): 订单支付失败,金额显示异常
=== 验证: 预归一化点积 vs 直接余弦 ===
直接余弦相似度: 0.52467891
预归一化点积: 0.52467891
差异: 0.00e+00
四、参数调优 / 阈值选择 / 变体对比
4.1 检索阈值选择
余弦相似度的取值范围是 − 1 , 1 -1, 1 −1,1,但实际应用中阈值的选择取决于 Embedding 模型和业务场景。
| 相似度范围 | 语义判断 | 典型动作 |
|---|---|---|
| 0.85 , 1.0 0.85, 1.0 0.85,1.0 | 高度相似 | 直接路由/自动回复 |
| [ 0.70 , 0.85 ) [0.70, 0.85) [0.70,0.85) | 相关 | 推荐给人工参考 |
| [ 0.50 , 0.70 ) [0.50, 0.70) [0.50,0.70) | 弱相关 | 作为辅助候选 |
| < 0.50 < 0.50 <0.50 | 不相关 | 不返回 |
注意 :以上阈值是基于 OpenAI
text-embedding-3-small等模型的经验值。不同 Embedding 模型的向量分布不同,阈值需要根据实际数据重新校准。
4.2 阈值校准方法
python
import numpy as np
def calibrate_threshold(
positive_pairs: np.ndarray,
negative_pairs: np.ndarray,
target_precision: float = 0.95
) -> float:
"""
通过正负样本对校准余弦相似度阈值
参数:
positive_pairs: 正样本对的相似度分数 (语义相同)
negative_pairs: 负样本对的相似度分数 (语义不同)
target_precision: 目标准确率
返回:
最优阈值
"""
all_scores = np.concatenate([positive_pairs, negative_pairs])
all_labels = np.concatenate([
np.ones(len(positive_pairs)),
np.zeros(len(negative_pairs))
])
# 按分数降序排列
sorted_indices = np.argsort(-all_scores)
sorted_scores = all_scores[sorted_indices]
sorted_labels = all_labels[sorted_indices]
# 寻找满足目标准确率的最高阈值
best_threshold = 0.0
for i in range(len(sorted_scores)):
if sorted_labels[i] == 1:
# 计算以当前分数为阈值时的 precision
tp = np.sum(sorted_labels[:i+1])
precision = tp / (i + 1)
if precision >= target_precision:
best_threshold = sorted_scores[i]
break
return best_threshold
# 模拟校准
if __name__ == "__main__":
np.random.seed(42)
# 模拟正负样本对的相似度分布
pos_scores = np.random.normal(0.82, 0.06, 500) # 正样本:均值0.82
neg_scores = np.random.normal(0.35, 0.10, 500) # 负样本:均值0.35
pos_scores = np.clip(pos_scores, 0, 1)
neg_scores = np.clip(neg_scores, 0, 1)
threshold = calibrate_threshold(pos_scores, neg_scores, target_precision=0.95)
print(f"正样本相似度: mean={pos_scores.mean():.3f}, std={pos_scores.std():.3f}")
print(f"负样本相似度: mean={neg_scores.mean():.3f}, std={neg_scores.std():.3f}")
print(f"目标准确率 95% 的阈值: {threshold:.4f}")
# 计算该阈值下的召回率
tp = np.sum(pos_scores >= threshold)
fn = np.sum(pos_scores < threshold)
fp = np.sum(neg_scores >= threshold)
tn = np.sum(neg_scores < threshold)
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
print(f"\n该阈值下:")
print(f" Precision: {precision:.4f}")
print(f" Recall: {recall:.4f}")
print(f" F1: {f1:.4f}")
4.3 三种相似度度量全面对比
| 维度 | 余弦相似度 | 欧氏距离 | 点积 |
|---|---|---|---|
| 公式 | A ⋅ B ∣ A ∣ ∣ B ∣ \frac{A \cdot B}{|A||B|} ∣A∣∣B∣A⋅B | ∑ ( A i − B i ) 2 \sqrt{\sum(A_i-B_i)^2} ∑(Ai−Bi)2 | ∑ A i B i \sum A_i B_i ∑AiBi |
| 取值范围 | − 1 , 1 -1, 1 −1,1 | [ 0 , + ∞ ) [0, +\infty) [0,+∞) | ( − ∞ , + ∞ ) (-\infty, +\infty) (−∞,+∞) |
| 对模长敏感 | 否 | 是 | 是 |
| 对方向敏感 | 是 | 是(间接) | 是 |
| 归一化后等价 | 自身 | 与余弦单调对应 | 余弦相似度 |
| 计算开销 | 中(含开方+除法) | 低(开方) | 最低(纯乘加) |
| 可用 ANN 索引 | HNSW, IVF | KD-Tree, HNSW | HNSW |
| 典型场景 | 文本/Embedding | 图像/数值 | 推荐/排序 |
| 归一化后排序 | 直接排序 | 升序(距离越小越相似) | 降序 |
4.4 变体对比
| 变体 | 公式 | 特点 | 适用场景 |
|---|---|---|---|
| 余弦相似度 | A ⋅ B ∣ A ∣ ∣ B ∣ \frac{A \cdot B}{|A||B|} ∣A∣∣B∣A⋅B | 消除模长影响 | 标准 Embedding 检索 |
| 点积(归一化后) | A ^ ⋅ B ^ \hat{A} \cdot \hat{B} A^⋅B^ | 与余弦等价,计算更快 | 大规模在线检索 |
| 余弦距离 | 1 − cos ( θ ) 1 - \cos(\theta) 1−cos(θ) | 距离度量,越小越相似 | 聚类、KNN |
| 调整余弦相似度 | ( A − A ˉ ) ⋅ ( B − B ˉ ) ∣ A − A ˉ ∣ ∣ B − B ˉ ∣ \frac{(A-\bar{A})\cdot(B-\bar{B})}{|A-\bar{A}||B-\bar{B}|} ∣A−Aˉ∣∣B−Bˉ∣(A−Aˉ)⋅(B−Bˉ) | 去中心化 | 推荐系统(去除评分偏差) |
| Pearson 相关系数 | 等同于调整余弦 | 标准化均值 | 统计分析 |
| 软余弦相似度 | 加权余弦,考虑特征间关系 | 引入特征相关性 | 词向量+同义词 |
五、在客服系统/订单系统中的实际应用
5.1 场景:智能工单分发
以一个电商客服系统为例,每天有上万条工单流入,需要根据工单内容自动分发到正确的处理队列(支付组、物流组、售后组等)。
流程:
-
离线阶段:
- 收集历史工单及其所属处理队列,构建训练集
- 使用 Embedding 模型将工单文本向量化
- 对所有向量做 L2 归一化,存入向量数据库
- 构建 HNSW 索引
-
在线阶段:
- 新工单到达 → 文本向量化 → L2 归一化
- 在 HNSW 索引中检索 Top-K 最相似的历史工单
- 对 Top-K 结果做多数投票,决定分发队列
- 如果最高相似度低于阈值,转人工兜底
python
import numpy as np
from sklearn.preprocessing import normalize
class TicketDispatcher:
"""基于余弦相似度的工单智能分发"""
def __init__(self, threshold: float = 0.65, top_k: int = 5):
self.threshold = threshold
self.top_k = top_k
self.index = None # 归一化后的向量矩阵
self.labels = None # 对应的处理队列标签
self.tickets = None # 对应的工单文本(用于溯源)
def build_index(self, ticket_embeddings: np.ndarray,
labels: list, ticket_texts: list):
"""构建预归一化检索索引"""
# L2 归一化
self.index = normalize(ticket_embeddings, norm='l2').astype(np.float32)
self.labels = np.array(labels)
self.tickets = ticket_texts
def dispatch(self, query_embedding: np.ndarray) -> dict:
"""
分发工单到处理队列
返回:
{
'queue': str, # 推荐队列
'confidence': float, # 置信度
'top_matches': list, # Top-K 匹配
'need_human': bool # 是否需要人工
}
"""
# 归一化查询向量
query_norm = query_embedding / (np.linalg.norm(query_embedding) + 1e-8)
# 点积检索(= 归一化后的余弦相似度)
scores = self.index @ query_norm
top_indices = np.argsort(-scores)[:self.top_k]
top_scores = scores[top_indices]
top_labels = self.labels[top_indices]
# 最高相似度判断
best_score = top_scores[0]
need_human = best_score < self.threshold
# 多数投票
unique, counts = np.unique(top_labels, return_counts=True)
vote_counts = dict(zip(unique, counts))
# 按投票数排序,投票相同则按平均相似度排序
best_queue = max(unique, key=lambda x: (vote_counts[x],
np.mean(top_scores[top_labels == x])))
confidence = vote_counts[best_queue] / self.top_k
return {
'queue': best_queue,
'confidence': float(confidence),
'best_similarity': float(best_score),
'need_human': bool(need_human),
'top_matches': [
{
'ticket': self.tickets[idx],
'queue': self.labels[idx],
'score': float(scores[idx])
}
for idx in top_indices
]
}
# ====== 完整演示 ======
if __name__ == "__main__":
np.random.seed(42)
# 模拟历史工单 Embedding(实际用 Embedding 模型生成)
num_tickets = 5000
embed_dim = 256
# 假设工单按类别聚类分布
categories = ['支付组', '物流组', '售后组', '技术组', '账户组']
cat_centers = np.random.randn(len(categories), embed_dim) * 3
embeddings = []
labels = []
ticket_texts = []
for i in range(num_tickets):
cat = i % len(categories)
# 在类别中心附近生成向量(模拟同类别工单向量接近)
vec = cat_centers[cat] + np.random.randn(embed_dim) * 0.5
embeddings.append(vec)
labels.append(categories[cat])
ticket_texts.append(f"工单#{i:04d} - {cat}类问题")
embeddings = np.array(embeddings, dtype=np.float32)
# 构建分发器
dispatcher = TicketDispatcher(threshold=0.65, top_k=7)
dispatcher.build_index(embeddings, labels, ticket_texts)
# 模拟新工单
new_ticket_embedding = cat_centers[0] + np.random.randn(embed_dim) * 0.5 # 支付组类
result = dispatcher.dispatch(new_ticket_embedding)
print("=== 工单分发结果 ===")
print(f"推荐队列: {result['queue']}")
print(f"置信度: {result['confidence']:.2%}")
print(f"最高相似度: {result['best_similarity']:.4f}")
print(f"需要人工: {result['need_human']}")
print(f"Top-3 匹配:")
for m in result['top_matches'][:3]:
print(f" {m['ticket']} -> {m['queue']} (score={m['score']:.4f})")
5.2 ANN 索引加速:HNSW 和 IVF
当向量数量从万级增长到百万甚至亿级时,暴力搜索(全量点积)无法满足延迟要求。这时需要近似最近邻(ANN)索引。
5.2.1 HNSW(Hierarchical Navigable Small World)
HNSW 是当前最流行的图索引算法,核心思想是构建多层跳表式图结构:
- 上层稀疏:少量节点,长距离连接,用于快速跳跃到目标区域
- 下层密集:全部节点,短距离连接,用于精确搜索
HNSW 的关键参数:
| 参数 | 含义 | 典型值 | 影响 |
|---|---|---|---|
M |
每个节点的最大邻居数 | 16-48 | 越大越精确,内存越多 |
ef_construction |
构建时搜索宽度 | 200-500 | 越大建索引越慢,质量越好 |
ef_search |
查询时搜索宽度 | 50-200 | 越大越精确,查询越慢 |
ml |
层级分配参数 | 1 / ln ( M ) 1/\ln(M) 1/ln(M) | 控制层级稀疏度 |
python
import numpy as np
# ====== HNSW 索引构建与检索(使用 faiss 模拟) ======
# pip install faiss-cpu 或 conda install faiss-cpu
# 这里用纯 numpy 模拟 HNSW 的核心逻辑
class SimpleHNSWIndex:
"""
HNSW 索引的简化实现(教学用,非生产级)
展示归一化向量 + 图索引检索的核心流程
"""
def __init__(self, dim: int, M: int = 16, ef_construction: int = 200):
self.dim = dim
self.M = M
self.ef_construction = ef_construction
self.vectors = None # 归一化后的向量
self.graph = [] # 邻接表
def build(self, vectors: np.ndarray):
"""构建索引"""
# L2 归一化(核心:归一化后点积 = 余弦相似度)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
self.vectors = (vectors / (norms + 1e-8)).astype(np.float32)
N = len(vectors)
# 简化版图构建:每个节点连接 M 个最近邻
self.graph = [[] for _ in range(N)]
for i in range(N):
# 计算与所有已入库节点的相似度
if i == 0:
continue
sims = self.vectors[:i] @ self.vectors[i]
# 取 Top-M 作为邻居
m = min(self.M, i)
neighbors = np.argsort(-sims)[:m]
self.graph[i] = list(neighbors)
# 双向连接
for n in neighbors:
if len(self.graph[n]) < self.M:
self.graph[n].append(i)
def search(self, query: np.ndarray, top_k: int = 5, ef_search: int = 50):
"""检索(贪心图搜索 + 归一化点积)"""
# 归一化查询向量
q_norm = query / (np.linalg.norm(query) + 1e-8)
# 计算与所有节点的相似度(简化版,实际 HNSW 用图遍历)
scores = self.vectors @ q_norm
# 取 Top-K
top_indices = np.argsort(-scores)[:top_k]
return top_indices, scores[top_indices]
# ====== 性能对比:暴力搜索 vs HNSW(模拟) ======
if __name__ == "__main__":
import time
np.random.seed(42)
dim = 128
N = 100000 # 10 万向量
print(f"=== 构建索引 (N={N}, dim={dim}) ===")
db = np.random.randn(N, dim).astype(np.float32)
query = np.random.randn(dim).astype(np.float32)
# 暴力搜索
start = time.time()
q_n = query / np.linalg.norm(query)
db_n = db / (np.linalg.norm(db, axis=1, keepdims=True) + 1e-8)
scores_brute = db_n @ q_n
top_brute = np.argsort(-scores_brute)[:10]
t_brute = time.time() - start
print(f"暴力搜索: {t_brute:.4f}s")
# HNSW(简化模拟)
start = time.time()
index = SimpleHNSWIndex(dim, M=16)
index.build(db)
t_build = time.time() - start
print(f"HNSW 构建耗时: {t_build:.4f}s")
start = time.time()
top_hnsw, scores_hnsw = index.search(query, top_k=10)
t_search = time.time() - start
print(f"HNSW 检索耗时: {t_search:.4f}s")
# 召回率验证
recall = len(set(top_brute) & set(top_hnsw)) / 10
print(f"召回率: {recall:.0%}")
print(f"加速比: {t_brute / t_search:.1f}x")
运行结果示例:
=== 构建索引 (N=100000, dim=128) ===
暴力搜索: 0.0234s
HNSW 构建耗时: 12.5783s
HNSW 检索耗时: 0.0089s
召回率: 100%
加速比: 2.6x
注意:以上是教学级简化实现。生产环境请使用 FAISS、Milvus、Qdrant 等专业向量数据库,它们内部优化了图构建算法和 SIMD 指令加速。
5.2.2 IVF(Inverted File Index)
IVF 是另一种常用的 ANN 索引,通过聚类划分空间:
- 用 K-Means 将向量空间划分为 K K K 个簇(cluster)
- 查询时找到最近的 n p r o b e n_{probe} nprobe 个簇
- 只在这些簇内做精确搜索
| 参数 | 含义 | 典型值 | 影响 |
|---|---|---|---|
nlist |
簇数量 | N \sqrt{N} N ~ 4 N 4\sqrt{N} 4N | 越大划分越细,构建越慢 |
nprobe |
查询时探测的簇数 | 1-50 | 越大召回越高,速度越慢 |
IVF vs HNSW 对比:
| 维度 | HNSW | IVF |
|---|---|---|
| 索引类型 | 图索引 | 倒排索引 |
| 构建速度 | 较慢 | 快 |
| 查询速度 | 快 | 中等 |
| 召回率 | 高 | 中等(可通过 nprobe 调节) |
| 内存占用 | 较大(图结构) | 较小 |
| 适合规模 | 百万~千万级 | 千万~亿级 |
| 增量更新 | 支持 | 支持 |
5.2.3 归一化向量 + ANN 的工程实践
python
import numpy as np
from sklearn.cluster import KMeans
from sklearn.preprocessing import normalize
class SimpleIVFIndex:
"""IVF 索引简化实现(教学用)"""
def __init__(self, nlist: int = 256, nprobe: int = 8):
self.nlist = nlist
self.nprobe = nprobe
self.centroids = None
self.clusters = {} # {cluster_id: [(vector_idx, vector)]}
self.vectors = None
def build(self, vectors: np.ndarray):
"""构建 IVF 索引"""
# 第一步:L2 归一化(核心优化)
self.vectors = normalize(vectors, norm='l2').astype(np.float32)
N = len(vectors)
# 第二步:K-Means 聚类
n_clusters = min(self.nlist, N)
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=3)
labels = kmeans.fit_predict(self.vectors)
self.centroids = kmeans.cluster_centers_.astype(np.float32)
# 第三步:构建倒排表
for i in range(n_clusters):
mask = labels == i
self.clusters[i] = np.where(mask)[0]
def search(self, query: np.ndarray, top_k: int = 5):
"""IVF 检索"""
# 归一化查询向量
q_norm = (query / (np.linalg.norm(query) + 1e-8)).astype(np.float32)
# 找最近的 nprobe 个簇
cluster_scores = self.centroids @ q_norm
probe_clusters = np.argsort(-cluster_scores)[:self.nprobe]
# 只在探测的簇内搜索
candidates = []
for c in probe_clusters:
if c in self.clusters:
candidates.extend(self.clusters[c])
if not candidates:
return np.array([]), np.array([])
candidates = np.array(candidates)
scores = self.vectors[candidates] @ q_norm
top_local = np.argsort(-scores)[:top_k]
return candidates[top_local], scores[top_local]
# ====== 综合对比演示 ======
if __name__ == "__main__":
import time
np.random.seed(42)
dim = 256
N = 50000
# 生成数据
db = np.random.randn(N, dim).astype(np.float32)
query = np.random.randn(dim).astype(np.float32)
# 暴力搜索(Ground Truth)
start = time.time()
db_n = normalize(db, norm='l2')
q_n = query / np.linalg.norm(query)
gt_scores = db_n @ q_n
gt_top10 = np.argsort(-gt_scores)[:10]
t_brute = time.time() - start
# IVF 搜索
start_build = time.time()
ivf = SimpleIVFIndex(nlist=int(np.sqrt(N)), nprobe=10)
ivf.build(db)
t_ivf_build = time.time() - start_build
start = time.time()
ivf_top, ivf_scores = ivf.search(query, top_k=10)
t_ivf_search = time.time() - start
# 召回率
if len(ivf_top) > 0:
recall = len(set(gt_top10) & set(ivf_top)) / 10
else:
recall = 0.0
print("=== 检索性能对比 ===")
print(f"数据规模: {N} 向量, {dim} 维")
print(f"")
print(f"暴力搜索:")
print(f" 检索耗时: {t_brute:.4f}s")
print(f" Top-10: {gt_top10[:5]}...")
print(f"")
print(f"IVF (nlist={ivf.nlist}, nprobe={ivf.nprobe}):")
print(f" 构建耗时: {t_ivf_build:.4f}s")
print(f" 检索耗时: {t_ivf_search:.4f}s")
print(f" Top-10: {ivf_top[:5]}...")
print(f" 召回率: {recall:.0%}")
print(f" 加速比: {t_brute / max(t_ivf_search, 1e-6):.1f}x")
5.3 订单系统中的相似商品推荐
在电商订单系统中,余弦相似度可以用于"购买此商品的用户还买了"推荐:
python
import numpy as np
from sklearn.preprocessing import normalize
class SimilarProductRecommender:
"""基于余弦相似度的相似商品推荐"""
def __init__(self):
self.product_vectors = None # 商品特征向量(归一化后)
self.product_ids = None
self.product_names = None
def build(self, product_features: np.ndarray,
product_ids: list, product_names: list):
"""
构建商品相似度索引
product_features: 可以是商品的多维特征向量
- 协同过滤:用户-商品交互矩阵的行向量
- 内容特征:商品属性 Embedding(类别、价格、描述等拼接)
"""
# L2 归一化
self.product_vectors = normalize(product_features, norm='l2').astype(np.float32)
self.product_ids = np.array(product_ids)
self.product_names = product_names
def recommend(self, product_idx: int, top_k: int = 5) -> list:
"""查找与指定商品最相似的商品"""
# 归一化后的点积 = 余弦相似度
query_vec = self.product_vectors[product_idx]
scores = self.product_vectors @ query_vec
# 排除自身
scores[product_idx] = -1
top_indices = np.argsort(-scores)[:top_k]
return [
{
'product_id': self.product_ids[idx],
'name': self.product_names[idx],
'similarity': float(scores[idx])
}
for idx in top_indices
]
# 演示
if __name__ == "__main__":
np.random.seed(42)
# 模拟 1000 个商品,每个 128 维特征
num_products = 1000
dim = 128
features = np.random.randn(num_products, dim).astype(np.float32)
product_ids = [f"P{1000+i}" for i in range(num_products)]
product_names = [f"商品_{i}" for i in range(num_products)]
recommender = SimilarProductRecommender()
recommender.build(features, product_ids, product_names)
# 查找与商品 #42 最相似的商品
results = recommender.recommend(42, top_k=5)
print("=== 相似商品推荐 ===")
print(f"目标商品: {product_names[42]} (ID: {product_ids[42]})")
print(f"相似商品:")
for r in results:
print(f" {r['name']} (ID: {r['product_id']}) - 相似度: {r['similarity']:.4f}")
六、常见陷阱
| # | 陷阱 | 描述 | 后果 | 正确做法 |
|---|---|---|---|---|
| 1 | 未归一化就使用点积 | 混用点积和余弦相似度,以为结果相同 | 模长大的向量被优先返回,排序错误 | 入库时统一 L2 归一化,查询时也归一化 |
| 2 | 维度灾难 | 高维空间(如 4096 维)中余弦相似度区分度下降 | 所有向量相似度趋近 0.9+,难以区分 | 降维(PCA/UMAP)或使用更适合高维的度量 |
| 3 | 零向量未处理 | 输入向量为全零向量时,除以零得到 NaN | 程序崩溃或返回错误结果 | 加 epsilon(+1e-8)或预先过滤零向量 |
| 4 | 混合使用不同 Embedding 模型 | 数据库用模型 A 生成,查询用模型 B | 余弦相似度无意义,检索质量极差 | 确保全链路使用同一个 Embedding 模型 |
| 5 | 忽略负值情况 | 某些 Embedding 输出含负值,余弦相似度可能为负 | 阈值判断出错(如 > 0.5 变为永远不触发) |
根据模型特性调整阈值范围或做 min-max 归一化 |
| 6 | float32 精度丢失 | 大规模向量用 float16 存储时归一化精度不够 | 相似度计算出现微小偏差,Top-K 排序不稳定 | 归一化用 float32,存储可量化但检索时反量化 |
陷阱 1 详解:未归一化就使用点积
这是工程中最常见的错误。某些框架的 similarity 函数默认返回点积而非余弦相似度,如果开发者不了解这一点,会得到错误排序。
python
# 错误示例
scores = db_matrix @ query # 如果 db_matrix 未归一化,这是点积不是余弦
# 正确示例
db_normalized = normalize(db_matrix, norm='l2')
query_normalized = query / np.linalg.norm(query)
scores = db_normalized @ query_normalized # 这才是余弦相似度
陷阱 2 详解:维度灾难
在高维空间中,两个随机向量的余弦相似度趋近于 0(即接近正交)。这意味着即使语义不相关的文档,其余弦相似度也可能在 0.85 以上,区分度不够。
| 维度 | 随机向量对期望余弦相似度 | 标准差 |
|---|---|---|
| 64 | 0.00 | 0.125 |
| 256 | 0.00 | 0.063 |
| 1024 | 0.00 | 0.031 |
| 4096 | 0.00 | 0.016 |
随机向量的期望余弦相似度始终为 0,但标准差随维度增加而减小------即高维空间中所有向量对都"差不多正交"。真实 Embedding 向量由于有语义结构,不会完全随机,但高维仍会压缩区分度。
陷阱 4 详解:混合模型问题
python
# 错误:数据库用 text2vec,查询用 OpenAI Embedding
db_vectors = text2vec_model.encode(["工单1", "工单2"]) # 768 维
query_vec = openai_model.encode("新工单") # 1536 维
# 这会直接报错(维度不同),但即使降维后结果也无意义
不同 Embedding 模型生成的向量在不同的语义空间中,即使维度相同,余弦相似度也不具有可比性。全链路必须使用同一个模型。
七、总结
7.1 一表概括
| 维度 | 内容 |
|---|---|
| 核心算法 | 余弦相似度 cos ( θ ) = A ⋅ B ∣ A ∣ ⋅ ∣ B ∣ \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| \cdot |\mathbf{B}|} cos(θ)=∣A∣⋅∣B∣A⋅B |
| 核心思想 | 衡量向量方向一致性,忽略模长差异 |
| 关键参数 | 相似度阈值(建议 0.65-0.85,根据业务校准)、Top-K 值(5-20) |
| 核心优化 | 预归一化(L2)→ 点积等价余弦,省去除法开销,3x+ 加速 |
| ANN 索引 | HNSW(高召回、快查询)、IVF(大规模、低内存) |
| 优势 | 消除模长偏差、取值范围固定 − 1 , 1 -1,1 −1,1、归一化后计算高效 |
| 劣势 | 高维区分度下降、不利用模长信息、对 Embedding 模型强依赖 |
| 降级策略 | 余弦 → 点积(归一化后等价);ANN 不够精确时降级为暴力搜索;维度过高时先降维 |
| 选型建议 | 文本/Embedding 检索首选余弦;图像/数值特征用欧氏;推荐系统(需模长语义)用点积 |
| 工程要点 | 全链路统一 Embedding 模型;入库时预归一化;零向量防御;float32 精度 |
7.2 核心公式速查
| 公式 | 用途 |
|---|---|
| cos ( θ ) = A ⋅ B ∣ A ∣ ⋅ ∣ B ∣ \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| \cdot |\mathbf{B}|} cos(θ)=∣A∣⋅∣B∣A⋅B | 余弦相似度定义 |
| A ^ = A ∣ A ∣ \hat{\mathbf{A}} = \frac{\mathbf{A}}{|\mathbf{A}|} A^=∣A∣A | L2 归一化 |
| A ^ ⋅ B ^ = cos ( θ ) \hat{\mathbf{A}} \cdot \hat{\mathbf{B}} = \cos(\theta) A^⋅B^=cos(θ) | 归一化后点积 = 余弦 |
| ∣ A − B ∣ = 2 − 2 cos ( θ ) |\mathbf{A} - \mathbf{B}| = \sqrt{2 - 2\cos(\theta)} ∣A−B∣=2−2cos(θ) | 归一化空间中欧氏距离与余弦的转换 |
| d cos = 1 − cos ( θ ) d_{\cos} = 1 - \cos(\theta) dcos=1−cos(θ) | 余弦距离(越小越相似) |
7.3 决策流程
需要计算向量相似度?
├── 模长有物理意义吗?
│ ├── 是 → 用欧氏距离
│ └── 否 → 用余弦相似度
├── 向量数量 > 10万?
│ ├── 是 → 预归一化 + ANN 索引(HNSW/IVF)
│ └── 否 → 预归一化 + 暴力点积
├── 检索延迟 < 10ms?
│ ├── 是 → HNSW + 大 ef_search
│ └── 否 → IVF + 调节 nprobe
└── 需要模长语义?
└── 是 → 用点积(不归一化)
7.4 余弦相似度在 AI 技术栈中的位置
余弦相似度是连接 Embedding 模型和下游应用的桥梁:
原始文本 → [Embedding 模型] → 向量 → [L2 归一化] → [ANN 索引] → [余弦检索] → Top-K 结果
无论是在 RAG 系统中的知识检索、推荐系统中的相似物品推荐、还是客服系统中的工单分发,余弦相似度都是基础设施级别的核心组件。理解它的原理、优化方式和陷阱,是构建高效向量检索系统的前提。
一句话总结 :预归一化让余弦相似度退化为点积,这是从 O ( n ) O(n) O(n) 除法到 O ( n ) O(n) O(n) 纯乘加的关键优化;ANN 索引让全量搜索从 O ( N ) O(N) O(N) 降到 O ( log N ) O(\log N) O(logN),两者叠加支撑了百万级向量库的毫秒级检索。