Chapter 04

检索与一致性:把写路径和索引接成读路径

02 章拆开了写路径——数据按 shard 哈希进 vchannel、落 WAL、攒成 segment;03 章拆开了单个 segment 内部的索引怎么把“扫全部”变成“扫一小部分”。这一章把两者合成:一次 search 如何扇出到所有相关 segment、各自检索、再多级归并成最终 top-k,然后讲清楚一致性级别到底在控制什么——它不是“强/弱”开关,而是给一个时间戳选值。

本章你将建立的 schema

  • 读路径:search → proxy 扇出 → 流式节点查 growing 段 / query node 查 sealed 段 → 段级检索 → 每分片 reduce → proxy 跨分片 merge → top-k。
  • 扇出的代价:段越多、扇出越宽、归并越重——把 02 的 compaction 和 03 的 nprobe/ef 接到这条读路径上。
  • 过滤与裁剪:标量 filter、partition key 裁剪、过滤比如何左右索引选择,以及“带过滤的 ANN 为什么更难”。
  • 一致性机制:TSO 与 guarantee timestamp,四个级别 = 给 guarantee timestamp 选四个不同的值,默认 Bounded。

前三章是零件,这一章是装配。读路径不是一个新机制,而是 01 的“没有整表大索引、每段各建索引”和 02 的“proxy 无状态、segment 是统一调度单元”两条事实的直接推论。把这条推论走完,一致性、过滤、归并代价就都落到同一张图上。代码片段只用来让概念落地,未在本机运行。

4.1读路径:一次 search 的扇出与归并

一次检索 = 把查询广播到所有相关 segment、各跑一次检索、再分两级把结果归并成最终 top-k。

为什么是这个形状

01 §1.1 / §1.6 定下一条事实:一张 collection 没有“整表一个大索引”,索引是每个 sealed segment 各建一份。既然检索结构散落在多个 segment 上,一次查询就无法“查一个索引”了事——它必须扫遍所有相关 segment,再把各段的局部 top-k 合并成全局 top-k。读路径的形状,是物理组织逼出来的。

比文档深一层的机制:一次 search 落到无状态的 proxy(02 §2.2 的入口),proxy 把它扇出到两类执行者——流式节点负责本分片的 growing 段(还没 sealed、没索引,只能暴力扫,回指 01 §1.4),query node 负责已加载的 sealed 段(走 03 的向量索引)。每个 segment 各自独立跑一次检索,吐出自己的局部 top-k。然后归并分两级:每个分片内部先 reduce,把该分片下所有 segment 的局部 top-k 合并成分片级 top-k;proxy 再跨分片 merge,把各分片结果合成全局 top-k 返回。proxy 做最终跨分片归并这件事来自 02 §2.2——proxy 无状态,归并是纯计算,不依赖本地数据,所以哪个 proxy 都能做。

Proxy(无状态) 接收 search · 扇出 广播 广播 流式节点 扫 growing 段 · 暴力 Query node 扫 sealed 段 · 走索引 growing 段 growing 段 sealed 段 sealed 段 每分片 reduce 合段内 top-k 每分片 reduce 合段内 top-k 跨分片 merge → 最终 top-k
图 4.1一次检索要扫遍很多 segment 再两级归并,不存在“整表一个索引”一查了事(回指 01 §1.6)。注意:growing 段走暴力、sealed 段走索引,是两条不同的检索路径;左右对称扇出、向中间汇聚,归并先在每个分片内做、最后由 proxy 跨分片合成。
一句话钉住

读路径 = 段级并行检索 + 多级归并。把它和 02 的写路径对照:写是“一条数据按哈希进一个 shard 的一条流”,读是“一个查询扇出到所有 shard 的所有段再收回来”。写收敛、读扇出——这是同一套 segment 组织在两个方向上的镜像。

想一想

同一个 query node 上加载了同一分片的 5 个 sealed segment。一次 limit=10 的检索,这台 query node 该返回多少个候选给上层 reduce?返回 10 个够吗?

展开答案(先停 10 秒)

每个 segment 各自返回自己的 top-10,所以这台 query node 手里有 5 × 10 = 50 个候选;它先做一次段内 reduce,得到这台节点的 top-10 再往上交。关键:单个 segment 只返回 top-10 不足以保证全局正确——设想全局第 10 名恰好是某个 segment 的第 1 名,而另一个 segment 的第 2~10 名全都更靠前。所以每个 segment 必须返回至少 limit 个候选,归并层才能在并集上重排出正确的全局 top-k。这就是为什么“段越多,每层要搬运和重排的候选越多”——直接引出 4.2 的代价。

4.2段扇出与归并的代价

段越多,扇出越宽、每层归并要重排的候选越多——这是 02 的 compaction 为什么重要、03 的查询旋钮在哪里起作用的交汇点。

为什么需要盯住它

4.1 的读路径里,延迟由两部分组成:每个 segment 上检索花多久,和把多少个 segment 的结果归并起来。前者由 03 的旋钮控制,后者由 segment 数量控制。段数失控时,归并这部分会从可忽略变成主导项。

比文档深一层的机制:扇出宽度 ≈ 相关 segment 总数。两个方向会把它推大。其一,sealed 段碎片化:手动 flush 过频(01 §1.5 的警告)或写入模式零碎,会留下大量小 sealed 段,每个都要被派去检索、都要贡献一份 top-k 进归并——这正是 02 §2.5 里 compaction 存在的理由:把小段合并成大段,直接削窄扇出。其二,growing 段堆积:写得快但 sealed + indexed 跟不上时,流式节点上挂着一批 growing 段,它们只能暴力扫(01 §1.4),段越多暴力扫的总量越大,直接拖慢这一路。

把 03 的旋钮接到这里:nprobe(IVF,扫多少个簇)和 ef(HNSW,候选队列多大)决定每个 segment 上检索的召回与延迟(03 §3.8)。它们是段级参数——调大 ef,是让每一个 segment 都搜得更狠,代价随段数线性放大。所以“调召回”和“控段数”是两件必须一起看的事:旋钮决定单段成本,compaction 决定段数,二者相乘才是这一路的总延迟。

失败模式 · 串起 02

“检索越来越慢”常常不是索引的锅,而是段数失控:要么小 sealed 段没被 compaction 及时合并(扇出过宽),要么 growing 段没及时 sealed+indexed(暴力扫面积过大)。诊断时先看段数和段状态分布,再去动 03 的 nprobe/ef——否则只是在每个失控的段上加更多功。

4.3过滤与分区裁剪

标量过滤和 partition 裁剪都在“缩小要检索的数据”,但裁剪只在查询显式带上分区维度时才生效。

为什么需要它

实战里的检索几乎都带条件:“某租户的、最近 7 天的、status=active 的”相似文档。把无关数据挡在检索之外有两个层次——partition 裁剪在段级把整批 segment 排除(最省),标量 filter 在段内把不满足条件的 entity 滤掉。

比文档深一层的机制:partition key 裁剪(01 §1.2)是最廉价的一层——查询带上分区维度,调度器直接不把无关 partition 的 segment 派给执行者,那些段连碰都不碰。标量 filter 表达式则在每个被检索的段内部生效。最反直觉的一点在这里:带过滤的 ANN 比纯 ANN 更难,不是更简单。图索引(HNSW)靠在邻接图上贪心游走找近邻;过滤会让游走踩空——途经的节点大量不满足条件,图的连通性被打断,要么走更多步、要么提前陷入候选不足。聚类索引(IVF)同理:簇内满足过滤的候选凑不满 topK,被迫去扫更多簇。过滤比(满足条件的占比)越低,这种打断越严重。

查询 filter: date ≥ 今天-3 显式带上分区维度 → 触发裁剪 collection 的 4 个 partition(按日期) P: 5 天前 裁掉 · 不碰 P: 4 天前 裁掉 · 不碰 P: 2 天前 命中 · 检索 P: 今天 命中 · 检索
图 4.2命中的 partition(accent)进入检索,其余被裁掉(虚线灰显)连段都不加载。注意:裁剪靠查询显式带上分区维度才生效(回指 01 §1.2)——不带条件,4 个 partition 全扫,裁剪等于没设。

过滤比反过来左右索引选型,这是 03 §3.9 留下的前向引用,现在收回:图索引(HNSW)在过滤比 < 85% 时通常更优——它对“滤掉一部分”相对鲁棒,剩下的候选仍能在图上连通;而 IVF 在 85~95% 的高过滤比叠加大 topK(> 2000)时反而更优——此时几乎不滤,IVF 扫整簇的简单结构没有图游走被打断的损耗,大 topK 下吞吐占优。换句话说,“选哪个索引”不只看数据规模,还要看查询长什么样:过滤比和 topK 是和规模并列的输入。

把三件事钉在一起

partition 裁剪、标量 filter、索引选型不是三个独立选项,而是同一个目标“少检索、检索得准”的三个层次:裁剪在段级砍掉整批,filter 在段内砍掉行,索引决定砍剩下的怎么搜得快。设计时从外往里走——先看能不能用 partition key 裁掉大头,再看过滤比落在哪个区间该配哪种索引。

4.4一致性机制:TSO 与 guarantee timestamp

一致性级别不是“强/弱”开关,而是给检索的 guarantee timestamp 取哪个值——一条连续的光谱。

为什么需要它

01 §1.7 给了四档的名字和默认值(Bounded),但没说它们底下是同一个机制。分布式下“刚写的立刻可见”要付延迟代价(等所有写入对齐);把这个取舍参数化,让能容忍滞后的场景用一点新鲜度换吞吐。参数化的载体,就是 guarantee timestamp。

比文档深一层的机制:02 §2.2 里 MixCoord 兼任时间戳预言机(TSO oracle)——它派发全局单调递增的 TSO 时间戳。02 §2.4 的写路径里,每条写入都带一个 TSO。读路径这边,每次检索带一个 guarantee timestamp,语义是:“返回结果前,必须看到所有 TSO ≤ 这个时刻的写入”。执行者(query node / 流式节点)会等到本地已消费的数据追上 guarantee ts 才开始检索。于是四个一致性级别,本质是给 guarantee timestamp 选四个不同的值:

  • Strong = guarantee ts 取最新时刻(向 MixCoord 要当前 TSO)。要求看到此刻为止的全部数据 → 执行者得等本地数据完全对齐,最慢。
  • Bounded = guarantee ts 取最新时刻减一个容忍窗口(默认)。允许漏掉最近这个窗口内的写入 → 不必等最新,有界滞后。
  • Session = guarantee ts 取本 client 最后一次写入的 TSO。只保证看到自己这个会话写过的东西 → read-your-writes,但不保证看到别人刚写的。
  • Eventually = 不卡 guarantee ts(取 0 / 最早)。执行者拿到什么就搜什么,不等待 → 最快,无新鲜度保证。
TSO 时间 → w1@t1 w2@t2 w3@t3 Eventually 不卡 · 任意子集 Bounded(默认) = t3 − Δ · 见 w1,w2 Session = 本会话最后写 Strong = t3 · 见全部 · 等待 容忍窗口 Δ
图 4.3四个级别 = 在同一条时间轴上给 guarantee timestamp 选不同的落点。注意:默认的 Bounded 落在 t3 减一个容忍窗口 Δ 处,看到的是一份略旧的快照(漏掉最近的 w3);Strong 落在最新 t3 要等全部对齐,Eventually 不卡、看到的是任意子集。
比文档深一层

官方文档把四档列成并列选项,容易让人以为是四个独立机制。底下其实是一个机制 + 一个可调的值:guarantee timestamp 在时间轴上滑动。Strong 滑到最右(最新、最慢),Eventually 滑到最左(不等、最快),Bounded 和 Session 在中间各取一个有意义的点。理解成“连续光谱上的四个采样”,比背四条定义更经得起追问。

4.5四个级别的代价对比

同一条光谱上,越靠“看到最新”越慢、越靠“不等待”越快——按场景对新鲜度的真实需求来选。

把四个采样点摊成一张对照表。默认是 Bounded(不是 Strong)——这是从关系库迁移过来最反直觉的一点(01 §1.7 已埋):默认给的是有界滞后,不是 read-your-writes。

表 4.1 · 四个一致性级别 = guarantee timestamp 的四种取值(默认 Bounded 标记)
级别能看到多新延迟代价典型场景
Strong此刻为止全部写入(guarantee ts = 最新 TSO)最高:等本地数据完全对齐才检索金融 / 强读写一致:写完必须立刻被任何读看到
Bounded略旧快照,漏掉最近容忍窗口 Δ 内的写入中等且有界:不等最新,滞后有上限RAG 离线灌库后查、推荐召回——容忍秒级滞后换吞吐(默认)
Session本 client 自己写的一定可见,别人的不保证低:只需追上自己最后写入的 TSO刚写入要立刻自查、写后读的交互式流程
Eventually任意子集,无顺序保证最低:完全不等待日志 / 监控类:能查到就行,不在乎漏掉最新几条
search_consistency.py · 示例,未在本机验证 Python
# 一致性级别按次设置:同一 collection 不同查询可用不同级别
# 不指定则用 collection 默认(Bounded) —— 注意默认不是 Strong
res = client.search(
    collection_name="docs",
    data=[query_vector],
    limit=10,
    filter='tenant == "acme" and date >= "2026-05-27"',  # 标量过滤 + 可触发 partition 裁剪
    search_params={"metric_type": "COSINE", "params": {"ef": 64}},  # ef 是段级旋钮(03)
    consistency_level="Session",   # 写后要自查 → Session;要绝对最新 → Strong(付延迟)
)
想一想 · 设计题

一个客服系统:坐标系用 Milvus 存历史工单向量。坐席提交一张新工单后,立刻要在“相似历史工单”里看到自己刚提交的这张(用于去重提示);但对别的坐席几秒前提交的工单晚一点看到没关系。选哪个一致性级别?为什么不选 Strong?

展开答案(先停 10 秒)

选 Session。需求精确命中它的语义:“本 client 自己写的一定可见”——坐席读到自己刚写的那张工单即可,别的坐席的写入允许滞后。

不选 Strong 的原因:Strong 要求看到所有人此刻为止的全部写入,执行者得等本地数据完全对齐,延迟最高;而需求里“别人的工单晚点看到没关系”说明根本不需要全局最新。用 Strong 是为用不到的保证付延迟。也不选 Bounded——它连“自己刚写的”都不保证可见(落在容忍窗口里就被漏掉),去重提示会失灵。

4.6跨概念综合:一个 RAG 场景同时拍三个板

真实选型不是孤立调一个参数,而是一致性、索引、partition 三者在同一个场景里相互牵制。

场景:一个企业知识库 RAG。文档块持续增量写入(每天新增、偶尔重灌某个空间的文档),同时在线检索(用户提问 → 召回相关块喂给大模型)。数据按空间/租户天然分组,规模到千万级向量。把前面所有零件摊开,同时拍三个板:

板一:一致性级别 → Bounded

RAG 召回容忍秒级滞后——用户提问时,刚灌进去几秒的某个块晚一点可见,不影响答案质量。Bounded(默认)正合适:用有界滞后换检索吞吐。例外:如果产品要“刚上传的文档立刻能被同一用户问到”,那条交互路径单独用 Session——按次设置,不必整个 collection 改默认(呼应 4.5 的“一致性级别按次设”)。

板二:索引 → 看过滤比和 topK,不只看规模

千万级规模两种主流索引都扛得住,真正的决定因素是查询形态(4.3 收回的 03 §3.9)。RAG 召回通常带过滤(按空间、按文档类型)但过滤比不会太低,且 topK 一般不大(几十)。这落在图索引(HNSW)更优的区间——过滤比 < 85% 且小 topK,HNSW 对过滤鲁棒、延迟低。只有当某些查询变成“几乎不过滤 + 超大 topK(>2000)”时,IVF 才反超。

板三:partition → 用空间/租户做 partition key

数据天然按空间分组,且检索几乎总带空间条件——这正是 partition key 的理想场景(01 §1.2)。按空间设 partition key,查询带上空间维度就段级裁掉其他空间的全部 segment(4.3 图 4.2),把扇出宽度从“全库段数”压到“本空间段数”。

三者如何相互影响

三个板不是独立的:partition 裁剪先把扇出压窄,直接减轻 4.2 的归并代价,也让 4.4 的 guarantee ts 等待面更小(要对齐的段更少);索引选型决定裁剪后每个段搜得多快;一致性级别决定要不要为新鲜度多等。一个例子串起来:重灌某空间文档时会产生一批 growing 段和待 compaction 的小段(4.2),此时该空间的检索若用 Strong 会等这批数据全部对齐(4.4)、又因小段多而扇出宽(4.2)——三处代价叠加。改用 Bounded + 等 compaction 收敛,延迟立刻回落。这种“一处变动牵动三处”的推演,正是 05 章选型辨析的预演。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 一次 search 从 proxy 出发到返回 top-k,中间经过哪两类执行者、各负责哪种 segment?归并分哪两级、分别在哪里做?
  2. “检索越来越慢”,在动 nprobe/ef 之前应该先排查什么?为什么 02 的 compaction 与这件事直接相关?
  3. 四个一致性级别底下其实是同一个机制。这个机制是什么?四个级别分别给它的那个值取了什么?默认是哪个?
  4. 设计题:一个推荐系统,离线批量灌入物料向量(每小时一批),在线高 QPS 召回,容忍分钟级新鲜度滞后。一致性级别选哪个?如果改成“用户刚收藏的物料要立刻出现在他自己的推荐里”,这条路径又该选哪个?
答案(先做完再展开)
  1. 经过流式节点(查本分片 growing 段、暴力扫)和 query node(查 sealed 段、走索引)。归并两级:先每分片 reduce把段内 top-k 合并,再由 proxy 跨分片 merge成全局 top-k。
  2. 先排查段数与段状态:小 sealed 段是否堆积(扇出过宽)、growing 段是否没及时 sealed+indexed(暴力扫面积大)。compaction 的作用就是合并小段、削窄扇出(02 §2.5);段数失控时,调 nprobe/ef 只是在每个失控段上加更多功。
  3. 机制是 guarantee timestamp(“看到所有 TSO ≤ 该时刻的写入”,TSO 由 MixCoord 这个 oracle 派发)。Strong = 最新 TSO(等全部对齐);Bounded = 最新减容忍窗口;Session = 本 client 最后写入的 TSO;Eventually = 不卡(取最早)。默认 Bounded。
  4. 常规召回选 Bounded:容忍分钟级滞后换高 QPS 吞吐,默认即可。“刚收藏要立刻出现在自己推荐里”这条路径单独用 Session(read-your-writes),按次设置,不动 collection 默认。
进阶挑战 · 刚好够不着

把读路径、归并代价、一致性叠在一条时间线上推

一个 collection:4 个 shard,按租户做 partition key。某租户刚通过一次大批量 insert 灌入 200 万条向量,此刻这批数据大部分还在 growing 段、少量 sealed 但尚未 indexed。紧接着该租户发起一次带租户过滤的检索,limit=50,一致性用了默认 Bounded。

(a) 这次检索的扇出会落到哪些执行者、扫哪些段?为什么这次会明显比平时慢?(b) 如果把一致性临时改成 Strong,延迟会怎样变化,为什么?(c) 要把这次检索的延迟降下来,在“立刻”和“等一会儿”两个时间尺度上,各有什么手段?

提示(卡住再展开)

(a) partition key 裁剪只留本租户的段(4.3),但本租户这批新数据大量在 growing 段→流式节点要暴力扫(4.1/4.2);少量 sealed 但没 indexed 的也走不了索引。扇出虽被裁剪压窄,但段都是“慢段”。(b) Strong 把 guarantee ts 推到最新 TSO,执行者要等这 200 万条全部对齐才开始检索(4.4)——在数据正流入时,这个等待会更长。(c)“立刻”:降不了暴力扫的本质,但可以把 limit/旋钮收一收减轻单段成本,或确认查询确实带了租户条件让裁剪生效;“等一会儿”:等 growing 段 sealed+indexed、等 compaction 把小段合并(4.2),走上索引后这批段的检索成本数量级下降。