上周三凌晨两点,我盯着屏幕上 429 错误码发呆。我们团队用 Milvus 跑的 RAG 服务,在 200 并发下延迟飙到 3.8 秒,直接打穿了网关超时设置。这不是 Milvus 的错,是我们选错了场景——小规模私有化部署,却上了个为亿级向量设计的重型武器。那天我发誓要换掉它,于是有了这篇 Qdrant 实战记录。

为什么是 Qdrant 而不是 Chroma 或 Pinecone

Day 1 我们列了张评估表。候选对象三个:Qdrant、Chroma、Pinecone。Pinecone 先被淘汰——私有化部署一年授权费 4.2 万美元,我们这种 20 人团队直接劝退。Chroma 倒是轻量,但它的过滤器和向量检索是两套独立索引,组合查询时性能衰减 60% 以上。

说实话,选 Qdrant 一开始是赌运气。我们看中它的 Rust 底层和 payload 索引一体化设计,但心里没底。实测数据说话:单机 16GB 内存,100 万条 768 维向量,Qdrant 的过滤查询延迟是 23ms,Chroma 是 87ms。差距不是一星半点。

不过呢,Qdrant 的文档对中文开发者不算友好。官方示例全是英文,社区中文资料少得可怜——CSDN 上翻来覆去就那几篇翻译稿,连个像样的踩坑记录都没有。

我们团队决定硬啃。两周时间,从零搭建到生产环境,踩过的坑比代码行数还多。

部署与初始化:Docker 一把梭还是裸机编译

第一坑:Docker 镜像的 CPU 指令集陷阱

Qdrant 官方 Docker 镜像默认编译目标是 x86-64-v2。我们两台测试机一台是 2019 年的 i5-9400F,另一台是云厂商的 AMD EPYC 7452。前者跑起来没问题,后者在查询时直接 SIGILL 崩溃。查了三天日志,最后在 GitHub Issue #1124 里翻到线索——EPYC 7452 支持 v3 指令集,但 Qdrant 的检测逻辑误判了。

解法:手动指定镜像 tag qdrant/qdrant:v1.7.3-unprivileged,这个版本改进了 CPU 特性检测。或者干脆用源码编译,但编译一次要 40 分钟,内存吃掉 8GB。

第二坑:向量维度不一致导致的隐性召回率下降

Day 5 我们导入第一批 OpenAI text-embedding-ada-002 向量(1536 维)。测试召回率只有 71%,以为模型问题。后来写脚本抽查数据,发现 3.2% 的向量其实是 1535 维——有个预处理步骤把结尾的 [0] 值截断了。Qdrant 不会报错,它按 1535 维建立索引,查询时自动补零填充。

这导致那些截断向量之间的距离计算全部偏离。清洗后召回率回升到 93%。教训:写个向量维度校验脚本,每次导入前跑一遍。

第三坑:payload 索引的爆炸式增长

我们给数据加了 6 个 payload 字段做过滤。建完索引,WAL 文件从 1.2GB 涨到 7.8GB。Qdrant 默认每个字段都建索引,但 keyword 类型字段基数不高的话纯属浪费。看官方文档才明白,应该用 integer 或 bool 替代高基数字符串。

整改方案:

  • 状态字段从 string 改成 integer,用枚举映射
  • 时间戳字段用 datetime 类型而不是 string 比较
  • 关闭不需要过滤字段的索引(create_payload_index 时指定 on_disk: true)

优化后 WAL 回落到 2.1GB,查询性能还提升了 18%。

数据导入与增量更新:别信官方那套“零停机”

Qdrant 官方文档说支持在线 upsert,实测是放屁。我们的场景是每天凌晨同步 5 万条新文档。直接用 upsert 跑,前 20 分钟没问题,后面索引构建线程把 CPU 占满,查询 P99 延迟从 45ms 飙到 2.1 秒。

我们团队搞了个双集合切换方案:

  1. 主集合 documents_live,副集合 documents_staging
  2. 凌晨 2 点,新数据写入 staging
  3. 导入完成后,用 collection_snapshot 备份 live,然后 delete + upsert 批量切到 staging
  4. 用 Nginx 做加权轮询,逐步把流量切到新集合

实测切换过程零查询中断,但注意 collection_snapshot 在 100 万向量时要花 90 秒,期间会锁写操作。所以备份必须提前到凌晨 1:30 完成。

检索效果调优:从 61% 到 94% 的三个关键参数

我们的 RAG 场景是技术文档问答。一开始直接上 cosine 相似度,Top-5 命中率只有 61%。调了三个参数后,飙升到 94%:

  • ef_construct(索引构建时的候选数):默认 100,调到 256。索引体积增加 22%,但召回率提升 14%
  • m(HNSW 图的最大连接度):默认 16,调到 32。查询速度变慢 11%,但召回率提升 9%
  • score_threshold(检索阈值):设为 0.78,低于此值的直接过滤。误伤率 3%,但精确率从 84% 涨到 91%
参数组合 ef_construct m 召回率 P95 延迟 索引体积
默认 100 16 61% 19ms 1.9GB
均衡 200 24 78% 27ms 2.3GB
高召回 256 32 94% 41ms 2.8GB
极限 512 48 96% 88ms 3.6GB

回头看,第 3 组参数是我们的甜点:延迟在可控范围,召回率已超实际需求。第 4 组性能边际递减严重——从 94% 到 96% 只涨了 2 个点,延迟却翻倍。

生产环境监控与告警:Qdrant 没有的坑

内存池泄漏的伪装者

第 2 周我们遇到一个诡异问题:服务跑 6 小时后,内存从 2.3GB 缓慢涨到 5.8GB,然后 GC 风暴,延迟抖动 400ms。排查了 Qdrant 配置、Java 客户端、网络层,都没问题。最后用 heaptrack 检测,发现是 Rust 客户端(qdrant-client 库)在流式响应时未释放 Bytes 对象。升级到 v1.2.0 后解决。

磁盘满导致索引损坏

上个月生产环境磁盘只有 40GB,因为日志文件没开轮转,storage/storage.log 涨到 23GB。磁盘满后 Qdrant 强制重启,索引文件损坏,整个集合无法查询。恢复花了两小时,用了官方 consistency_check 工具才修好。

经验:

  • 设置 log_level=warn 减少日志量
  • 定期跑 curl localhost:6333/collections 检查磁盘余量
  • 备份策略改为每日全量 + 每 6 小时增量,用 curl -X POST /collections/{name}/snapshots

结论与局限

Qdrant 是我们目前用过最顺手的向量数据库。单机 100 万向量场景下,性能、稳定性、运维复杂度都吊打 Milvus。但我也要承认 Qdrant 不如 Milvus 的地方:集群扩展能力。Milvus 的分布式架构天生支持水平扩展,Qdrant 的集群方案(distributed 模式)在 3 节点下延迟抖动明显,官方自己都建议 5 节点起步。如果你要处理千万级向量且需要多租户隔离,Milvus 更合适。

另一个限制是 Qdrant 的过滤机制。虽然比 Chroma 好,但对于 OR 类型的组合过滤,它仍然需要二次扫描,性能衰减明显。我们后来改用多集合分片 + 应用层合并的方式规避。

最后说个不吐不快的点:Qdrant 的 HTTP API 响应体太大了。单次查询返回 100 条结果,JSON 有 1.8MB,内网没问题,公网就是灾难。我们加了个 with_payload: false 选项,只拿 ID 再回主库捞数据,省了 70% 带宽。

第 5 周我们彻底稳定下来,RAG 服务的 P95 延迟稳定在 89ms,召回率 92.5%。但每次看到 Qdrant 的 GitHub 仓库更新,我都会想:下一个版本又会埋什么雷?