4、精确检索与暴力搜索:全量遍历算法、时间复杂度分析、适用场景与局限性

说到向量检索,很多人第一反应就是“快”。但有个问题绕不开——你到底要不要100%精确的结果?

如果你要的是“一个都不能少”,那不好意思,你得回到最原始的方法:暴力搜索。也叫全量遍历,或者英文里常说的 Brute-Force Search。

我刚开始接触向量检索时,也觉得这玩意儿太笨了。但后来在某个金融风控项目里,客户要求召回率必须100%,少一个向量都不行。嗯,那时候我才意识到,暴力搜索虽然“笨”,但它是所有近似方法的基准线,也是你理解其他算法的起点。

什么是全量遍历?

说白了,就是拿着你的查询向量,跟数据库里每一个向量挨个算距离。

假设你有100万个向量,每个向量是128维。查询时,系统会计算100万次余弦相似度或欧氏距离,然后挑出距离最近的K个。

没有任何捷径,没有任何索引结构。就是硬算。

核心特征:

  • 结果100%精确,没有近似误差
  • 实现极其简单,几乎不需要预处理
  • 查询时间与数据量成正比增长

时间复杂度分析

我们来拆一下这个复杂度。你想想看,一次暴力搜索到底要花多少时间?

假设:

  • 数据集有 N 个向量
  • 每个向量的维度是 d
  • 距离计算(比如欧氏距离)需要 O(d) 次浮点运算

那么一次查询的时间复杂度就是:

O(N × d)

什么意思?就是数据量翻倍,查询时间也翻倍。维度翻倍,查询时间也翻倍。

我给大家列个表,直观感受一下:

数据量 N 维度 d 单次查询耗时(估算) 每秒可处理查询数(QPS)
1万 128 ~1ms ~1000
10万 128 ~10ms ~100
100万 128 ~100ms ~10
1000万 128 ~1s ~1
1亿 128 ~10s ~0.1

看到没?数据量到千万级别,单次查询就要1秒。到亿级,10秒起步。这在很多在线场景里根本没法用。

我的经验: 我曾经在一个推荐系统项目里,初期用暴力搜索做离线评估。数据量只有50万,跑一次全量召回大概50ms,勉强能接受。但上线后用户量暴涨,数据很快到了500万,查询时间直接飙到500ms+。用户反馈“刷不动了”,我们才紧急切了近似检索。

适用场景

暴力搜索虽然慢,但并不是一无是处。我总结了几类它仍然适用的场景:

  1. 小数据集(N < 1万)
    数据量小的时候,暴力搜索反而最简单直接。不需要建索引,不需要调参,代码写出来就能跑。
  2. 离线批量处理
    比如离线聚类、数据清洗、批量标注。这些场景对延迟不敏感,但要求结果精确。
  3. 作为基准测试
    评估近似检索算法的召回率时,暴力搜索的结果就是“标准答案”。没有它,你没法知道近似算法到底丢了多少精度。
  4. 低QPS场景
    如果每秒只有几次查询,用户也能接受几百毫秒的等待,那暴力搜索完全够用。

局限性

局限性其实很明显,我直接列出来:

  • 无法扩展到大数据集——N 到百万级,延迟就不可接受了
  • 高维度下计算开销大——d 到几百甚至上千,每次距离计算都很重
  • 没有增量更新优势——每次新增向量,查询时仍然要全量遍历
  • 内存占用高——所有向量必须常驻内存,否则磁盘IO更慢

我曾经踩过的坑: 有一次我图省事,直接用暴力搜索处理了200万条128维的向量。结果单次查询要200ms,QPS只有5。上线后用户一多,服务直接被打挂。后来我才意识到,暴力搜索的瓶颈不只是CPU,还有内存带宽——每次查询都要把整个向量矩阵扫一遍,对缓存非常不友好。

暴力搜索的流程图

下面这张图展示了暴力搜索的完整流程。你可以看到,整个过程没有任何剪枝或跳过,就是一条路走到黑。

暴力搜索(全量遍历)流程图 输入查询向量 q 初始化:best_dist = ∞, best_id = -1 遍历所有向量 i = 0 到 N-1 计算 dist = distance(q, vec[i]) dist < best_dist ? 是 更新 best_dist = dist, best_id = i 否 返回 best_id 及距离 说明: • 无任何剪枝或索引 • 必须遍历所有向量 • 时间复杂度 O(N×d)

代码示例:暴力搜索的简单实现

下面是一个用 Python + NumPy 实现的暴力搜索。代码很简单,但你可以用它来验证其他近似算法的召回率。

import numpy as np

def brute_force_search(query, vectors, k=10, metric='cosine'):
    """
    暴力搜索:全量遍历,返回最相似的 k 个向量索引和距离
    
    参数:
        query: 查询向量,shape (d,)
        vectors: 所有向量,shape (N, d)
        k: 返回 top-k 结果
        metric: 距离度量,'cosine' 或 'euclidean'
    
    返回:
        indices: 最相似向量的索引,shape (k,)
        distances: 对应的距离,shape (k,)
    """
    N = vectors.shape[0]
    
    if metric == 'cosine':
        # 余弦相似度 -> 距离 = 1 - 相似度
        norms = np.linalg.norm(vectors, axis=1)
        query_norm = np.linalg.norm(query)
        similarities = np.dot(vectors, query) / (norms * query_norm + 1e-10)
        distances = 1.0 - similarities
    elif metric == 'euclidean':
        # 欧氏距离
        diff = vectors - query
        distances = np.sqrt(np.sum(diff ** 2, axis=1))
    else:
        raise ValueError(f"Unknown metric: {metric}")
    
    # 按距离升序排序,取前 k 个
    sorted_indices = np.argsort(distances)
    indices = sorted_indices[:k]
    distances = distances[indices]
    
    return indices, distances

# 示例用法
if __name__ == "__main__":
    # 生成 10000 个 128 维向量
    N, d = 10000, 128
    vectors = np.random.randn(N, d).astype(np.float32)
    query = np.random.randn(d).astype(np.float32)
    
    # 暴力搜索 top-10
    indices, distances = brute_force_search(query, vectors, k=10)
    print("Top-10 索引:", indices)
    print("对应距离:", distances)

我个人的习惯: 在开发近似检索算法时,我会先用暴力搜索跑一遍小数据集,把结果存下来作为“黄金标准”。然后每次调参或改算法,都用这个标准来验证召回率。这样能快速发现 bug 或参数问题,避免在错误的方向上越走越远。

什么时候该放弃暴力搜索?

我给大家一个简单的判断标准:

  • 如果 N > 10万 且 延迟要求 < 100ms,暴力搜索基本没戏
  • 如果 N > 100万 且 延迟要求 < 1s,暴力搜索也扛不住
  • 如果 N > 1000万,不管什么延迟要求,暴力搜索都不现实

这时候,你就得考虑近似检索了。比如 HNSW、IVF、PQ 这些算法。它们会在精度和延迟之间做权衡,但能让你在毫秒级完成亿级数据的检索。

不过,那是后面章节的内容了。这一章你只要记住:暴力搜索是所有近似算法的基准线,理解它的局限,你才能更好地理解为什么需要那些“花里胡哨”的索引结构。