去年 2 月我们做了一次四方对决。Pinecone、Weaviate、Milvus、Qdrant。同样的数据集(OpenAI `text-embedding-3-small` 出的 ~12M 个 1536 维向量),同样的查询负载,同样的硬件预算。Qdrant 赢了,这个决定在三次部署和生产负载十倍增长下都站住了。这篇是工程日志:为什么我们选了它、怎么调它、规模上线后什么坏了、以及今天面对同样选择我会跟一个团队说什么。
Anvil 的 RAG 栈分两层。检索层是 Qdrant,每个知识库一个 collection,命名为 `kb_<knowledge_base_id>`。生成层默认 DeepSeek v4-flash,复杂综合时升到 v4-pro。embedding 模型是 OpenAI `text-embedding-3-small`,1536 维,是聊天迁移后我们留下的唯一一段 OpenAI。所有东西通过 `services/ai-engine/` 接上。本文中的数字来自大约 4,800 个知识库每月 1,200 万次查询的生产流量。
为什么 Qdrant 击败了替代方案
我会具体讲每个系统怎么输的,因为对比比结论重要。
Pinecone 是工程做得最好的托管服务。API 干净、延迟好、仪表板友好。我们淘汰它是因为它是闭源 SaaS,我们需要一个自托管选项给要求 in-region 部署的企业客户。我们规模上的成本也不轻 — 大概是我们自己跑 Qdrant 集群同样负载的 3 倍,还没算他们较新的"pod"定价调整。如果你只跑小集群、不需要发到客户环境里去,Pinecone 真的是阻力最小的路径。我们没那个奢侈。
Weaviate 功能丰富、开源。模块齐全 — 生成式搜索、hybrid 检索、多租户。我们测过。同样硬件下我们跑的基准比 Qdrant 慢一点,内存占用明显大些,多租户模型需要的运维心力比我们想投入的多。决定性因素其实非技术:当时 Weaviate 社区很热情但规模较小,我们"摸清平台"的内部周期有限。Qdrant 让人感觉"无聊"是好的那种。
Milvus 是你有 10 亿+ 向量和一个专门 ML infra 团队时的对的选择。架构(查询/索引/数据节点分离)给你别的系统没有的运维杠杆。我们这个规模 — 几千万向量、不是几十亿 — 那些杠杆就是不必要的复杂度。我们会在单 collection 跨过 5 亿向量时重新认真考虑 Milvus,我们离这远着。
Qdrant 在四个对我们特定负载重要的轴上赢:
- Rust 快的 HNSW:我们的查询体量下,每查询 CPU 成本重要。我们基准里在相同 recall 目标下,Qdrant 的 HNSW 实现比 Weaviate 大约低 35% CPU。
- Payload 过滤:每次 Anvil 查询都带 tenant_id 通常还有 knowledge-base-id 过滤。Qdrant 让我们把这些字段标记为索引化,过滤就在 HNSW 遍历中生效,不是之后,所以我们不必扫一百万候选去找属于对的租户的那十个。
- 自托管、简单运维:Qdrant 是一个单二进制加存储。我们用 Helm 部署到自己的 Kubernetes 用于托管服务,给自托管企业客户发 docker-compose。运维复杂度比 Milvus 低很多。
- Tier-1 免费档:Qdrant Cloud 的免费档(1GB 向量)对最小客户 collection 来说足够慷慨,让我们简化基础设施 — 我们不跑单独的"免费档"集群,只是把最小租户分片到一个用 payload 隔离的共享 collection 里。
上规模的 collection 设计
我们用每个知识库一个 Qdrant collection,命名 `kb_<knowledge_base_id>`。Knowledge_base_id 是 CUID2 字符串,所以 collection 名长得像 `kb_clxa7m9b50000jx08v3yzkn4d`。这种形状一直是对的判断。我们考虑过三个替代方案,各自被否:
单一共享 collection,按 payload 里 tenant_id 字段分区。否,因为按 collection 调 HNSW 是个真实杠杆 — 不同 KB 体量和访问模式差异很大,全局 HNSW 参数谁也不优。还有,删 KB 变成"过滤删 N 百万行"而不是"drop collection",慢得多,而且产生的 tombstone 在压实之前留在内存里。
每租户一个 collection(不是每 KB)。否,因为有的租户有 200+ 个内容完全不同的 KB,访问模式是"搜某个特定 KB",不是"跨整个租户搜"。collection 内为正确的 KB 过滤是浪费功。
每对(租户、语言)一个 collection。考虑过。我们没追,因为相对每 KB collection 的收益小,而更多 collection 的运维开销(现在 ~4,800 个)不轻。Qdrant 处理上千 collection 没问题,但我们的配置下到一万左右就开始有感了。
Payload 模式,保持最小
每个点的 payload 就四个字段:
- chunk_text:embedding 计算所用的原文。我们带它是为了能返回检索结果不再做一次额外查找。
- source_doc_id:CUID2,指回我们 Postgres `knowledge_document` 表里的源文档。
- source_url:可选的原始来源 URL 或路径。
- chunk_index:这个 chunk 在文档内的整数位置。检索时做兄弟 chunk 扩展有用。
没有嵌套对象、没有 struct 数组、没有时间戳。两个原因。第一,Qdrant 的 payload 索引机制在扁平标量字段上比在嵌套结构上更快,我们大量按 source_doc_id 过滤。第二,模式抖动是没人写进规格文档里的运维税 — 每次 payload 模式变更都有重建索引的风险,在我们最大的 collection 上重建是几小时的性能降级。
当我们需要更多元数据(作者、created_at、文档类型)时,它存在 Postgres,检索时按 source_doc_id join。Postgres 这类 join 快,我们让向量存储窄聚焦在向量这件事上。
我们生产里用的 HNSW 参数
默认值合理但不最优。我们生产值,针对我们 retrieval-recall eval 套件调过:
- m = 16:每个节点的双向链数。我们测过 m=8、16、32。m=16 在我们数据上击中 recall/内存的甜点。m=32 多了 ~7% recall 但内存翻倍;m=8 省内存但 recall 掉 4 点。
- ef_construct = 128:建图时考虑多少候选。越高越慢索引、越好 recall。我们从默认 100 推到 128,因为索引是离线的,我们愿意用慢写换更好的读。
- 查询时 ef = 64:动态搜索宽度。我们每次查询从 ef=64 起,需要时让 Qdrant 自动调高。ef=64 在我们 eval 套件上给我们 95%+ 的 recall@10,而不用为那些需要 200+ 的尾部查询付费。
- full_scan_threshold = 10000:当 payload 过滤会留下少于 10K 候选时,Qdrant 跳过 HNSW 走平凡扫描。这对窄过滤更快,我们把阈值从默认的 5000 调高,因为我们很多查询的有效候选池本来就小。
- 热 collection 上 on_disk = false,冷的上 true:存 RAM 里的向量更快;在盘上的向量省内存。我们按滚动 7 天窗口的查询率给 collection 分冷热并相应迁移。
量化:PQ vs Scalar,以及为什么我们挑了 Scalar
量化压缩向量让索引占更少内存,代价是损失一点精度。Qdrant 支持三种模式:Scalar、Product(PQ)、Binary。我们三种都在 eval 套件上测过。
- Scalar 量化(int8):比 fp32 向量小 ~4 倍。我们套件上 recall 损失:0.4 点。对索引影响最小。这是我们生产里发的。
- Product 量化(PQ):小 ~16 倍。Recall 损失:3.2 点。我们考虑给冷 collection 用,但客户在回答质量上能感到 recall 跌。
- Binary 量化:小 ~32 倍。Recall 损失:11.7 点。在我们这种依赖长尾 B2B 词汇紧密语义匹配的负载上不可行。
Scalar 给了我们 PQ 75% 的内存节省,recall 损失只有 PQ 的 1/8。对我们的成本-质量权衡,scalar 是明显的选。如果我们的负载是几亿向量且能容忍 recall 跌,PQ 才合理 — 不是我们。
规模上线后什么坏了(以及我们怎么处理)
过去 14 个月里的五次事故,按它们教给我们什么排序。
事故一:小 VM 上的内存压力。早期部署我们在 n2-standard-8(32GB RAM)实例上跑 Qdrant。一波新 KB 索引把工作集推过可用内存,OS 杀了容器,我们丢了大约 12 分钟的写可用性。修复是双管齐下:把索引构建工作负载分到更大的实例池,再配置 Qdrant 的 memmap 行为让冷段在压力下优雅 page out 而不是触发 OOM。教训:向量数据库是内存绑定的系统,"以后再修容量"不是战略。
事故二:snapshot 恢复漂移。我们靠 Qdrant 的 snapshot 功能做备份。我们跑了一次恢复演练,发现把 1.6 版本的 snapshot 恢复到 1.10 集群上产生的 collection,payload 索引必须从头重算 — 这个重算在我们最大的 collection 上是 90 分钟操作,期间查询掉回到暴力扫描。教训:版本 snapshot 匹配它被拿的版本,在备份链两端都钉住。我们现在每季度做一次跨版本恢复演练。
事故三:payload 索引重算成本。我们给一个 payload 模式加了一个新索引字段。在 4M 点的 collection 上索引成本比我们预期高 — 大约 25 分钟,期间查询延迟 p99 翻倍因为索引器吃 CPU。我们现在把模式变更安排在非高峰窗口,先在隔离副本上跑再提升。
事故四:加节点后的 rebalance。给分片集群加节点触发 rebalance,rebalance 流量形态(很多长跑、大 payload 的传输)和稳态流量差异够大,我们的 autoscaler 在 rebalance 时配低了。我们现在显式地在加节点之前扩容,不是之后。
事故五:我们 embedding 流水线里 float32 vs float64 表示的一个细微 bug。一个上游变更让一批 embedding 被存为 float64(因为 pandas dtype 推断怪癖),其余都是 float32。Qdrant 愉快地存了两种。命中那些点的查询 recall 奇怪地低,直到我们想清楚 dtype 不匹配。修复是一个写入前显式断言 float32 的流水线契约。教训:在边界处相信无聊的东西。
部署拓扑建议
中小负载(< 5M 向量、< 1M 查询/天):单 Qdrant 实例加热备份的复制。16-32 vCPU、64GB RAM、NVMe 存储。有运维能力就自己跑,没有就用 Qdrant Cloud。
Anvil 规模(10-50M 向量、5-15M 查询/天):3-6 节点的分片集群。我们跑 6 节点,每节点 32 vCPU / 128GB RAM,每 6 小时 snapshot 到 S3,第二区域有热备份集群做灾备。我们用 Helm 部署;Qdrant operator 成熟到我们不必写多少自定义控制器逻辑。
100M+ 向量:开始认真容量规划。多分片 collection、冷段在盘存、仔细的租户布置。那个规模,也认真评估 Milvus;有些负载从它的节点类分离里获益。
我们什么时候不会选 Qdrant
三个场景。第一,如果你需要一个完全托管的服务、运维能力为零、又不部署到客户环境,Pinecone 是阻力最小的路径,即使更贵。第二,如果你的负载在单 collection 上超 5 亿向量,Milvus 的架构开始挣得起它的复杂度。第三,如果你的团队深陷 LangChain/LlamaIndex 生态、想要最多开箱即用模块支持,Weaviate 有最深的框架集成。这些都不符合我们的画像,但会符合某些人的。
如果重做我会做什么不一样
两件事。一:先建 eval 套件。我们在有真正 retrieval-recall eval 之前花了三个月调 HNSW 参数,大部分调优是靠感觉。eval 落地那天,我们一周的进展比之前三个月还多。在你优化任何东西之前先建 eval。
二:在 payload 模式纪律上投入。我们两年里改过四次 payload 模式,每次都比上次更痛因为 collection 更大了。如果重来,我会前期建模最小 payload — chunk_text、source_doc_id、chunk_index、加一个 tenant_id — 没有迁移计划就拒绝加任何东西。
Qdrant 不是向量 DB 版图里最炫的选择,但在我们这个规模的生产 B2B RAG 上,它一直是最可预测的。无聊在基础设施里是个 feature。三次部署、两次大版本升级、两万客户工作区、负载十倍增长 — 系统都顶住了。这个履历比任何基准都有用。