Administrator
发布于 2024-11-13 / 11212 阅读
166

向量数据库生产实践:从选型到性能调优

数据量涨到 2300 万,向量检索 P99 从 60ms 崩到 1.8 秒

我们的商品语义搜索,7 月份上线时是 420 万条向量,P99 稳定在 60 毫秒。到 10 月底商品库扩到 2300 万条,接口 P99 涨到 1820 毫秒,同时 Milvus 集群的内存水位长期在 91%,隔三差五触发一次 OOMKill。

# Prometheus 里拉出来的趋势
milvus_search_latency_p99{collection="sku"}     2024-07-15  0.061
milvus_search_latency_p99{collection="sku"}     2024-09-01  0.284
milvus_search_latency_p99{collection="sku"}     2024-10-28  1.821
container_memory_usage_bytes{pod="querynode-2"} 2024-10-28  0.91 * limit

这篇记录从 10 月 28 号到 11 月 10 号,我们怎么把它压回到 95 毫秒的。

先搞清楚慢在哪:三个阶段分别量

Milvus 的一次查询可以拆成三段,我在客户端打了分段埋点(用的是 Milvus 的 Java SDK 2.4.x):

SearchResp resp = client.search(SearchReq.builder()
        .collectionName("sku")
        .data(Collections.singletonList(new FloatVec(queryVec)))
        .topK(50)
        .filter("category_id in [101, 205, 308] and price <= 500")
        .searchParams(params)
        .build());

配合服务端 metrics 拆出来:

阶段7 月(420 万)10 月(2300 万)
队列等待3 ms410 ms
向量检索(粗排)38 ms680 ms
标量过滤 + 回表取字段16 ms730 ms

三个环节全慢了,但原因不同。

队列等待:QueryNode 不够,且没开副本

$ kubectl get pod -n milvus | grep querynode
querynode-0   1/1   Running   3 (2d ago)    # 重启过 3 次
querynode-1   1/1   Running   1 (5d ago)
querynode-2   0/1   OOMKilled 4             

三个 QueryNode 里挂了一个,剩下的扛全部流量,队列就堆起来了。内存爆是因为我们把 2300 万条原始向量全量加载在内存里,用的是 IVF_FLAT 索引,没有任何压缩。

标量过滤慢:表达式走了暴力扫描

这段最意外。price <= 500 这种范围过滤,在没有标量索引的情况下是逐条比对的。2300 万条里符合 category_id in [...] 的只有 41 万条,但过滤是在向量检索之后做的后过滤——先召回 topK×N 个候选,再逐条判断,判断不通过就丢弃。

当时的 nprobe 是默认的 10,召回 1000 个候选里可能只有 200 个满足过滤条件,剩下的全白干。

索引选型与参数调优

我们拿 2300 万全量数据做了四组对比测试,机器是 32C128G 单 QueryNode,topK=50,并发 50:

索引参数内存占用P99 延迟召回率@50构建耗时
IVF_FLATnlist=4096, nprobe=1088 GB1240 ms0.91242 min
IVF_FLATnlist=4096, nprobe=6488 GB1890 ms0.98142 min
IVF_SQ8nlist=8192, nprobe=3223 GB310 ms0.96855 min
HNSWM=32, efConstruction=200, ef=128121 GB78 ms0.9933 h 20 min
DISKANN默认9 GB420 ms0.9411 h 50 min

最终选了 IVF_SQ8,理由如下:

  • HNSW 速度最快、召回最高,但 121 GB 内存我们给不起(单节点 128G,还要留余量给系统和其他组件)。
  • DISKANN 内存只要 9 GB,但延迟 420 毫秒且依赖 NVMe,我们的云盘是普通 SSD,实测会更差。
  • IVF_SQ8 用标量量化把 768 维 float32 压成 int8,内存降到四分之一,召回只掉 1.3 个点,是我们能接受的最优点。

nlist 和 nprobe 的经验取值

这两个参数决定了 IVF 系列的精度和速度,调起来有章可循:

  • nlist = 4 × sqrt(N)。2300 万开方约 4796,我们取 8192(Milvus 建议往上取到 2 的幂)。
  • 每个簇要有 500 到 2000 条。2300 万 / 8192 ≈ 2800 条每簇,稍微偏大,但可接受。
  • nprobe 从 N/nlist 的 1% 往上试。我们最后定在 32,此时扫描 32 个簇约 9 万条,占全量 0.39%。
Map<String, Object> params = new HashMap<>();
params.put("nprobe", 32);
params.put("metric_type", "COSINE");

IndexParam index = IndexParam.builder()
        .fieldName("embedding")
        .indexType(IndexParam.IndexType.IVF_SQ8)
        .metricType(IndexParam.MetricType.COSINE)
        .extraParams(Map.of("nlist", 8192))
        .build();

nprobe 增大召回率会上升,但不是线性的。我们从 16 调到 32,召回从 0.941 涨到 0.968,翻到 64 只涨到 0.972,延迟却翻倍。把召回率推到 1.0 是极其不划算的,找到拐点就停手。

混合过滤:三种做法的取舍

做法一:Partition(我们最终选择的)

商品有明确的一级类目(约 60 个),按类目建分区是最直接的剪枝:

client.createPartition(CreatePartitionReq.builder()
        .collectionName("sku").partitionName("cat_101").build());
// 查询时只搜指定分区
client.search(SearchReq.builder()
        .collectionName("sku")
        .partitionNames(List.of("cat_101", "cat_205", "cat_308"))
        ...);

效果显著:搜 3 个类目的数据量从 2300 万降到 140 万,延迟从 310 毫秒降到 42 毫秒。代价是分区数不能太多(Milvus 官方建议单集合分区不超过 4096),而且一次查询跨太多分区就没意义了。

做法二:标量索引 + 迭代过滤

对于 pricebrand_id 这类没法做分区的过滤条件,Milvus 2.4 支持开启迭代过滤(iterative filtering):它会在向量检索的过程中逐批检查过滤条件,直到凑够 topK,而不是一次性召回一大堆再筛。

params.put("iterator_extension_ratio", 2.0);   // 每轮多召回的比例
client.search(SearchReq.builder()
        .filter("brand_id == 8821 and price <= 500 and status == 1")
        .searchParams(params)
        .build());

开启前后对比(过滤命中率 8% 的场景):

过滤方式候选扫描量P99 延迟topK 是否凑满
后过滤(nprobe=32)9 万310 ms
后过滤 + 强过滤(命中率 0.3%)9 万295 ms否,只返回 11 条
迭代过滤(ratio=2.0)约 26 万640 ms

迭代过滤解决了"凑不满 topK"的问题,但延迟翻倍。我们的选择是:过滤命中率低于 1% 的条件才开迭代过滤,其余用后过滤。判断命中率靠离线统计,每天跑一次采样。

做法三:把过滤条件编码进向量

这个思路有点野,但对"价格区间"这种有序标量意外地好使。做法是把归一化后的价格作为一个额外维度拼进向量,检索时把目标价格也拼进查询向量。

// 768 维语义向量 + 1 维价格权重
float[] hybrid = new float[769];
System.arraycopy(semanticVec, 0, hybrid, 0, 768);
hybrid[768] = normalizePrice(price) * 0.15f;   // 权重需要调优

我们测试下来,价格相关性从 0.62 提到 0.79,但语义相关性从 0.968 掉到 0.912。最后只在一个"找同价位替代品"的场景用了它,主搜索没用。

数据同步:全量重建不能停服

商品数据每天变动约 40 万条,还有每周一次的全量重算(模型换了要重新 embedding)。早期我们是直接往同一个集合写,重建期间查询抖动严重。

现在的架构:

MySQL binlog → Canal → Kafka → 增量同步服务 → Milvus(实时写入)
                                                  ↓
                        每周全量:新建 sku_v20241111 集合 → 灌数据
                                                  ↓
                                   建索引 → 灰度验证 → alias 原子切换

核心是 alias 切换

// 1. 建新集合并灌数据
client.createCollection(buildSchema("sku_v20241111"));
bulkLoad("sku_v20241111", allRows);
client.createIndex(...);

// 2. 灰度:5% 流量打过去,对比召回结果
verify("sku_v20241111", sampleQueries(500));

// 3. 原子切换,业务代码永远用 alias "sku_online"
client.alterAlias(AlterAliasReq.builder()
        .collectionName("sku_v20241111")
        .alias("sku_online")
        .build());

// 4. 观察 24 小时后删老集合
client.dropCollection(DropCollectionReq.builder()
        .collectionName("sku_v20241106").build());

增量同步这边有三个必须处理的点:

  1. Upsert 的幂等。Kafka 重投会导致重复写,Milvus 的主键 upsert 是幂等的,但要注意主键必须是业务 ID(我们的 sku_id),不能用自增。
  2. 删除要单独处理。Milvus 的 delete 是软删除,会留下 tombstone,删多了要 compact。我们每天凌晨跑一次 compaction。
  3. Embedding 失败要有死信队列。商品描述为空、超长(超过模型 8192 token 限制)的记录会 embedding 失败,进 DLQ 人工处理,不能阻塞主流程。
// 消费端的骨架
@KafkaListener(topics = "sku_cdc", concurrency = "8")
public void onMessage(List<SkuEvent> events) {
    List<FloatVec> vecs = new ArrayList<>();
    for (SkuEvent e : events) {
        try {
            vecs.add(embed(e));
        } catch (Exception ex) {
            dlq.send(e, ex);       // 不阻塞整批
            Metrics.counter("embed.fail").increment();
        }
    }
    client.upsert(UpsertReq.builder().collectionName("sku_online").data(rows).build());
}

一致性级别:我最后选了 Bounded

Milvus 提供四种一致性级别,选错了要么读到脏数据,要么延迟暴涨。这是我在调优中期才发现的一个隐藏开关。

级别语义P99 延迟我们的场景
Strong读到的永远是最新的340 ms太慢,且商品数据容忍秒级延迟
Bounded容忍固定时间窗口的陈旧(默认 3 秒)96 ms采用
Session自己写的最新的能读到102 ms我们没有"写完立刻读"的场景
Eventually不保证88 ms省 8 毫秒,不值得冒数据不一致的风险
client.search(SearchReq.builder()
        .consistencyLevel(ConsistencyLevel.BOUNDED)
        .guaranteeTimestamp(...)
        .build());

Bounded 相比 Strong 省了 244 毫秒,代价是刚写入的数据最多 3 秒后才可见。商品上下架延迟 3 秒被检索到,业务上完全接受。

我们挂的告警

改造完之后补了一套监控,这几个指标是我觉得最有用的:

# 1. 查询延迟分位
histogram_quantile(0.99, rate(milvus_proxy_search_latency_bucket[5m])) > 0.15

# 2. 队列积压(这个最灵敏,比延迟更早发现问题)
milvus_proxy_search_queue_length > 20

# 3. 内存水位,超过 80% 就要考虑扩容或者换索引
container_memory_usage_bytes{pod=~"querynode.*"}
  / container_spec_memory_limit_bytes > 0.8

# 4. 增量同步延迟,超过 5 分钟说明 Canal 或 Kafka 有问题
kafka_consumergroup_lag{consumergroup="sku-milvus-sync"} > 10000

第二个指标(队列积压)是我在事故复盘时加的。它比延迟更早暴露问题——延迟是结果,队列长度是原因。后来有两次 QueryNode 内存缓慢上涨,就是队列长度先抖动的,比延迟告警早了 8 分钟。

成本

改造前后的集群规格和费用(月度,云上按量):

项目改造前改造后
QueryNode 规格3 × 32C128G4 × 16C64G
索引内存总量264 GB92 GB
OOMKill 次数/月110
月度成本2.86 万元1.94 万元
P99 延迟1820 ms95 ms
召回率@500.9120.968

机器变多了但单机规格降了,总成本反而低。这里的关键是SQ8 量化把内存从 88 GB 降到 23 GB,让我们能用便宜的小规格机器横向扩展,而不是堆大内存机器。

下篇预告

这篇先把《向量数据库生产实践:从选型到性能调优》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考