这篇是实践总结,不是厂商文档的复述。四个自建方案加一个托管方案,我放在同一个需求下比:已经跑在 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 的复杂度是真的,没有对应负载的小团队上它,通常是在给自己造故障源。
没有标准答案,只有这个约束下代价最小的那个。把维度、过滤比例、并发、更新频率写下来,再对着表看。