Chapter 03
生产工程:过滤、hybrid、一致性
上一章拆开了单机索引的三角权衡——HNSW 的图、IVF 的 cell、量化的压缩。这一章把它放进真实系统:当查询要带条件、要混合关键词、要面对并发写入和分片时,会冒出哪些索引层看不见的难题。
延续:pgvector 0.8 · Milvus 2.6 · Qdrant 1.15 · Weaviate ACORN | 阅读时间:~半天(深潜) | 代码验证状态:含可运行 SQL/Python 片段,pgvector 过滤数字引自社区实测,标注「未本地验证」处除外
这一章把什么变成可决策的
- 过滤与 ANN 的根本张力:post-filter「缺斤少两」、pre-filter 切断图连通性、filtered-HNSW 作为架构性解法——为什么这不是调参问题。
- hybrid 检索为什么是标配:稠密向量抹平精确 token,稀疏召回补上长尾标识符;RRF 融合 + rerank 的失败模式互补。
- 写入路径的最终一致性与删除代价:异步建索引的可见性窗口、tombstone 软删导致的召回劣化、HNSW 常驻 RAM 的内存账。
02 章的索引知识让人答得出「HNSW 是什么」。但面试官的下一刀往往是:「查询要带一个 WHERE category = 'x' 的过滤,召回会怎样?」——这道题筛掉绝大多数只读过索引原理的候选人。原因在于:过滤、hybrid、一致性这三件事,索引层的抽象里根本看不见,却是 demo 与生产系统之间的全部距离。
01过滤难题:ANN 与 metadata 的根本张力
向量索引为「找最近邻」而生,但生产查询几乎都带条件;把条件加进去,召回会以反直觉的方式崩坏。
01 章讲过 metadata filtering——每条向量挂一组结构化字段(tenant_id、category、created_at),查询时要求「语义最近 且 满足这些条件」。听起来像在 SQL 查询后加个 WHERE,但 ANN 索引(02 章的 HNSW 图、IVF 的 cell 划分)是为无条件的最近邻搜索建的:它的图边、它的倒排桶,都不知道 metadata 的存在。把过滤强加进去,有三种策略,召回差异巨大。
一个表里有一千万条向量,其中只有 0.5% 属于 category = 'finance'。查询:「找语义最近的 15 条 finance 文档」。若先用 HNSW 取语义最近的 15 条、再把非 finance 的筛掉,会发生什么?
展开
几乎必然返回不到 15 条——甚至 0 条。HNSW 取的「最近 15 条」里几乎都不是 finance(因为 finance 只占 0.5%),筛完所剩无几。这就是 post-filter 的「缺斤少两」:问题不是慢,是结果数量不对、且漏掉了真正该返回的命中。
① 运行方式:三种策略各自怎么走
post-filter(先 ANN,后过滤):让索引照常取最近的 m 个候选,再在结果集上应用 WHERE。索引完全不知道过滤条件存在。实现最简单,几乎所有库的「默认行为」或退路都是它。
pre-filter(先过滤,后搜):先按 metadata 算出「合格点集合」,再只在这个子集上做最近邻。直觉上这是「最准」的——只在该搜的范围里搜,不会漏。但代价藏在索引结构里(下面展开)。
filtered / integrated HNSW(过滤内嵌进图遍历):不在搜索前后做,而是把过滤条件下推进索引算法本身。遍历 HNSW 图时,遇到不满足条件的点就跳过(不计入 top-k,但仍可借它的边走向下一跳);当过滤命中率低到图几乎走不通时,回退到全量暴力扫该子集。这是 Qdrant、Weaviate(ACORN)、Pinecone、Zilliz 各自实现的架构性解法。
新手的心智模型是「pre-filter 更准但更慢、post-filter 更快但不准,二选一权衡」。这个模型是错的。post-filter 不只是不准——它会静默返回错误的结果数量;pre-filter 在大数据集上也不是「稳妥的慢」——它会同时损召回又拖延迟。真正的解法是第三条路 filtered-HNSW,它不在这个二选一里。
② 三种策略的代价拆解
post-filter 的代价 ——「缺斤少两」是数据正确性问题,不是性能问题。pgvector 社区的一次实测最能说明:一个带过滤的查询写 LIMIT 15,HNSW 先取 15 个候选再过滤,最终只返回 11 行;更糟的是,这 11 行还漏掉了 23 个本应更近的命中——那些命中排在 HNSW 候选窗口之外,根本没进入过滤环节。用户看到的不是「慢」,是「结果少了、还少了最该出现的那几条」,且没有任何报错。
pre-filter 的代价 —— 切断 HNSW 图的连通性,召回崩塌。HNSW 的检索靠图的边一跳一跳逼近目标(02 章的 ef_search 控制搜索宽度)。pre-filter 把不合格的点从图里摘掉后,剩下的合格点之间不再连通——原本连接它们的边经过的是被摘掉的点。搜索走到一半发现无路可走,召回直接掉下去。在大数据集 + 低命中率时,引擎被迫退化成近线性暴力扫合格子集:实测从 <100ms 劣化到 200–300ms。所以 pre-filter 是「又慢、召回又不稳」,不是稳妥选项。
filtered-HNSW 的代价 —— 是工程复杂度,不是召回。它把召回保住了(图内遍历时跳过灰点而非删点,连通性不破坏;命中率低时回退全扫保正确),代价转移到了别处:算法实现复杂、对每条 metadata 字段往往要建额外的过滤索引(payload index)、低基数过滤的回退策略要调。换言之,代价从「召回 / 延迟」搬到了「实现与运维」——这也是为什么它是专用向量库的卖点,而不是一行配置。
备选方案与权衡表
| 策略 | 召回表现 | 延迟 | 何时用 |
|---|---|---|---|
| post-filter | 差:返回 <k 行、漏更近命中(静默) | 低(但结果不可靠) | 过滤命中率高(>50%)、对结果数量不敏感的场景 |
| pre-filter | 大集合上崩:图连通性被切断 | 高:常退化成近线性暴力扫(200–300ms) | 合格子集极小(几百条内),暴力扫本就够快 |
| filtered-HNSW | 好:图内跳过灰点、低基数回退全扫,召回不掉 | 低且稳定(<100ms 量级) | 生产默认;中高基数过滤、多租户、命中率跨度大 |
缓解手段(在没有 filtered-HNSW 时,或为它兜底)
- 提高
ef_search:02 章讲过它扩大 HNSW 搜索宽度。过滤场景下调大它,能让候选窗口装下更多命中、缓解 post-filter 缺斤少两——但延迟随之上升,是花钱买召回。 - oversample(超额取):知道过滤命中率约 10%,就取
10×k个候选再过滤,期望剩下 ~k个。简单有效,但命中率波动时仍会不足。 - iterative scan(迭代扫描,pgvector 0.8.0+):候选不够时自动继续从索引取下一批、直到凑满
k或扫完。直接针对 post-filter 缺斤少两,是 pgvector 给出的官方解。 - 对过滤列建 partial index / 分区:把高频过滤值(如某个大租户)单独建 partial index 或按
partition key分区,让过滤先天廉价。
为什么 pre-filter 在小结果集(合格点只有几百条)上反而是好选择,在大结果集上却灾难?
展开
合格点只有几百条时,「退化成暴力扫子集」恰恰是优点——几百条的精确扫描快且召回 100%,图连通性问题无所谓(本来就不需要图)。合格点上百万时,暴力扫太慢,而想用图又发现图被切断、召回崩。同一个机制,代价随基数从「可忽略」翻转成「致命」——这正是 challenge 里按基数分流的依据。
02Hybrid 检索:稠密与稀疏的失败模式互补
纯稠密向量会把精确 token「抹平」;hybrid 用一路稀疏召回把长尾标识符捞回来,再用 rerank 收口。2026 已是标配。
① 运行方式:两路并行 → 融合 → rerank
hybrid 检索把两种召回并行跑、再合一:
- dense(稠密 / 向量):01 章的 embedding 向量 + 02 章的 ANN 索引,管语义——「意思相近」。
- sparse(稀疏 / 关键词):BM25 或 SPLADE,按词项命中打分,管精确 token——原词、原 ID。
- RRF(Reciprocal Rank Fusion,倒数排名融合):不看两路各自的原始分(量纲不可比),只看每条文档在各路里的名次,按
1/(k+rank)求和重排。一条文档在任一路里排得靠前,融合分就高。 - rerank(重排):对融合后的 top-N,用 cross-encoder(如 Cohere rerank)把 query 和每篇文档拼在一起过一遍模型,给出更精的相关性分。代价高,所以只对少量候选做。
② 为什么纯 dense 不够:精确 token 被静默抹平
embedding 的本事是「把语义压成几何邻近」,这同时是它的盲区:它会把一个精确符号和它的「语义近邻」混为一谈。查 ERR_2087 这个错误码,纯稠密检索会返回一堆「讲错误处理、讲日志排查」的语义相近文档,却不含 ERR_2087 这个原词本身。失败的典型对象:
- 错误码、状态码(
ERR_2087/HTTP 451) - SKU、订单号、UUID、版本号(
v2.6.1) - 函数名、API 名(
ef_search、halfvec)
这类长尾标识符恰恰是 embedding 训练里见得最少、最该被原样匹配的词。纯 dense 对它们静默失败——不报错,只是返回的全不是要找的那条。BM25 对原词是精确命中,正好补上;反过来,BM25 对「换了说法的同义问法」无能为力,dense 补上。两者失败模式互补,这才是 hybrid 成为标配的根因,而不是「多一路更全」这种模糊直觉。
③ 带来的代价
hybrid 不是免费的:要同时维护稠密索引和稀疏索引(两套存储、两套更新路径);RRF 的 k 参数、两路候选数要调;rerank 引入一次额外的模型推理,是整条链路里最贵的一跳(cross-encoder 对每个候选都要跑一遍,所以只能对 RRF 收窄后的几十条做,不能对全库做)。延迟和成本都上去了——换来的是长尾标识符不再静默丢失。是否值得,取决于查询里精确 token 的占比。
RRF 融合为什么不直接把两路的相似度分加权求和,而要绕一圈用「名次」?
展开
稠密的 cosine 相似度落在 [-1,1]、BM25 分无上界且分布随语料剧烈变化——两者量纲不可比。直接加权,结果会被分值范围大的那一路主导,权重几乎没法调稳。RRF 只用名次(第 1 名、第 2 名……),天然无量纲、对分数分布不敏感,所以鲁棒、几乎不用调。代价是丢掉了「领先多少」的信息,但在融合场景这是划算的取舍。
03写入路径与一致性:可见性是有窗口的
向量库多为最终一致:写入后索引在后台异步构建,立即查会查不到、backfill 期间还会报错。强一致要显式声明。
① 运行方式:insert 与建索引是两段
一条向量的写入不是原子地「写完即可查」。典型路径:insert 落库 → 索引在后台异步构建(把新向量接进 HNSW 图或分配进 IVF cell)→ 才进入可被 ANN 检索的状态。这中间有一个可见性窗口:数据已写入,但还没接进索引。
Milvus 把这件事显式化:数据落盘形成 segment(存储 / 索引的单元),新写入先进 growing segment、再 seal、再建索引转为可高效检索的 sealed segment。并且允许按查询声明 consistency level(如 Strong / Bounded / Eventually),用一致性换延迟。
② 备选 / 权衡:一致性级别怎么选
这本质是经典的一致性—延迟权衡,落在向量库上:
- 最终一致(默认):写入返回快、吞吐高,但「写后即读」会读不到刚写的。绝大多数 RAG / 离线灌库场景够用——灌完再开放查询即可。
- 强一致 / 有界一致(显式声明):保证读到指定时刻前的所有写入,代价是查询要等索引追上、延迟升高。用于「写入后必须立刻能检索到」的在线场景(如用户刚上传文档就要能搜)。
③ 带来的代价
最终一致的代价是认知负担转移到了调用方:写入接口返回 200 不代表数据可查,调用方要么容忍延迟可见、要么显式升一致性级别买强一致。一个高频生产事故就是:测试时单条写入、立刻查、偶尔查不到,误判成「数据写丢了」去排查存储——根因只是撞进了可见性窗口。要点:向量库多为最终一致,强一致是要显式声明的,不是默认。
04更新与删除的代价:tombstone 与召回劣化
图索引硬删难,普遍用 tombstone 软删;删改累积后召回逐步劣化,到阈值必须 compaction / rebuild。内存账还常被「持久化」误导。
① 运行方式:软删不是真删
从 HNSW 图里真删一个点很难——它的边是其他点导航路径的一部分,物理摘除要重连周边、代价高。所以业界普遍做 tombstone(软删):给点打个「已删除」标记,检索时遇到就跳过,但点和它的边仍留在图里。更新 = 软删旧点 + 插入新点。
② 带来的代价:召回随墓碑累积劣化
软删省了即时代价,却埋下慢性病:
- 召回逐步劣化:墓碑越堆越多,检索时大量算力花在「走到一个点、发现是墓碑、跳过」上,有效候选被稀释;图的导航质量下降。
- unreachable points(不可达点):当一个活点的邻居大半成了墓碑,它会从图的主连通区里失联——存在,却再也搜不到。
- 处置阈值:业界经验是删除 / 更新累计超过 10–20% 的节点,就触发 compaction 或 rebuild——物理清掉墓碑、重建图。这是一次重操作,要排期。
常见误判:「向量库能持久化到磁盘,所以不吃内存。」错。HNSW 检索要求全量向量 + 图链常驻 RAM才有它标称的延迟——一旦换页到磁盘,性能塌方。实际内存占用约是原始向量的 1.2–2 倍(图的边链额外开销)。算一笔账:1 亿条 × 1024 维 × float32 ≈ 400GB RAM,单机放不下。「持久化」解决的是宕机不丢数据、重启能恢复,不解决在线检索要把数据怼进内存这件事——这也是 04 章存算分离要破的局。
③ 缓解
除了到阈值 rebuild,工程上常按时间 / 租户分段(segment / partition),让删除集中在少数段、只 compaction 这些段而非全库;以及用量化(下一节)把常驻 RAM 的体积压下来,从源头缓解内存账。
05分布式与存算分离(简要)
单机放不下就 sharding + replication;更激进的方向是把向量挪到对象存储、内存只做缓存——~100× 价差驱动,详见 04 章。
① 运行方式:分片 + 副本
- sharding(分片):按
id或partition key哈希 / 路由,把数据切到多个节点;查询要么定向到某分片(带 partition key 时),要么扇出到所有分片再归并 top-k。上一节的 400GB 内存账就是分片的直接动因。 - replication(副本):每个分片多副本,扛节点故障、分摊读流量。Milvus 等把 sharding + replication 做进架构。
② 前沿方向:存算分离
把全量向量放进对象存储(S3),内存只缓存热点 / 索引结构。驱动力是赤裸的价差:S3 约 $0.02/GB,内存约 $2+/GB,差约 100 倍。对 400GB 这种规模,这是把成本从「天文」拉回「可承受」的关键。代价是冷数据查询要从对象存储拉、延迟更高,于是缓存策略和索引布局成了新战场。这是 2026 的活跃前沿(turbopuffer、StreamingDiskANN 一脉),04 章详解。
06量化在生产:把「省内存」和「保召回」拼起来
02 章把量化当索引压缩讲;生产里它是内存账的主力解,但必须配 rescore 才不丢召回。
① 运行方式:压缩检索 + 原始 rescore
接 02 章的量化:把 float32 向量压成更小的表示来做粗筛,再用原始 float32 对粗筛结果重新打分(rescore)排序。两段式,让「省内存」与「保召回」同时成立:
- scalar 量化(int8):每维压成 1 字节,~4× 压缩。精度损失小,最常用。
- binary 量化(1 bit):每维压成 1 比特,~32× 压缩。极省内存,但单靠它召回掉得多——必须靠原始向量 rescore 兜回来。
- rescore:量化向量负责「快速从百万里捞出几百个候选」,原始 float32 负责「在这几百个里排准」。省内存的是前者(常驻量化向量),保召回的是后者。
② pgvector 的 halfvec:另一条省法
pgvector 0.7+ 提供 halfvec(float16,半精度):相比 float32 省 50% 存储,且 HNSW 构建快约 30%,召回损失通常可忽略。它和量化是两个层次——halfvec 是「每个数用一半位宽」,量化是「换一种更激进的表示 + rescore」;生产里可叠用。
| 方案 | 压缩 / 省量 | 召回 | 必须 rescore? |
|---|---|---|---|
| halfvec (float16) | 省 50% 存储、build 快 ~30% | 损失通常可忽略 | 否(一般不需) |
| scalar (int8) | ~4× | 小幅损失 | 建议 |
| binary (1 bit) | ~32× | 裸用掉得多 | 必须(靠原始 float32 兜回) |
③ 带来的代价
量化的代价是多一段 rescore 链路 + 需要同时保留原始向量供兜底(否则无从 rescore,binary 召回就回不来)。即省了常驻检索的内存,却没省下「为 rescore 备一份原始向量」的存储——典型的拿存储 / 复杂度换内存与召回的平衡。压多狠取决于召回容忍度:能接受 binary + rescore,就拿到 32× 的内存红利去缓解上面那笔 400GB 的账。
自测
先合上屏幕,在脑子里或纸上答完,再展开对照。每题都是原子的——答不利索说明那个机制还没落地。
-
一个查询带过滤、写了
LIMIT 15,结果只回了 11 行。这是性能问题还是正确性问题?属于三策略里的哪一种、为什么?答案
正确性问题,属 post-filter 的「缺斤少两」。HNSW 先取 15 个语义最近候选、再过滤,过滤后不足 15;更糟的是它还漏掉了候选窗口外那些本应更近的命中(实测漏 23 个)。修法:iterative scan(pgvector 0.8.0+)、oversample、或换 filtered-HNSW。
-
稠密向量召回质量已经很好,为什么还要加一路 BM25 稀疏召回?举一类纯 dense 会静默失败的查询。
答案
embedding 会把精确 token 抹平,返回语义近但不含原词的文档。失败对象:错误码(
ERR_2087)、SKU / 订单号 / UUID、函数名等长尾标识符——它们恰恰最该被原样匹配,纯 dense 不报错地丢掉。BM25 对原词精确命中,正好补上;两者失败模式互补,这才是 hybrid 成标配的根因。 -
「向量库能持久化到磁盘,所以不怎么吃内存」——错在哪?1 亿条 1024 维 float32 用 HNSW 约需多少 RAM?
答案
HNSW 检索要全量向量 + 图链常驻 RAM才有标称延迟,换页到盘就性能塌方。实际占原始的 1.2–2×。1 亿 × 1024 × 4 字节 ≈ 400GB RAM,单机放不下,得分片或走存算分离。「持久化」只解决宕机不丢、重启能恢复,不解决在线检索要把数据怼进内存。
-
(跨机制综合)为什么 pre-filter 在大集合上反而降召回,而不是像直觉说的「更准只是更慢」?把它和 HNSW 的图结构联系起来答。
答案
HNSW 靠图的边一跳跳逼近最近邻。pre-filter 先把不合格点从图里摘掉,剩下合格点之间原本经由被摘点连接的边也断了——图连通性被切断,搜索走到一半无路可走,召回崩;引擎被迫退化成近线性暴力扫合格子集(
<100ms→ 200–300ms)。所以是「又慢、召回又不稳」,不是稳妥的「更准更慢」。解法是 filtered-HNSW:遍历时跳过灰点而非删点,连通性不破坏。
多租户系统,按 tenant_id 分流过滤策略
一个多租户系统,每个 query 必须按 tenant_id 过滤。租户基数差异极大:小的只有 10 条向量,大的有 10 万条。单一过滤策略对两端都不优。post-filter / pre-filter / filtered-HNSW 三种策略,怎么按租户基数分流?各自的临界点和退化风险在哪?
提示
回到「代价随基数翻转」这条主线:
超小租户(~10–几百条):pre-filter 退化成的「暴力扫子集」此时是优点——精确、召回 100%、图连通性问题无所谓(根本不需要图)。直接扫该租户全部向量最省心。
超大租户(~10 万条及以上):暴力扫太慢,pre-filter 又会切断图、召回崩;post-filter 会缺斤少两。该用 filtered-HNSW——图内跳过非本租户点、保连通性保召回。
分流的工程实现:理想是按 tenant_id 分区 / 分 collection,让大租户各自成段(可独立建 HNSW),小租户走暴力扫;查询时按租户规模路由到对应策略。临界点(几百 ~ 几千条)需用真实数据测召回与延迟来定,不是拍脑袋——这正是 AGENTS.md 说的「执行后立即验证、连真实环境联调」。