大规模向量相似性搜索实战:Vearch 分片、索引与调优
2026/9/18 9:19:09 网站建设 项目流程

在大规模向量相似性搜索系统这个方向摸爬滚打几年,踩过的坑基本能写一本小册子。最早做图片去重的时候,几十万条向量丢进单机索引里随便跑跑就完事了;后来业务量上来了,数据量从百万级冲到亿级,查询并发从个位数涨到几百,同一套代码就开始出各种幺蛾子:内存爆了、召回率莫名其妙掉到 0.6、写入一多查询就开始抖。vearch 就是在这种背景下进入视野的——它是一个分布式的向量相似性搜索系统,支持向量检索加标量过滤的混合查询,能做在线增删改和水平扩容。这篇文章不打算复述官方文档,而是按照一个真实项目落地的顺序,把大规模向量相似性搜索系统里那几个绕不过去的挑战摊开讲:数据怎么切、索引怎么选、参数怎么算、线上出问题怎么查。适合正在做推荐召回、图像检索、语义搜索、去重聚类的同学,也适合刚接触向量检索、想知道单机方案什么时候会撑不住的人。

1. 先搞清楚大规模向量检索到底难在哪

1.1 从"索引能装进内存"到"一台机器装不下"

小规模场景下,向量检索其实是个很舒服的问题。一百万条 128 维的 float32 向量,原始数据体积是 1000000 × 128 × 4 字节 ≈ 512MB,加上索引结构撑死一两个 GB,随便一台 32G 内存的机器都能塞下,暴力检索(Flat)都能跑到毫秒级。这个阶段你几乎不需要考虑什么工程问题,算法库里几行代码就搞定了。

数据量涨到一亿条,情况立刻变了。同样的 128 维向量,原始数据就是 51.2GB,这还没算索引结构本身的开销。实际部署里,索引结构加上 ID 映射、倒排表、图的邻接表这些东西,通常要在原始数据基础上乘 1.3 到 1.5,也就是 70GB 往上。更麻烦的是,向量索引和普通的 KV 存储不一样,它很难优雅地"部分加载"——图索引的邻居是跨节点跳转的,你没法像读文件那样只读一小块。所以当数据规模超过单机内存上限时,你要么牺牲精度做量化压缩,要么做分片把数据切开,没有第三条路。

这就是第一个核心挑战:数据规模的增长会同时击穿内存容量和单机吞吐两个上限,而这两个问题的解法往往是互相牵制的——分片能解决容量,但分片会带来跨分片的召回合并问题;量化能解决内存,但量化会损失精度。做架构设计的时候,实际上是在这条曲线上找一个业务能接受的平衡点。

1.2 四个绕不开的硬指标:规模、延迟、召回、更新

我把大规模向量检索的挑战归纳成四个维度,它们之间是互相打架的关系,理解了这一点,后面所有的参数调优都不会迷路。

规模决定了你必须分片。分片之后每个分片是一个独立的检索单元,查询请求要广播到所有相关分片,然后把各分片的 Top-K 结果合并成全局 Top-K。这里有个容易被忽略的细节:如果查询要求返回 Top-10,那么每个分片至少要返回 Top-10,合并后才能保证全局正确;有些实现为了省带宽只让分片返回 Top-3,那最终结果的召回率一定是有损的。

延迟决定了你能用多复杂的索引。图的索引(HNSW 这类)检索路径短、召回高,但构建慢、内存占用大;倒排加量化的索引(IVF_PQ 这类)内存友好、构建快,但需要扫描多个倒排桶才能把召回拉起来,延迟和召回是一对矛盾。P99 延迟这个指标尤其要注意,均值好看没用,分片一多,尾部延迟会被最慢的那个分片拖死。

召回是个业务指标而不是技术指标。技术同学容易陷入"我的召回率 0.95 已经很高了"的自我满足,但业务方关心的是"用户想找的那条内容有没有出现在前 10 条里"。这两个口径经常差很远。我的习惯是:在离线用全量暴力检索的结果作为基准,测出索引方案的 Recall@K,把它当成一个必须持续监控的线上指标,而不是上线前测一次就完事。

更新是最容易被低估的。向量库不是只读的,商品会下架、图片会删除、用户画像会变化。一边是大批量写入,一边是在线查询,这两件事在同一个进程里抢 CPU 和 IO,稍不注意查询延迟就会出现毛刺。更隐蔽的问题是删除——很多向量索引的删除是"标记删除",物理空间不会立刻释放,跑一段时间后内存占用会比预期高出一大截,必须靠定期的 compaction 来回收。

挑战维度具体表现常见解法代价
规模单机内存装不下索引水平分片、向量量化合并开销、精度损失
延迟P99 抖动、尾部慢分片减少扫描桶数、副本读召回下降、成本上升
召回业务结果不达预期调大扫描范围、换索引类型延迟上升、内存上升
更新写入抖动、删除不释放批量写、定期 compaction实现复杂度上升

这张表我建议贴在工位上,每次调参之前先问自己:我这次调整,是把压力从哪一列转移到了哪一列。

1.3 为什么"向量库 + 标量过滤"比纯向量检索更难

真实业务里几乎没有纯向量检索。用户搜"红色连衣裙",本质是向量相似度加颜色等于红色、类目等于连衣裙的复合条件。这个组合查询在工程上非常恶心,因为它把两种完全不同形态的检索揉在了一起。

一种做法是"先过滤后检索":先用标量条件筛出候选集,再在候选集里做向量比对。问题是如果标量条件筛选出来的集合很大(比如筛出 8000 万条),那向量检索还是要在这 8000 万条里做,索引基本用不上;如果筛出来的集合很小(比如只有 200 条),那直接暴力比对反而最快。另一种做法是"先检索后过滤":先拿向量 Top-N,再用标量条件过滤。这个做法在过滤条件稀疏的时候会灾难性失效——Top-N 里可能一条都不满足条件,返回空结果。

vearch 这类系统选择的是把过滤条件下推到检索过程中,在遍历倒排桶或者图邻居的时候同步判断标量条件。这个实现难度不低,因为过滤逻辑要和索引遍历逻辑耦合在一起,还要保证在分布式环境下每个分片上的过滤行为一致。实测下来,当过滤条件的选择性在 1% 到 30% 这个区间时,下推过滤的效果最好;选择性超过 50%,下推反而会因为额外的判断开销拖慢速度。

2. vearch 的整体设计思路拆解

2.1 数据模型:从"库表"到"空间"的映射关系

vearch 的数据模型分三层,理解这三层的对应关系是后面所有操作的基础。最外层是 DB,对应关系型数据库里的"数据库"概念,主要作用是做资源隔离和权限划分,一般一个业务一个 DB。中间层是 Space,这个词在别的系统里可能叫 Collection 或者 Table,它才是真正的核心——一个 Space 定义了向量的维度、相似度度量方式、索引类型和标量字段的 schema,还定义了分片数和副本数。最内层是 Document,就是一条条数据,每条 Document 有一个主键和若干字段,其中包含一个或多个向量字段。

这个模型设计的好处是把"索引配置"和"数据存储"绑定在了一起。你建 Space 的时候就得想清楚分片数、索引类型这些事,建完之后这些属性通常不可改。这是个挺硬核的设计取舍——灵活度换来了性能上的确定性。我个人的体会是:建 Space 之前一定要把数据量增长曲线和召回要求想清楚,因为上线之后再想调整分片数,基本等于重建整个 Space 再灌一遍数据,这个成本在亿级数据下是几个小时的停机时间,业务方很难接受。

一个典型的 Space 定义长这样(结构参考官方文档,具体字段以你所用版本的 API 为准):

{ "name": "image_feature", "partition_num": 12, "replica_num": 3, "engine": { "name": "gamma", "index_size": 2000000, "metric_type": "L2", "ncentroids": 4096, "nprobe": 32, "nsubquantizers": 64, "nbit": 8 }, "properties": { "item_id": { "type": "keyword", "index": true }, "category": { "type": "integer", "index": true }, "create_time": { "type": "integer", "index": true }, "feature": { "type": "vector", "dimension": 128, "format": "normalization" } } }

这里面 partition_num 是最关键的一个数字。它决定了数据被切成多少份,每份由一个独立的检索单元承载。分片太少,单分片数据量太大,内存和构建时间都扛不住;分片太多,查询时广播的分支数量上升,合并开销和尾部延迟都会恶化。我的经验公式是:先按总数据量除以单分片能舒适承载的数据量(通常 1000 万到 2000 万条)算出下界,再按集群机器数取一个整数倍,最后向上取整到能均匀分布到机器上的值。上面例子里 2.4 亿条数据、单分片目标 2000 万条,12 个分片刚好。

2.2 路由与分片:一次查询是怎么落到具体节点上的

vearch 集群的角色划分比较清晰,我按请求走的顺序说一遍。客户端请求先打到 Router 层,Router 是无状态的,可以随意扩缩容,它负责解析请求、确定这个 Space 涉及哪些分片、把请求扇出到对应的 Partition Server。Partition Server 才是真正干活的,每个分片的数据和索引都在这里面,同时它还要处理写入和索引构建。Master 节点负责元数据管理和集群调度,比如分片在哪些机器上、副本怎么分布,它不参与数据路径,所以一般情况下它不会是瓶颈。

这里有个实际部署时要注意的点:Router 层的负载均衡策略会直接影响尾部延迟。因为一次查询要等所有目标分片都返回结果才能合并,如果扇出策略是"按分片轮询",那某个刚好在跑 compaction 的分片就会成为长尾。更稳的做法是让客户端带上一个超时和降级策略——部分分片超时的时候,就用已返回的分片结果做合并,宁可召回稍降也不能让整个请求挂住。这个降级逻辑需要在客户端实现,官方 SDK 一般不会默认帮你做。

另一个容易被忽略的是副本读。replica_num 设为 3 意味着每个分片有三份数据,查询的时候是只在主副本上读还是可以读从副本,对吞吐的影响很大。允许读从副本能把查询吞吐提升接近 3 倍,但代价是副本之间的数据同步可能存在毫秒级的延迟,刚写入的数据不一定马上能查到。做近实时场景(比如秒级更新的推荐)时,这个一致性窗口必须和业务方对齐清楚。

2.3 计算与存储分离带来的弹性能力

大规模检索系统的一个核心痛点是:索引构建是个重活儿,而查询是个轻活儿,两者的资源需求峰值完全错开。如果计算和存储绑死在同一批机器上,那构建索引的时候查询就必然受影响。vearch 的架构在这一点上做了分离设计,Partition Server 负责计算和索引,底层可以用独立的存储层来持久化原始数据和索引快照。

这个设计带来的直接好处是扩容变简单了:新增机器后,把部分分片迁移过去,新节点从存储层拉取数据重建本地索引,期间老节点继续提供服务。代价是至少有一份数据要在存储层和计算层之间来回复制,网络带宽和存储成本都要额外算进去。我算过一笔账,2.4 亿条 128 维向量,原始向量数据大约 115GB,加上标量字段和索引快照,存储侧准备 300GB 到 400GB 是比较舒服的余量。

提示:做容量规划的时候,千万不要只看向量本身的体积。标量字段的倒排索引、主键到内部 ID 的映射表、以及索引构建过程中的临时文件,加起来经常比向量数据本身还大。我踩过一次坑,按向量体积算了 100GB 存储,结果实际用到 260GB 就告警了。

3. 核心细节:索引构建与参数调优的实操依据

3.1 索引类型怎么选:倒排量化还是图索引

vearch 里可选的索引类型主要是两类:一类是倒排加量化(IVF 系列,配合 PQ 做向量压缩),一类是图索引。这两种没有绝对的好坏,只有场景适配。

倒排量化的逻辑是:先用聚类把向量空间划成若干区域(每个区域的中心点叫质心),检索时先算出查询向量离哪些质心最近,只在这些质心对应的倒排桶里做精确比对。这里的核心参数是 ncentroids(质心数量)和 nprobe(每次扫描几个桶)。它的优点是内存占用可控、构建速度快、支持增量添加;缺点是召回率依赖 nprobe,nprobe 调大延迟就上来。

图索引的逻辑是:给每个向量建立若干条指向邻居的边,检索时从入口点出发,贪心地往离查询向量更近的邻居跳,跳几步就能到目标附近。它的优点是召回率和延迟都很优秀,通常比倒排量化高出一截;缺点是内存占用大(要存边表)、构建慢、删除不友好,而且它的召回是"跳"出来的,某些离群点的表现会不稳定。

我的选型经验是这样的:数据量在千万级以内、机器内存充裕、召回要求极致(比如 Recall@10 要 0.98 以上),选图索引;数据量上亿、内存吃紧、需要频繁更新,选倒排量化。如果业务对召回特别敏感又不得不做量化,可以走组合方案——先用倒排量化做粗筛拿到比较大的候选集,再用原始向量精排。这个两段式方案在推荐召回里非常常见,代价是多一份原始向量存储。

对比项倒排量化(IVF+PQ)图索引
内存占用低,可压缩至原始 1/8高,通常为原始 1.5 倍以上
构建速度快,亿级数据小时级慢,亿级数据数十小时
召回率中高,依赖 nprobe高且稳定
增量更新友好一般,删除代价大
适用规模亿级以上千万级以内

3.2 质心数量与扫描桶数的计算过程

这两个参数是调优的主战场,很多人凭感觉填,其实有比较清晰的推导路径。

先说 ncentroids。经验公式是 ncentroids ≈ 数据量的平方根,但这个值在亿级数据下会大到离谱(1 亿开平方是 10000,看着还行,10 亿就是 31623)。实际生产里我一般取 4096 到 65536 这个区间,理由是:质心数量翻倍,训练成本大约翻倍,而每个倒排桶的平均长度减半,检索时命中目标桶的概率提升有限。具体怎么定?我的做法是按单分片的数据量来算——单分片 2000 万条,取 8192 个质心,平均每桶约 2440 条;单分片 500 万条,取 4096 个质心,平均每桶约 1220 条。保持每桶在几百到几千这个量级,扫描效率最高。

再说 nprobe。这个参数直接决定了每次查询要扫描多少个桶,扫描桶数 × 每桶长度 ≈ 实际比对的向量数量。假设 8192 个质心,nprobe 设为 32,就是扫描 4‰ 的桶,大约 32 × 2440 ≈ 78000 条向量做精确比对。这个量级在单核上是毫秒级的。如果把 nprobe 调到 128,比对量变成 31 万条,延迟大约涨 4 倍,而召回率的提升可能只有 2 到 3 个百分点,性价比就下来了。

这里有个实测出来的规律可以参考:nprobe 从 1% 的质心数开始调,每次翻倍,观察 Recall@10 的曲线,当曲线出现明显的拐点(继续加大 nprobe 召回提升不足 0.5%)时,就停在这个值。我遇到过的最优值一般在质心数的 2% 到 5% 之间。举个例子,8192 个质心,nprobe 落在 64 到 200 之间比较常见。

3.3 向量量化的收益计算与精度代价

量化是解决内存问题的核心手段,值得单独算一笔账。一条 128 维的 float32 向量是 128 × 4 = 512 字节。用 PQ 压缩时,把 128 维切成 64 段,每段 2 维,每段用一个 8 位的编码表示(也就是 256 个聚类中心里选一个),那么压缩后的编码长度就是 64 字节。压缩比 = 512 / 64 = 8 倍。

这意味着什么?2.4 亿条向量,不压缩是 2.4 亿 × 512 字节 ≈ 115GB,压缩后是 2.4 亿 × 64 字节 ≈ 14.4GB。这个差距直接决定了你需要多少台机器。按每台 128G 内存、单机可用 100G 计算,不压缩至少要 2 台专门放向量(还得算索引开销,实际 3 台),压缩后一台就够了。

代价是精度。量化的本质是用近似值代替原始值,计算距离的时候用的是查表得到的近似距离,所以排序结果会有偏差。实测经验是:nsubquantizers 取到维度的 1/2 到 1/4,nbit 取 8,Recall@10 的损失通常在 2 到 5 个百分点之间。128 维取 64 段是比较保守的做法,如果内存实在紧张,取 32 段(每段 4 维)能再省一半内存,但召回损失可能扩大到 8 个百分点以上。

还有一个容易被忽略的细节:归一化。如果你的相似度度量用的是内积或者余弦,向量入库前必须做归一化处理,否则量化和距离计算的结果都会偏。vearch 的向量字段有个 format 配置,可以指定 normalization,让它入库时自动归一化。这个配置看起来小,但漏掉它的后果是召回率莫名其妙地低,而且很难排查,因为从数据上看一切都"正常"。

注意:量化参数在建 Space 的时候就要定下来,后续修改通常需要重建索引。所以第一版不要一上来就压到极限,先留一点内存余量,等数据量真的涨上来了再考虑加压缩比。我见过为了省机器把 nsubquantizers 压到 16 的,结果召回率掉到 0.7,最后返工重建,反而多花了时间。

4. 从零搭一套 Vearch 并跑通压测

4.1 环境准备与部署方式选择

部署方式上,开发验证阶段我强烈建议用容器方式起一个单机版,所有组件跑在一个进程里,省去配置多个节点的麻烦。生产环境则是每个角色独立部署,Master 至少 3 个节点保证元数据高可用,Router 按查询 QPS 横向扩展,Partition Server 按数据量和内存容量规划。

硬件配置上的经验值:Partition Server 是内存消耗大户,按"单分片索引内存 × 该节点上分片数"的 1.3 倍来配内存,多出来的 30% 留给索引构建时的临时开销和系统的页缓存。磁盘方面,如果用 SSD 做索引入库的临时空间,建索引的速度能比机械盘快 3 倍以上,这个钱值得花。CPU 的话,16 核起步比较稳妥,因为检索本身是计算密集型,而且还要和索引构建抢核。

网络方面有个坑要提前说:Router 到 Partition Server 的内网带宽要够。一次查询扇出到 12 个分片,每个分片返回 Top-10 的结果和对应向量的元数据,如果返回的字段很多(比如把图片 URL、标题都带上),单次查询的响应体积可能到几十 KB,QPS 上千的时候就是几十 MB/s 的持续流量。我就遇到过因为结果字段太多把内网打满的情况,后来把非必要字段从检索结果里去掉,只返回 ID,再由上层服务批量查详情,带宽立刻降下来了。

4.2 建库建表与数据灌入的实际操作

第一步建 DB 和 Space,用 REST 接口就行:

# 建库 curl -X PUT http://127.0.0.1:9001/db/_create \ -H 'content-type: application/json' \ -d '{"name": "test_db"}' # 建 Space,配置见前面第 2.1 节的 JSON curl -X PUT http://127.0.0.1:9001/space/test_db/_create \ -H 'content-type: application/json' \ -d @space.json

第二步灌数据。这里有几个实操细节直接影响入库速度和后续召回质量。

批量大小要控制好。我实测下来,单次批量 500 到 1000 条比较合适,太小则网络往返开销大,太大则单次请求超时风险高、内存峰值高。如果数据是自己生成的特征,注意向量的数值范围要合理,全部是 0 或者数值量级差异极大(有的维度是 0.001,有的是 1000)会让聚类效果变差,索引构建出来质量很低。

入库速度方面,我用 8 个并发、每批 500 条灌 1000 万条 128 维数据,大概花了 40 分钟左右,平均每秒 4000 多条。这里有个加速技巧:先灌数据,等数据全部落盘之后再触发索引构建。如果边灌边建索引,索引会经历多次重建,总耗时反而更长。vearch 的索引构建是后台异步进行的,所以入库接口返回成功不代表马上可查,需要留出构建时间窗口。

import requests import numpy as np BASE = "http://127.0.0.1:9001" headers = {"content-type": "application/json"} def upsert_batch(db, space, docs): url = f"{BASE}/document/upsert" payload = {"db_name": db, "space_name": space, "documents": docs} r = requests.post(url, json=payload, headers=headers, timeout=30) r.raise_for_status() return r.json() # 模拟生成 1000 条 128 维数据 docs = [] for i in range(1000): vec = np.random.randn(128).astype(np.float32) # 归一化,配合内积/余弦度量使用 vec = vec / np.linalg.norm(vec) docs.append({ "item_id": f"item_{i}", "category": i % 20, "create_time": 1700000000 + i, "feature": vec.tolist() }) print(upsert_batch("test_db", "image_feature", docs))

4.3 查询接口与压测脚本

查询接口用起来很直观,核心是把查询向量、返回条数和过滤条件组装好:

def search(db, space, query_vec, topk=10, nprobe=32, category=None): url = f"{BASE}/document/search" query = { "vector": [{"field": "feature", "feature": query_vec.tolist()}], "size": topk, "params": {"nprobe": nprobe} } if category is not None: query["filter"] = [{"range": {"category": [{"gte": category, "lte": category}]}}] payload = {"db_name": db, "space_name": space, "query": query} r = requests.post(url, json=payload, headers=headers, timeout=10) r.raise_for_status() return r.json()

压测这块,我的做法分两步。第一步测单条延迟,用一份固定的查询集(比如 1000 条真实查询向量),串行跑一遍,记录下来 P50、P95、P99 三个值。第二步测吞吐,用固定并发数(从 1 开始逐步加到 64)压 5 分钟,观察 QPS 和延迟的变化曲线,找到延迟开始明显恶化的那个并发点,那个点就是这套配置的实际容量上限。

这里有个必须做的对照实验:同一份查询集,用全量暴力检索跑一遍结果作为基准,然后计算索引检索的 Recall@10。具体做法是,暴力检索拿到真实 Top-10,索引检索拿到近似 Top-10,两者的交集大小除以 10 就是召回率。这个实验做一次只要几分钟,但能帮你确认手上的参数到底行不行,比看任何文档都靠谱。

def recall_at_k(base_ids, approx_ids, k=10): base_set = set(base_ids[:k]) approx_set = set(approx_ids[:k]) return len(base_set & approx_set) / k # 遍历查询集,统计平均召回 recalls = [] for qvec in query_set: base = search("test_db", "image_feature", qvec, topk=10, nprobe=8192) # 近似全扫描 approx = search("test_db", "image_feature", qvec, topk=10, nprobe=32) base_ids = [d["_id"] for d in base["data"]] approx_ids = [d["_id"] for d in approx["data"]] recalls.append(recall_at_k(base_ids, approx_ids)) print(f"平均召回率: {sum(recalls) / len(recalls):.4f}")

4.4 上线后要盯住的几个指标

系统跑起来之后,监控面板上我会固定看这几个数:查询 P99 延迟、单分片的内存使用率、索引构建任务的队列长度、以及一个容易被忽略的指标——每次查询实际扫描的向量条数。最后一个指标是很多问题的根因,它突然变大通常意味着数据分布发生了变化,比如某个类目的数据暴增导致个别倒排桶变得特别长。

还有一个指标是副本同步延迟。如果从副本落后主副本太多,读从副本拿到的就是旧数据,对于"刚发布的商品马上要能被搜到"这类需求会出问题。这个延迟一般在毫秒级,但集群压力大的时候可能涨到秒级,需要设一个阈值告警。

5. 常见问题与排查技巧实录

5.1 召回率突然掉了一截,怎么定位

这是最常见也最让人头疼的问题。我的排查顺序是固定的四步。

第一步,确认是不是查询侧的问题。拿一条已知能搜到的向量,直接在单分片上做暴力检索,看目标结果是否存在于该分片。如果不存在,说明数据没写进去或者写到了别的分片,问题在写入侧。这里有个常见原因:主键重复导致覆盖。如果业务用的主键设计不当(比如用时间戳生成,高并发下撞了),后来的数据会覆盖前面的,看起来数据量对得上,实际丢了不少。

第二步,确认是不是过滤条件的问题。把过滤条件去掉再查一次,如果召回恢复了,那就是过滤条件和向量检索的交互出了问题。常见情况是过滤条件的选择性太高(比如筛出来的候选集只占全量的 0.1%),导致每个分片返回的 Top-K 里满足条件的太少。解法是要么调大 size 让它多返回一些再过滤,要么把强过滤字段设计成和分片键关联,让查询只打到少数几个分片。

第三步,检查 nprobe 有没有被改动。有些团队为了压延迟会把 nprobe 调小,运行一段时间后忘了这回事,业务方反馈搜索质量下降,查半天代码没变,最后发现是配置漂移。

第四步,看数据分布。如果新入库的数据在向量空间里聚集在一个离原有数据很远的区域,而聚类质心是基于老数据训练的,那新数据的检索效果就会很差。这是我踩过的最隐蔽的一个坑——索引的质心不会自动适应新数据分布,需要定期用新数据重新训练质心。我们当时的做法是每周离线跑一次质心重训,然后滚动重建各分片的索引。

5.2 写入一多查询就抖,瓶颈在哪

这个现象几乎每个大规模检索系统都会遇到。根因是写入和查询在竞争同一批资源,主要是 CPU 和磁盘 IO。

排查的时候先看 CPU 使用率的时间序列,如果写入高峰和查询延迟毛刺在时间上高度重合,基本可以确定是资源竞争。解法有三条路:一是错峰,把大批量写入放到业务低峰期;二是限流,给写入通道设一个并发上限,别让它把 CPU 吃满;三是物理隔离,把承担大批量写入的分片和承担在线查询的分片分到不同机器上。

还有一个隐藏原因是 compaction。索引在后台合并小文件的时候会大量占用磁盘 IO,这个过程的开始和结束往往没有明显的外部信号,但会让查询延迟在几分钟内持续偏高。判断方法是对比磁盘 IO 利用率和查询延迟的曲线,如果 IO 打满的同时延迟上升,基本就是 compaction 在作祟。缓解办法是限制 compaction 的并发数和 IO 速率,牺牲一点写入速度换查询的稳定性。

5.3 内存用着用着就满了,怎么回收

内存缓慢增长然后告警,这个问题通常有三个来源。

第一个是删除的数据没有立即释放。前面提过,向量索引的删除大多是标记删除,物理空间要等 compaction 才回收。如果你的业务有大量删除(比如商品下架),一定要定期手动触发或者配置定期 compaction,并且监控"标记删除占比"这个指标,超过 20% 就该处理了。

第二个是查询缓存。有些实现在 Router 层做结果缓存来提速,缓存没有设上限的话会一直涨。这个要看具体版本的实现,配置缓存大小上限是必要的。

第三个是索引的额外开销被低估了。前面算内存的时候我建议按 1.3 倍算,但如果你的标量字段很多,每个字段都有倒排索引,那这个系数可能要提到 1.8 甚至 2.0。最靠谱的做法不是算,是实测:拿一份真实数据灌进去,跑一轮索引构建,然后用系统工具看进程的实际 RSS 内存,用这个数反推容量规划。

5.4 常见问题速查表

现象可能原因排查动作处理方式
召回率下降nprobe 变小、质心未更新、过滤过严对比配置、检查过滤选择性恢复参数、重训质心、调整过滤策略
查询延迟毛刺写入竞争、compaction 抢 IO对齐写入和延迟时间线错峰写入、限制 compaction 速率
内存持续增长标记删除未回收、缓存无上限看删除占比、看缓存配置定期 compaction、设置缓存上限
部分查询超时慢分片、扇出过多看单分片延迟分布客户端降级、拆分热点分片
写入吞吐低批量太小、索引同步构建看批量大小和构建队列调大批量、改为后台异步构建
新数据搜不到索引构建未完成、副本延迟检查构建任务状态等待构建、调大一致性级别

提示:这张表里的每一条我都在生产环境里遇到过至少一次。建议把"排查动作"这一列做成监控告警的联动项,很多问题提前 10 分钟发现就能避免一次故障。

6. 选型判断与成本控制的实际经验

6.1 什么规模该上分布式方案

这条线我摸索了很久,给个相对具体的判断依据:单机内存能否装下索引 + 查询 P99 是否稳定在业务可接受范围。如果两个都满足,别急着上分布式,单机方案的运维复杂度低得多,调优也简单。如果其中任何一个不满足,就该考虑分布式了。

具体到数字上,我的经验是数据量在 3000 万条 128 维向量以下、查询 QPS 在 200 以内,单机方案通常够用。超过这个规模,分布式带来的收益才开始明显超过它的复杂度成本。当然这跟向量维度关系很大,512 维向量的内存占用是 128 维的 4 倍,这条线要相应下调。

还有一个容易被忽略的判断维度是增长的确定性。如果业务预期数据量半年内会翻 10 倍,那即使当前规模单机能扛,也建议直接上分布式架构,因为迁移成本远高于一次性搭好。反过来,如果数据量长期稳定在小规模,硬上分布式就是给自己找麻烦——分片合并、副本同步、跨节点问题排查,每一样都要花时间。

6.2 机器成本到底花在哪

最后算一笔实在的账。一套支撑 2.4 亿条 128 维向量的检索集群,按前面提到的配置(PQ 压缩到 64 字节、12 个分片、3 副本),我的估算大致是这样:

向量数据压缩后约 14.4GB,加上标量字段倒排索引和 ID 映射,单副本大约 35GB 到 45GB。3 副本就是 105GB 到 135GB 的有效数据。但每个分片还要算上索引构建时的临时开销和系统页缓存,实际每台 Partition Server 配 128GB 内存比较稳妥,按每台承载 3 到 4 个分片算,需要 3 到 4 台。加上 Router 和 Master 各 2 到 3 台小规格机器,整体 6 到 8 台机器能撑起这套规模。

真正花钱的地方其实是重建索引的时间成本。2.4 亿条数据重建一次全量索引,我实测下来要 6 到 10 个小时。这意味着每次调整索引参数,都要预留一个维护窗口。所以我们后来养成了一个习惯:所有索引参数的调整,先在 1/10 规模的数据子集上验证召回率和延迟,确认可行之后再上全量重建。这个流程能省下大量返工时间,也避免了频繁的大规模重建影响线上稳定性。

我个人的体会是,向量检索系统的调优没有一劳永逸的配置,它是一个随着数据分布和业务需求持续变化的过程。真正值钱的不是某组"最优参数",而是一套能快速验证参数好坏的流程——离线基准、召回率对照、小规模先验证、全量再上。把这条流程搭起来,比记住任何具体数字都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询