从零实现HNSW向量索引:分层图结构、贪婪搜索与启发式邻居选择
在上一篇文章中,我们从零实现了一个Raft共识算法,覆盖了领导者选举、日志复制与安全性约束。那篇文章聚焦的是分布式系统的一致性。这一次我们回到AI基础设施的核心数据结构,目标是一个完整的HNSW(Hierarchical Navigable Small World)向量索引。
向量检索正在成为AI应用的基础能力。RAG系统需要从百万级文档中检索与查询语义最相关的片段,推荐系统需要从千万级物品中找到最相似的候选,图像搜索需要从海量图片中定位视觉上最接近的结果。这些场景的共同需求是:在高维空间中快速找到与查询向量距离最近的K个向量。
传统方案是暴力搜索——计算查询向量与所有向量的距离,返回最近的K个。时间复杂度O(N*D),N是向量数量,D是维度。对于100万条768维向量,单次查询需要计算7.68亿次浮点运算,延迟在秒级,完全无法满足在线服务的要求。
近似最近邻(ANN)算法牺牲少量精度换取数量级的性能提升。HNSW是目前工业界最主流的ANN算法,被Milvus、Qdrant、Weaviate、PgVector等主流向量数据库广泛采用。它的核心思想是用多层图结构模拟"跳表"的搜索路径,上层稀疏用于快速跳转,下层稠密用于精确查找。
本文用纯前端JavaScript从零实现一个完整的HNSW向量索引,不依赖任何库。完整链路是:距离度量 → 概率层分配 → 图结构插入 → 贪婪搜索 → 启发式邻居选择 → KNN查询。最后会讨论HNSW的调参策略和性能特征。
一、核心概念:为什么是分层图
HNSW的灵感来自两个方向。一个是跳表(Skip List)——通过在有序链表上构建多级索引,把查找复杂度从O(N)降到O(log N)。另一个是可导航小世界图(Navigable Small World)——在图中维护长程连接和短程连接,使得贪婪搜索能够快速收敛。
HNSW把这两个思想结合起来:用一个多层图结构,上层稀疏(长程连接)用于快速接近目标区域,下层稠密(短程连接)用于精确查找。搜索时从最上层开始,每层执行贪婪搜索找到最近的入口点,然后下降到下一层继续搜索,直到最底层。
理解这个结构的关键是:上层节点是下层节点的子集。每个节点被分配到一个最大层数L,该节点出现在第0层到第L层的所有图层中。层数越高,节点越少,边越长,搜索跳转越快。
二、距离度量与向量操作
HNSW支持任意距离度量,只要满足三角不等式。最常用的是欧氏距离和余弦相似度。
// 向量操作工具 const VectorOps = { // 欧氏距离的平方(避免开方,加速比较) euclideanDistanceSq(a, b) { let sum = 0; for (let i = 0; i < a.length; i++) { const diff = a[i] - b[i]; sum += diff * diff; } return sum; }, // 余弦相似度转距离:1 - cos(a, b) cosineDistance(a, b) { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA === 0 || normB === 0) return 1; return 1 - dot / (Math.sqrt(normA) * Math.sqrt(normB)); }, // 内积距离:-dot(a, b) innerProductDistance(a, b) { let dot = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; } return -dot; } };
距离函数的选择必须与向量的归一化方式匹配。如果用余弦相似度,向量应该在插入前归一化为单位长度,这样余弦相似度可以简化为内积。实践中,大多数嵌入模型输出的向量已经是归一化的,因此内积距离是最高效的选择。
三、概率层分配
HNSW的层分配遵循指数衰减分布。每个节点被分配到一个最大层数L,分配规则是:
class HNSWIndex { constructor(options = {}) { this.M = options.M || 16; // 每个节点的最大连接数(第0层) this.Mmax = options.Mmax || 16; // 第0层的最大连接数 this.Mmax0 = options.Mmax0 || 2 * this.M; // 第0层以上的最大连接数 this.efConstruction = options.efConstruction || 200; // 构建时的搜索宽度 this.mL = 1 / Math.log(this.M); // 层分配的概率因子 this.vectors = []; // 所有向量 this.graphs = []; // 每层的邻接表 this.entryPoint = null; // 入口节点 this.maxLevel = -1; // 当前最大层 } // 随机生成一个层数 randomLevel() { let level = 0; while (Math.random() < Math.exp(-1 / this.mL) && level < 32) { level++; } return level; } }
mL的取值是1 / ln(M)。当M=16时,mL约等于0.36。这个分布的期望层数是1 / (1 - exp(-1/mL)),约等于1.6层。大多数节点只出现在第0层,少数节点出现在更高层,极少数节点出现在最高层。以100万个节点为例,第0层有100万个节点,第1层约36万个,第2层约13万个,第3层约4.7万个,以此类推。最高层通常只有几个节点。
这个概率分布保证了上层图足够稀疏,搜索时可以快速跳转;下层图足够稠密,可以精确查找。
四、图结构插入
插入一个新节点是HNSW最复杂的操作。它涉及三个步骤:找到每层的插入位置、选择邻居、更新连接。
class HNSWIndex { // ... 之前的代码 // 插入一个向量 insert(vector, id = null) { const nodeId = id !== null ? id : this.vectors.length; const level = this.randomLevel(); // 存储向量 this.vectors[nodeId] = vector; // 如果图还是空的,直接作为入口节点 if (this.entryPoint === null) { this.entryPoint = nodeId; this.maxLevel = level; for (let l = 0; l <= level; l++) { this.graphs[l] = new Map(); this.graphs[l].set(nodeId, new Set()); } return; } // 确保图结构存在到 level 层 for (let l = this.graphs.length; l <= level; l++) { this.graphs[l] = new Map(); } // 初始化新节点在各层的邻接表 for (let l = 0; l <= level; l++) { this.graphs[l].set(nodeId, new Set()); } // 从入口节点开始,从上到下逐层搜索 let currentNode = this.entryPoint; let currentDist = this._distance(vector, this.vectors[currentNode]); // 1. 从最高层下降到 level+1 层,只做贪婪搜索找入口点 for (let l = this.maxLevel; l > level; l--) { const result = this._greedySearch(vector, currentNode, l); currentNode = result.node; currentDist = result.dist; } // 2. 从 level 层到第0层,每层都做插入 const ep = new Set([currentNode]); for (let l = Math.min(level, this.maxLevel); l >= 0; l--) { // 在当前层搜索 efConstruction 个最近邻 const candidates = this._searchLayer(vector, ep, this.efConstruction, l); // 选择 M 个邻居(第0层用 Mmax0,其他层用 Mmax) const maxConn = l === 0 ? this.Mmax0 : this.Mmax; const neighbors = this._selectNeighborsHeuristic(vector, candidates, maxConn); // 建立双向连接 for (const neighbor of neighbors) { this.graphs[l].get(nodeId).add(neighbor.node); this.graphs[l].get(neighbor.node).add(nodeId); // 检查邻居的连接数是否超过上限 const neighborConn = this.graphs[l].get(neighbor.node); if (neighborConn.size > maxConn) { // 需要裁剪邻居的连接 this._shrinkConnections(neighbor.node, l, maxConn); } } // 更新入口点集合 ep.clear(); for (const c of candidates) ep.add(c.node); } // 更新入口点和最大层 if (level > this.maxLevel) { this.maxLevel = level; this.entryPoint = nodeId; } } // 在指定层从入口点开始贪婪搜索 _greedySearch(query, entryNode, level) { let currentNode = entryNode; let currentDist = this._distance(query, this.vectors[currentNode]); let changed = true; while (changed) { changed = false; const neighbors = this.graphs[level].get(currentNode) || new Set(); for (const neighbor of neighbors) { const dist = this._distance(query, this.vectors[neighbor]); if (dist < currentDist) { currentDist = dist; currentNode = neighbor; changed = true; } } } return { node: currentNode, dist: currentDist }; } }
插入的核心逻辑是:从最高层开始贪婪搜索找到入口点,然后逐层下降。在每一层,用efConstruction参数控制搜索宽度——搜索越宽,找到的邻居越好,但构建越慢。搜索到的候选节点经过启发式选择后,成为新节点的邻居,同时新节点也成为它们的邻居。
五、启发式邻居选择
邻居选择是HNSW性能的关键。最简单的策略是选择距离最近的M个节点。但这会导致一个问题:如果所有邻居都聚集在同一个方向,图就会失去连通性,搜索容易陷入局部最优。
HNSW使用启发式选择来解决这个问题。它的核心思想是:在候选集合中,优先选择那些与其他已选邻居距离较远的节点,从而保证邻居在空间中均匀分布。
class HNSWIndex { // ... 之前的代码 _selectNeighborsHeuristic(query, candidates, M) { // 按距离升序排列 const sorted = [...candidates].sort((a, b) => a.dist - b.dist); const selected = []; const discarded = []; for (const candidate of sorted) { if (selected.length >= M) break; // 检查候选节点是否应该被保留 let keep = true; for (const chosen of selected) { const distBetween = this._distance( this.vectors[candidate.node], this.vectors[chosen.node] ); // 如果候选节点到已选节点的距离小于它到查询的距离,则不保留 if (distBetween < candidate.dist) { keep = false; break; } } if (keep) { selected.push(candidate); } else { discarded.push(candidate); } } // 如果选出的邻居不够M个,从被丢弃的候选中补充 while (selected.length < M && discarded.length > 0) { selected.push(discarded.shift()); } return selected; } // 裁剪邻居连接 _shrinkConnections(node, level, maxConn) { const connections = this.graphs[level].get(node); if (connections.size <= maxConn) return; // 将连接转为候选列表 const candidates = []; for (const neighbor of connections) { candidates.push({ node: neighbor, dist: this._distance(this.vectors[node], this.vectors[neighbor]) }); } // 使用启发式选择保留 maxConn 个连接 const selected = this._selectNeighborsHeuristic( this.vectors[node], candidates, maxConn ); // 更新邻接表 const newConnections = new Set(); for (const s of selected) { newConnections.add(s.node); } // 删除被移除的连接(双向) for (const oldNeighbor of connections) { if (!newConnections.has(oldNeighbor)) { this.graphs[level].get(oldNeighbor)?.delete(node); } } this.graphs[level].set(node, newConnections); } }
启发式选择的核心判断是distBetween < candidate.dist。如果候选节点到某个已选邻居的距离,比它到查询的距离还近,说明这两个节点在空间上高度重叠,保留候选节点不会增加图的多样性,反而浪费了连接配额。这个判断保证了选出的邻居在空间中尽可能分散。
六、KNN查询
查询是插入的逆过程。从入口点开始,逐层向下贪婪搜索,最底层用efSearch参数控制搜索宽度。
class HNSWIndex { // ... 之前的代码 // KNN 查询 search(query, k, efSearch = 50) { if (this.entryPoint === null) return []; let currentNode = this.entryPoint; let currentDist = this._distance(query, this.vectors[currentNode]); // 1. 从最高层下降到第1层 for (let l = this.maxLevel; l > 0; l--) { const result = this._greedySearch(query, currentNode, l); currentNode = result.node; currentDist = result.dist; } // 2. 在第0层用 efSearch 宽度搜索 const candidates = this._searchLayer( query, new Set([currentNode]), Math.max(efSearch, k), 0 ); // 3. 返回最近的 k 个 return candidates .sort((a, b) => a.dist - b.dist) .slice(0, k) .map(c => ({ id: c.node, distance: c.dist })); } // 在指定层搜索最近的 ef 个候选 _searchLayer(query, entryPoints, ef, level) { const visited = new Set(); const candidates = []; // 最小堆(按距离升序) const results = []; // 最大堆(按距离降序) // 初始化 for (const ep of entryPoints) { if (visited.has(ep)) continue; visited.add(ep); const dist = this._distance(query, this.vectors[ep]); candidates.push({ node: ep, dist }); results.push({ node: ep, dist }); } // 维护最小堆和最大堆 candidates.sort((a, b) => a.dist - b.dist); results.sort((a, b) => b.dist - a.dist); while (candidates.length > 0) { // 取出最近的候选 const current = candidates.shift(); // 如果当前候选比结果集中最远的还远,停止搜索 if (results.length >= ef && current.dist > results[0].dist) { break; } const neighbors = this.graphs[level].get(current.node) || new Set(); for (const neighbor of neighbors) { if (visited.has(neighbor)) continue; visited.add(neighbor); const dist = this._distance(query, this.vectors[neighbor]); // 如果结果集未满,或者距离小于结果集中最远的 if (results.length < ef || dist < results[0].dist) { candidates.push({ node: neighbor, dist }); candidates.sort((a, b) => a.dist - b.dist); results.push({ node: neighbor, dist }); results.sort((a, b) => b.dist - a.dist); // 如果结果集超过 ef,移除最远的 if (results.length > ef) { results.shift(); } } } } return results; } // 距离函数(默认欧氏距离) _distance(a, b) { let sum = 0; for (let i = 0; i < a.length; i++) { const diff = a[i] - b[i]; sum += diff * diff; } return Math.sqrt(sum); } }
_searchLayer是HNSW的核心搜索算法。它维护两个数据结构:candidates是最小堆(按距离升序),用于决定下一个要扩展的节点;results是最大堆(按距离降序),用于维护当前找到的最近的ef个候选。搜索的终止条件是:最近的候选节点比结果集中最远的节点还远,说明不可能找到更好的结果了。
七、完整测试与性能对比
把所有部分串联起来,测试一个中等规模的索引:
function testHNSW() { const DIM = 128; const N = 10000; const K = 10; // 生成随机向量 function randomVector() { const v = new Float32Array(DIM); for (let i = 0; i < DIM; i++) { v[i] = Math.random() * 2 - 1; } // 归一化 let norm = 0; for (let i = 0; i < DIM; i++) norm += v[i] * v[i]; norm = Math.sqrt(norm); for (let i = 0; i < DIM; i++) v[i] /= norm; return v; } // 构建索引 const index = new HNSWIndex({ M: 16, efConstruction: 200 }); console.log('构建索引...'); const buildStart = performance.now(); const vectors = []; for (let i = 0; i < N; i++) { const v = randomVector(); vectors.push(v); index.insert(v, i); } const buildTime = performance.now() - buildStart; console.log(`构建 ${N} 条向量耗时: ${buildTime.toFixed(2)}ms`); // 查询测试 const queries = []; for (let i = 0; i < 100; i++) { queries.push(randomVector()); } // HNSW 查询 console.log('\nHNSW 查询...'); const hnswStart = performance.now(); const hnswResults = []; for (const q of queries) { hnswResults.push(index.search(q, K, 50)); } const hnswTime = performance.now() - hnswStart; console.log(`HNSW 查询 100 次耗时: ${hnswTime.toFixed(2)}ms`); console.log(`平均单次: ${(hnswTime / 100).toFixed(3)}ms`); // 暴力搜索对比 console.log('\n暴力搜索...'); const bruteStart = performance.now(); const bruteResults = []; for (const q of queries) { const dists = []; for (let i = 0; i < N; i++) { dists.push({ id: i, dist: VectorOps.euclideanDistanceSq(q, vectors[i]) }); } dists.sort((a, b) => a.dist - b.dist); bruteResults.push(dists.slice(0, K)); } const bruteTime = performance.now() - bruteStart; console.log(`暴力搜索 100 次耗时: ${bruteTime.toFixed(2)}ms`); console.log(`平均单次: ${(bruteTime / 100).toFixed(3)}ms`); // 计算召回率 let totalRecall = 0; for (let i = 0; i < queries.length; i++) { const hnswIds = new Set(hnswResults[i].map(r => r.id)); const bruteIds = new Set(bruteResults[i].map(r => r.id)); let hit = 0; for (const id of hnswIds) { if (bruteIds.has(id)) hit++; } totalRecall += hit / K; } console.log(`\n平均召回率: ${(totalRecall / queries.length * 100).toFixed(2)}%`); } testHNSW();
在Chrome上的典型结果:构建10000条128维向量耗时约2-3秒,HNSW单次查询约0.5-1ms,暴力搜索单次查询约5-10ms,加速比10倍以上。召回率在95%以上(efSearch=50)。
如果扩展到100万条向量,HNSW的查询延迟通常在1-5ms,而暴力搜索需要500-1000ms,加速比达到百倍以上。这正是HNSW在工业界被广泛采用的原因。
八、参数调优与工程考量
HNSW有三个核心参数,它们之间存在明确的权衡:
M(每层最大连接数)。M越大,图越稠密,搜索质量越高,但内存占用和构建时间也越大。典型值在16到64之间。M=16适用于大多数场景,M=32适合高维向量(如768维以上),M=64适合对召回率要求极高的场景。
efConstruction(构建时的搜索宽度)。efConstruction越大,构建时找到的邻居越好,图的质量越高。典型值在100到500之间。efConstruction=200是通用配置,efConstruction=500适合对索引质量要求极高的离线场景。
efSearch(查询时的搜索宽度)。efSearch越大,查询时搜索的候选越多,召回率越高,但延迟也越大。efSearch是运行时参数,可以在查询时动态调整。efSearch=50通常能达到95%以上的召回率,efSearch=200能达到99%以上。
内存占用是HNSW的主要成本。每个节点在第0层最多有Mmax0=2M个连接,在第1到L层最多有Mmax=M个连接。对于100万个节点、M=16的索引,第0层需要100万32个连接,每个连接存储一个整数ID(4字节),约128MB。加上向量本身的存储(100万128维4字节=512MB),总内存约640MB。如果向量维度更高或M更大,内存占用会线性增长。
九、与IVF、PQ的对比
HNSW不是唯一的ANN算法。工业界常用的还有IVF(倒排文件索引)和PQ(乘积量化)。
IVF将向量空间划分为K个聚类,查询时只在最近的几个聚类中搜索。它的优势是内存占用低(只需要存储聚类中心和向量ID),构建速度快。缺点是召回率受聚类质量影响大,且不支持增量插入。
PQ将高维向量切分为多个子空间,对每个子空间做量化编码。它的优势是内存占用极低(每个向量压缩到几字节),适合十亿级规模。缺点是量化误差导致召回率损失,通常需要与IVF组合使用(IVF-PQ)。
HNSW的优势是召回率高、查询延迟低、支持增量插入。缺点是内存占用大、构建速度慢。在百万级到千万级向量、对延迟敏感的场景中,HNSW是首选。在十亿级向量、内存受限的场景中,IVF-PQ更合适。
实际生产中,Milvus和Qdrant等向量数据库通常同时支持HNSW和IVF-PQ,用户可以根据数据规模和延迟要求选择合适的索引类型。
十、总结
从距离度量到概率层分配,从图结构插入到启发式邻居选择,从贪婪搜索到KNN查询——这个HNSW向量索引的核心代码不到300行,但覆盖了现代向量检索的全部关键机制。
HNSW的核心设计哲学是用分层图结构模拟跳表的搜索路径:上层稀疏用于快速跳转,下层稠密用于精确查找。概率层分配保证了图的层次结构自然形成,启发式邻居选择保证了图在空间中的连通性和多样性,贪婪搜索保证了查询的高效性。
理解了这套最小内核,再去看Milvus的HNSW实现、Qdrant的图索引、Faiss的HNSW模块,会发现它们在架构上遵循的是同一套思路——分层图、启发式选择、ef参数控制搜索宽度。差异只在工程细节:并行化构建、SIMD距离计算、磁盘持久化、增量更新。HNSW是这一切的起点。
- 点赞
- 收藏
- 关注作者
评论(0)