Skip to content

向量数据库实战(Milvus/Chroma)

从 Embedding 的第一个维度到 RAG 系统的最后一公里——手把手带你玩转向量数据库。


3. 相似度搜索——向量数据库的核心能力

上一章我们学会了把文本变成向量。现在问题来了:有了一堆向量,怎么从中找到最相似的那几个

这就是向量数据库存在的根本价值——相似度搜索。它要解决两个核心问题:

  1. 怎么定义"相似"? → 距离度量(本节)
  2. 怎么快速找到? → 索引算法(第 4 章)

3.1 三种距离度量:余弦、欧氏、内积

"两个向量有多相似"——这个问题的答案取决于你用什么方式来度量距离。主流有三种。

余弦相似度(Cosine Similarity)

最常用的度量方式,也是大多数 AI 应用的默认选择

余弦相似度——只看方向,不看长度:

  核心思想:
    两个向量夹角越小 → 方向越一致 → 语义越相似

  数学定义:
    cos(A, B) = (A · B) / (‖A‖ × ‖B‖)

      A · B    = 向量点积(对应维度相乘再求和)
      ‖A‖      = 向量的模(长度)
      结果范围  = [-1, 1]

  直觉理解(2D 示意):


               /│
         B → / │
             /  │     cos = 1.0  → 完全相同方向
            /   │     cos = 0.0  → 完全正交(无关)
           / θ  │     cos = -1.0 → 完全相反
    A → ──/─────┼────▶


  实际例子:
    "Python 性能优化"  ↔  "让程序跑得更快"   → cos ≈ 0.85
    "Python 性能优化"  ↔  "今天午饭吃什么"   → cos ≈ 0.12
    "Python 性能优化"  ↔  "Python 性能优化"  → cos = 1.00
python
import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# 余弦相似度的特点:只看方向
a = np.array([1, 2, 3])
b = np.array([2, 4, 6])  # a 的 2 倍,方向完全相同

print(cosine_similarity(a, b))  # 1.0 → 完全相似
# 虽然长度差一倍,但方向一致 → 余弦相似度 = 1

为什么余弦最常用:Embedding 模型生成的向量,方向编码了语义信息,而长度往往没有明确含义。所以只看方向(余弦)比看绝对距离(欧氏)更合理。

欧氏距离(Euclidean Distance / L2)

最直觉的距离度量——空间中两点之间的直线距离。

欧氏距离——既看方向,也看长度:

  数学定义:
    L2(A, B) = √(Σ(ai - bi)²)

  直觉理解(2D 示意):


      │     ● B (3, 4)
      │   ╱
      │ ╱  d = √((3-1)² + (4-1)²) = √13 ≈ 3.61
      │╱
    A ●(1, 1)
      └──────────────▶

  值域:[0, +∞)
    → 0 表示完全相同
    → 越大表示越不相似
    → 注意:和余弦相反,这里是"越小越相似"

  与余弦的关键区别:
  ─────────────────────────────────
    余弦:只看角度 → [1, 2] 和 [100, 200] 相似度 = 1
    欧氏:看绝对距离 → [1, 2] 和 [100, 200] 距离 = 139.7

    → 当向量已归一化(长度 = 1)时,两者等价
    → 大多数 Embedding 模型会做归一化,所以实际差别不大

内积(Inner Product / IP)

内积——余弦的"不归一化版本":

  数学定义:
    IP(A, B) = Σ(ai × bi) = A · B

  与余弦的关系:
    cosine(A, B) = IP(A, B) / (‖A‖ × ‖B‖)

    → 如果向量已经做了 L2 归一化(‖A‖ = ‖B‖ = 1)
    → 那么 IP(A, B) = cosine(A, B)
    → 这就是为什么很多系统推荐先归一化,再用内积

  优势:
    → 计算比余弦快(少了两次求模的开销)
    → 归一化后结果等价
    → 大规模场景下性能差异明显

三者对比速查

度量方式值域越大越像?考虑长度?计算速度推荐场景
余弦相似度[-1, 1]✅ 是❌ 否中等默认选择,语义搜索
欧氏距离[0, +∞)❌ 否(越小越像)✅ 是中等聚类、空间分析
内积(-∞, +∞)✅ 是✅ 是最快已归一化的向量
实际选择很简单:

  你的向量已经归一化了吗?(大多数模型默认会)
  ├── 是 → 用内积(IP),速度最快,结果等同于余弦
  └── 否 → 用余弦(Cosine),自动处理长度差异

  在 Milvus / Chroma 中指定度量方式:
    Milvus:  metric_type = "IP" / "L2" / "COSINE"
    Chroma:  distance_fn = "cosine" / "l2" / "ip"

一句话建议:90% 的场景用余弦相似度就对了。如果你用的 Embedding 模型会自动归一化(如 BGE 设置 normalize_embeddings=True),可以切换到内积来提升搜索速度。

3.2 从暴力搜索到 ANN:为什么需要索引

知道了怎么计算两个向量的相似度,最朴素的搜索方法就是——把所有向量都算一遍,取最相似的 K 个。

暴力搜索(Brute Force)

暴力搜索的工作方式:

  数据库中有 N 个向量,用户查询 1 个向量

  ┌─────────────────────────────────────────────┐
  │ 查询向量 Q:[0.82, 0.15, 0.93, ...]         │
  │                                             │
  │ 逐个计算距离:                                │
  │   Q ↔ 向量 1:  cos = 0.31  │               │
  │   Q ↔ 向量 2:  cos = 0.87  │ ← 记录       │
  │   Q ↔ 向量 3:  cos = 0.15  │               │
  │   Q ↔ 向量 4:  cos = 0.92  │ ← 记录       │
  │   ......                    │               │
  │   Q ↔ 向量 N:  cos = 0.44  │               │
  │                                             │
  │ 排序,取 Top-K                               │
  └─────────────────────────────────────────────┘

  时间复杂度:O(N × D)
    N = 向量总数
    D = 向量维度

小数据量下,暴力搜索完全够用。但当数据量上来后:

暴力搜索的性能灾难:

  数据规模         维度    单次查询计算量         预期耗时
  ─────────────────────────────────────────────────────
  1 万条           1024    1024 万次乘法          ~1ms  ✅
  10 万条          1024    1.02 亿次乘法          ~10ms ✅
  100 万条         1024    10.2 亿次乘法          ~100ms ⚠️
  1000 万条        1024    102 亿次乘法           ~1s   ❌
  1 亿条           1024    1024 亿次乘法          ~10s  ❌❌

  问题:
    → 100 万条时延迟已经到 100ms,勉强可用
    → 1000 万条时 1 秒,用户已经等不了了
    → 如果还要支持 100 QPS 并发?完全不可能

  结论:暴力搜索是 O(N),数据量翻 10 倍,搜索时间也翻 10 倍

ANN:用精度换速度

ANN(Approximate Nearest Neighbor,近似最近邻) 是向量数据库的核心武器——它不保证找到"绝对最相似"的结果,但能做到"非常相似"且快几个数量级

暴力搜索 vs ANN 搜索:

  暴力搜索(精确):
    → 遍历所有 N 个向量
    → 保证找到 Top-K 最精确的结果
    → 时间:O(N × D)

  ANN 搜索(近似):
    → 只看"可能相关"的一小部分向量
    → 结果可能不是绝对最优,但 95%+ 的情况下一样好
    → 时间:O(log N) 或 O(√N)

  性能对比(1000 万条 1024 维向量):
  ┌──────────────────┬──────────┬──────────┐
  │                  │ 暴力搜索  │ ANN 搜索  │
  ├──────────────────┼──────────┼──────────┤
  │ 搜索时间          │ ~1s      │ ~1ms     │
  │ 速度提升          │ 基准      │ 1000x    │
  │ 召回率            │ 100%     │ 95-99%   │
  │ 需要索引?         │ 不需要   │ 需要      │
  └──────────────────┴──────────┴──────────┘

  → 牺牲 1-5% 的精度,换来 1000 倍的速度提升
  → 这个交易太划算了

ANN 的核心思想

ANN 是怎么做到"不全看也能找到"的?

  核心思路:提前建立一个"地图"(索引),
  搜索时只看地图上"附近区域"的向量

  类比:
  ┌───────────────────────────────────────────┐
  │ 暴力搜索 = 找一本书                         │
  │   → 把图书馆所有书翻一遍                     │
  │   → 一定能找到,但太慢了                     │
  │                                           │
  │ ANN 搜索 = 先看图书分类系统                  │
  │   → 要找编程书?直接去"计算机"区             │
  │   → 再在"计算机"区的"Python"书架上找          │
  │   → 可能漏掉放错位置的书,但 99% 能找到      │
  │   → 速度快了 100 倍                        │
  └───────────────────────────────────────────┘

  不同的 ANN 算法 = 不同的"分类系统"设计:
    • HNSW  → 多层高速公路网络(第 4 章详解)
    • IVF   → 先聚类分区,再区内搜索
    • DiskANN → 磁盘友好的图索引

核心权衡:ANN 搜索的本质是精度 vs 速度的权衡。通过调整索引参数,你可以在"极快但稍不精确"和"稍慢但更精确"之间自由调节。在实际应用中,99% 的召回率已经远远够用——用户根本感知不到那 1% 的差异。

3.3 Top-K 检索与相似度阈值

有了距离度量和 ANN 索引,接下来要解决实际使用中的两个问题:返回多少条结果结果质量怎么控制

Top-K:返回最相似的 K 个结果

Top-K 检索的工作流程:

  用户问:"Python 怎么做异步编程?"

    ├── 1. 问题向量化 → Q = [0.72, -0.31, ...]

    ├── 2. 在向量数据库中搜索 Top-5

    └── 3. 返回结果(按相似度降序):
         ┌────┬────────────────────────┬──────────┐
         │ #  │ 文档内容                │ 相似度    │
         ├────┼────────────────────────┼──────────┤
         │ 1  │ asyncio 协程教程        │ 0.92     │
         │ 2  │ Python 并发编程指南      │ 0.87     │
         │ 3  │ async/await 实战        │ 0.85     │
         │ 4  │ 多线程 vs 异步的对比     │ 0.78     │
         │ 5  │ FastAPI 异步请求处理     │ 0.71     │
         └────┴────────────────────────┴──────────┘

  → K 越大,返回的结果越多,覆盖面越广
  → K 越小,结果越精准,但可能漏掉相关内容

K 值怎么选

K 值的选择取决于你的应用场景:

  RAG 知识库问答:
  ─────────────────────────────────────
    → 推荐 K = 3~5
    → 太多了反而会塞入不相关的内容,干扰 LLM 生成
    → 每个 chunk 约 500 字,5 个 = 2500 字
    → 加上系统提示和用户问题,刚好在模型上下文窗口内

  语义搜索引擎:
  ─────────────────────────────────────
    → 推荐 K = 10~20
    → 用户可以自己翻页浏览
    → 类似 Google 每页显示 10 条结果

  推荐系统:
  ─────────────────────────────────────
    → 推荐 K = 20~100
    → 后续还要经过业务规则过滤和重排序
    → 需要足够大的候选池

  异常检测:
  ─────────────────────────────────────
    → 推荐 K = 1~3
    → 只关心"最相似的正常模式"是什么
    → 偏离度大于阈值就报警

相似度阈值:过滤低质量结果

Top-K 有一个问题——它一定会返回 K 条结果,即使所有结果的相似度都很低:

没有阈值过滤的问题:

  用户问:"量子计算的最新进展?"
  你的文档库里全是 Python 教程,完全没有量子计算的内容

  Top-3 结果(无阈值):
  ┌────┬──────────────────────┬──────────┐
  │ #  │ 文档内容              │ 相似度    │
  ├────┼──────────────────────┼──────────┤
  │ 1  │ Python 科学计算       │ 0.23     │ ← 不相关
  │ 2  │ NumPy 矩阵运算        │ 0.21     │ ← 不相关
  │ 3  │ 数据结构与算法         │ 0.18     │ ← 不相关
  └────┴──────────────────────┴──────────┘

  → 虽然返回了 3 条,但没有一条真正相关
  → 如果直接喂给 LLM,会生成基于不相关文档的"幻觉"答案

  加上阈值过滤(threshold = 0.6):
  ┌────┬──────────────────────┬──────────┐
  │ #  │ 文档内容              │ 相似度    │
  ├────┼──────────────────────┼──────────┤
  │    │ (无结果通过阈值)      │          │
  └────┴──────────────────────┴──────────┘

  → 0 条结果返回
  → 系统可以回复:"抱歉,知识库中暂无相关内容"
  → 比瞎编一个答案好多了

阈值怎么设

相似度阈值的经验参考(余弦相似度):

  相似度范围        含义              建议
  ─────────────────────────────────────────
  0.85 - 1.00      高度相关          高置信度返回
  0.70 - 0.85      比较相关          可以返回
  0.50 - 0.70      有些关联          谨慎返回
  0.30 - 0.50      关联很弱          建议过滤
  0.00 - 0.30      基本无关          必须过滤

  常用阈值设置:
    • 严格场景(医疗、法律):threshold ≥ 0.80
    • 通用问答(RAG):      threshold ≥ 0.60
    • 宽泛搜索(探索性):    threshold ≥ 0.40

  ⚠️ 注意:这些阈值是经验值,不同 Embedding 模型的
  相似度分布不同,建议用自己的数据测试后微调

实战代码:Top-K + 阈值过滤

python
def search_with_threshold(
    query_embedding: list[float],
    collection,           # Chroma 或 Milvus 的 collection
    top_k: int = 5,
    threshold: float = 0.6
) -> list[dict]:
    """带阈值过滤的语义搜索"""

    # 1. 执行 Top-K 搜索
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=top_k
    )

    # 2. 过滤低于阈值的结果
    filtered = []
    for doc, distance in zip(results["documents"][0], results["distances"][0]):
        similarity = 1 - distance  # Chroma 返回的是距离,转换为相似度
        if similarity >= threshold:
            filtered.append({
                "content": doc,
                "similarity": round(similarity, 4)
            })

    # 3. 如果没有通过阈值的结果
    if not filtered:
        return [{"content": "知识库中暂无相关内容", "similarity": 0}]

    return filtered

实用建议:在 RAG 系统中,推荐使用 Top-K = 5 + threshold = 0.6 作为起步配置。然后根据实际测试数据微调——观察用户提问的命中率和 LLM 回答的质量,逐步找到最优值。


本章小结

知识点要点
余弦相似度只看方向不看长度,值域 [-1, 1],越大越相似,默认首选
欧氏距离看绝对距离,值域 [0, +∞),越小越相似
内积归一化后等同于余弦,计算更快,适合大规模场景
暴力搜索O(N×D) 复杂度,百万级以上不可用
ANN 搜索近似最近邻,牺牲 1-5% 精度换 1000 倍速度
Top-KRAG 推荐 3-5,搜索引擎 10-20,推荐系统 20-100
相似度阈值过滤低质量结果,RAG 建议 ≥ 0.6,严格场景 ≥ 0.8

下一章预告:索引算法揭秘——HNSW、IVF、DiskANN。我们会深入三大主流 ANN 索引算法的工作原理,用直觉而非数学来理解它们是怎么做到"不全看也能找到"的,以及在什么场景下该选哪种索引。

坚持是一种品格