向量数据库实战(Milvus/Chroma)
从 Embedding 的第一个维度到 RAG 系统的最后一公里——手把手带你玩转向量数据库。
3. 相似度搜索——向量数据库的核心能力
上一章我们学会了把文本变成向量。现在问题来了:有了一堆向量,怎么从中找到最相似的那几个?
这就是向量数据库存在的根本价值——相似度搜索。它要解决两个核心问题:
- 怎么定义"相似"? → 距离度量(本节)
- 怎么快速找到? → 索引算法(第 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.00import 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 + 阈值过滤
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-K | RAG 推荐 3-5,搜索引擎 10-20,推荐系统 20-100 |
| 相似度阈值 | 过滤低质量结果,RAG 建议 ≥ 0.6,严格场景 ≥ 0.8 |
下一章预告:索引算法揭秘——HNSW、IVF、DiskANN。我们会深入三大主流 ANN 索引算法的工作原理,用直觉而非数学来理解它们是怎么做到"不全看也能找到"的,以及在什么场景下该选哪种索引。