原创 韩淳 2026-08-10 18:30 上海
想象一个很普通的电商搜索页:用户上传一张连衣裙的照片,以图搜款。关于检索本身,向量数据库做得很好——五千万商品里取回最相似的一千条,典型配置下只需几十毫秒。
但产品真正要交付的,从来不只是这一千条结果。左侧有品牌筛选器;顶部可以让用户按价格、按销量、按评分排序;运营还想知道这次召回集中在哪些品牌、各自的价格水平和代表款。
于是系统里通常会出现第二段逻辑:工程师需要把检索结果拉回应用层,用 pandas 或业务代码做分组、计数、排序,再拼成最终响应。
久而久之,我们把这种架构当成了理所当然:向量数据库负责把东西找到,至于怎么把这些结果通过分组、统计、排序、分页变成业务真正需要的「答案」,由应用自己解决。
过去,在传统 SQL 数据库里,这些本来就是查询能力的一部分。到了向量数据库时代,却长期需要在应用侧二次开发。
Milvus 3.0 把这段逻辑收进了引擎:让query 和 search 都支持按标量字段排序,query 支持 GROUP BY 加聚合函数,search 的结果集可以直接做分层聚合分析。
接下来,这篇文章会重点讲清楚三件事:第一,为什么这部分逻辑应该进入数据库,而不是留在应用层;第二,Milvus 在分布式场景下,是如何让聚合和排序保持正确的语义;第三,这些能力的边界在哪里?
01
为什么后处理不应该长期留在应用层
在应用层做后处理,表面上只是「多写几行代码」,实际上藏着三个代价:数据搬运、语义正确性,以及额外的系统复杂度。
第一个代价是搬运,为了几 KB 的答案,搬几百MB的数据
还是那个商品库:运营想看「在售商品里各类目的数量和均价」,过滤条件命中两百万行。
如果应用层自己聚合,就必须先把这些数据取出来。哪怕每行只返回类目、价格和主键,加上序列化开销,一行也可能超过上百字节。两百万行,就是几百 MB 的网络传输和客户端内存。
但最后的答案有多大?
假设只有两百个类目,每个类目返回一个数量和一个均价,最终结果不过几 KB。
为了几 KB 的答案搬运几百 MB 的数据,中间差着五个数量级;而且报表每刷新一次,这笔成本就再付一次。
第二个代价是应用层很容易得到一个“看起来正确”的答案。
相比网络成本,语义错误更危险,因为它通常不会报错。最典型的例子就是分页加排序。
用户希望「按价格从低到高浏览检索结果」。如果应用每次只拿一页结果回来,然后对当前页排序,那么得到的只是这一页内部的价格顺序,而不是整个结果集的全局顺序。
于是翻到第二页,发现价格可能突然比第一页还低。
过去,不少系统的排序功能一直是有问题的,只是很少有人翻到第二页仔细验证。
类似的问题还包括可见性。
一条刚刚被删除的数据是否还应该参与统计?TTL 已经过期但物理数据尚未清理的记录要不要算?某个查询时刻,究竟哪些版本的数据对当前请求可见?
这些可见性语义只有数据库自己知道。在客户端算,等于默认「我拉到的就是对的」。而这个假设在一个持续写入、持续删除的系统里并不成立。
第三个代价是架构,一次 count,最后可能新增一套分析链路。
当应用层后处理越来越复杂,很多团队最后会走向同一种架构:向量数据库负责在线检索;数据定期导出,经 ETL 进入另一套分析系统;统计和报表在那边完成。
技术上当然可行,但多一份数据,就会多一条同步链路,多一种一致性问题,也多一个故障面。
这三个代价指向同一个结论:分组、统计、排序,如果计算可以在数据所在的位置完成,就不要先移动数据再移动计算。
02
Milvus 3.0如何在分布式系统中做聚合?
从 3.0 开始,这些能力成为 Milvus 的原生查询语义(接口上,pymilvus、RESTful v2、Go SDK 陆续覆盖):
Query聚合:query 支持 GROUP BY 与 count / sum / avg / min / max,像 SQL 一样直接返回统计结果;
Search 聚合:向量检索的结果可以直接做分桶统计、逐桶取样;
Order By:search 和 query 都支持按标量字段排序——多字段、升降序、明确的 null 语义,并且和分页正确组合。
当然,这些都是SQL 数据库几十年前就有的概念,但在一个分布式向量数据库里完成这些能力,其实并不容易。
Milvus 作为一个典型的分布式数据库,其一个 collection 的数据,会分散在成百上千个 segment 中,这些 Segment 又分布在不同的查询节点上。同时,一部分刚写入的数据可能仍然位于流式执行路径中。
这意味着 一个简单的GROUP BY category背后,并不存在一个把所有数据读出来然后算一下的单机执行环境。
但如果把所有数据汇到一个节点再算,那就回到了把数据搬出来的老路,只是搬运发生在数据库内部,但网络和内存成本依旧存在。
Milvus 采取的是典型的分布式聚合路径:尽可能把计算推到数据所在的位置。每个 Segment 先对自己的数据执行局部聚合,为每个分组生成部分聚合状态;同一个节点上的结果先做一次归并;最后由接入层 Proxy 汇总不同节点的部分结果,得到全局结果。
过程中,跨节点传输的不再是海量原始行,而是已经“压缩”过的聚合状态。
数据量由多少行数据,变成:多少个分组 × 每个分组的中间状态。
当然,这里还有一个容易被忽略的问题:这个方案成立的前提,是每个聚合函数的中间状态都可以正确归并。并不是所有聚合函数都可以直接“局部算一次,再把局部结果算一次”。
count 可以相加,sum 可以相加,min 和 max 也可以继续取最小值和最大值。但 avg 不行。两个部分平均值合并,不等于全局平均值。所以 avg 在引擎内部被拆成 sum 和 count 两个可归并的状态,一路归并到最后一层才相除。
这个细节看起来很底层,在数据库内也不是什么很高难度的操作,但它恰好解释了为什么这类逻辑应该由数据库来做:在应用层手写聚合的用户,大概率不会意识到「分批拉数据、分批算平均、最后合并」是错的。
同样的原则也适用于数据可见性。
Milvus 在 Segment 本地执行聚合时,就会同时应用 MVCC(多版本并发控制)的可见性规则。已经删除或过期、当前查询不应该看到的数据,会在最底层就被排除。
这种情况下,我们最终得到的不是客户端恰好拉到的那些数据的统计,而是:这个查询时刻,按照数据库一致性语义真正可见的数据的统计。
这层语义在客户端无法复现。
顺带一提,limit 在 query 聚合里的含义也变了:它限制的不再是返回多少行数据,而是返回多少个分组。聚合的结果规模由组数决定,通常远小于行数——这也是为什么普通 query 不带过滤条件时必须给 limit,而聚合查询可以豁免。
03
Search 聚合:给相似性检索一个分析视角
Query 聚合和 Search 聚合解决的是两类不同的问题。Query 聚合回答的是满足某个结构化条件的数据,整体是什么分布?。Search 聚合回答的是另一个问题:和这个查询最相似的这批数据,呈现出什么结构?
这不是传统 SQL 里最典型的问题。实际上,它是把语义检索与结构化分析两个步骤合到了一次执行里。
Milvus 3.0 的 search 聚合是分层的桶聚合:按字段分桶;桶内可以算指标(count / sum / avg / min / max);桶可以按数量、按键、按指标排序;桶内还可以嵌套下一层聚合,或者用 top_hits 取出每个桶里最有代表性的几条文档。这套表达能力 对标的是 Elasticsearch 这类成熟全文检索引擎的 aggregation 语义,在向量数据库产品里并不多见。
开头那个以图搜款页面就是一个典型例子。
一次向量检索之后,应用真正想知道的可能是:召回主要集中在哪些品牌?每个品牌有多少候选?这些品牌的平均价格是多少?每个品牌最相似的三件商品是什么?
过去,这意味着先把大量结果拉出来,再自行统计。现在,这些信息可以作为一次 Search 请求的组成部分直接返回。
RAG 也是一样。假设一次检索返回几十个 Chunk。单纯看 Top-K,我们只知道“有哪些片段相似”。但如果再按 doc_id 分桶,就会得到另一个维度的信息:结果到底高度集中在两三份文档里,还是分散在几十份来源中?
这里需要特别把统计口径说清楚:search 聚合不是「先取 top-N,再对它做全量统计」。在聚合规格中,用户会声明需要多少个桶(size)、每个桶保留多少条样本(top_hits.size)。引擎根据这套规格反推需要检索和保留多少候选,桶的计数和指标在这些样本上计算。
也因此,请求里的 limit 在聚合模式下不生效,检索规模完全由聚合规格决定;想让统计更接近全集,把桶数和每桶样本数调大即可。
04
它和 grouping search 是什么关系
熟悉 Milvus 的用户很容易产生另一个疑问:从 2.4 开始,Search 已经支持 group_by,也就是 Grouping Search。现在又增加 Search 聚合,两者是不是同一种能力?
它们都出现了“分组”,但作用在完全不同的层面。
Grouping search 改变的是「返回哪些结果」:按字段分组,每组只出最相似的一条或几条,返回的仍然是文档列表——经典用法是 RAG 里按 doc_id 分组,chunk 粒度检索、文档粒度返回,保证结果多样性。
Search 聚合返回的则是「结果的统计视图」:桶、计数、指标,文档本身退到 top_hits 里,作为每个桶的样本。
一个简单的判断标准:页面上要渲染的是列表,用 grouping search;要渲染的是筛选器计数、分布、统计卡片,用 search 聚合。
05
排序:看起来简单,做对不容易
这几个能力里,order by 看起来最不起眼,也正因为如此,它过去最容易被放到客户端自行解决,然后掉进上面说的分页陷阱。
但在分布式检索系统里,会排序和能得到正确的全局顺序是两件完全不同的事。
向量 Search 的原始结果天然按照相似度组织,而且候选本身来自多个节点。当用户要求按价格、时间或其他业务字段排序时,排序只有在全局结果完成归并之后才有意义。
当用户要求按价格排时,引擎在全局结果确定之后、分页窗口切出之前完成重排——offset 应用在排序之后,「第二页」才是全局意义上的第 11 到 20 名,而不是「相似度第二页内部再按价格排一遍」。这个顺序被固化在执行流程里,用户不再有机会做错。
排序一旦成为数据库能力,一些过去可以含糊处理的细节就必须定义清楚。
比如 null。Milvus 的默认规则与 PostgreSQL 一致:升序时 null 排在最后,降序时 null 排在最前。同时,也可以针对每个字段显式指定 nulls_first 或 nulls_last。
二是排序字段不需要出现在 output_fields 里:引擎会在内部自动补齐排序所需的列,排完序再按用户要求的字段返回——你不需要为了排序而改变返回结构。
排序也和 grouping search 正确组合:按字段分组的 search 里,以每组的最佳命中(组内相似度最高的那条)为键对组排序,组内顺序保持不变,返回结构仍然是分组的。排序键可以是多字段组合(价格降序、同价再按标题升序),支持数值、字符串、布尔等标量类型。
到这里说的都是 search。3.0 把同样的排序语义也给了 query——这件事的意义容易被低估。query 是没有向量参与的那半个世界:管理后台要「按创建时间看最新入库的一千条」,运营要「把满足条件的商品按价格从高到低翻上几页」,数据探查要「先看看极端值长什么样」。在此之前,query 的翻页顺序实际上锁死在主键上,「按业务字段浏览」只能把数据拉出来自己排——又回到了开头那笔账。现在 filter、排序、分页在引擎里按正确的顺序组合,query 第一次成为可以按业务语义浏览的接口。而且 search 和 query 的 order by 是同一套表达——同样的字段、方向和 null 规则,pymilvus、RESTful v2、Go SDK 正在按这套语义陆续补齐入口。
06
代码实操示例
回到开头的搜款页面,那段 pandas 代码换成下面这些请求。示例基于 pymilvus(以 3.0 正式 SDK 文档为准)。
query 聚合,像 SQL 一样:
res = client.query(
collection_name="products",
filter='status == "on_sale"',
output_fields=["category", "count(*)", "avg(price)"],
group_by_fields=["category"],
)
# [{'category': 'books', 'count(*)': 18734, 'avg(price)': 45.3}, ...]
query / search 排序,分页与排序正确组合:
res = client.query(
collection_name="products",
filter="category == 'books'",
output_fields=["title", "price"],
order_by_fields=[{"field": "price", "order": "desc"},
{"field": "title", "order": "asc"}],
limit=10, offset=10, # 全局第 11-20 名,不是"第二页内部排序"
)
res = client.search(
collection_name="products",
data=[query_vector],
limit=50,
output_fields=["title", "price"],
order_by_fields=[{"field": "price", "order": "asc"}],
)
search 聚合:分桶、算指标、逐桶取样,一次调用完成:
from pymilvus import SearchAggregation, TopHits
agg = SearchAggregation(
fields=["brand"],
size=10, # 返回前 10 个桶
metrics={"avg_price": {"avg": "price"}}, # 每个品牌的平均价格
order=[{"_count": "desc"}], # 桶按落入样本数降序
top_hits=TopHits(size=3, sort=[{"_score": "desc"}]), # 每桶 3 条样本; IP/cosine 用 desc, L2 用 asc
)
res = client.search(
collection_name="products",
data=[query_vector],
search_aggregation=agg, # 聚合模式下检索规模由聚合规格派生, limit 不生效
)
07
什么场景需要它
RAG 与 Agent。Agent 问数据库的并不总是:给我最相关的几份文档。还有可能询问知识库里关于这个主题的记录,按产品线是怎么分布的?这类统计性问题过去要么答不了,要么靠拉全量。
Search 聚合让这层信息可以和召回本身一起返回。更有意思的是,它还可以成为召回质量的一个在线信号。同一个问题检索回来几十个 Chunk:如果大部分结果高度集中在两三份文档里,这通常意味着检索结果形成了比较明确的证据集中区;如果结果分散在几十个来源,每个桶只有零星样本,那么这次召回本身可能就缺乏明显共识。
当然,这不代表仅凭桶分布就能判断答案是否正确,但它给 RAG 和 Agent 多了一个以前获取成本很高的观察维度。当这类结构信息可以伴随每次 Search 低成本返回,它就不再只是一个离线分析指标,也可以参与 Agent 自己的检索决策:继续回答,还是调整 Query 再搜一次。
电商与内容推荐。商品检索是最直接的场景。还是开头那个页面:筛选器、排序、分布,全部都能随检索请求原生返回,应用层的 pandas 后处理可以全部删掉。
日志与安全分析。安全排查时, 相似事件检索是排查的第一步:拿一条可疑日志,找出全库里和它相似的事件。但真正要回答的问题在第二步:这些相似事件集中在哪几台主机?最早出现在什么时候?按日期分桶看是在收敛还是在扩散?过去这两步跨两个系统——向量库负责相似性,导出之后进分析引擎数分布。现在一次请求同时拿到「相似的事件」和「事件的分布」,排查从两条链路变成一次往返。
运营与数据探查。对过滤结果直接 count、avg,不再为了一个数字把数据导出一遍。
尾声
把聚合和排序加入 Milvus,并不意味着向量数据库要变成一个通用分析数据库。
Milvus 3.0 解决的是 围绕在线检索和查询产生的结构化计算,而不是取代完整 OLAP 引擎。Join、窗口函数、复杂子查询仍然不在这个能力边界内。
真正的大规模离线分析,依然应该交给专业分析引擎。例如通过外表和存储共享,让 Spark 等系统直接读取同一份底层数据,是另一条架构路径,也是 Milvus 3.0 更大的故事线之一。
聚合本身也有明确的类型限制:
* 分组键支持整数和字符串;Search 聚合还支持布尔;
* 浮点数不能作为分组键;
* sum / avg 只接受数值字段;
* min / max 支持数值和字符串;
* Array、JSON、向量字段不能直接参与聚合。
Query 聚合目前可以按照分组键排序,但按聚合结果——例如 count(*)——排序仍在演进;如果不指定排序,分组返回顺序不保证。
Search 聚合目前也不支持 Hybrid Search。
另外,前面提到的统计口径最后再强调一次:search 聚合的桶和指标基于每桶保留的样本,统计的是本次召回的候选集,不是全量数据——它回答「这次召回长什么样」,不回答「库里的分布」。要精确的全量统计,用 query 聚合。
作者介绍韩淳Senior Software Engineer at Zilliz
阅读推荐
官宣开源|Milvus 3.0 正式发布
Milvus 3.0 开源解读之backfill|亿级 AI 数据,如何做高效特征回填
Milvus 3.0 开源解读|从Kafka、Pulsar到Woodpecker,数据库如何低延迟、低成本的写入
Milvus 3.0 开源解读之Manifest|AI 数据管理,应该彻底放弃文件中心架构