【AI 算法精讲 15】余弦相似度:向量检索与归一化内积

文章目录

  • [【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 场景:智能工单分发

以一个电商客服系统为例,每天有上万条工单流入,需要根据工单内容自动分发到正确的处理队列(支付组、物流组、售后组等)。

流程

  1. 离线阶段

    • 收集历史工单及其所属处理队列,构建训练集
    • 使用 Embedding 模型将工单文本向量化
    • 对所有向量做 L2 归一化,存入向量数据库
    • 构建 HNSW 索引
  2. 在线阶段

    • 新工单到达 → 文本向量化 → 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)索引。

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 索引,通过聚类划分空间:

  1. 用 K-Means 将向量空间划分为 K K K 个簇(cluster)
  2. 查询时找到最近的 n p r o b e n_{probe} nprobe 个簇
  3. 只在这些簇内做精确搜索
参数 含义 典型值 影响
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),两者叠加支撑了百万级向量库的毫秒级检索。

相关推荐
TPBoreas1 小时前
AI 架构图绘制 + Mermaid 实战:从痛点到 Diagram as Code
人工智能·ai
IT_陈寒1 小时前
React状态更新延迟搞晕我,原来是闭包在捣鬼
前端·人工智能·后端
weelinking1 小时前
weytoken微元算力企业级大模型治理平台深度评测
java·大数据·人工智能
dayuOK63071 小时前
可灵AI获近30亿美元融资,视频大模型赛道开启IPO竞赛
人工智能·ai作画·新媒体运营·aigc·ai写作
曦尧1 小时前
Terax:轻量级终端优先的 AI 原生开发工作区
ai·自动化
byte轻骑兵1 小时前
【AVRCP】规范精讲[39]:文件夹列表截断,MTU限制下的批量数据传输
人工智能·人机交互·avrcp·蓝牙耳机·蓝牙音频
yubajin1 小时前
【AI】我们没有护城河,OpenAI也没有
人工智能·语言模型
Mr.Daozhi1 小时前
从零构建 AI 学术论文助手(四):PDF.js 实时截图 + Gemini 视觉分析
javascript·人工智能·pdf·canvas·pdf.js·gemini
凡科建站2 小时前
价格便宜的ai建站平台有哪些?2026年价格便宜的ai建站平台推荐
人工智能