土法炼钢兴趣小组的算法知识备份

【向量检索引擎】Query Node 与 Segcore:段级 search 如何执行

文章导航

分类入口
databasestorage
标签入口
#milvus#query-node#segcore#sealed#growing#search#vector-engine

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

第 3 篇 把数据切成 Growing / Sealed;第 5 篇 说明 Streaming Node 上的 Query Delegator 负责 shard 级增量查询。仍缺一环:Sealed 段在 Query Node 上如何被加载并执行段级 search,以及它与 Knowhere 索引内核的边界——很多人把「Segcore」当成「Knowhere 换个名字」,或者以为 Query Node 是传统数据库那种带页缓存的有状态节点,这两个直觉都会在排障时把人带偏。

本文以官方 Data ProcessingArchitecture OverviewStreaming ServiceKnowhere 为准,描述查询数据面。不展开索引类型参数(第 8 篇),不展开跨 shard reduce 的全部细节(第 9 篇)。

本文是「向量检索引擎」系列第 7 篇(共 19 篇)。→ 系列目录

版本锚定:Milvus v2.6.21(源码钉点);Knowhere 由该 tag 的 internal/core/thirdparty/knowhere/CMakeLists.txt 拉取 v2.6.18。文中「Segcore」指 Milvus 用于 segment 级向量/标量执行 的 C++ 核心层(与 Go 控制面、Knowhere 索引库分层);段生命周期与查询流以官方 Data Processing 叙述为 A 级依据。


官方把 Collection 上的查询定义为:在指定集合中找与目标向量最近的 \(k\) 个向量,或距离阈值内的全部向量,并返回主键与字段。

物理上:

数据 持有者 角色
Growing segments Streaming Node 实时、可变
Sealed segments Query Node(从对象存储加载) 历史、不可变

Proxy 并不自己扫所有段;它把请求交到相关 shard 的 Streaming Node。每个 Streaming Node 上的 Query Delegator

  1. 生成查询计划;
  2. 本地搜索 Growing
  3. 联系持有相关 Sealed 的 Query Node 取历史结果;
  4. 聚合成该 shard 的结果。

Proxy 再合并各 shard 结果。Architecture 的 search 示例流与此一致:多级 reduce——Query Node 跨多 segment 归并 → Streaming Node 归并 Query Node 结果 → Proxy 归并各 Streaming Node。

flowchart TB
  client["Client search"]
  proxy["Proxy"]
  sn["Streaming Node<br/>Delegator + Growing"]
  qn["Query Node<br/>Sealed segments"]
  client --> proxy --> sn
  sn -->|"segment search"| qn
  sn -->|"shard reduce"| proxy

二、Query Node:历史数据面

Architecture 对 Query Node 的定义很短:从对象存储加载历史数据,并提供历史数据查询。结合 Data Processing 与 handoff:

  1. Growing flush 成 Sealed,或 Data Node compaction 完成后,Coordinator handoff
  2. Coordinator 在 Query Node 间分配 Sealed,平衡内存、CPU、segment 数,释放冗余。
  3. Query Node 按需从对象存储加载段数据(及索引,见第 8、10 篇),执行 segment-level search
  4. 新建索引加载后,替换对应的增长数据视图(Architecture 插入流)。

Query Node 被描述为存算分离下的 无状态 Worker(状态在对象存储与元数据中):扩缩与故障恢复依赖重新加载 segment,而不是本地磁盘上的可变页缓存语义(与经典 RDBMS buffer pool 不同)。这一点决定了 Query Node 重启或扩容不是「数据丢了要恢复」,而是「按 Coordinator 的 data view 重新拉一遍」——代价是冷启动期间的加载延迟,不是数据风险。

具体到扩容场景:新加入一个 Query Node,Coordinator 需要把部分 Sealed segment 的 data view 重新分配给它,这个过程本身要读对象存储、反序列化、加载索引到内存——在这段时间内,旧节点仍持有旧 view 继续服务,读流量不会因为「多了一台机器」立刻分摊过去,只有当新节点完成加载、Coordinator 更新路由后才会生效。缩容或故障恢复是同一个过程的反向版本:故障节点的 segment 被重新分配给存活节点,风险点是「同时失效的节点数」是否超过了剩余容量能承受的加载并发。


三、Segcore:段级执行层(相对 Knowhere)

官方 Knowhere 文档给出垂直分层:

  1. 最底:硬件;
  2. 其上:第三方索引库(Faiss、Hnswlib、Annoy 等);
  3. Knowhere:选择在 CPU/GPU 上执行索引构建与搜索,并对接索引/查询路径;
  4. 通过 CGO,Go 包调用 C/C++ 代码。

在这套分层里,段(segment)是查询与写入的数据单元,索引是段上的加速结构。工程上 Milvus 用 Segcore 表达「一个 segment 内的列式/向量数据 + 检索/过滤执行」;Knowhere 则 只处理向量索引相关计算(Knowhere 文档原话:Knowhere only handles the operations on vector indexing)。

官方 queryNode-related Configurations 将多项参数挂在 queryNode.segcore.* 下(例如 chunkRows:Segcore 把 segment 再切成 chunk 的行数,默认 128;以及 interim index 的 nlist / nprobe 等),从配置面确认 Query Node 的段执行子系统就叫 Segcore,并与 Knowhere 线程池比例等开关并列。仓库内另有 Segcore Search 设计文档描述 Boolean 表达式 → bitset → 向量引擎的访问者模式(B 级设计笔记)。

3.1 源码落点(milvus-io/milvus v2.6.21

段级入口不在「某个神秘的 Segcore 类名」上,而在 SegmentInternalInterface::Search:加共享锁后构造 ExecPlanNodeVisitor,由计划树驱动过滤与向量搜索:

// milvus-io/milvus v2.6.21
// internal/core/src/segcore/SegmentInterface.cpp
SegmentInternalInterface::Search(...) const {
    std::shared_lock lck(mutex_);
    check_search(plan);
    query::ExecPlanNodeVisitor visitor(*this, timestamp, placeholder_group,
                                       cancel_token, consistency_level,
                                       collection_ttl);
    auto results = std::make_unique<SearchResult>();
    *results = visitor.get_moved_result(*plan->plan_node_);
    results->segment_ = (void*)this;
    return results;
}

Growing 与 Sealed 在向量距离计算上分叉:

段类型 函数 调用
Growing SegmentGrowingImpl::vector_search query::SearchOnGrowing(...)SegmentGrowingImpl.cpp
Sealed(索引就绪) ChunkedSegmentSealedImpl::vector_search query::SearchOnSealedIndex(...)
Sealed(尚无索引) 同上 query::SearchOnSealedColumn(...) 暴力扫列

软删掩码入口之一是 SegmentGrowingImpl::mask_with_deletedeleted_record_.Query(bitset, ...)。过滤表达式如何变成「1=跳过」的 bitset,见第 11 篇对 FilterBitsNode 的钉点。

把这层关系画成分层图,比只列表格更容易记住「Segcore 编排、Knowhere 只算向量」的边界——尤其是 Segcore 内的标量过滤与删除 bitset 不经过 CGO 边界重新进 Knowhere,而是作为参数传给同一次索引 Search/Query 调用:

flowchart TB
  subgraph control["路由与控制面"]
    proxy["Proxy / Coordinator"]
    deleg["Query Delegator"]
  end
  subgraph seg["Segcore:段执行层"]
    exec["Segment executor<br/>chunk 编排 + 标量过滤 + 删除 bitset"]
  end
  subgraph kw["Knowhere:索引执行层"]
    vecidx["VecIndex Train / Query"]
  end
  subgraph lib["第三方索引库"]
    faiss["Faiss / Hnswlib / Annoy"]
  end
  hw["CPU / GPU 硬件"]
  control --> exec
  exec -->|"CGO: Query(vector, topk, BitsetView)"| vecidx
  vecidx --> faiss
  faiss --> hw

本系列采用如下可核对边界(与官方分层与配置命名一致,不展开未稳定的私有符号表):

负责 不负责
Proxy / Coordinator / Delegator 路由、计划、归并、拓扑 单个向量距离循环的细节
Segcore(段执行) 段内 search/query 编排、与标量字段/删除位图协作 跨 shard 调度
Knowhere 向量索引 Train/Query、SIMD/GPU 后端 Collection 级 DDL

软删与 bitset 的协作在 Knowhere 文档中写明:Milvus 用 bitset 实现软删,Knowhere 把 bitset 应用到暴露的索引查询 API——段执行层必须把「哪些行仍可见」交给索引查询,而不是先物理删除再搜索。细节见官方 Bitset 与第 13 篇。

queryNode.segcore.interimIndex.* 这组配置也值得在这里点一句:Growing 段上没有第 8 篇的重型图/量化索引,但完全暴力扫描在段变大后代价也会上升,interim index 是 Segcore 在 Growing 段上维护的一种轻量临时索引,用于在「还没资格建正式索引」和「纯暴力扫描」之间找一个中间点。它属于 Segcore 调度层的优化,不是 Knowhere 的新索引类型——这也是为什么它挂在 segcore.* 配置前缀下,而不是 Knowhere 的索引参数里。


四、Growing 路径 vs Sealed 路径

维度 Growing(Streaming Node) Sealed(Query Node)
数据位置 节点内存侧增长结构 + WAL 已提交 对象存储快照,加载到 Query Node
索引 实时路径优先保证可搜;重索引在封段后由 Data Node 做 每段可有独立向量/标量/主键索引
可变性 追加、删除广播影响可见性 不可变;替换靠 compaction + handoff
失败模式 WAL 迁移、Wait for Ready 加载失败、内存不足以容纳段/索引
排障优先看的指标 Growing 段大小、WAL 消费滞后 段加载耗时、内存/显存占用、handoff 是否完成

一次用户可见的 search 必须在 Delegator 侧 合并两类候选。若只测 Sealed、忽略 Growing,会在持续写入场景下得到偏乐观的延迟与偏悲观的新鲜度。这两条路径不是「先后」关系,而是 并发 关系——Delegator 同时向本地 Growing 执行器与远端 Query Node 发起搜索,二者互不等待:

sequenceDiagram
  participant P as Proxy
  participant D as Delegator(Streaming Node)
  participant G as Growing executor(本地)
  participant Q1 as Query Node A(Sealed 子集)
  participant Q2 as Query Node B(Sealed 子集)
  P->>D: search(plan, GuaranteeTs)
  par 搜 Growing
    D->>G: segment search(Growing)
    G-->>D: 本地候选
  and 搜 Sealed
    D->>Q1: segment search(Sealed 子集 1)
    D->>Q2: segment search(Sealed 子集 2)
    Q1-->>D: 候选
    Q2-->>D: 候选
  end
  D->>D: 合并 Growing + Sealed 候选,按分数截断
  D-->>P: shard 结果

图中的 par 块是关键:Growing 搜索不必等 Sealed 返回,反之亦然。这也解释了为什么「Query Node 全部健康、延迟正常」不能保证整体 search 延迟正常——Growing 侧的执行器在 Streaming Node 上,与 Query Node 是两套资源池,任一侧慢都会拖慢 Delegator 的合并时刻。

4.1 本机微基准:Flat 金标准 vs HNSW(非 Knowhere 计时)

为钉住「暴力搜索是召回金标准、近似索引用延迟换召回」这一工程角色,本机用 hnswlib(非 Milvus 进程)跑了小规模对照。脚本:reproduce/run_exp_07_08.sh

环境 Linux 6.8、2 核、MemAvailable≈176 MiB、Python 3.12.3、numpy 1.26.4、hnswlib
数据 \(N=10^4\)\(d=128\)\(n_q=50\)\(k=10\),向量 L2 归一化,L2,seed=42
HNSW \(M=16\)ef_construction=200ef_search=200
口径 批查询墙钟中位数(5 轮)
路径 中位延迟 (ms) vs Flat
Flat(暴力) 251.4 1.0(定义)
HNSW 8.84 0.922

同一脚本下该次记录中 Flat 约为 HNSW 的 28× 墙钟(批查询)。绝对毫秒在 2 核低内存主机上会随负载波动(复跑可能看到 Flat 中位在数百毫秒量级漂移),召回与「Flat 远慢于 HNSW」的相对关系更可引用。这些数字不是 Segcore/Knowhere 延迟,只说明:排障或验收时应保留 Flat/IDMAP 基线;调 ef 会在延迟与召回之间移动,不能只盯 QPS。


五、段级归并:本篇只需记住的接口直觉

在单个 Query Node 上,一次请求可能覆盖 多个 Sealed segment。Architecture 写明 Query Node 先做跨 segment 的 reduce,再交给 Streaming Node。实现上等价于:

  1. 对每个相关 segment 调用段级 search(经 Segcore → Knowhere);
  2. 按距离(或评分)合并 Top-\(k\)
  3. 应用一致性与删除可见性约束(第 12–13 篇)。

跨 Query Node、跨 shard 的归并留给第 9 篇;本篇强调:ANN 库一次 search ≠ Milvus 一次 search——后者是分布式段候选的归并树。


六、延迟归因:症状落在哪一层

排障时最容易浪费时间的地方,是不先区分「延迟落在哪一层」就直接去改索引参数。下表把常见症状对到本文划出的层次,作为动手改配置前的第一遍检查:

症状 更可能的层 先检查什么
全体 search 延迟均匀升高,与是否刚写入无关 Knowhere / 硬件 CPU 是否打满、SIMD 路径是否命中、GPU 显存是否争抢(第 8 篇)
只有刚写入后短时间内的查询慢,过一会恢复正常 Growing 路径 / Streaming Node Growing 段大小、Streaming Node 资源、是否叠加了大量并发写
某些请求恒定比其它请求慢一截,与数据量无关 Delegator 归并 / 跨节点往返 该 shard 是否倾斜、涉及的 Query Node 数是否偏多(第 9 篇)
冷启动或扩容后一段时间内延迟尖刺,随后恢复 Query Node 加载 段/索引加载并发限制、对象存储访问延迟(第 6、17 篇)
延迟正常但结果与预期不符(漏召回、多召回) Segcore 过滤 / bitset / Knowhere 参数 表达式下推是否正确、软删 bitset 是否更新、索引参数是否匹配数据规模
同一集群不同 Collection 表现差异很大 段大小与索引类型选择 segment 数量、每段行数、是否所有段都已完成 handoff(第 10 篇)

这张表不是排障手册的替代品(详见第 17 篇),只是把本文划出的层次翻译成「先看哪里」的顺序——大多数「调 ef 没用」的困惑,根源是延迟其实落在 Delegator 归并或 Query Node 加载这两层,而不是 Knowhere 内部。


七、最小故事:一次 search 如何在 Delegator 上被拼出来

设想一个只有 1 个 shard 的 Collection:最近 5 分钟写入的数据还在 1 个 Growing segment 里,历史数据已经 flush 成 3 个 Sealed segment,分别加载在 2 个 Query Node 上(Query Node A 持有 segment 1、2;Query Node B 持有 segment 3)。客户端发起 search(top_k=10)

  1. Proxy 把请求转给这个 shard 唯一的 Streaming Node;
  2. 该 Streaming Node 上的 Delegator 同时做两件事:在本地 Growing segment 上跑一次段级 search,取回不少于 10 个候选;并向 Query Node A、B 各发一次 RPC;
  3. Query Node A 内部对 segment 1、2 分别调用 Knowhere Query(),各自拿到候选后先在 本机 reduce 成一份结果再返回给 Delegator;Query Node B 对 segment 3 同理;
  4. Delegator 收到「Growing 候选」「Query Node A 候选」「Query Node B 候选」三份结果,按距离合并、截断到 10 个,作为这个 shard 的最终结果返回 Proxy。

这个故事里没有任何一步是「一次 FAISS 调用」——即使 Collection 小到只有 4 个 segment,也已经出现了 两级 reduce(Query Node 内跨 segment、Delegator 跨 Growing/Query Node)。多 shard 时还要在 Proxy 上加第三级(第 9 篇)。排障时如果只盯着 Knowhere 的单次查询延迟,会漏掉这条链路上任何一级 reduce 或某个 Query Node 加载慢带来的尾延迟。


八、常见误解

误解一:Segcore 就是 Knowhere 换个名字,两者可以互换使用。 官方文档把两者的职责分得很清楚:Knowhere 「只处理向量索引运算」;Segcore 还要做 chunk 编排、标量表达式求值、删除 bitset 的维护与传递。一次 search 在 Segcore 层可能已经做了大量标量过滤,真正进 Knowhere 的只是「哪些向量、按什么 bitset 过滤」这一步。混用两个名词会让排障时找错代码层——标量过滤慢,问题往往在 Segcore 的表达式执行,不在 Knowhere 的向量距离计算。

误解二:只要 Sealed 段上的索引召回好,Growing 段可以先不管。 Growing 段承载的是刚写入但还没 flush 的数据;忽略它意味着「最近的数据搜不到」。第四节的时序图已经说明 Growing 与 Sealed 搜索是并发发起的,二者对最终结果同等重要——只是 Growing 上大概率没有重型图索引,走的是更朴素的匹配路径(实时性优先,重索引留给封段后)。

误解三:Query Node 加载失败或重启等价于数据丢失,需要人工介入恢复数据。 Query Node 是无状态 Worker:它持有的段数据本身仍完整地留在对象存储与元数据里。加载失败通常意味着「这次没能把段搬进内存/显存」,处理方式是排查内存额度、并发加载限流或对象存储可用性,而不是当成存储层故障去做数据恢复。

误解四:ef / nprobe 调不动延迟,就说明监控或索引参数有问题。 如果延迟症状按第六节的表格落在 Delegator 归并或 Query Node 加载这两层,改 Knowhere 层的搜索参数不会有效果——这两层根本不经过那几个参数控制的代码路径。先定位症状所在的层,再决定改哪个参数,比反复试参数组合更快。

误解五:interim index 是 Knowhere 里的一种新索引类型,配置它等价于给 Growing 段选了 HNSW 或 IVF。 interim index 挂在 queryNode.segcore.* 而不是 Knowhere 的索引类型枚举下,是 Segcore 在「暴力扫描」与「重型索引」之间的调度层优化,不是第 8 篇讨论的 VecIndex 插件体系里的新成员。把它当成 Knowhere 索引类型去调参,会找错配置入口。


九、学术谱系、工程间隙、开放问题

9.1 谱系

阶段 内容
静态 ANN 单索引结构上的近似 Top-\(k\)
动态图索引 FreshDiskANN(arXiv 2021):单机内存增量 + 磁盘图后台合并
向量 DBMS SIGMOD 2021 Milvus:动态数据 + 异构执行(Knowhere)
2.6 数据面 Growing/Sealed 分流 + Delegator 拼装一次查询

段级执行让「动态更新」与「不可变索引」可以并存:更新打在 Growing/删除 bitset,重型图索引建在 Sealed 上。这与图索引社区自己应对「动态更新」的路线是同构问题:Singh et al. 提出的 FreshDiskANN(arXiv:2105.09613,2021,未经期刊/会议 peer review 的预印本)用「前台内存增量 + 后台合并进磁盘图」应对 DiskANN 原生静态索引不支持增量的问题,思路与 Growing(内存增量)→ Sealed(后台固化并建索)几乎一一对应,区别是 Milvus 把这条分界线上升为跨节点的架构边界(Streaming Node vs Query Node),而不是单机图结构内部的两个分区。

9.2 工程间隙

9.3 开放问题

  1. 多副本下 Delegator 与 Query Node 的调度如何最小化跨节点往返(第 14 篇)?
  2. 极高过滤选择性时,段级执行应先滤后搜还是先搜后滤——算法争论见 frontier/09,引擎路径见第 11 篇。
  3. GPU Knowhere 路径下,段加载与设备内存的编排是否与 CPU 段缓存同构?
  4. Growing 段是否也值得引入类似 FreshDiskANN 的「近似图结构」而非纯朴素匹配,以在实时路径上换取更好的召回——目前官方叙述未给出定论,值得关注后续 release 的 interim index 演进。
  5. interim index 的召回质量与「正式索引建好后」的召回差多少,是否需要在监控里单独区分「Growing 命中 interim index」与「Sealed 命中正式索引」两类结果的置信度?

十、小结

三句话小结

  1. Query Node 服务 Sealed 历史,Streaming Node 服务 Growing 实时,Delegator 把两者的候选并发拼成一份 shard 结果。
  2. Segcore 是段级执行层,负责 chunk、标量过滤与删除 bitset;Knowhere 是向量索引内核,只算向量距离——排障先分清延迟落在哪一层。
  3. 一次 Milvus search 从来不是单次 ANN 库调用,而是段内、节点内、shard 间的多级归并树,且各级候选是并发而非串行产生的。

下一篇进入索引内核:Knowhere,那里会拆开 VecIndex 的插件契约与 bitset 如何嵌进索引查询。


参考资料

核心论文

  1. Wang et al., Milvus: A Purpose-Built Vector Data Management System, SIGMOD 2021(异构执行与动态向量数据动机)。
  2. Singh, A., Subramanya, S. J., Krishnaswamy, R., Simhadri, H. V., FreshDiskANN: A Fast and Accurate Graph-Based ANN Index for Streaming Similarity Search, arXiv:2105.09613, 2021(预印本,未经 peer review;仅作 Growing/Sealed 二分的类比来源,不代表 Milvus 采用同一算法)。

文档与源码

  1. Milvus Documentation v2.6.x, Data Processing(data query、handoff)。
  2. Milvus Documentation v2.6.x, Architecture Overview(Query Node、search 多级 reduce)。
  3. Milvus Documentation v2.6.x, Streaming Service(Query Delegator)。
  4. Milvus Documentation v2.6.x, Knowhere(分层、CGO、bitset、只处理向量索引运算)。
  5. Milvus Documentation v2.6.x, queryNode-related ConfigurationsqueryNode.segcore.* 配置面)。
  6. milvus-io/milvus, docs/design-docs/…/segcore/Search.md(Segcore 搜索与表达式 bitset;B 级设计笔记)。
  7. milvus-io/milvus v2.6.21internal/core/src/segcore/SegmentInterface.cppSegmentInternalInterface::Search);SegmentGrowingImpl.cppvector_searchSearchOnGrowingmask_with_delete);ChunkedSegmentSealedImpl.cppSearchOnSealedIndex / SearchOnSealedColumn)。
  8. 本机微基准:reproduce/run_exp_07_08.sh(hnswlib Flat vs HNSW;非 Knowhere 计时)。
  9. 第 3 篇第 5 篇系列 index

返回 系列目录 | 上一篇:Streaming 与 WAL | 下一篇:Knowhere

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-12 · database / storage

【向量检索引擎】Data Node:compaction 与 index build

说明 Milvus 2.6.x 中 Data Node 作为离线 Worker 如何承接 Coordinator 下发的建索引与 compaction,输入输出如何进出对象存储;用最小故事、常见误解与队列积压图说明索引堆积如何拖垮查询新鲜度与资源争用。


By .