
🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 183 篇,Milvus 最佳实战「2026」系列第 24 篇
大家好,欢迎来到 术哥无界 | ShugeX | 运维有术。
我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者!
Talk is cheap, let's explore。无界探索,有术而行。

先看一个场景:你在做 RAG 应用,用户问了一个问题,你把问题向量化,从 Milvus 里召回了几百个相关片段。但这些片段散落在几十个文档里,你想按文档分组,每组只返回最相关的 3 条,再按文档的发布时间排个序。复杂一点,你还想知道每个文档的平均相关度,或者按文档类型统计命中数。
放到 3.0 之前,你只能这样干:把几百条结果全部拉到应用层,自己写代码分组、排序、截断、算指标。搜 100 条拉 100 条,网络开销和客户端内存全吃满。如果搜索结果有一万条但你只需要前 100 个分组,你也得拉一万条——数据库不知道你要怎么分组,只能全给你。
Milvus 3.0 的 Search Aggregation 和 Order By 就是冲着这个来的:把分组、聚合、排序从应用层搬进数据库内部。
但这篇文章不是功能介绍。翻完四个设计文档和源码之后,我发现这套机制的设计取舍比想象中复杂。它不是在 Milvus 里塞了一个 Elasticsearch,而是在 ANN 搜索的近似性之上,用一层隔离架构补上了分析层。这个过程中最核心的矛盾是:向量检索本身就是近似的,那建立在它之上的聚合,能做到多精确?
说明:本文内容基于 Milvus 3.0 源码(zilliztech/milvus,
internal/agg、internal/proxy/search_agg等模块)和官方设计文档(Search Embedded Aggregation MEP、Group By、Order By 系列)分析整理而成,源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证。文中的配置模板和参数建议仅供参考,实际效果请以你的业务数据和环境测试结果为准。如果有实际使用经验,欢迎在评论区分享交流。
这事得从行业趋势说起。2026 年向量数据库的竞争维度变了——不再只是谁能搜得更准,而是谁能搜完直接分析。SingleStore 把实时聚合和向量搜索整合在同一系统,PostgreSQL 靠 pgvector 反向整合向量搜索,Elasticsearch 一直在做文本 + 向量 + 聚合的混合负载。
Zilliz CTO 有句话说得挺直接:团队不只是做简单的搜索了。AI Agent 需要的不只是找到最相似的向量,而是找到后按业务规则排序、分组、统计。Milvus 3.0 官方博客里把这条路概括为:Search, analyze, and process from one copy——一份数据,检索、分析、处理一站式完成。
但向量数据库做聚合有个天然矛盾:ANN 搜索本身就是近似的,聚合结果能精确吗?
Milvus 的选择是:承认近似,用工程手段在精度和延迟之间做权衡。这个选择的代价和收益,后面会展开说。

整个设计最让我觉得有意思的地方,是它画了一条非常清晰的边界:Segcore 只做最笨的事,Proxy 包揽所有聪明的事。
翻设计文档 20260413-search_embedded_agg.md 的时候,这个边界在表格里写得明明白白:
层 | 职责 | 聚合感知? | 嵌套感知? | 排序感知? |
|---|---|---|---|---|
Segcore | 多字段 flat composite-key group-by | 否 | 否 | 否 |
QueryNode / Delegator | 合并多 segment 结果,按 composite key 去重 | 否 | 否 | 否 |
Proxy | 解析 GroupBy 树 → 构建 context → 接收 flat 结果 → 重建层级 + 指标 + 排序 | 是 | 是 | 是 |
Segcore 眼里没有聚合,没有嵌套,没有指标。它只认一件事:你给我一个 composite key 的列表,我按 key 分组,每组返回 top-N 结果。至于这些 key 之间是什么层级关系、每个 bucket 要算什么指标、bucket 之间怎么排序——Segcore 一概不管。
所有这些复杂逻辑全部压在 Proxy 层。具体流程是:
Proxy 拿到用户定义的 GroupBy 树(比如第一层按 category 分组,第二层按 brand 子分组),先把所有层级递归展开,flatten 成一组 composite key 请求。发给 Segcore 的 top-K 不是用户指定的 size,而是所有层级 size 的乘积再乘以安全因子:segcore_topk = Π(level.size) × group_count_safe_factor,上限 65535。
举个例子:用户要求第一层返回 10 个 category,每个 category 下返回 5 个 brand。那 segcore_topk = 10 × 5 × 4 = 200。多拉 4 倍的数据,是为了降低后续层级重建时漏桶的概率。
Segcore 拿到这个扁平请求,按 composite key 分组,把结果以 flat 形式返回给 Proxy。Proxy 的 SearchAggregationComputer 用一次扫描完成自顶向下的递归重建:遍历每条 flat 结果 → 提取 group key → 标准化 → 插入 bucket map → Count 递增 → 指标更新 → Top-K 堆维护 → 子行收集。嵌套层级通过 sub_group 递归表达,最大深度 4 层。
整个流程,Segcore 看到的始终是一个扁平的 group-by 请求,对嵌套和指标一无所知。Proxy 像一个翻译器,把用户的树形意图翻译成 Segcore 能理解的扁平指令,再把 Segcore 返回的扁平结果翻译回树形结构。
为什么要这样设计?Segcore 是 C++ 写的,跑在查询引擎的热路径上,让它做复杂聚合和嵌套重建会严重影响延迟。把重逻辑放在 Go 写的 Proxy 层,虽然牺牲了单个节点的计算效率,但换来一个关键好处:改聚合逻辑不需要动 Segcore 的二进制。

源码里有两套分组逻辑,走的路径不一样:
group_by_field_id > 0,走 SearchResultData.group_by_field_value(field 8)这个线载体,只能做单字段去重。这是 3.0 之前就有的能力,官方用户文档只覆盖了这一套。agg_info != nil,走 SearchResultData.fields_data,用多字段 composite key 做去重。这才是 3.0 真正新增的东西——支持多字段分组、分组内指标计算、bucket 排序、嵌套子分组。两条路径互不交叉,各有各的线载体。最直观的判断方式是:如果你只用 group_by_field,走的是老路径;如果你用 GroupBy 对象或 search_aggregation,走的是新路径。
但有意思的是,用户文档严重落后于实现。翻 v3.0.x 官方文档里的 grouping-search 页面,只覆盖了基础 Grouping Search——单字段 group_by_field、group_size、strict_group_size 那几个参数,对新聚合 API 只字未提。设计文档写得很详细,源码也实现了,但普通用户根本不知道这些能力的存在——这大概和功能默认关闭有关,后面会说到。

这个点值得单独拿出来说,因为它很容易被误解成缺陷。
官方博客里有一句话:Search aggregation 操作在 ANN 检索结果集上进行,而非全量数据。因此分面计数是近似值。需要精确计数时,应使用 query-side aggregation(全量扫描)。
初看这句话,你可能会觉得这是一个限制。但翻完设计文档里的 Approximation Contract 章节,你会发现这是刻意的工程权衡。
近似表现在三个层面:
Bucket 存在性不保证。ANN 检索池外的 key 就是不可见的。你搜了 top-100 条,某个文档的片段没进这个范围,那它就不会出现在分组结果里。哪怕确实有相关内容,也看不到。文档里甚至明确写了:不保证 bucket 的存在性。
第二个层面是 count。count 只统计检索池中的行——一个文档实际有 20 个相关片段,但 ANN 只召回了 5 个,count 就是 5,不是 20。
最麻烦的是嵌套指标偏差会随层级传播放大:第一层 bucket 的 count 不准确,基于它做的第二层子分组就更不准,层级越深,偏差越严重。
那怎么缓解?就是前面提过的 group_count_safe_factor:top-K 按层级 size 的乘积放大(默认 factor = 4),多拉数据,降低漏桶的概率。这个思路和 Elasticsearch 的 shard_size 参数类似——多拉一些数据,代价是更多的计算和内存开销。
说白了,这是一道数学题:你想要精确的聚合,就得在 Segcore 层做全量扫描,但那就不是 ANN 搜索了,延迟会爆炸。Milvus 的选择是接受近似,用一个安全因子降低误差,换来了 ANN 搜索的延迟优势。
这个选择对不对,取决于场景。实时推荐场景,几百毫秒的延迟不可接受,近似聚合完全够用。BI 报表要精确计数,老老实实用 query-side aggregation。
这个区别不看源码的话,光看文档很容易搞混。
Search Order By 完全在 Proxy 层执行,在向量搜索的 requery 之后。管道是:
reduce → merge_ids → requery → gen_ids → organize → pick → order_by源码在 internal/proxy/search_pipeline.go:1808 的 orderByOperator。这个执行位置决定了两个关键限制:必须触发 requery 获取字段值,多一次网络往返,延迟直接加一截;所有排序字段数据必须加载到内存,排序完全在 Proxy 单节点完成,没有下推。
orderByOperator 的实现倒是挺直接:验证所有 order_by 字段存在,支持多字段排序(优先级递减),也支持动态字段 JSON 子路径(比如 metadata["price"])。排序用 sort.SliceStable,等值行保持原始顺序,复杂度 O(n log n × f),f 是排序字段数。
但 Group By 场景下有个微妙之处:它按每组 top entity 的标量字段值排序组。也就是说,如果你按 price 排序,它取每个 group 里第一行的 price 值作为排序依据,而不是整个 group 的平均值或中位数。
Query Order By 则完全不同。它下推到 Segcore 执行,每段本地排序后返回 Top-K,QueryNode 合并,Proxy 全局 merge-sort。Segcore 里有一个叫 SortBuffer 的组件(internal/core/src/exec/SortBuffer.h),Velox 风格设计,只排序指针(8 字节),不移动行数据。多字段排序时,每个字段可以独立指定 ASC/DESC 和 NULLS FIRST/LAST。当 limit < n/2 时,用 partial_sort 做 O(n log k) 优化。
Segcore 的查询管道(单次投影模式)长这样:
FilterBitsNode → MvccNode → ProjectNode[pk, B, C, A, SegOffset] → OrderByNode(B, C, limit)为什么两条路径差这么多?想一下就明白了:Query 是全量扫描,数据在 Segcore 本地,排序跟着数据走,自然可以下推。Search 是 ANN 检索,结果分散在多个 segment,必须在 Proxy 层合并后才能排序。这是两种查询语义的必然分化,不是实现偷懒。
顺带一提,设计文档里标了一个已知限制:SortBuffer 目前没有 max_sort_rows 守卫,排序数据量过大时理论上存在内存风险,目前还没实现。如果要做超大结果集的排序,得自己留意。

internal/agg/ 这个 Go 包是整个聚合能力的底座,被 Search Aggregation 和 Query Aggregation 两条路径共享。
它提供了一套完整的抽象:AggregateBase 接口定义 Update / NewState / UpdateState / Terminate / ToPB 五个方法。SumAggregate 做数值累加,CountAggregate 做计数(跳过 null),MinAggregate 和 MaxAggregate 做有序比较。AvgAggregate 内部拆成 sum + count 两个 slot,Terminate 时做除法。FieldValue 封装值 + null 标记,Row 和 Bucket 负责 key 匹配和聚合累积。
Search Aggregation 复用的方式很典型:search_agg/metricPlan 持有 agg.AggregateBase,computer.go 在扫描时为每个 bucket 创建独立的 accumulator 状态。同一个 SumAggregate,Search 路径用,Query 路径也用,指标计算逻辑只写一遍。
从设计文档的时间线也能看出工程节奏:20260130-embeded-group-by.md(1 月 30 日)先提出三层架构,20260203-query-orderby.md(2 月 3 日)做 Query 排序。等到 20260413-search_embedded_agg.md(4 月 13 日,修订到 4 月 22 日),Search 聚合的完整 MEP 才定下来。底层聚合框架先行,上层 Search Aggregation 只管层级重建、bucket 排序、嵌套展开,完全不碰聚合计算本身。
翻到实现状态的时候,有一个细节让我觉得这个团队挺务实。
proxy.search.embeddedAggregation.enabled 这个 feature flag,在 3.0 GA 里默认是 off。也就是说,你在 GA 版本里启动一个 Milvus 实例,Search Aggregation 默认不工作,得手动打开。
这个选择背后,我猜是稳定性考量。整个链路的每个环节——Segcore 的 flat group-by、QueryNode 的去重合并、Proxy 的层级重建和指标计算——都是新代码。而且部分功能在 Beta 文档里标注着 Compatible with Milvus 3.0.x,暗示与早期版本可能不完全兼容。默认关闭,给团队留了回旋余地,也给用户留了选择权。
Phase 1 的能力范围也有明确边界:指标只支持 count / sum / avg / max / min;bucket 排序支持 _count / _key / 任意指标别名;TopHits.sort 只影响展示,不影响指标计算。不支持 hybrid search + group_by 组合、不支持 highlight、不支持 JSON 字段、不支持 limit 与 group_by 同用。设计文档里提到的 metric_safe_factor 在 Phase 1 里只是 proto 里保留的一个字段,实际是 no-op。
这些限制说明一件事:Search Aggregation 没打算一步到位,它是分阶段交付的工程成果。Phase 1 解决的是有没有的问题,Phase 2 才轮到好不好。
另外,GA 到现在(截至 2026 年 7 月 31 日)才两周,Reddit、Hacker News 上还没形成规模讨论,生产环境的使用案例也几乎没有公开的。想找现成的踩坑经验,估计还要等一阵子。
回到开头那个问题:Milvus 3.0 做 Search Aggregation,是不是在变成 Elasticsearch?
不是。
Elasticsearch 的聚合基于全量倒排索引,count 是精确的,sum 是精确的,avg 是精确的。Milvus 的聚合基于 ANN 检索结果集,全是近似值。这是两种不同的设计哲学:ES 追求精确,Milvus 追求低延迟。谁好谁坏,取决于你在乎什么。Airbyte 的对比测试里,Milvus 在纯向量搜索上大约快 15%,p95 延迟好约 20%。但混合负载——文本、向量、聚合一体——仍是 ES 的强项。
不过,这条路线本身不是孤例。SingleStore 在做实时聚合 + 向量搜索,pgvector 在 PostgreSQL 里做混合负载,连 MariaDB 都在 2026 年平台上加了向量搜索。整个行业在往同一个方向走:向量数据库的定位正在移动——从存向量、搜向量,挪向分析型数据库。行业分析师 Kevin Petrie 说得更直白:向量只是多模态拼图的一部分。
Milvus 3.0 的 Search Aggregation 和 Order By,是这个方向上的一个具体落子。它没有超越谁,但确实补上了之前明显缺失的一环——把检索后分析从应用层搬到了数据库内部,而且是用近似换取延迟的方式做的。对已经在用 Milvus 的团队来说,这意味着不用再写那些分组聚合的胶水代码了。
至于这个能力能不能撑住、用户文档什么时候跟上、Phase 2 什么时候落地——这些问题的答案,半年后再看也不迟。

翻完 Milvus 3.0 的 Search Aggregation 源码,最让我印象深刻的反而不是技术本身,而是团队的工程纪律。
画一条清晰的层隔离边界,让 Segcore 只做最笨的事,Proxy 包揽所有复杂逻辑。承认近似是设计选择而非 Bug,用 group_count_safe_factor 在精度和延迟之间做权衡。底层聚合框架先搭好,Search 和 Query 两条路径共享。默认关闭 feature flag,给自己留足回旋余地。
这些选择都不性感,但都很务实。如果你正在评估 Milvus 3.0 的聚合能力,建议重点关注两个问题:你的场景能接受近似聚合吗?如果能,group_count_safe_factor 调多大合适?这两个问题的答案,会比任何技术细节都更影响你的实际体验。
好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。