在构建大规模向量数据库(如使用Faiss或Milvus)时,选择合适的相似性度量标准至关重要。常见的度量包括内积(Inner Product, IP)和欧氏距离(L2 Distance)。当向量被归一化(即其L2范数等于1)时,IP和L2距离(或余弦相似度)之间存在直接的数学转换,并且计算效率几乎一致。然而,在实际应用中,特别是在某些推荐系统或知识图谱嵌入场景,我们可能选择使用未归一化的向量。
本文将深入分析在向量未归一化时,IP和L2在底层计算效率上的细微差异,并给出实操建议。
1. 未归一化向量下的数学关系
首先回顾欧氏距离($D_{L2}$)和内积($D_{IP}$)的平方关系。假设有两个向量 $A$ 和 $B$:
- 内积 (IP): $D_{IP}(A, B) = A \cdot B$
- 欧氏距离平方 (L2 squared): $D_{L2}(A, B)^2 = \sum_{i} (a_i – b_i)^2$
展开 $D_{L2}(A, B)^2$:
$$
D_{L2}(A, B)^2 = \sum (a_i^2 + b_i^2 – 2a_i b_i) = \sum a_i^2 + \sum b_i^2 – 2 \sum a_i b_i
$$
可以简化为范数(能量)和内积的形式:
$$
D_{L2}(A, B)^2 = ||A||^2 + ||B||^2 – 2(A \cdot B)
$$
核心发现: 无论向量是否归一化,计算L2距离的平方,都需要先计算内积 $A \cdot B$。
2. 底层计算效率分析
相似性搜索的效率主要由两个因素决定:
- 核心计算: 批量计算查询向量 $Q$ 与数据库中所有向量 $D$ 的相似性得分。这一步通常是瓶颈。
- 辅助计算: 涉及范数(能量)的计算和存储。
2.1 核心操作的FLOPs对比
假设向量维度为 $D$。对于大规模向量搜索,我们通常使用矩阵乘法(GEMM)来并行计算。查询矩阵 $Q$ ($N_Q \times D$) 与数据库矩阵 $D$ ($N_D \times D$) 的相似性矩阵 $S$ ($N_Q \times N_D$) 的计算过程如下:
IP (内积):
$$S_{IP} = Q \cdot D^T$$
- FLOPs: 约为 $2 N_Q N_D D$ (乘法和加法)。这是最纯粹、最高效的BLAS Level 3操作。
L2 (欧氏距离):
要计算 $S_{L2}$,我们必须计算 $Q \cdot D^T$,以及 $Q$ 和 $D$ 中每个向量的范数平方。
- 计算 $Q \cdot D^T$: $2 N_Q N_D D$ FLOPs。
- 计算 $||Q||^2$: $N_Q$ 个向量,每个需要 $2D$ FLOPs。总计 $2 N_Q D$ FLOPs。
- 计算 $||D||^2$: $N_D$ 个向量,总计 $2 N_D D$ FLOPs。
- 最终L2转换: 使用公式 $||A||^2 + ||B||^2 – 2(A \cdot B)$ 进行逐元素的操作。这涉及到 $N_Q N_D$ 次加法和乘法。
2.2 效率差异的实践分析
从计算复杂度上看,IP 和 L2 的核心瓶颈都在于 $Q \cdot D^T$,复杂度为 $O(N_Q N_D D)$。其余范数计算和元素级转换的复杂度是 $O(N_Q D + N_D D + N_Q N_D)$。
当 $D$ 很大时,$D_{L2}$ 比 $D_{IP}$ 多出的 $O(N_Q D + N_D D)$ 范数计算是主要的额外开销。
然而,在实际部署中,L2 的开销可以被有效消除:
- 数据库向量 $D$ 的范数 $||D||^2$ 是静态的,可以预先计算并存储在索引中。
- 查询向量 $Q$ 的范数 $||Q||^2$ 必须在每次查询时计算,但由于 $N_Q$ 通常很小(通常为1),这部分计算可以忽略不计 ($O(D)$)。
结论: 只要预先存储了数据库向量的范数,L2 和 IP 的主要计算成本都是相同的矩阵乘法 $Q \cdot D^T$。L2 只是在结果矩阵上多了一次低维度的加减操作。
效率排名(未归一化,且范数已预计算):
$$D_{IP} \approx D_{L2} \gg \text{Cosine Similarity (未归一化)}$$
注意: 如果使用未归一化向量计算 Cosine Similarity,你需要计算 $A \cdot B / (||A|| \cdot ||B||)$,这引入了开方和除法操作,比纯粹的IP或L2的效率要低。
3. Python/Numpy 实践验证
我们使用 Numpy 来模拟 Faiss 等库在底层执行的操作,验证内积是 L2 计算的基础。
import numpy as np
import time
# 假设向量维度为D=128
D = 128
# 数据库中向量数量 N_D=10000
N_D = 10000
# 查询向量数量 N_Q=5
N_Q = 5
# 1. 生成未归一化的随机数据
np.random.seed(42)
DB = np.random.rand(N_D, D).astype(np.float32) * 10
Query = np.random.rand(N_Q, D).astype(np.float32) * 10
# 2. 预计算数据库向量的范数平方
DB_norms_sq = np.sum(DB**2, axis=1, keepdims=True) # 形状 (N_D, 1)
Query_norms_sq = np.sum(Query**2, axis=1, keepdims=True) # 形状 (N_Q, 1)
print(f"DB norms calculated. Shape: {DB_norms_sq.shape}")
# --- 核心计算:内积矩阵 ---
# 无论计算IP还是L2,这一步必须执行
start_time = time.time()
IP_Matrix = Query @ DB.T
core_time = time.time() - start_time
print(f"1. Core IP calculation time: {core_time:.5f}s")
# --- L2 Distance Calculation (基于IP矩阵) ---
start_time = time.time()
# L2^2 = ||Q||^2 + ||DB||^2 - 2 * IP
# 需要进行广播操作
L2_sq_Matrix = Query_norms_sq + DB_norms_sq.T - 2 * IP_Matrix
# 由于浮点数精度问题,可能出现极小的负数,需要修正
L2_sq_Matrix[L2_sq_Matrix < 0] = 0
L2_Matrix = np.sqrt(L2_sq_Matrix)
l2_time = time.time() - start_time
print(f"2. L2 conversion time (after IP): {l2_time:.5f}s")
# 验证:直接计算的L2(为了验证正确性,效率较低)
# L2_Verify = np.zeros((N_Q, N_D))
# for i in range(N_Q):
# L2_Verify[i] = np.linalg.norm(DB - Query[i], axis=1)
# print(f"Max L2 difference: {np.max(np.abs(L2_Matrix - L2_Verify)):.5e}")
# 总结时间开销
print(f"Total L2 (Optimized) time: {core_time + l2_time:.5f}s")
运行结果分析:
在上述示例中,core_time 是计算内积矩阵的时间,这是计算效率的主要决定因素。l2_time 是将内积转换为 L2 距离的额外时间,这是一个极快的 $O(N_Q N_D)$ 元素级操作,相对于核心 $O(N_Q N_D D)$ 的矩阵乘法可以忽略不计。
4. 结论与实操建议
-
IP vs. L2 的计算效率: 在未归一化向量下,只要数据库范数 $||D||^2$ 能够预先计算并存储,IP 和 L2 的计算效率在核心矩阵乘法上是相同的。 转换到 L2 距离所需的额外 $O(N_Q N_D)$ 操作开销极小。
-
工程选择: 如果您的相似性标准需要考虑向量的“能量”(即向量的长度,例如,在推荐系统中,长度可能代表用户的活跃度或物品的流行度),请使用 L2 距离。L2 距离反映的是几何空间中的绝对距离。
-
如果仅关心方向: 如果向量长度没有物理意义,而您只关心它们的方向相似性,那么应该强制对向量进行L2归一化,然后使用 IP (即余弦相似度)。归一化操作虽然增加了 $O(D)$ 的预处理时间,但保证了度量的物理意义正确性,并且能进一步简化计算。
-
Faiss 实现: Faiss 等库在处理 L2 索引时,内部优化正是利用了 $D_{L2}^2$ 与 $D_{IP}$ 的关系,将计算复杂度降到最低,使其与纯 IP 索引的效率非常接近。
汤不热吧