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 ANN 取 m 个 不知过滤条件 筛 metadata 结果集变少 < k 行 漏掉更近命中 数据正确性问题 pre-filter 先删非命中点 只留合格子集 × × 图连通性受损 召回崩 / 退化暴力扫 filtered-HNSW 命中点 跳过(灰) 遍历时跳过、不删边 召回不掉
图 3.1同一个「带过滤的检索」,三种策略召回差异巨大。注意:左中两栏(post / pre)的失败都是静默的——一个返回行数变少、一个召回悄悄掉下去,都不报错;只有右栏 filtered-HNSW 在图遍历时跳过灰点而非删点,连通性不破坏,召回才保得住。

备选方案与权衡表

过滤三策略:召回、延迟、适用基数
策略召回表现延迟何时用
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 和每篇文档拼在一起过一遍模型,给出更精的相关性分。代价高,所以只对少量候选做。
query dense 向量召回 ANN · 管语义 sparse BM25 召回 词项 · 管精确 token RRF 融合 按 rank 合一 rerank cross-encoder 结果
图 3.2稠密管语义、稀疏管精确 token,RRF 把两个排名合一。注意:RRF 融合的是两路的名次而非原始分——稠密的 cosine 分和 BM25 分量纲不可比,强行加权会被某一路的分布主导;用 rank 才稳。

② 为什么纯 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),用一致性换延迟。

时间 insert 落库 异步建索引 接进 HNSW 图 queryable 可被检索 可见性窗口 此间查询:查不到 / 报错
图 3.3写入后到可检索之间存在一段可见性窗口。注意:「刚写入立即读」是这一窗口最常见的 bug——返回查不到不是数据丢了,而是还没接进索引;backfill / 建索引期间发查询甚至会直接报错。强一致需求必须显式设 consistency level。

② 备选 / 权衡:一致性级别怎么选

这本质是经典的一致性—延迟权衡,落在向量库上:

  • 最终一致(默认):写入返回快、吞吐高,但「写后即读」会读不到刚写的。绝大多数 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」;生产里可叠用。

生产量化选项:压缩比 vs 召回兜底
方案压缩 / 省量召回必须 rescore?
halfvec (float16) 省 50% 存储、build 快 ~30% 损失通常可忽略 否(一般不需)
scalar (int8) ~4× 小幅损失 建议
binary (1 bit) ~32× 裸用掉得多 必须(靠原始 float32 兜回)

③ 带来的代价

量化的代价是多一段 rescore 链路 + 需要同时保留原始向量供兜底(否则无从 rescore,binary 召回就回不来)。即省了常驻检索的内存,却没省下「为 rescore 备一份原始向量」的存储——典型的拿存储 / 复杂度换内存与召回的平衡。压多狠取决于召回容忍度:能接受 binary + rescore,就拿到 32× 的内存红利去缓解上面那笔 400GB 的账。

自测

先合上屏幕,在脑子里或纸上答完,再展开对照。每题都是原子的——答不利索说明那个机制还没落地。

  1. 一个查询带过滤、写了 LIMIT 15,结果只回了 11 行。这是性能问题还是正确性问题?属于三策略里的哪一种、为什么?
    答案

    正确性问题,属 post-filter 的「缺斤少两」。HNSW 先取 15 个语义最近候选、再过滤,过滤后不足 15;更糟的是它还漏掉了候选窗口外那些本应更近的命中(实测漏 23 个)。修法:iterative scan(pgvector 0.8.0+)、oversample、或换 filtered-HNSW。

  2. 稠密向量召回质量已经很好,为什么还要加一路 BM25 稀疏召回?举一类纯 dense 会静默失败的查询。
    答案

    embedding 会把精确 token 抹平,返回语义近但不含原词的文档。失败对象:错误码(ERR_2087)、SKU / 订单号 / UUID、函数名等长尾标识符——它们恰恰最该被原样匹配,纯 dense 不报错地丢掉。BM25 对原词精确命中,正好补上;两者失败模式互补,这才是 hybrid 成标配的根因。

  3. 「向量库能持久化到磁盘,所以不怎么吃内存」——错在哪?1 亿条 1024 维 float32 用 HNSW 约需多少 RAM?
    答案

    HNSW 检索要全量向量 + 图链常驻 RAM才有标称延迟,换页到盘就性能塌方。实际占原始的 1.2–2×。1 亿 × 1024 × 4 字节 ≈ 400GB RAM,单机放不下,得分片或走存算分离。「持久化」只解决宕机不丢、重启能恢复,不解决在线检索要把数据怼进内存。

  4. (跨机制综合)为什么 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 说的「执行后立即验证、连真实环境联调」。