文章摘要
文章介绍了Milvus 3.0聚合排序对向量检索分析能力的重构。此前应用层后处理有数据搬运、语义错误、架构复杂的代价,而Milvus 3.0将结构化计算能力整合为原生查询语义,包括Query聚合、Search聚合和统一排序。它采用分布式聚合路径,降低网络成本。其适用于RAG、电商、日志分析等场景,但也有能力边界,如不取代OLAP引擎。

我们不妨先设想一个常见的电商以图搜款场景:用户上传一张连衣裙的照片,系统需要从五千万件商品中召回最相似的一千条结果,在常规配置下,向量数据库可以在几十毫秒内完成这一步检索。但产品真正要交付的,从来不止这一千条原始结果——页面左侧需要品牌筛选器,顶部支持按价格、销量、评分排序,运营还需要知道本次召回集中在哪些品牌、各自的价格水平和代表性商品。

以往的系统架构中,工程师往往需要把检索结果拉回应用层,用pandas或自定义业务代码完成分组、计数、排序等操作,再拼接成最终的业务响应。久而久之,这种模式成了行业默认的做法:向量数据库只负责召回相似数据,后续的结构化处理全部交给应用层。但在传统SQL数据库中,这些分组、统计、排序能力本就是查询的原生功能,到了向量数据库时代,却长期需要在应用侧二次开发。

Milvus 3.0将这部分逻辑整合进了引擎:让query和search都支持按标量字段排序,query支持GROUP BY和聚合函数,search的结果集可以直接进行分层聚合分析。接下来我们会从三个维度展开讲解:为什么这类后处理逻辑应该移入数据库内部、Milvus在分布式场景下如何保证聚合和排序的语义正确性、以及这些能力的适用边界。

应用层后处理的三重代价

在应用层做后处理,表面上只是多写几行代码,但实际上隐含了三重高昂的成本:数据传输浪费、语义正确性风险,以及额外的系统架构复杂度。

第一重代价:无效的数据搬运

还是以电商商品库为例,假设运营需要统计在售商品的类目分布和均价,过滤条件命中了两百万条数据。如果在应用层完成聚合,需要先把这些数据拉取到本地:哪怕每行只返回类目、价格和主键,加上序列化开销后每行也会超过百字节,两百万行数据就会产生几百MB的网络传输和客户端内存占用。但最终的统计结果其实只有几KB——比如两百个类目,每个类目返回数量和均价,最终结果不过几KB的数据量,两者相差五个数量级,而且每次报表刷新都要重复这笔成本。

第二重代价:隐蔽的语义错误

相比数据传输的成本,语义错误的危害更隐蔽,因为它通常不会抛出报错。最典型的例子就是分页加排序:用户希望按价格从低到高浏览检索结果,如果应用每次只拉取一页数据再对当前页排序,得到的只是单页内的局部顺序,而非全局排序。翻到第二页时,很可能会发现价格比第一页更低,这类问题在很多系统中都存在,只是很少有人仔细验证第二页的内容。

还有数据可见性的问题:刚刚删除的数据、TTL过期但尚未清理的记录,哪些应该被当前查询包含?这些可见性规则只有数据库本身清楚,在客户端计算就默认了拉取到的数据是完整正确的,但在持续写入删除的系统中,这个假设并不成立。

第三重代价:额外的架构复杂度

当应用层的后处理逻辑越来越复杂,很多团队会逐渐形成两套架构:向量数据库负责在线检索,数据定期导出后通过ETL进入专门的分析系统,统计和报表都在那里完成。这种方案虽然可行,但多了一套数据同步链路,就多了一致性问题和故障风险。

这三重代价最终指向同一个结论:分组、统计、排序这类计算,应该尽可能在数据所在的位置完成,而不是先移动海量数据再进行计算。

Milvus 3.0的原生聚合与排序能力

从3.0版本开始,Milvus将这些结构化计算能力整合为原生查询语义,目前pymilvus、RESTful v2、Go SDK已经陆续覆盖了相关接口,核心能力包括三个部分:

  • Query聚合:支持GROUP BY与count/sum/avg/min/max等聚合函数,可直接返回统计结果,类似SQL查询逻辑;
  • Search聚合:向量检索的结果可以直接进行分桶统计、逐桶取样分析;
  • 统一排序能力:search和query都支持按标量字段排序,支持多字段组合、升降序和明确的null语义,并且可以和分页逻辑正确组合。

这些能力虽然是SQL数据库中早已成熟的功能,但在分布式向量数据库中实现它们并不容易。Milvus作为典型的分布式数据库,一个集合的数据会分散在成百上千个segment中,这些segment分布在不同的查询节点上,同时还有一部分刚写入的数据处于流式执行路径中。这意味着一个简单的GROUP BY操作,并不存在一个可以读取所有数据再统一计算的单机执行环境。

如果将所有数据汇总到一个节点再计算,虽然可行,但本质上还是回到了数据搬运的老路,只是搬运发生在数据库内部,网络和内存成本依然存在。Milvus采用了典型的分布式聚合路径:尽可能将计算推送到数据所在的位置。每个segment先对自身的数据执行局部聚合,为每个分组生成部分聚合状态;同一节点上的局部结果先进行一次归并;最后由接入层Proxy汇总不同节点的部分结果,得到全局最终结果。

在这个过程中,跨节点传输的数据不再是海量的原始行数据,而是已经经过压缩的聚合状态。数据量从“总行数 × 单条数据大小”变成了“分组数 × 单分组中间状态大小”,大幅降低了网络传输成本。

这里有一个容易被忽略的细节:并非所有聚合函数都可以直接通过局部计算再归并得到全局结果。比如count可以直接相加,sum也可以直接相加,min和max也可以继续取全局的最小和最大值,但avg不行——两个局部平均值合并后并不等于全局平均值。因此Milvus引擎内部会将avg拆分为sum和count两个可归并的状态,直到最后一层归并完成后再相除得到全局平均值。这个细节看似底层,却解释了为什么这类逻辑应该由数据库来完成:在应用层手写聚合的用户,大概率不会意识到“分批拉数据、分批计算平均再合并”是错误的。

同样的原则也适用于数据可见性:Milvus在segment本地执行聚合时,就会应用MVCC多版本并发控制的可见性规则,已经删除或过期、当前查询不应该看到的数据会在最底层就被排除。最终得到的统计结果,是基于当前查询时刻符合数据库一致性语义的可见数据,这层语义无法在客户端复现。

另外需要注意,query聚合中的limit参数含义也发生了变化:它不再限制返回的行数,而是限制返回的分组数量。聚合结果的规模由分组数决定,通常远小于原始数据行数,这也是为什么普通query不带过滤条件时必须指定limit,而聚合查询可以豁免该参数。

Search聚合:为相似检索提供分析视角

Query聚合和Search聚合解决的是两类完全不同的问题。Query聚合回答的是“满足特定结构化条件的数据整体分布如何”,而Search聚合回答的是“与查询最相似的这批数据呈现出怎样的结构”。这并非传统SQL中的典型查询场景,而是将语义检索和结构化分析合并到了一次执行过程中。

Milvus 3.0的Search聚合采用分层桶聚合模式:可以按字段分桶,桶内支持count/sum/avg/min/max等指标计算,桶可以按数量、按键、按指标排序,桶内还可以嵌套下一层聚合,或者通过top_hits取出每个桶中最具代表性的文档。这套表达能力对标了Elasticsearch等成熟全文检索引擎的聚合语义,在向量数据库产品中并不多见。

还是以开头的电商搜款场景为例:一次向量检索后,应用真正需要的信息可能包括:召回结果主要集中在哪些品牌?每个品牌有多少候选商品?这些品牌的平均价格是多少?每个品牌最相似的三件商品是什么?过去这些信息需要先拉取大量检索结果,再在应用层自行统计;现在这些信息可以作为一次Search请求的组成部分直接返回。

RAG场景也是如此:假设一次检索返回了几十个文本片段,单纯看Top-K结果只能知道“哪些片段相似”,但如果按doc_id分桶,就能得到另一个维度的信息:结果是高度集中在两三份文档中,还是分散在几十份来源中?这可以帮助判断检索结果的多样性和可靠性。

需要特别明确Search聚合的统计口径:它并非“先取Top-N再对结果做全量统计”。在聚合规格中,用户需要声明需要的桶数量(size)、每个桶保留的样本数(top_hits.size),引擎会根据这些规格反推需要检索和保留的候选集数量,桶的计数和指标都会在这个候选集上计算。因此,请求中的limit参数在聚合模式下不会生效,检索规模完全由聚合规格决定;如果希望统计结果更接近全量召回,可以调大桶数和每桶的样本数。

Search聚合与Grouping Search的区别

熟悉Milvus的用户可能会有疑问:从2.4版本开始,Search就已经支持group_by(也就是Grouping Search),现在又新增了Search聚合,两者是否是同一功能?虽然两者都涉及分组,但作用层面完全不同。

Grouping Search改变的是“返回哪些结果”:按字段分组后,每组只返回最相似的一条或几条数据,最终返回的仍然是文档列表。经典用法是在RAG场景中按doc_id分组,实现chunk粒度检索、文档粒度返回,保证结果的多样性。

而Search聚合返回的是“结果的统计视图”:包括桶、计数、指标等信息,原始文档只会出现在top_hits中作为每个桶的样本。一个简单的判断标准是:如果页面需要渲染列表,使用Grouping Search;如果需要渲染筛选器计数、分布图表、统计卡片,则使用Search聚合。

排序:看似简单,实现正确的全局顺序并不容易

在所有新增能力中,order by看起来最不起眼,也因此过去最常被放到客户端自行处理,最终陷入前面提到的分页陷阱。但在分布式检索系统中,“会排序”和“能得到正确的全局顺序”是完全不同的两件事。

向量Search的原始结果天然按照相似度排序,而且候选数据本身来自多个节点。当用户要求按价格、时间或其他业务字段排序时,排序只有在全局结果完成归并之后才有意义。Milvus引擎会在全局结果确定之后、分页窗口切出之前完成重排,offset参数会应用在排序之后,确保“第二页”是全局意义上的第11到20条数据,而非“相似度第二页内部再按价格排序”。这个排序逻辑被固化在执行流程中,用户不会再因为局部排序而出现错误。

当排序成为数据库的原生能力后,一些过去可以含糊处理的细节必须被明确定义:

  • 空值处理:Milvus的默认规则与PostgreSQL一致,升序时空值排在最后,降序时空值排在最前,同时也支持针对每个字段显式指定nulls_first或nulls_last;
  • 排序字段无需出现在output_fields中:引擎会在内部自动补齐排序所需的列,排序完成后再按照用户要求的字段返回,不需要为了排序而改变返回结构;
  • 多字段组合排序:支持按多个字段排序,比如先按价格降序,同价格下再按标题升序,支持数值、字符串、布尔等标量类型,也可以和Grouping Search正确组合,按每组的最佳命中为键对组进行排序。

需要注意的是,Milvus 3.0将这套排序语义同时扩展到了query接口:过去query的翻页顺序通常锁死在主键上,想要按业务字段浏览只能将数据拉取到本地自行排序,现在filter、排序、分页可以在引擎内按正确的顺序组合,query第一次成为可以按业务语义浏览的接口。而且search和query的order by采用同一套表达规则,包括相同的字段、排序方向和空值处理逻辑,目前相关接口正在pymilvus、RESTful v2、Go SDK中陆续补齐。

代码实操示例

我们可以用开头的电商搜款场景为例,将原本需要用pandas完成的后处理替换为Milvus 3.0的原生查询代码,以下示例基于pymilvus 3.0 SDK:

1. 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}, ...]

2. Query/Search排序,正确组合分页与排序

# Query排序分页示例
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条,而非单页内部排序
)

Search排序示例

res = client.search(
collection_name=“products”,
data=[query_vector],
limit=50,
output_fields=[“title”, “price”],
order_by_fields=[{“field”: “price”, “order”: “asc”}],
)

3. 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相似度用desc,L2用asc
)
res = client.search(
    collection_name="products",
    data=[query_vector],
    search_aggregation=agg,  # 聚合模式下limit参数不生效,检索规模由聚合规格决定
)

适用场景分析

RAG与智能代理场景:智能代理向向量数据库的查询不再只是获取最相关的几份文档,还可以询问特定主题的记录按产品线的分布情况。这类统计性问题过去要么无法回答,要么需要拉取全量数据。Search聚合可以让这类统计信息和召回结果一起返回,还可以作为检索质量的在线信号:如果检索结果高度集中在少数文档中,说明召回有明确的证据集中区;如果结果分散在多个来源,则说明召回缺乏共识。这类结构信息可以帮助智能代理判断是否需要调整查询词再次检索。

电商与内容推荐场景:商品检索是最直接的应用场景,开头提到的电商搜款页面中的筛选器、排序、数据分布等功能,都可以通过一次检索请求原生完成,完全可以删除应用层的pandas后处理逻辑。

日志与安全分析场景:安全排查时,相似事件检索是第一步:用一条可疑日志找出全库中相似的事件。但真正的排查需要回答这些相似事件集中在哪些主机、最早出现的时间、按日期分桶的变化趋势。过去这需要跨两个系统完成:向量数据库负责相似性检索,导出数据后进入分析引擎统计分布;现在一次请求就可以同时获取相似事件和事件分布,将排查流程从两条链路简化为一次往返。

运营与数据探查场景:可以直接对过滤结果进行count、avg等统计,无需为了获取一个统计数字而将数据导出到其他系统。

能力边界与限制

将聚合和排序加入Milvus,并不意味着向量数据库要变成通用分析数据库。Milvus 3.0解决的是围绕在线检索和查询产生的结构化计算,而非取代完整的OLAP引擎。Join、窗口函数、复杂子查询仍然不在当前能力范围内。真正的大规模离线分析,依然应该交给专业的分析引擎,比如通过外表和存储共享,让Spark等系统直接读取同一份底层数据,这也是Milvus 3.0的长期架构规划之一。

聚合能力本身也有明确的类型限制:

  • 分组键支持整数和字符串类型,Search聚合还额外支持布尔类型;
  • 浮点数不能作为分组键;
  • sum/avg仅支持数值字段;
  • min/max支持数值和字符串字段;
  • Array、JSON、向量字段无法直接参与聚合。

另外需要注意:Query聚合目前支持按分组键排序,但按聚合结果(比如count(*))排序的功能仍在演进中,如果不指定排序规则,分组返回的顺序不保证。Search聚合目前暂不支持混合检索。最后再次强调Search聚合的统计口径:它的桶和指标基于本次召回的候选集,而非全量数据,仅回答“本次召回长什么样”,而非“数据库中的整体分布”,如果需要精确的全量统计,请使用Query聚合。

Milvus 3.0的聚合与排序能力,本质上是填补了向量数据库从“召回数据”到“交付业务答案”之间的最后一段距离,让向量数据库的价值可以更完整地落地到业务场景中。

以上内容不代表本平台立场,仅供读者参考