给搜索结果打上名次是MongoDB应用中的常见硬需求:商品要按销量排名,文章要按相关度排名,用户积分榜也要按分数排名。但聚合管道中并没有$ranked这个阶段,真正能生成名次的是MongoDB 5.0引入的$setWindowFields阶段配合$rank运算符。这篇文章会把正确的排名写法、传统替代方案以及与全文搜索的整合都演示清楚,并给出可以直接在mongosh中运行的代码。

为什么$ranked不存在,正确的排名运算符是$rank
如果你在官方文档中搜索$ranked,会得到未命中的结果。很多教程提到的$ranked其实是对$rank的误写,或者是把其他搜索引擎里的ranked概念混了进来。MongoDB聚合管道里的排名并不是一个独立的阶段,而是$setWindowFields的一个窗口函数,就像SQL里的ROW_NUMBER()、RANK()一样。
$setWindowFields的作用是在不改变文档数量的前提下,为每条文档计算出一个新字段。它需要至少指定一个排序规则sortBy,然后通过output来定义输出的字段名和窗口函数。$rank就是一个窗口函数,它按照sortBy的字段顺序给每条文档分配名次。最简单的用法如下:
db.products.aggregate([
{
$setWindowFields: {
partitionBy: "$category",
sortBy: { score: -1 },
output: {
rank: { $rank: {} }
}
}
}
])
这段代码会按category字段分区,在每个分类内部按score从高到低排序,并新增一个rank字段。如果两条商品记录score相同,它们会获得相同的名次,下一条记录则跳过中间名次,例如出现1、1、3这样的序列。如果不需要分区,可以省略partitionBy字段,直接做全局排名。
需要注意的是,$rank后面的花括号必须写成空对象,这是窗口函数要求的语法。如果你写成{ $rank: 1 }或者给$rank传其他参数,MongoDB会直接抛错。常见的报错还有Unrecognized pipeline stage name: '$ranked',这说明你还没有把运算符名字写对。
传统聚合排名写法:$sort + $group + $push
在$setWindowFields出现之前,开发者通常用$sort加$group再加$push的方式给文档排名。思路是先把所有文档按目标字段排序,再把它们收拢到一个数组中,利用$unwind的includeArrayIndex参数拿到数组下标,最后通过$replaceRoot把原文档和名次合并回去。
下面是一个全局排名的传统写法示例:
db.products.aggregate([
{ $sort: { score: -1 } },
{ $group: { _id: null, docs: { $push: "$$ROOT" } } },
{ $unwind: { path: "$docs", includeArrayIndex: "rank" } },
{
$replaceRoot: {
newRoot: {
$mergeObjects: ["$docs", { rank: { $add: ["$rank", 1] } }]
}
}
}
])
$unwind展开数组时,includeArrayIndex会从0开始编号,所以要用$add给每个下标加1,才能得到人类习惯的第一名、第二名。这个方法从结果上看是可行的,但它有一个明显缺陷:$group把所有文档放进一个BSON数组,受16MB文档大小限制影响,数据量稍大就容易报错。另外,如果原文档结构复杂,$mergeObjects合并字段时还需要处理重名字段覆盖问题。
所以传统写法更适合小批量数据或已经经过$limit截断的结果集。而对于需要对全量集合做排名、甚至需要按分组排名的场景,$setWindowFields是更稳定、更简洁的选择。
与Atlas Search全文搜索结合实现结果排名
搜索结果排名的一个典型场景是全文检索后按相关度排序。MongoDB Atlas Search支持$search阶段,它会为每个匹配文档生成一个score字段,表示与查询条件的相关程度。我们可以在$search之后直接使用$setWindowFields,按score降序生成名次,再做分页或截断。
db.articles.aggregate([
{
$search: {
index: "default",
text: {
query: "mongodb ranking",
path: "title"
}
}
},
{
$setWindowFields: {
sortBy: { score: -1 },
output: { searchRank: { $rank: {} } }
}
},
{ $match: { searchRank: { $lte: 10 } } }
])
这段聚合先通过$search在title字段中查找关键词,Atlas Search会自动为每条结果生成score;随后$setWindowFields按score降序给结果加上searchRank字段;最后$match只保留排名前10的文档。这样做比在应用层排序再去重要可靠,因为排名逻辑在数据库内完成,分页时不会漏掉同名次的记录。
如果数据量较大,建议在score字段上创建索引,并让sortBy的字段与索引顺序一致,减少内存排序压力。此外,$setWindowFields要求整个窗口函数在单次扫描中完成,所以避免在它之前使用$unwind等会爆炸文档数的阶段,否则排名结果可能不符合预期。
排名参数差异与实战建议
除了$rank之外,$setWindowFields还提供了$denseRank和$documentNumber。它们的区别集中在并列值的处理上。下面这张表可以帮你快速判断应该用哪一个:
| 运算符 | 并列处理 | 名次示例 |
|---|---|---|
| $rank | 同名次后跳过 | 1,1,3 |
| $denseRank | 同名次不跳过 | 1,1,2 |
| $documentNumber | 无并列概念 | 1,2,3 |
如果你的产品需求是排行榜中并列名次后要隔开,比如两个并列第一后直接是第三名,就用$rank;如果希望下一个名次紧跟着是第二名,就用$denseRank;如果只是需要稳定的行号而不关心并列,用$documentNumber即可。$documentNumber相当于窗口内的递增序号,它与排序字段的值是否相同无关。
实际开发中还要留意版本兼容性。$setWindowFields在MongoDB 5.0及以上版本才能使用,如果你的数据库还停留在4.x,只能退回到传统$sort加$group方案,或者考虑升级数据库。对于已经使用Atlas的团队来说,版本通常由云服务自动管理,开启MongoDB 5.0以上的集群后就可以放心使用窗口函数了。
最后再强调一次,不要在管道中写$ranked,否则会得到未识别阶段错误。正确做法是$setWindowFields内部的$rank,并且务必指定sortBy,否则排名没有意义。掌握了这个工具后,搜索结果排名、榜单生成、分页截断都会清晰很多。