Vector Search
用多层近邻图,在海量向量里快速找到相似项
HNSW 是一种近似最近邻搜索算法。它把每个向量看成图上的节点,用“高层稀疏、底层密集”的多层小世界图,把一次高维搜索变成从粗到细的图遍历。
一句话:HNSW 先在很稀疏的高层图里快速接近目标区域,再逐层下降到更密的图,最后在底层用一个候选队列做精细搜索。
efSearch 可以换取更接近精确搜索的结果。
M 越大,图越准也越重。
Vector Search
HNSW 是一种近似最近邻搜索算法。它把每个向量看成图上的节点,用“高层稀疏、底层密集”的多层小世界图,把一次高维搜索变成从粗到细的图遍历。
一句话:HNSW 先在很稀疏的高层图里快速接近目标区域,再逐层下降到更密的图,最后在底层用一个候选队列做精细搜索。
efSearch 可以换取更接近精确搜索的结果。
M 越大,图越准也越重。
红点是当前节点,橙点是候选节点,绿点是最终近邻。
候选池越大,访问节点越多,召回通常越高。
Intuition
它不是把所有向量逐个比较,而是提前建好一张“知道谁和谁相近”的图。查询时,算法沿着越来越近的节点移动。
每个向量节点保留若干个邻居。如果当前节点离查询向量还不够近,就沿着更近的邻居继续走。这样能跳过大部分无关节点。
纯本地近邻图容易陷在局部。HNSW 通过分层结构保留一些跨区域连接,让搜索先用长距离跳转接近目标区域。
高层节点少、边稀疏,像快速通道;底层包含所有节点,负责最后的细粒度候选扩展和排序。
Search
HNSW 查询可以分成“贪心下楼”和“底层候选扩展”两段。前者快,后者稳。
索引维护一个最高层入口点。查询向量进来后,先和入口点计算距离。
检查当前节点的邻居,只要有更近的邻居就移动过去。直到这一层没有更近节点。
把上一层找到的最近节点作为下一层起点,继续向目标区域靠近。
在第 0 层不只保留一个当前最优,而是维护候选队列,访问更大邻域后返回前 k 个最近节点。
Indexing
HNSW 是增量构建的。每来一个新向量,算法随机决定它能进入多高的层,然后在每一层找到合适邻居并连边。
绝大多数节点只在第 0 层,少数节点会被提升到更高层。层数通常服从指数衰减分布。
和查询类似,先在高层贪心下降,找到每一层接近新节点的位置。
在需要插入的层里扩大搜索,得到一批可能连接的邻居。这个候选池越大,建图质量通常越好。
每个节点最多保留约 M 条边。裁剪时不只选最近的点,也会保留方向分散的点,减少局部拥挤。
Tuning
实际使用 HNSW 时,调参就是在查询延迟、构建时间、召回率和内存占用之间找平衡。
| 参数 | 含义 | 调大后的影响 | 经验方向 |
|---|---|---|---|
M |
每个节点保留的最大邻居数,控制图的连通密度。 | 召回更好,搜索更稳,但内存和构建成本上升。 | 常见范围 16 到 64;数据复杂、召回要求高时增大。 |
efConstruction |
构建索引时的候选池大小。 | 图质量更好,之后查询召回更高,但建索引更慢。 | 通常大于 M,比如 100 到 400;离线构建可取更大。 |
efSearch |
查询底层时保留和扩展的候选池大小。 | 召回提升,访问节点更多,查询延迟增加。 | 线上常按 SLA 动态调整;它必须不小于返回的 k。 |
k |
最终返回的最近邻数量。 | 需要更大的候选池支撑,否则尾部结果质量不稳定。 | Top 10、Top 50、Top 100 的配置通常不同。 |
| 距离函数 | 例如 L2、内积、余弦相似度。 | 不直接是调大调小问题,但会改变图结构和排序结果。 | 余弦检索常先做向量归一化;训练和检索口径要一致。 |
Pseudo Code
真实实现会处理并发、删除、内存布局和 SIMD 距离计算,但主干逻辑大致如下。
function search(query, k, efSearch):
entry = graph.entryPoint
current = entry
for level from graph.maxLevel down to 1:
changed = true
while changed:
changed = false
for neighbor in current.neighbors[level]:
if distance(query, neighbor) < distance(query, current):
current = neighbor
changed = true
candidates = maxHeap()
results = maxHeap()
visited = set(current)
push candidates with current
push results with current
while candidates is not empty:
nearest = pop closest from candidates
worst = farthest item in results
if distance(query, nearest) > distance(query, worst):
break
for neighbor in nearest.neighbors[0]:
if neighbor in visited:
continue
visited.add(neighbor)
if results.size < efSearch
or distance(query, neighbor) < distance(query, worst):
push candidates with neighbor
push results with neighbor
keep only efSearch closest in results
return top k closest from results
function insert(vector):
newLevel = randomLevel()
entry = graph.entryPoint
current = entry
for level from graph.maxLevel down to newLevel + 1:
current = greedySearch(vector, current, level)
for level from min(newLevel, graph.maxLevel) down to 0:
pool = searchLayer(
query = vector,
entry = current,
ef = efConstruction,
level = level
)
neighbors = selectNeighbors(pool, M)
connectBidirectional(vector, neighbors, level)
for neighbor in neighbors:
if neighbor.degree(level) > M:
neighbor.neighbors[level] =
selectNeighbors(neighbor.neighbors[level], M)
current = closest item in pool
if newLevel > graph.maxLevel:
graph.entryPoint = vector
graph.maxLevel = newLevel
Practice
HNSW 的效果往往很好,但它不是魔法。数据分布、过滤条件和工程约束都会影响最终体验。
需要用标注集或暴力搜索小样本评估 recall@k,再决定参数。
先过滤再图搜可能候选太少,边搜边过滤可能召回下降;复杂过滤通常需要额外策略。
很多实现采用标记删除、后台合并或重建索引,频繁更新要提前设计。
余弦、内积和 L2 的排序关系不同;归一化策略不一致会让结果看起来“飘”。
向量本体之外,还要存节点 ID、层级、邻居列表和删除标记。M 对内存很敏感。
低延迟接口可以用较小 efSearch,高质量重排链路可以给更大的候选池。