把距离换到匹配时踩过的坑
最开始的想法是:用关键词匹配,比如 TF-IDF 或者 BM25。
事情是这样的,最近在做内容推荐功能。
为什么要折腾相似度搜索
事情是这样的,最近在做内容推荐功能。用户看完一篇文章,系统需要找出"相似"的其他文章推给用户。听起来简单,但一旦涉及几百万篇文档,传统的方法就不灵了。
最开始的想法是:用关键词匹配,比如 TF-IDF 或者 BM25。但问题是,关键词匹配太"硬"了——“机器学习"和"深度学习"在语义上很接近,但关键词匹配可能认为它们完全不同。而且用户搜索"猫”,系统应该返回"猫咪"、“小猫"这些词,但关键词匹配可能不行。
所以需要一种更智能的方式:把文本转换成向量(vector),然后计算向量之间的"相似度”。这就是向量相似度搜索的由来。
需求到底是什么
具体来说,需求是这样的:
- 准确度:语义相近的内容应该被认为是相似的
- 性能:要在几百毫秒内从百万级数据中找到最相似的几十条
- 内存友好:不能把几百万个向量全加载到内存里
- 易维护:代码要简单,新人能看懂
一开始我想,那不就是算一下余弦相似度吗?有什么难的。结果一上手就发现事情没那么简单。
核心概念:向量相似度到底是什么
先说点基础。向量就是一串数字,比如 [0.5, 0.3, 0.2, 0.8]。把文本转换成向量后,每个维度代表一些"特征"(比如某些词的重要性、某些语义信息等)。
相似度就是两个向量"靠近"的程度。常见的计算方法有:
余弦相似度
最常用的是余弦相似度,它计算的是两个向量的夹角:
cosine_similarity(A, B) = (A · B) / (||A|| * ||B||)
用人话说:两个向量方向越一致,相似度越高。取值范围是 [-1, 1],1 表示完全相同,-1 表示完全相反,0 表示正交(没有关系)。
余弦相似度的优点是不关注向量长度。比如两个文档长度不同,但内容相似,余弦相似度还是能识别出来。这在文本场景下很重要。

欧氏距离
欧氏距离就是两个点的直线距离:
euclidean_distance(A, B) = sqrt(Σ(A[i] - B[i])²)
距离越小,相似度越高。但欧氏距离受向量长度影响,所以在文本场景用得少。
其他度量方法
- 曼哈顿距离:城市街区距离,计算的是各维度差的绝对值之和
- 汉明距离:用于二进制向量,计算有多少位不同
实现方案:从暴力到优化
第一版:暴力计算(教训的开始)
最开始写了个"暴力版",思路很简单:把所有向量存到列表里,每次查询时遍历一遍,算余弦相似度,取前 K 个。
import numpy as np
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def brute_force_search(query_vec, all_vectors, top_k=10):
similarities = []
for idx, vec in enumerate(all_vectors):
sim = cosine_similarity(query_vec, vec)
similarities.append((idx, sim))
similarities.sort(key=lambda x: x[1], reverse=True)
return similarities[:top_k]
看起来很简单,但问题来了:
- 性能太慢:如果有 100 万个向量,每个向量 512 维,查询一次要算 100 万次点积和归一化,至少要几秒。这在生产环境不可接受。
- 内存吃紧:100 万个 512 维向量,每个 4 字节(float32),需要 2GB 内存。还行,但要是到 1000 万呢?
第二版:使用 Faiss(开源救星)
后来同事推荐了 Facebook 的 Faiss 库,这是个专门做向量相似度搜索的库。试了一下,效果立竿见影。
Faiss 的核心思想是"近似最近邻搜索"(ANN),不是精确找到最相似的,而是"差不多"最相似的,但速度快很多。
最简单的用法:
import faiss
import numpy as np
# 假设有 100 万个 512 维向量
dimension = 512
n_vectors = 1_000_000
vectors = np.random.random((n_vectors, dimension)).astype('float32')
# 构建 IndexFlatIP(内积索引,等价于余弦相似度)
index = faiss.IndexFlatIP(dimension)
index.add(vectors)
# 查询
query = np.random.random((1, dimension)).astype('float32')
distances, indices = index.search(query, k=10) # 返回前 10 个最相似的
这个版本快很多,查询时间从几秒降到了几十毫秒。但还是有个问题:所有向量都在内存里,内存占用还是大。
第三版:使用 IVFFlat 索引(进一步优化)
Faiss 提供了一种叫 IVFFlat 的索引,它把向量分成若干个"桶"(cluster),查询时只搜索最近的几个桶。

nlist = 100 # 分成 100 个桶
quantizer = faiss.IndexFlatIP(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
# 先训练索引(聚类)
index.train(vectors)
index.add(vectors)
# 查询时设置搜索的桶数
index.nprobe = 10 # 只搜索最近的 10 个桶
distances, indices = index.search(query, k=10)
这样速度更快了,但精度略有损失。实际测试中,前 10 个结果里可能有 8-9 个和暴力搜索一致,够用了。
踩过的坑
坑 1:向量归一化
一开始没注意,发现 Faiss 的 IndexFlatIP 返回的结果和手动算的不一样。查了半天才知道,Faiss 的 IndexFlatIP 计算的是内积,不是余弦相似度。如果向量没有归一化,内积和余弦相似度是不一样的。
解决方案:在加入索引前,把所有向量归一化:
def normalize_vectors(vectors):
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / norms
vectors = normalize_vectors(vectors)
坑 2:IVFFlat 的训练问题
第一次用 IVFFlat,直接 index.add(vectors),结果报错说索引没训练。查文档才知道,IVFFlat 需要先 train,它会用 K-means 聚类算法把向量分成若干个桶。
而且训练数据不能太少,否则聚类效果不好。建议至少用几千个向量来训练。
# 确保 vectors 足够多(至少几千个)
if len(vectors) < 1000:
# 用一部分重复数据补足
extra_vectors = np.tile(vectors, (1000 // len(vectors) + 1, 1))
index.train(extra_vectors[:1000])
else:
index.train(vectors)
index.add(vectors)
坑 3:精度与速度的平衡
IVFFlat 的 nprobe 参数很重要,它控制搜索多少个桶。
nprobe=1:最快,但精度差nprobe=nlist:最慢,但精度接近暴力搜索
我的经验是:从 nprobe=10 开始试,根据实际需求调整。如果要求高精度,可以调大;如果要求速度快,可以调小。
坑 4:向量维度太大
有一次用了 1024 维的向量,结果内存爆炸。后来查了资料才知道,维度太高会带来两个问题:
- 维度灾难:高维空间中,几乎所有点都是等距的,很难找到"最近邻"
- 内存占用大:1024 维向量比 512 维多一倍内存
解决方案:
- 降低向量维度(比如从 1024 降到 512)
- 使用 PCA 或其他降维方法
- 如果用 BERT,可以不用取所有隐藏层,只取一部分
完整流程:从文本到相似度搜索
下面用流程图展示整个相似度搜索的流程:
代码示例:一个完整的流程
下面是一个完整的例子,从文本到向量再到相似度搜索:
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
# 1. 加载文本编码模型(把文本转成向量)
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
# 2. 准备数据
documents = [
"机器学习是人工智能的一个子领域",
"深度学习是机器学习的一种方法",
"猫是一种可爱的动物",
"猫咪非常受欢迎",
"深度学习用神经网络解决问题",
]
# 3. 把文本转成向量
vectors = model.encode(documents, convert_to_numpy=True)
# 4. 归一化向量
def normalize_vectors(vectors):
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / norms
vectors = normalize_vectors(vectors)
# 5. 构建 Faiss 索引
dimension = vectors.shape[1]
nlist = min(10, len(vectors)) # 桶数不能超过数据量
quantizer = faiss.IndexFlatIP(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
index.train(vectors)
index.add(vectors)
# 6. 查询
query_text = "神经网络"
query_vec = model.encode([query_text], convert_to_numpy=True)
query_vec = normalize_vectors(query_vec)
index.nprobe = min(5, nlist) # 搜索最近的 5 个桶
distances, indices = index.search(query_vec, k=3)
# 7. 输出结果
print(f"查询: {query_text}")
for i, (dist, idx) in enumerate(zip(distances[0], indices[0])):
print(f" {i+1}. {documents[idx]} (相似度: {dist:.3f})")
输出示例:
查询: 神经网络
1. 深度学习用神经网络解决问题 (相似度: 0.856)
2. 深度学习是机器学习的一种方法 (相似度: 0.723)
3. 机器学习是人工智能的一个子领域 (相似度: 0.698)
可以看到,“神经网络"和"深度学习"的语义最接近,所以匹配到了相关文档。
性能对比:不同方法的速度
我在自己的机器上测试了几种方法的速度(100 万个 512 维向量):
| 方法 | 查询时间 | 内存占用 | 精度(Top-10 命中率) |
|---|---|---|---|
| 暴力搜索 | 3.2 秒 | 2GB | 100% |
| IndexFlatIP | 45 毫秒 | 2GB | 100% |
| IndexIVFFlat (nprobe=10) | 15 毫秒 | 2GB | 92% |
| IndexIVFFlat (nprobe=5) | 8 毫秒 | 2GB | 85% |
可以看到,IVFFlat 在速度上有明显优势,但精度略有损失。实际业务中可以根据需求权衡。
总结与反思
折腾了一圈,总结一下心得:
- 不要重新造轮子:Faiss 这种成熟的开源项目已经优化了很多细节,自己从零写很难达到同样效果。
- 理解原理很重要:虽然可以直接调 API,但理解余弦相似度、聚类算法这些原理,能帮你更好地调参和排错。
- 权衡精度与速度:实际业务中,不一定要 100% 精确,92% 精度但快 3 倍,可能更有价值。
- 归一化是关键:用余弦相似度时,一定要记得归一化,否则结果会偏差很大。
相似度搜索这个领域还有很多可以探索的,比如:
- HNSW 索引:一种更快的索引结构,精度也更好
- 混合搜索:结合向量相似度和关键词匹配
- 实时更新:如何动态添加或删除向量
- GPU 加速:Faiss 支持 GPU,速度更快
如果你也在做推荐、搜索或内容匹配,希望这篇文章能帮你少走点弯路。毕竟,从"想到"到"做到”,中间有很多坑要踩。
补充资料
如果你觉得这篇文章有帮助,或者有什么问题想交流,欢迎留言讨论。
版权声明: 本文首发于 指尖魔法屋-把距离换到匹配时踩过的坑(https://blog.thinkmoon.cn/post/364-ai-similarity-search-distance-match-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。