把距离换到匹配时踩过的坑

最开始的想法是:用关键词匹配,比如 TF-IDF 或者 BM25。

事情是这样的,最近在做内容推荐功能。

为什么要折腾相似度搜索

事情是这样的,最近在做内容推荐功能。用户看完一篇文章,系统需要找出"相似"的其他文章推给用户。听起来简单,但一旦涉及几百万篇文档,传统的方法就不灵了。

最开始的想法是:用关键词匹配,比如 TF-IDF 或者 BM25。但问题是,关键词匹配太"硬"了——“机器学习"和"深度学习"在语义上很接近,但关键词匹配可能认为它们完全不同。而且用户搜索"猫”,系统应该返回"猫咪"、“小猫"这些词,但关键词匹配可能不行。

所以需要一种更智能的方式:把文本转换成向量(vector),然后计算向量之间的"相似度”。这就是向量相似度搜索的由来。

需求到底是什么

具体来说,需求是这样的:

  1. 准确度:语义相近的内容应该被认为是相似的
  2. 性能:要在几百毫秒内从百万级数据中找到最相似的几十条
  3. 内存友好:不能把几百万个向量全加载到内存里
  4. 易维护:代码要简单,新人能看懂

一开始我想,那不就是算一下余弦相似度吗?有什么难的。结果一上手就发现事情没那么简单。

核心概念:向量相似度到底是什么

先说点基础。向量就是一串数字,比如 [0.5, 0.3, 0.2, 0.8]。把文本转换成向量后,每个维度代表一些"特征"(比如某些词的重要性、某些语义信息等)。

相似度就是两个向量"靠近"的程度。常见的计算方法有:

余弦相似度

最常用的是余弦相似度,它计算的是两个向量的夹角:

cosine_similarity(A, B) = (A · B) / (||A|| * ||B||)

用人话说:两个向量方向越一致,相似度越高。取值范围是 [-1, 1],1 表示完全相同,-1 表示完全相反,0 表示正交(没有关系)。

余弦相似度的优点是不关注向量长度。比如两个文档长度不同,但内容相似,余弦相似度还是能识别出来。这在文本场景下很重要。

余弦相似度示意图:向量 A 和 B 方向接近,相似度高;向量 A 和 C 方向相反,相似度低

欧氏距离

欧氏距离就是两个点的直线距离:

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]

看起来很简单,但问题来了:

  1. 性能太慢:如果有 100 万个向量,每个向量 512 维,查询一次要算 100 万次点积和归一化,至少要几秒。这在生产环境不可接受。
  2. 内存吃紧: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),查询时只搜索最近的几个桶。

IVFFlat 索引结构:向量被分成多个聚类,查询时只搜索最近的几个聚类,大幅提升速度

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 维的向量,结果内存爆炸。后来查了资料才知道,维度太高会带来两个问题:

  1. 维度灾难:高维空间中,几乎所有点都是等距的,很难找到"最近邻"
  2. 内存占用大:1024 维向量比 512 维多一倍内存

解决方案

  • 降低向量维度(比如从 1024 降到 512)
  • 使用 PCA 或其他降维方法
  • 如果用 BERT,可以不用取所有隐藏层,只取一部分

完整流程:从文本到相似度搜索

下面用流程图展示整个相似度搜索的流程:

graph LR A[原始文本] --> B[文本编码<br/>Sentence Transformers] B --> C[向量数组] C --> D[向量归一化] D --> E[构建 Faiss 索引] E --> F[IVFFlat 训练] F --> G[索引就绪] G --> H[查询文本] H --> I[编码查询向量] I --> J[归一化查询向量] J --> K[Faiss 搜索] K --> L[返回 Top-K 相似结果] style A fill:#e1f5e1 style H fill:#e1f5e1 style E fill:#e3f2fd style K fill:#fff3e0 style L fill:#ffebee

代码示例:一个完整的流程

下面是一个完整的例子,从文本到向量再到相似度搜索:

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 秒2GB100%
IndexFlatIP45 毫秒2GB100%
IndexIVFFlat (nprobe=10)15 毫秒2GB92%
IndexIVFFlat (nprobe=5)8 毫秒2GB85%

可以看到,IVFFlat 在速度上有明显优势,但精度略有损失。实际业务中可以根据需求权衡。

总结与反思

折腾了一圈,总结一下心得:

  1. 不要重新造轮子:Faiss 这种成熟的开源项目已经优化了很多细节,自己从零写很难达到同样效果。
  2. 理解原理很重要:虽然可以直接调 API,但理解余弦相似度、聚类算法这些原理,能帮你更好地调参和排错。
  3. 权衡精度与速度:实际业务中,不一定要 100% 精确,92% 精度但快 3 倍,可能更有价值。
  4. 归一化是关键:用余弦相似度时,一定要记得归一化,否则结果会偏差很大。

相似度搜索这个领域还有很多可以探索的,比如:

  • HNSW 索引:一种更快的索引结构,精度也更好
  • 混合搜索:结合向量相似度和关键词匹配
  • 实时更新:如何动态添加或删除向量
  • GPU 加速:Faiss 支持 GPU,速度更快

如果你也在做推荐、搜索或内容匹配,希望这篇文章能帮你少走点弯路。毕竟,从"想到"到"做到”,中间有很多坑要踩。

补充资料

如果你觉得这篇文章有帮助,或者有什么问题想交流,欢迎留言讨论。

版权声明: 本文首发于 指尖魔法屋-把距离换到匹配时踩过的坑https://blog.thinkmoon.cn/post/364-ai-similarity-search-distance-match-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!