这篇是实践总结,不是厂商文档的复述。四个自建方案加一个托管方案,我放在同一个需求下比:已经跑在 PostgreSQL 上的知识库要加语义检索,多租户、按文档类型过滤、白天有并发、夜里跑增量。文里的延迟和 QPS 来自第三方汇总,不是我自己压的数。

一个多租户知识库的检索需求

业务表、权限、审计本来就在 PostgreSQL 里;现在要给文档加向量检索,chunk 总量几百万;每次查询必须限定租户,还要能按文档类型再筛一层;白天有人在线用,夜里批量灌数据。团队不大,没人专职做搜索运维。

真正难的不是"能不能搜出来",而是三件事:过滤得进到检索内部,增量写入不能把在线查询拖死,不想再养一个分布式系统。

维度、过滤选择率与并发

很多人上来就问"多少向量该换库"。这个问题问早了。同样一百万条向量,768 维和 3072 维的索引内存需求差 4 倍,这一条就足以推翻结论。

按起作用的顺序排:维度、过滤选择率、更新频率、召回率目标、并发数、备份与数据所在地。过滤选择率最容易被忽略——每次都要限定到一个只占全库很小比例的租户,"全库几百万"和"有效候选几万"就是两码事,要不要预过滤全由这个比例决定。并发同理,单发 top-5 和几百路并发抢同一份内存里的图结构,是两种负载。

各方案的定位与代价

下面这张表是几篇第三方选型总结里的共识,属于实践口径,不是任何一家的官方说法。

方案 优先考虑的场景 主要代价
pgvector 已用 PostgreSQL,要 SQL、JOIN、事务,不想多管一套服务 HNSW 索引吃内存,filter 后召回打折,水平扩展自己设计
Pinecone 全托管快速上线,少管集群与索引 SaaS 成本,区域与供应商限制,数据更新最终一致
Qdrant metadata filter、多租户、dense/sparse/multivector、部署弹性 自架要处理高可用、容量、备份、升级
Weaviate BM25 与向量融合、named vectors、多租户、可调 ranking schema 与 hybrid 权重要治理,配置面大
Milvus 大型分布式负载、多索引、GPU、冷热分层 组件与运维复杂度最高,小团队易过度工程

各自卖的东西不同:pgvector 卖"少一个系统",Qdrant 卖过滤前置,Milvus 卖规模,Pinecone 卖省事。

pgvector 的建表与 HNSW 参数

先用它把最小可用链路搭起来:一张 chunk 表,一个 HNSW 索引,一条带租户和类型过滤的 top-k 查询。

CREATE TABLE kb_chunk (
    id          bigserial PRIMARY KEY,
    tenant_id   text        NOT NULL,
    doc_type    text        NOT NULL,
    body        text        NOT NULL,
    embedding   vector(1536) NOT NULL,
    updated_at  timestamptz NOT NULL DEFAULT now()
);

-- 向量索引:m / ef_construction 决定召回上限和构建成本
CREATE INDEX kb_chunk_vec_idx ON kb_chunk
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 24, ef_construction = 128);

-- 过滤列上的普通 B-tree,让 WHERE 能先收窄
CREATE INDEX kb_chunk_scope_idx ON kb_chunk (tenant_id, doc_type);

-- 查询期的召回旋钮,按会话设,不用重建索引
SET hnsw.ef_search = 80;

SELECT id, body, 1 - (embedding  $1) AS score
FROM kb_chunk
WHERE tenant_id = $2 AND doc_type = $3
ORDER BY embedding  $1
LIMIT 10;

参数怎么定,我是这么取舍的:m 是每个节点的邻居数,往 24 调能抬召回,但索引体积和构建时间一起涨,内存宽裕才值得;ef_construction 是建索引时的候选队列,128 比默认更慢更准,建一次用很久,可以给;ef_search 是查询期旋钮,召回不够先动它,别急着重建索引。lists 属于 IVFFlat,和 HNSW 是两套索引,别混着配。维度高的场景可以用 halfvec 把存储减半。

Qdrant 的 payload 预过滤

同样的需求换成 Qdrant,配置重点不在向量参数,而在把过滤字段做成 payload 索引,检索时把条件一起下推。

from qdrant_client import QdrantClient, models

client = QdrantClient(host="127.0.0.1", port=6333)

client.create_collection(
    collection_name="kb_chunk",
    vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE),
    hnsw_config=models.HnswConfigDiff(m=24, ef_construct=128),
)

# 没有这一句,过滤会在遍历时逐点判断,预过滤的好处拿不到
client.create_payload_index(
    collection_name="kb_chunk",
    field_name="tenant_id",
    field_schema=models.PayloadSchemaType.KEYWORD,
)

result = client.query_points(
    collection_name="kb_chunk",
    query=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(key="tenant_id", match=models.MatchValue(value="t_42")),
            models.FieldCondition(key="doc_type", match=models.MatchValue(value="faq")),
        ]
    ),
    limit=10,
    search_params=models.SearchParams(hnsw_ef=80),
).points

它先在满足 payload 条件的子集里走 HNSW 再出 top-k,不会出现"检索完再筛、只剩两条"。代价是过滤字段占内存,索引越多写入越慢。

先过滤与后过滤的召回差异

两条路径的差别不在实现,在召回。下面用伪代码摊开。

# 后过滤:先在全库取 top-k,再按租户筛
candidates = ann_search(query_vec, k=20)          # 不带任何过滤条件
rows = [r for r in candidates if r.tenant_id == "t_42"]
# 假设该租户只占全库 2%,期望命中 20 * 0.02 = 0.4 条,实际经常是 0 条

# 预过滤:先拿到该租户的候选集,再在其中做向量检索
ids = select_id_from_btree(tenant_id="t_42", doc_type="faq")
rows = ann_search(query_vec, k=10, restrict_to=ids)

0.4 条说明的不是排序不准,是候选池里根本没有这个租户的向量。补救有三条:按比例放大 k(20 变 1000,延迟跟着涨)、把过滤下推到索引、接受召回损失再用关键词补一遍。pgvector 的 iterative scan 属于第一条,结果不足时继续扫,但扫多少仍由选择率决定。

索引构建期的内存与锁

pgvector 的 HNSW 构建非常吃内存,量大时不要在高峰在线建。我的做法是低峰后台跑,先把 maintenance_work_mem 调上去,尽量在内存里完成,避免退化成磁盘临时文件。

CREATE INDEX CONCURRENTLY 能避免长时间阻塞写入,但不减少内存峰值,也不缩短耗时——它换的是可用性,不是成本。另有一条社区口径:超过 500 万向量后召回和延迟明显劣化,这时要么分区、要么挪走冷数据、要么换库。不是精确阈值。

百万向量换库的口径问题

"超过 100 万向量就必须换库"这句话错在把一个多变量问题压成了单变量。

第一,它只看总数,不看维度和过滤选择率。一百万条 1536 维、每次限定到小比例租户,和一百万条 768 维、无过滤、低并发是两种负载:前者可能真撑不住,后者往往很宽裕。

第二,它把"单节点上限"当成了"必须换库的时点"。社区口径的量级是 Chroma 约 100 万、pgvector 约 500 万、Qdrant 数千万、Milvus 集群十亿级。这是量级参考而非门槛,量级之内参数没调对一样会慢。

第三,它不算迁移账。双写、历史回灌、查询改写、权限与备份重建、监控重做、回滚方案,是一整轮工程;而调索引参数只是改配置。

第四,它跳过压测。拿自己的数据分布压一轮:同维度、同过滤比例、同并发,看 P95 和召回,再决定动不动。

第三方汇总的延迟与 QPS

表格里的数字来自第三方汇总,测试条件是 100 万向量、1536 维,不是我的实测,也没有独立复现,只能当量级参考。

方案 P50 (ms) P95 (ms) P99 (ms) 单节点 QPS
Qdrant 2.1 4.5 8.2 2500
Pinecone 2.3 5.1 9.0 2000
Milvus 2.5 5.5 10.2 3000
Weaviate 3.2 6.8 12.5 1500
Chroma 5.0 12.0 25.0 500
pgvector 8.0 18.0 35.0 200

别读成排名。多一跳网络、一次跨租户过滤就能吃掉大半差距。

按负载走的选型树

把前面的判断收成一条路径,从最硬的约束开始问:

向量检索要不要和业务数据在同一个事务里?
├─ 要(同一个事务读业务表 + 写向量)→ pgvector
└─ 不要
   ├─ 不想运维集群、能接受写入最终一致 → Pinecone
   └─ 自己运维
      ├─ 过滤条件复杂、多租户、要 sparse + dense 混检
      │    ├─ 还要原生 BM25 融合、ranking 可调 → Weaviate
      │    └─ 只要过滤够快、部署弹性 → Qdrant
      └─ 十亿级、要 GPU、要冷热分层 → Milvus

任何一支,先压测再落地:同维度、同过滤比例、同并发。

什么团队不该选这套

已经在 PostgreSQL 上、数据量几百万以内、过滤简单、没人愿意维护第二个存储——这类选 pgvector,把参数调对就够了,别为了"看起来更专业"上一套集群。

反过来,查询以 BM25 为主、向量只做补充的话,pgvector 没有原生 BM25,分数得自己拼,这时候 Weaviate 更省事。规模明确在十亿级、要 GPU 和冷热分层,pgvector 和 Qdrant 都不是答案。Milvus 的复杂度是真的,没有对应负载的小团队上它,通常是在给自己造故障源。

没有标准答案,只有这个约束下代价最小的那个。把维度、过滤比例、并发、更新频率写下来,再对着表看。