ZeroOne AI
← 返回文章列表

RAG 实战教程(三):向量数据库检索算法,KNN、IVF、HNSW 与 Faiss 实战

👁 6
分类:工业AI

本文讲解RAG检索环节的核心:向量检索算法。先厘清相似度算法与检索算法的区别,再逐一剖析KNN/ANN、暴力检索Flat、倒排索引IVF(nlist/nprobe)与分层可导航小世界图HNSW(M/efConstruction/efSearch)的原理与适用场景,并通过Faiss在同一套接口下对三种索引做构建时间、查询时间与Recall@10的实际对比,最后给出调参与项目选型建议。

把文档切分、向量化并写入向量数据库之后,接下来的关键环节就是检索。当知识库只有几十、几百条数据时,让查询向量与所有文档向量逐一计算距离完全没有问题;但当知识库膨胀到十万、百万级 Chunk 后,每次查询都做全量计算,速度会越来越慢。

因此,向量数据库除了要解决"如何计算两个向量的距离",还要解决另一个问题:怎样少算一些,又尽量不漏掉真正相关的结果。这就是向量检索算法要处理的事情。常见方案包括暴力检索(Flat)、IVF 和 HNSW。本文先把三种方案的原理讲清楚,再使用 Faiss 对三种索引做一次实际对比。

一、相似度算法和检索算法不是一回事

在深入算法之前,先分清两个容易混淆的概念:

举例来说,向量数据库里保存了 100 万个文档向量,余弦相似度可以告诉我们查询向量与其中某个向量是否接近,但它不会自动减少计算次数。想把这 100 万次比较缩小到几千次,就必须额外建立索引。

所以,一次完整的向量检索通常包含两部分:

  1. 使用索引找到一批可能相关的候选向量;
  2. 计算查询向量与候选向量之间的距离,返回 Top K 结果。

二、KNN 和 ANN 有什么区别

KNN(K-Nearest Neighbors) 即 K 近邻,查找距离查询向量最近的 K 个向量。最直接的做法是让查询向量与数据库中的每一个向量都计算一次距离,排序后取前 K 个。这种方式得到的是精确结果,也常被称为暴力检索或穷举检索。

ANN(Approximate Nearest Neighbor) 即近似最近邻。它不会扫描全部向量,而是通过提前建立的索引缩小搜索范围,因此有可能漏掉少量真实近邻,但查询速度会快很多。

这里的"近似"并不是随便返回几个差不多的结果。IVF 和 HNSW 都有对应的参数,可以在召回率查询速度之间调节:参数开得越大,搜索范围通常越广,结果越接近暴力检索,但耗时也会增加。

三、Flat:把所有向量都查一遍

Flat 是最容易理解的索引。查询进来后,它会依次计算查询向量与全部文档向量的距离,返回距离最近的 K 个结果。

假设数据库中有 N 个向量、每个向量 D 维,一次查询大致需要 N × D 规模的计算。数据量翻一倍,需要比较的向量数量也跟着翻一倍。

Flat 的优点:

它的缺点也很明显:数据量上来以后,每次都扫描全部向量,查询时间越来越长。因此 Flat 适合数据量较小、查询次数不多,或必须拿到精确近邻的场景。在测试 IVF 和 HNSW 时,也常常先用 Flat 算出标准结果,再检查近似检索找回了多少。

四、IVF:先分组,再到附近的组里查

IVF(Inverted File Index)中文一般叫倒排文件索引。它的思路可以理解为:提前给向量分组,查询时只进入附近的几个组。

1. 构建索引

IVF 通常使用 K-Means 对向量聚类,得到若干个中心点(质心),每个向量被分配到距离自己最近的质心下面,形成多个倒排列表。这里有一个重要参数 nlist,表示一共划分多少个列表。

例如数据库中有 10 万个向量,nlist=100 时,相当于把这些向量大致分进 100 个组(每个组数量不一定完全相同)。注意:IVF 在添加数据之前需要先执行 train(),训练的目的不是训练 Embedding 模型,而是根据样本向量找到这些质心

2. 查询索引

收到查询向量后,IVF 会先判断它靠近哪些质心,然后只扫描这些质心对应的倒排列表。控制扫描列表数量的参数叫 nprobe

假设 nlist=100、nprobe=5,一次查询只会进入最接近的 5 个列表做精确距离计算,其余 95 个列表不参与本次查询,计算量大幅减少。

不过这也带来了漏检的可能:真正的最近邻如果被分到了第 6 个列表,而本次查询只检查前 5 个列表,它就不会出现在结果中。

因此:nprobe 越大,召回率通常越高,查询也越慢。当 nprobe 接近 nlist 时,IVF 的扫描范围接近全量数据,速度优势逐渐消失。

3. IVF 适合什么情况

IVF 的索引结构清晰,搜索范围容易通过 nlist 和 nprobe 控制,适合数据量较大、可以接受离线训练索引的场景。它不是每加一条数据都必须重新训练——已有质心仍然可以接收新向量;但如果后来加入的数据与原始数据分布差异很大,原有聚类会越来越不均匀,这时就需要考虑重新训练和构建索引。

五、HNSW:沿着图中的近路逐层查找

HNSW(Hierarchical Navigable Small World)通常翻译为分层可导航小世界图,Chroma 默认使用的向量索引就属于这一类。

HNSW 把向量组织成一张多层图:最底层保存全部节点,越往上节点越少、连接越稀疏。查询时从最高层的入口点开始,沿着距离查询向量更近的节点移动,找到当前层较近的位置后,再下降到下一层继续搜索。

这个过程有点像查路线:从一个城市的某条街去另一个城市的某个小区,不会从家门口开始检查全国所有道路,而是先确定城市间路线,到达目标城市后再找城区和街道。HNSW 上层负责快速跨过较远区域,底层做更精细的搜索。

1. M

M 控制图中每个节点建立多少条连接。M 较大时,一个节点可以通向更多邻居,通常有利于召回率,但索引占用更多内存,构建时间也会增加。

2. efConstruction

efConstruction 控制构建索引时搜索候选邻居的范围。值越大,建立连接时考虑的候选节点越多,图质量通常更好,但创建索引更慢。它主要影响索引构建阶段

3. efSearch

efSearch 控制查询时保留和检查多少个候选节点。值越大,搜索范围越广,召回率通常越高,同时查询时间增加。如果发现 HNSW 的结果与 Flat 相差较多,通常可以先提高 efSearch 再测试。

HNSW 查询速度快、支持继续添加新向量,但它需要保存额外的图连接,内存占用通常高于 Flat 和 IVFFlat;在 Faiss 的 HNSW 实现中,删除向量也不是直接支持的操作。

六、三种检索方式放在一起看

对比项FlatIVFHNSW
检索类型精确检索近似检索近似检索
基本思路扫描全部向量聚类分组后扫描部分列表在多层图中沿邻居搜索
是否需要训练不需要需要训练质心不需要单独训练,但构建图较慢
构建速度中等通常较慢
查询速度数据越多越慢较快大规模数据下通常较快
内存占用保存原始向量原始向量 + 倒排信息原始向量 + 图连接
常用参数基本没有nlist、nprobeM、efConstruction、efSearch
适合场景小数据、精确结果、基准测试大批量数据、离线构建索引查询多、内存充足、需持续添加数据

这张表只说明大致差别。实际项目中,数据分布、向量维度、机器配置和参数都会影响结果,不能只根据算法名称判断谁一定更快。

七、使用 Faiss 对比 Flat、IVF 和 HNSW

Faiss 是一个用于稠密向量相似度搜索和聚类的库,提供多种索引,也支持把索引写入文件再重新读取。不过它本身不是完整的向量数据库,不负责文档字段、Metadata 过滤、权限和服务接口等功能。这里用 Faiss,是因为它能用同一套接口切换 Flat、IVF 和 HNSW,对比起来很方便。

图中的柱形只表示需要对三种索引进行比较,不代表固定测试结果,实际数据以本机运行为准。

先安装 CPU 版本:

pip install faiss-cpu numpy

下面准备 10 万个 128 维向量,再生成 100 个查询向量。三种索引使用完全相同的数据,Flat 的结果作为标准答案:

from time import perf_counter

import faiss
import numpy as np

DIM = 128
DATA_SIZE = 100_000
QUERY_SIZE = 100
TOP_K = 10

rng = np.random.default_rng(42)

# Faiss 接收 float32 类型的二维数组
database_vectors = rng.random(
    (DATA_SIZE, DIM), dtype=np.float32
)
query_vectors = rng.random(
    (QUERY_SIZE, DIM), dtype=np.float32
)

def recall_at_k(exact_ids, result_ids):
    """计算近似结果相对于 Flat 结果的平均 Recall@K。"""
    total = 0.0

    for exact, result in zip(exact_ids, result_ids):
        total += len(set(exact) & set(result)) / len(exact)

    return total / len(exact_ids)

def search_time(index, queries, k, repeat=5):
    """预热一次,并返回多次查询的中位数耗时。"""
    index.search(queries, k)
    times = []

    for _ in range(repeat):
        start = perf_counter()
        distances, ids = index.search(queries, k)
        times.append(perf_counter() - start)

    return float(np.median(times)), distances, ids

# 1. Flat:精确检索
start = perf_counter()
flat_index = faiss.IndexFlatL2(DIM)
flat_index.add(database_vectors)
flat_build_time = perf_counter() - start

flat_search_time, _, flat_ids = search_time(
    flat_index, query_vectors, TOP_K
)

# 2. IVF:聚类后只搜索部分倒排列表
nlist = 256
nprobe = 16

start = perf_counter()
quantizer = faiss.IndexFlatL2(DIM)
ivf_index = faiss.IndexIVFFlat(
    quantizer,
    DIM,
    nlist,
    faiss.METRIC_L2,
)
ivf_index.train(database_vectors)
ivf_index.add(database_vectors)
ivf_index.nprobe = nprobe
ivf_build_time = perf_counter() - start

ivf_search_time, _, ivf_ids = search_time(
    ivf_index, query_vectors, TOP_K
)

# 3. HNSW:使用多层图检索
M = 32
ef_construction = 100
ef_search = 64

start = perf_counter()
hnsw_index = faiss.IndexHNSWFlat(DIM, M)
hnsw_index.hnsw.efConstruction = ef_construction
hnsw_index.add(database_vectors)
hnsw_index.hnsw.efSearch = ef_search
hnsw_build_time = perf_counter() - start

hnsw_search_time, _, hnsw_ids = search_time(
    hnsw_index, query_vectors, TOP_K
)

print(
    f"{'Index':<10} {'Build/s':>10} "
    f"{'Search/ms':>12} {'Recall@10':>12}"
)
print(
    f"{'Flat':<10} {flat_build_time:>10.4f} "
    f"{flat_search_time * 1000:>12.3f} {1.0:>12.3f}"
)
print(
    f"{'IVF':<10} {ivf_build_time:>10.4f} "
    f"{ivf_search_time * 1000:>12.3f} "
    f"{recall_at_k(flat_ids, ivf_ids):>12.3f}"
)
print(
    f"{'HNSW':<10} {hnsw_build_time:>10.4f} "
    f"{hnsw_search_time * 1000:>12.3f} "
    f"{recall_at_k(flat_ids, hnsw_ids):>12.3f}"
)

运行后会输出三种索引的构建时间、查询时间和 Recall@10。不同电脑得到的时间不一样,所以这里不写固定结果,需要观察的是它们之间的关系:

测试时不要只看一次查询的耗时。第一次运行可能受缓存、线程初始化和系统负载影响,所以代码中先预热一次,再取五次查询的中位数。

八、调整 IVF 的 nprobe

IVF 最常调的参数是 nprobe。可以继续使用上面的索引,分别测试几个值:

for nprobe in [1, 4, 16, 64, 128]:
    ivf_index.nprobe = nprobe

    elapsed, _, result_ids = search_time(
        ivf_index,
        query_vectors,
        TOP_K,
    )

    recall = recall_at_k(flat_ids, result_ids)

    print(
        f"nprobe={nprobe:<3} "
        f"search={elapsed * 1000:>8.3f} ms "
        f"recall={recall:.3f}"
    )

如果 nprobe=1,IVF 只进入一个倒排列表,查询很快,但更容易漏掉分布在相邻列表中的向量。逐渐提高 nprobe 后,Recall@10 通常会上升。它并不是越大越好,因为扫描列表过多后,IVF 会越来越接近暴力检索。

nlist 也需要结合数据量调整:分组太少,每个列表装入大量向量;分组太多,训练和定位质心的成本增加,数据不足时还可能出现一些很小的列表。比较稳妥的做法,是先选几个 nlist 建立不同索引,再分别测试 nprobe,而不是只改一个参数。

九、调整 HNSW 的 efSearch

HNSW 查询时最常调的是 efSearch:

for ef_search in [16, 32, 64, 128, 256]:
    hnsw_index.hnsw.efSearch = ef_search

    elapsed, _, result_ids = search_time(
        hnsw_index,
        query_vectors,
        TOP_K,
    )

    recall = recall_at_k(flat_ids, result_ids)

    print(
        f"efSearch={ef_search:<3} "
        f"search={elapsed * 1000:>8.3f} ms "
        f"recall={recall:.3f}"
    )

efSearch 较小时,HNSW 在图中检查的候选节点较少,查询更快;提高以后,搜索覆盖更多路径,召回率一般也会提高。

M 和 efConstruction 需要重新构建索引才能观察效果,因为它们影响图是如何建立的。实际测试时,可以先固定 M=32 和 efConstruction=100,把 efSearch 调到可接受的范围;如果召回率仍不够,再考虑提高构建阶段的参数。

十、索引也可以保存下来

Faiss 索引构建完成后可以保存到本地,不需要每次启动程序都重新创建:

faiss.write_index(hnsw_index, "rag_hnsw.index")

loaded_index = faiss.read_index("rag_hnsw.index")
loaded_index.hnsw.efSearch = 64

distances, ids = loaded_index.search(
    query_vectors,
    TOP_K,
)

保存的只是向量索引。如果还需要根据返回的 ID 找到原始 Chunk、文件名和页码,这些内容仍要单独保存,或者交给完整的向量数据库管理。

十一、实际项目中怎么选

在 RAG 中,检索算法没有一个固定的最佳参数。比较实用的做法是:先确定可以接受的召回率,再在这个范围内找查询时间较低的配置。如果只把参数调得很快,却把真正相关的 Chunk 漏掉了,后面的重排和大模型也补不回来。

SEO 关键词

RAG、向量数据库、向量检索、KNN、ANN、IVF、HNSW、Faiss、相似度检索、召回率、nprobe、efSearch、倒排索引、分层可导航小世界图、大模型知识库

评论(0