为什么遍历搜索这么贵?: 详细解释及示例 (RAG)
1. 概述
在 RAG (Retrieval-Augmented Generation) 管道中,检索步骤通常是将问题向量与数十万篇段落的向量匹配。最简单的方法——逐个向量地顺序比较——总是给出正确结果,但成本随着数据大小线性增长。本文解释为什么“遍历搜索”这么昂贵,Inverted-File Index (IVF) 如何通过聚类降低成本,以及当你接受近似结果时需要付出的代价。读完之后,你将能够用 Java 自己编写一个简单的 flat index 和 IVF index,测量速度和 recall 之间的权衡,并了解何时应该——或不应该——使用近似搜索。
2. 问题:Flat Index 与 O(n) 复杂度
2.1. Flat search 如何工作
Flat index(或 brute-force search)将查询与数据集中的所有向量进行比较,然后选出得分最高的向量(通常是 cosine similarity 或 dot product)。没有任何中间过滤步骤。
public record Passage(String id, float[] vector) {}
public class FlatVectorIndex {
private final List<Passage> passages;
public FlatVectorIndex(List<Passage> passages) {
this.passages = passages;
}
public List<Passage> search(float[] query, int topK) {
return passages.stream()
.sorted(Comparator.comparingDouble(p -> -cosine(query, p.vector())))
.limit(topK)
.toList();
}
private double cosine(float[] a, float[] b) {
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
}
每次调用 search 时,stream() 中的隐藏循环都必须计算与所有 passages 的余弦相似度。如果数据集有 n 个向量,每个向量有 d 维,则每个查询的成本是 O(n × d)。段落数量加倍,比较次数也加倍——这正是“O(n)“的含义。
注意: Flat search 给出“精确”结果——即根据你选择的距离公式完全正确。但“精确”并不意味着这个段落在语义上包含正确答案。几何精度和语义精度是两码事。
2.2. 衡量成本随数据大小的增长
public static void main(String[] args) {
for (int n : List.of(10_000, 100_000)) {
var passages = TestData.generatePassages(n, 128, 42);
var index = new FlatVectorIndex(passages);
float[] query = TestData.randomVector(128, 7);
long start = System.nanoTime();
index.search(query, 5);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.printf("n=%d -> %d ms%n", n, elapsedMs);
}
}
在开发机器上的说明性结果(实际数字取决于硬件,但趋势保持不变):
n=10000 -> 18 ms
n=100000 -> 176 ms
数据增加 10 倍,时间增加近 10 倍——与 O(n) 预测一致。对于一百万个段落和每秒许多查询,这种成本会迅速累加。这就是为什么 flat index 不能很好地扩展到生产 RAG 系统。
3. 解决方案:倒排文件索引 (IVF)
3.1. 聚类思想
IVF 通过不与每个向量比较来降低成本。相反:
- 当构建索引时,运行聚类(通常是 k-means)来找到代表每组相近向量的“质心”。
- 数据集中的每个向量被分配到最近的质心所在的组。
- 在搜索时,只选择质心最接近查询的几个组,然后只比较这些组中的向量。
想象一百万个段落被分成 1000 个组。如果只扫描 10 个组,你比较大约 10,000 个段落,而不是全部一百万个——成本降低近 100 倍。
3.2. nprobe 参数
扫描的组数称为 nprobe。扫描更多组通常会改进 recall(找到正确结果的比例),但也会增加成本。这是你在生产环境中直接调优的参数。
public class IvfIndex {
private final List<float[]> centroids;
private final Map<Integer, List<Passage>> buckets = new HashMap<>();
public IvfIndex(List<Passage> passages, List<float[]> centroids) {
this.centroids = centroids;
for (Passage p : passages) {
int cluster = nearestCentroidIndex(p.vector());
buckets.computeIfAbsent(cluster, k -> new ArrayList<>()).add(p);
}
}
public List<Passage> search(float[] query, int topK, int nprobe) {
List<Integer> clusters = nearestCentroidIndices(query, nprobe);
return clusters.stream()
.flatMap(c -> buckets.getOrDefault(c, List.of()).stream())
.sorted(Comparator.comparingDouble(p -> -cosine(query, p.vector())))
.limit(topK)
.toList();
}
private int nearestCentroidIndex(float[] v) {
return nearestCentroidIndices(v, 1).get(0);
}
private List<Integer> nearestCentroidIndices(float[] v, int k) {
return IntStream.range(0, centroids.size())
.boxed()
.sorted(Comparator.comparingDouble(i -> -cosine(v, centroids.get(i))))
.limit(k)
.toList();
}
private double cosine(float[] a, float[] b) {
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
}
nearestCentroidIndices 找到最接近查询的 k 个质心——这正是 nprobe 在调用 search(query, topK, nprobe) 时发挥作用的地方。构建索引部分(构造函数)运行一次,搜索部分每个查询运行一次——这就是为什么 IVF 在查询时快得多。
注意: 上面的示例使用预先给定的质心(从数据中采样)来保持代码简洁。在实践中,你需要运行真正的 k-means(多次迭代更新质心)以获得高质量的聚类——FAISS、Milvus 或 pgvector 等库已经为你完成了这项工作。
3.3. 使用 pgvector 的实际连接
如果你正在使用带有 pgvector 扩展的 PostgreSQL,IVF 和 nprobe 概念直接出现在 SQL 中:
CREATE INDEX ON passages USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);
SET ivfflat.probes = 10;
lists 等同于构建索引时的聚类数,probes 等同于查询时的 nprobe。你不需要自己编写 k-means——只需理解权衡以选择合理的数字。
4. 权衡:recall 与速度
IVF 是一种近似最近邻(ANN)搜索。它用绝对精度换取速度。问题是:聚类是根据数学距离形成的,而不是根据人类分类文档的方式形成的“干净”主题边界。
RAG 中的具体例子:关于“航班延误时退款”的条款可能位于保险文档的向量聚类附近,而不是“差旅费政策”聚类——尽管在主题上,员工会在差旅文档中查找它。如果 nprobe 只扫描“差旅费政策”聚类,正确答案会被完全遗漏,即使它存在于数据中。
4.1. 测试随 nprobe 变化的 recall 下降
class IvfIndexTest {
@Test
void nprobeThapCoTheLamMatKetQuaDung() {
var passages = TestData.generatePassages(2000, 64, 42);
var centroids = TestData.sampleCentroids(passages, 40, 42);
var ivf = new IvfIndex(passages, centroids);
var flat = new FlatVectorIndex(passages);
float[] query = passages.get(1337).vector();
var exact = flat.search(query, 5);
var approxFewProbes = ivf.search(query, 5, 1);
var approxManyProbes = ivf.search(query, 5, 40); // = 扫描全部,等同于 flat
assertThat(approxManyProbes).containsExactlyInAnyOrderElementsOf(exact);
assertThat(approxFewProbes).isNotEqualTo(approxManyProbes);
}
}
approxManyProbes 扫描全部 40 个聚类,所以结果与 flat search 完全一致——这是验证 IVF 没有逻辑错误的基准。approxFewProbes 只扫描 1 个聚类,最后一个 assertion 确认结果不同于精确情况——说明当 nprobe 过低时存在丢失 recall 的风险。
exact = [p1337, p0892, p0456, p1890, p0023]
approxFewProbes = [p1337, p0456, p0901, p0234] // 仅 4 个结果,缺失 p0892, p1890
approxManyProbes = [p1337, p0892, p0456, p1890, p0023]
当 nprobe 较低时,索引不仅返回的结果比正确数量少,还可能错过重要的“偏离聚类”的段落——正如上面保险/差旅费示例所示。
5. 何时使用 flat,何时使用 IVF
- 使用 flat search 时: 数据集较小(数万个段落或更少),或者你需要绝对精度用于审计/合规目的,或者你还在原型阶段,不想调优更多参数。
- 使用 IVF(或 HNSW)时: 数据集很大(数十万到数百万个段落),查询延迟是关键因素(聊天机器人、实时搜索),并且你愿意接受低于 100% 的 recall 来换取速度。
注意: 不要只因为速度基准测试看起来不错就选择过低的
nprobe。在你的 RAG 系统的真实问题集上测量 recall——缺少重要上下文的答案比晚了几十毫秒的查询更糟。
生产中的常见做法是:使用 IVF(或 HNSW)快速获取可接受 recall 的前 100 个候选项,然后使用重新排序步骤(cross-encoder 或在这 100 个候选项的小集合上进行 flat search)来弥补损失的精度。这种方法保持了 ANN 的速度,同时提高了最终结果的质量。
6. 结论
Flat search 精度绝对但成本随段落数量线性增长——当你的 RAG 必须服务数百万文档时无法扩展。IVF 通过聚类和 nprobe 参数解决了这个问题,用部分 recall 换取速度。代价是向量聚类与人类理解的主题边界不相符,所以重要的段落有时会“迷失”在未被扫描的聚类中。清楚地理解这种权衡可以帮助你选择正确的索引、调优正确的参数,并了解何时需要添加重新排序步骤来弥补精度损失。