资讯动态

cuVS+Elasticsearch:GPU加速亿级向量索引构建与查询实战

发布时间:2026/10/3 11:10:16 来源:尧图企业网站定制
前阵子做了一个日志语义检索的 PoC数据量是一亿三千八百万条 384 维 embedding。这个量级听起来不算夸张但细算一下单条向量按 float32 存储就是 1536 字节全量裸数据 212GB 出头。第一版图省事直接用 Elasticsearch 8.11 的 dense_vector 加 HNSW结果索引构建跑了一整夜没结束单条查询 p95 稳定在 180ms 开外召回率还忽高忽低。团队一开始怀疑是 HNSW 参数没调好但换了几组参数都差不多最后才意识到亿级向量的 CPU 索引构建确实已经撞上了物理墙。后来我们换成了“NVIDIA cuVS 构建 GPU 加速向量索引 Elasticsearch 管存储和过滤”的组合让 cuVS 用 GPU 把 1.38 亿条向量的压缩索引建完再把候选 doc_id 丢回 ES 做元数据检索。实测下来从原始 embedding 文件到可查询接口总耗时压在 10 分钟以内查询 p50 只有 44ms。这篇就把整个方案拆开讲清楚为什么 ES 原生会撞墙、cuVS 的加速原理、部署架构怎么搭、具体落地步骤、以及我们踩过的一堆坑。1. 先聊聊为什么 Elasticsearch 原生向量路数在这个量级会撞墙1.1 1.38 亿向量意味着什么先把这个数字落到具体体感上。1.38 亿条 384 维 float32 向量单条大小是 384 × 4 1536 字节全量就是 211.9GB。这个规模对现代服务器来说磁盘和内存都不算致命真正要命的是构建 ANN 索引的计算量。以 Elasticsearch 默认的 HNSW 为例它的构建过程本质上是逐条插入向量、动态维护一张多层近邻图。每插入一个点都要在图上做若干轮搜索找到合适的邻居并建立连接。这个过程的计算量不是线性的数据量越大每个新点需要考察的候选邻居就越多整体耗时呈现超线性增长。用个不太严谨但直观的类比给一栋楼的每个房间都绘制通往周边房间的通道1.38 亿个房间让 CPU 一间一间画和让 GPU 并行画效率差距是数量级的。所以这个量级下问题根本不是“ES 参数没调好”而是架构选型就不该用 CPU 全量构建 HNSW。我把结论放在前面千万级以下 ES 原生舒服亿级以上就得考虑专门的计算引擎。1.2 ES 原生 HNSW 的实际表现和瓶颈我们最初的测试环境是 24 核 CPU、128GB 内存、NVMe 磁盘ES 8.11 单节点预热后dense_vector 默认 HNSW 参数m16ef_construction100。灌入 1.38 亿条向量后索引构建跑了十几个小时仍然没有结束。更麻烦的是构建过程中查询性能也被拖得很厉害业务侧根本没法用。这背后有两个深层原因。第一Lucene 的索引是分段segment结构的HNSW 图按 segment 构建也就是说一个 shard 里可能存在多张子图。查询时必须并行搜多张子图再合并结果数据量大到亿级之后分段过多、图碎片化的问题会被急剧放大。第二距离计算全部压在 CPU 上。一条 384 维 float32 向量的欧氏距离是 1536 次乘加运算亿级数据意味着几千亿次距离计算而且这中间还伴随大量内存随机访问和 cache missCPU 就算有二十几个核也很难扛住。我这么说不是要否定 ES。它的强项是全文检索、分布式、生态成熟但当向量规模到了这个量级它作为一个“通用搜索引擎”的设计取舍决定了它不适合承担最重的那部分 ANN 计算。把什么都塞进 ES最终只会得到一个又慢又不稳定的集群。1.3 这个方案的适用边界先泼一盆冷水不是所有场景都该上 GPU。如果向量规模在一千万以下QPS 要求也不高ES 原生 dense_vector 足够省事运维成本极低。硬上一套 cuVS 服务反而是给自己找麻烦。这个方案真正适合的场景有三个特征向量规模亿级以上、业务方持有 GPU 资源至少一张 24G 以上显存的卡、查询延迟敏感。比如日志语义检索、图片特征去重、推荐系统候选召回。如果完全没有 ES 的元数据过滤和通用查询需求也可以直接纯 cuVS 加对象存储但大多数业务团队还是希望对外保留一个完整的查询入口ES 在这里的价值不可替代。所以后面这套架构的定位很明确用 GPU 做“计算密集的 ANN”用 ES 做“能力完整的业务查询层”。2. cuVS 的加速思路不是把 HNSW 搬到 GPU 那么简单2.1 cuVS 在 RAPIDS 生态里的定位NVIDIA cuVS 是 RAPIDS 生态里的向量搜索和聚类库早期属于 RAFT 的一部分后来独立出来专门做 nearest neighbors。底层是 CUDA/C上层提供 Python API也支持 C 直接调用。很多做向量检索的人可能更熟悉 FAISSFAISS 也有 GPU Index但实现思路比较像“把 CPU 算法平移过来”cuVS 里专门为 GPU 架构设计了 CAGRA 这类图索引构建和检索路径都针对大规模并行做了重构实际效果差异不小。对我们这种把 ES 当主存储、需要高性能 ANN 的场景来说cuVS 最大的价值是有多种索引类型可选并且训练、压缩、检索都能在 GPU 上完成不需要反复在 host 和 device 之间搬运数据。2.2 索引类型怎么选CAGRA、IVF-PQ 还是 IVF-FlatcuVS 里我们实际比较过三类索引CAGRA、IVF-Flat、IVF-PQ。它们的侧重点完全不同。CAGRA 是 NVIDIA 专门为 GPU 设计的基于图的索引构建和查询都在 GPU 上跑速度极快、召回率也很高代价是构建时最好把全部原始向量放进显存。IVF-Flat 是倒排加原始向量不压缩、召回率高但显存占用同样可观。IVF-PQ 则是把向量做乘积量化压缩显存占用小一个数量级适合超大规模数据代价是 PQ 有损召回率需要靠参数调优弥补。索引类型显存占用构建速度召回率适用场景CAGRA高需容纳原始向量极快非常高数据集能塞进单卡显存IVF-Flat高原始向量不压缩较快高召回优先、显存充足IVF-PQ低压缩后通常几 GB快中高需调参亿级以上的大规模数据我们最终选了 IVF-PQ。理由很直接212GB 原始数据放不进 A100 的 80GB 显存IVF-PQ 能把每条向量压到 24 字节整个索引只有 4~5GB检索时 GPU 完全装得下。如果你手头的数据是 128 维、总容量在 70GB 以内CAGRA 的效果会更好构建速度和召回率都更让人省心。2.3 关键参数背后的原理IVF-PQ 的核心参数有三个nlist、M、nprobe。这三个参数直接决定了索引的构建质量、存储大小和查询效率。nlist 是倒排聚类的中心数。数据进来后先做 k-means 聚类nlist 越大每个聚类桶越小检索时定位更精细但训练和构建成本也会上升。我们这边定的是 4096。M 是 PQ 子空间的数量这个参数会把 384 维拆成 M 段每段单独做量化。M 越大压缩后的失真越小但每条向量的存储空间也越大。我们选 M24也就是每段 16 维。nprobe 是查询时探测多少个聚类桶nprobe 越大召回越好延迟也越高我们最初设 32后面调优时反复动过。PQ 压缩的原理值得展开说一下。384 维 float32 向量按 24 段切分每段 16 维。对每段分别做 k-means 生成 256 个中心点然后每条向量在每段里只记录“离哪个中心最近”的编号。每个编号用 8bit 存储24 段就是 24 字节。原本 1536 字节变成 24 字节压缩整整 64 倍。打个比方这就不是用完整门牌号定位而是用“省市区 街道关键信息”快速锁定一个大致区域精度会有损失但查询成本低得多。3. 部署形态ES 管存储cuVS 管检索3.1 这套架构如何分工很多团队在接触 cuVS 时第一反应是“能不能把 GPU 索引塞进 ES 插件里”。我们后来明确放弃了这条路线原因有三点ES 是 Java 生态JNI 调 CUDA 的集成复杂度高升级 ES 或 cuVS 都会互相牵连GPU 节点出现故障时不应该拖垮主查询集群独立的 GPU 检索服务可以按照 GPU 使用率单独扩缩容部署灵活得多。我们的实际架构是ES 的职责收窄到三件事——文档主存储、业务字段过滤、最终结果聚合。cuVS 独立跑在一个 GPU 服务节点上只负责 ANN 检索对外暴露一个轻量 REST 接口。查询流程是用户请求进来先由 embedding 模型把文本或图片转成向量然后调 GPU 服务拿回 topK 候选 doc_id再带着业务过滤条件去 ES 批量取回完整文档最后组装返回。这套分工其实也是很多团队做向量检索引擎时的思维误区总想用一个系统干完所有事结果两边都将就。把 ANN 和业务查询拆开各自发挥各自的长处反而更干净。3.2 硬件、驱动、CUDA 版本与 ES 版本组合GPU 建议直接上 Ampere 或更新的架构A100 80G、L40S、H100 都行开发机用 RTX 4090 24G 也能跑只是数据集要缩小。驱动和 CUDA 建议拉到比较新的稳定组合驱动 550 系列、CUDA 12.4、cuVS 24.10、Python 3.10。ES 这边 8.11 和 8.13 都测过没有发现兼容性问题。强烈建议直接用 NVIDIA NGC 的 RAPIDS 容器镜像来部署 cuVS 服务镜像里已经装好了 cuVS 和全部依赖省去自己配 conda 环境的痛苦。ES 是 Java 服务和 GPU 服务分开部署GPU 节点不要和 ES 节点混部否则显存和 CPU 资源互相抢出问题都不好定位。组件建议版本GPUA100 80G / L40S / H100最低 RTX 4090驱动NVIDIA Driver 550CUDA12.4cuVS24.10Python3.10Elasticsearch8.11 / 8.13这里有个容易被忽略的点卡太老跑不了。cuVS 要求 GPU compute capability 7.0 以上也就是 Volta 起步CAGRA 建议 8.0 以上。拿 P100 这类 Pascal 老卡是跑不起来的。3.3 数据流与接口约定GPU 索引输出的结果是整数行号不是业务侧的 doc_id。所以数据流里必须维护一张“整数行号 - doc_id 字符串”的映射表构建时按顺序给每条向量编号查询时把行号转回 doc_id再去 ES 取数。映射表直接放服务内存里用 Python dict 或者哈希表都行亿级数据大约占 1~2GB 内存可接受。接口上我们用一个 FastAPI 服务收口暴露两个核心 POST 接口/build 负责接收待构建的向量文件路径并触发索引构建/search 接收 query 向量返回 topK 的 doc_id 数组和距离分数。做增量更新时如果业务上必须支持频繁删除建议直接定时全量重建索引而不是做复杂的增量索引维护。删除导致的行号漂移问题在 GPU 索引里非常麻烦不如 ES 侧在取数时用过滤条件把已删除 doc 滤掉来得简单。4. 完整落地过程从原始 embedding 到可查询的 ES 接口4.1 数据准备与预处理构建前提是已经拿到所有向量的 embedding 文件。我们统一存在 Parquet 文件里字段包括 doc_id、id 维度数组 vector以及若干业务字段。读取用 cuDF能直接拿到 GPU 上的 DataFrame省掉一次 host 到 device 的拷贝。预处理阶段做两件事。第一是向量归一化。语义检索默认用余弦相似度直接把向量做 L2 normalize 后内积结果就等于余弦相似度cuVS 那边配 metricinner_product 就行。第二是存储精度。embedding 文件用 float16 保存能省一半磁盘和显存带宽训练码本和构建索引时再转回 float32。数据量越大的场景float16 的收益越明显。还有一个非常实际的建议正式全量构建之前先用一个 10 万行的子集把整条链路跑通确认查询结果、映射关系、ES 返回都正常再上全量。全量构建一旦因为一个低级 bug 跑到一半挂了重跑一次很浪费时间。4.2 用 cuVS 构建 1.38 亿向量的 GPU 索引核心构建流程在 cuVS 里其实不长关键是多批处理和显存控制。伪代码逻辑如下import cudf import cupy as cp from cuvs.neighbors import ivf_pq # 分批读取 Parquet df cudf.read_parquet(embeddings_part_000.parquet) vectors df[vector].to_cupy().astype(cp.float32) # L2 归一化 norms cp.linalg.norm(vectors, axis1, keepdimsTrue) vectors vectors / norms # 配置 IVF-PQ 参数 params ivf_pq.IndexParams( n_lists4096, n_bits8, metricinner_product, ) # 训练 构建索引 index ivf_pq.build(vectors, params) index.save(es_cuvs_index.bin)真实生产环境里212GB 数据不可能一次性全部塞进显存。我们的处理是两步走先用抽样数据训练码本再分块把全量向量交给 GPU 做 PQ 压缩和倒排写入。抽样量控制在 200 万行左右足以训练出稳定的 k-means 中心。全量向量按 2000 万行一块反复循环读取、压缩、写入索引每处理完一块就释放显存确保峰值不会冲破 A100 的 80GB。这一步是整个方案里最需要细心的地方。很多人一上来就直接ivf_pq.build(全量数据)A100 也不一定扛得住。cuVS 支持先 train 再 extend把训练和全量写入拆开是处理超大数据集的正确姿势。4.3 索引写入 ES 与查询链路打通需要特别提醒我们不会把 1.38 亿条向量全量写进 ES 去做 dense_vector 索引。那等于让 ES 再建一遍 CPU 的 HNSW纯纯浪费资源。ES 里只存两样东西doc_id 和业务字段向量只按需存储、不开索引。如果业务上确实需要在 ES 里保留原始向量mapping 里一定要把 index 关掉{ mappings: { properties: { doc_id: { type: keyword }, vector: { type: dense_vector, dims: 384, index: false, similarity: cosine } } } }bulk 写入的优化也要注意。我们把 refresh_interval 临时设为 -1也就是关闭自动刷新灌完全量数据后再手动 refresh 一次。默认的默认刷新间隔是 1 秒亿级数据写入时频繁触发 refresh会让完整导入时间多出好几倍。写入完成后记得把 refresh_interval 恢复原值。查询链路实现时先调 cuVS 服务拿到 topK doc_id 数组再用 mget 或者带过滤条件的 terms 查询去 ES 取数。有一个细节容易被坑mget 返回的文档顺序不保证和传入 id 顺序一致回包前必须按自己的候选顺序重新排一次。4.4 验证三件事耗时、召回率、延迟搭建完成后必须验证三个指标构建耗时、召回率、端到端延迟。构建耗时的定义是从读取 embedding 文件开始到索引保存成功中间每一步都要计时方便排查瓶颈。召回率我们惯例上抽 2000 条 query和暴力检索的 topK10 结果做对比看有多少重合目标是至少 0.9。延迟则用压测工具打 POST /search统计 p50、p95、p99。如果召回率不达标调优顺序建议是先提高 nprobe再看 PQ 的 M 参数最后考虑加 re-rank 精排。如果延迟不达标优先看 ES 取数那边的瓶颈GPU 端单个 query 通常只有几毫秒ES 反而是大头。5. 实测记录10 分钟的构成与性能数字5.1 测试环境先说清楚我们的测试环境方便大家对照。GPU 节点是一台双路 64 核 CPU、512GB 内存、A100 80G 的服务器数据盘是 NVMe SSD。ES 是三个节点的集群每个节点 32 核 CPU、128GB 内存版本 8.13。数据集就是前面反复提到的 1.38 亿条 384 维 float32 embedding全量 212GB训练抽样 200 万条。5.2 构建耗时拆解一次完整的构建耗时拆解如下阶段耗时读取与归一化1 分 50 秒码本训练200 万抽样nlist4096M241 分 20 秒全量 PQ 压缩与倒排索引写入5 分 10 秒保存索引文件35 秒总计8 分 55 秒这里有个前提是数据在 NVMe 上读取速度够快。如果数据在 SATA 盘或者网络文件系统上光读取这步可能就要多花好几倍时间。对比来看同一份数据用 ES 原生 HNSW 跑了十六七个小时没建完我们直接放弃了用 CPU 版 FAISS 参考公开 benchmark 也得 8 到 12 小时。GPU 加速带来的收益主要是把大量并行的距离计算和聚类训练从 CPU 循环搬到了 CUDA 核上压缩写入阶段得益于高吞吐的 batch 处理。10 分钟这个数字不是理论值是我们操作层面的真实体感。只要训练抽样不过大、批次大小合理、数据文件放在 NVMe 上这个量级基本都能跑进 10 分钟。5.3 查询延迟与召回率查询性能的关键数字单条查询在 GPU 端耗时 3~5msES 取元数据加过滤 20~40ms端到端 p50 44msp95 76msp99 120ms。这个延迟对这个数据量来说已经非常理想了用户的搜索交互体感基本无感知。批量查询是 GPU 的强项。我们压测时一批打 1000 条 queryGPU 端总耗时约 30ms相当于单条均摊 0.03ms不含 ES 取数所以如果你的场景是离线批量打分或者内容去重这个方案的吞吐优势会体现得更加明显。召回率方面topK100、nprobe32 时召回率10 是 0.93。把 nprobe 从 32 提高到 64召回率升到 0.96但查询延迟多了大概 40%。这是个典型的平衡点取舍要看业务对召回和延迟的容忍度。5.4 与 CPU 方案对比把两种方案摆在一起看会更直观对比项ES 原生 HNSWcuVS ES索引构建十几小时未完成8 分 55 秒单条查询 p50180ms44ms显存需求无80G 级单卡 A100召回率稳定性调参困难稳定且可调运维复杂度低中多一个 GPU 服务CPU 方案并不是一无是处它的优势在于省事、资源门槛低、和现有 ES 体系完全融合。我的经验值是向量规模一千万到五千万是分水岭低于这个规模就老老实实用 ES 原生超过一亿并且你手里有 GPU 资源cuVS 这条路基本是绕不开的。6. 踩坑实录显存、驱动、版本和参数6.1 CUDA / 驱动版本不匹配导致的问题GPU 环境最烦的不是算法而是环境。我们第一次部署时cuVS 加载直接报 CUDA_ERROR_NO_DEVICE查了半天发现是容器没透传 GPU后来换了一台机器又遇到 compute capability 不支持才发现那张卡是 Pascal 架构的老卡压根不在 cuVS 支持列表里。排查步骤建议固定成套路先跑 nvidia-smi 看驱动版本和可支持的 CUDA 版本再进容器里跑 nvidia-smi 确认 GPU 能看到最后看 CUDA_VISIBLE_DEVICES 有没有把显存隔离到别的任务上。网上很多人搜“英伟达gpu错误代码43”多数情况下就是驱动和容器映射的问题和这里本质类似。环境问题直接上 NGC 容器镜像能避开 90% 的坑。6.2 显存峰值不是索引大小的错这个坑我们栽过一次。当时以为索引文件只有 5GB80GB 显存随便放结果训练阶段直接 OOM。原因是 k-means 训练过程中cuVS 的底层实现会在显存里放训练数据、中心矩阵、距离矩阵等多份中间变量。抽样 1000 万条、每条 384 维 float32光训练数据就是 15.3GB再叠加距离矩阵和梯度计算瞬间冲破 80GB。解决方式两点一是训练抽样量控制在 200 万左右给中间变量留足空间二是全量写入阶段用分批 extend而不是一次性把几十 GB 向量灌进显存。建议正式跑之前先用 PyNVML 写个小脚本监控显存曲线观察峰值避免半夜跑挂。6.3 nprobe 与召回率的交易nprobe 调参是个典型的性价比决策。nprobe 太低召回率难看nprobe 太高延迟翻倍。我们测试时 nprobe32 召回率 0.93nprobe64 召回率 0.96但延迟从 76ms 涨到 106ms 左右。对不同业务这个平衡点完全不一样一定要用自己的数据集实测。PQ 是有损压缩nprobe 再高也补不回所有精度。想要更高的召回率我们用的技巧是两阶段检索cuVS 先返回 topK200 的粗筛结果然后拿这些 doc 的原始 384 维向量在 GPU 上做一次精排取前 100 返回。实测召回率能从 0.93 提升到 0.97而精排 200 条的额外耗时只有 10ms 左右性价比非常高。6.4 ES 集成中的几个细节最后说几个 ES 集成时的实操细节。第一个是 dense_vector 的 index 一定要设为 false否则 ES 会额外建一份 CPU HNSW索引构建时间和存储空间白白翻倍。第二个是 bulk 导入期间把 refresh_interval 调到 -1写完再恢复这个操作能把全量数据导入时间压下去一大截。第三个是 mget 返回顺序问题返回文档顺序不保证和请求 id 顺序一致必须先按候选顺序重排再回包。第四个是过滤条件的下推策略如果业务过滤能把候选集缩小到原来的十分之一以下那就要考虑先过滤再 ANN如果过滤条件很弱先 ANN 再过滤效率更高。这个取舍必须用真实数据测不能拍脑袋。我们整套方案最终稳定跑在线上后我个人最大的体会是这个方案的难度不在 cuVS 本身而在把 GPU 索引服务和 ES 的元数据链路衔接好。第一次跑通之后后面所有迭代都很快。如果你手头也有类似规模的向量检索需求我的建议永远是先拿小数据跑通整个链路再逐步放大不要一上来就直接往 1.38 亿上冲。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑