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

GitHub 上有个 issue 挺有意思。Milvus 用户启用 L0 XGBoost FunctionChain 之后,search_iterator() 翻到第二页,返回的居然是第一页的内容。重排后的分数本身是对的,每条结果的 distance 和本地 XGBoost 的 raw 预测误差在 1e-5 以内,问题出在续页逻辑上。
社区有人从源码层面给了个推测:Proxy 的 search iterator v2 用当前页末尾那条返回的 score 当下一页的 last_bound。XGBoost 已经把 ANN 分数整个换掉了,下一跳的 ANN 游标拿到的是一个模型输出值。分数没错,游标错了。
这个 bug 藏着一个信号:在 Milvus 3.0 里,精排真的被搬进了引擎内部。分数不再只是向量距离,它可以是 decay 因子、加权组合、甚至一棵 XGBoost 树的输出。引擎里那些默认分数就是距离的老逻辑,开始感受到冲击。
官方处理这个 bug 的方式也值得琢磨:没有去修续页,而是直接拒绝。PR #51334 让 function rerank 和 search iterator 互斥,报错完事。这种取舍背后,是 Milvus 3.0 新引入的 Function Chain API 在设计上的一系列决定。这篇文章想拆的,就是这些决定为什么长这样。
先看 Function Chain 要解决的问题。Milvus 之前的检索后处理入口有两个:function_score 和 ranker 参数。它们都能做打分,但设计文档里写得很直白:只支持预定义打分公式,不是一个通用、有序的复合计划。
什么叫复合计划?社区里一个典型的检索场景是:
这套流程里有明确的顺序依赖:先算 freshness,再组合,再排序,再裁剪。旧入口表达不了这种有序的多步计划,常见做法是把全量结果拉回客户端自己算。
官方在 3.0 的发布说明里把动机说得很清楚:把 multi-stage retrieval pipeline 搬进引擎,减少 over-fetching,去掉对独立后处理服务的依赖。
那为什么不干脆用一个 JSON 字符串把整条链传给服务端?设计文档在 Rejected Alternatives 里列了一串理由:晚解析失败、弱类型信息、SDK 行为不一致、校验错误弱、数值类型有歧义。
JSON-in-string 的问题在于,参数错误要到服务端解析那一刻才暴露,而且 1 到底是 int 还是 float 全靠猜。
所以 Function Chain 选了结构化 protobuf。公开消息定义在 schemapb 里:FunctionChain 由 name、stage、ops 组成,FunctionChainOp 描述一个算子,参数用 FunctionParamValue 的 oneof 承载,bool/int64/double/string/array/object/bytes 七种类型,支持嵌套。类型在传输层就定死了,服务端不用猜。
typed chain 换来的是一整套确定性的东西:确定性执行序、typed 嵌套参数、显式字段依赖分析、一致的 $score 语义,以及未来可以继续加的 stage 和 operator。
有意思的是,连表达式参数都带着数值稳定性约束。比如 decay 要求 scale 大于 0、offset 大于等于 0、decay 在 0 到 1 之间,而且被硬限制在 0.001, 0.999 这个区间,就是为了避免 log(decay) 出现数值问题。这类细节说明,设计者把表达式的边界当成 API 的一部分在打磨,而不是留给运行时去报错。
社区对这个设计也有过讨论。issue #46565 里,有用户认可 L0/L1/L2 多阶段分层是正确方向,同时建议 decay 增加 half-life 参数、考虑用访问频率调制衰减速率,还提了一个观点:decay 应该作为 importance modifier 和 $score 组合,而不是独立替换相似度分数。
这些目前都还是建议,源码里的 decay 参数仍是 function/origin/scale/offset/decay 那一套。

Function Chain 的算子模型很克制,首版公开的只有三个:map、sort、limit。
map 求值一个表达式,把结果写进某个列,可以是临时变量,也可以是系统虚拟列 $score。sort 按列排序,支持 tie-break 列。limit 做候选裁剪,带 offset,按 query chunk 粒度裁剪。内部实现里还有 filter/select/group_by/merge,但首版没有全部开放。
有两个设计决定值得单独说。
链按用户发送的原样执行,不隐式追加 tail 算子。你发了 map 和 sort,服务端不会好心帮你补一个 limit 或 round_decimal。排序、裁剪、取整,都要在链里显式写出来。这跟很多帮你做完全部事情的框架思路相反,换来的是执行语义完全可预测。
顺带一提,首版 L2 并不校验至多一个 sort、sort 必须放在末尾这类严格顺序约束。设计文档把它列为 Open Question,留作 future stricter validation。现在的链只要语义上能跑,顺序上基本靠自觉。
排序是显式的,Milvus 不再从向量 metric type 推断方向。旧逻辑里 IP 是越大越好、L2 是越小越好,引擎自己知道怎么排。但一旦 $score 被重写成模型输出或加权组合,metric type 的语义就失效了,排序方向必须由用户显式声明。
$score 是这套设计的核心。它是一个系统虚拟列,不是集合字段:检索结果进来时,它从当前的 search score/distance 初始化;map("$score", expr) 可以覆盖它;sort 按它排序;最终它再序列化回 score/distance 字段,SDK 用户看到的就是正常的 hit distance。$id 是另一个系统值,只读,专门用来做 tie-break。
PyMilvus 的 DSL 长这样(设计文档里的示例):
from pymilvus import FunctionChain, FunctionChainStage
from pymilvus.function_chain import col, fn
chain = (
FunctionChain(FunctionChainStage.L2_RERANK, name="fresh_popular_rerank")
.map("freshness", fn.decay(col("published_at"), function="exp",
origin=current_time, scale=86400,
offset=0, decay=0.5))
.map("$score", fn.num_combine(col("$score"), col("freshness"),
col("popularity"), mode="weighted",
weights=[0.7, 0.2, 0.1]))
.map("$score", fn.round_decimal(col("$score"), decimal=4))
.sort(col("$score"), desc=True, tie_break_col=col("$id"))
.limit(10)
)这段链的语义:先用 exp decay 算 freshness,decay 输出的是 0 到 1 的因子,本身不碰 $score;再用 num_combine 把原始分数、freshness、热度按 0.7/0.2/0.1 加权;然后取四位小数;按新分数降序排,取前 10 条。decay 和 num_combine 的分工很明确:decay 只负责产出因子,组合是 num_combine 的事,每个表达式只干一件事,靠链的顺序把它们串起来。
链的依赖分析也是显式的。ChainRepr 的 RefreshInfo() 做上下文无关的结构分析,算出 RequiredInputs 和 WrittenNames:前者是在之前的算子写入之前就被读取的名字,后者是所有被写出的名字。
chain 包只报告结构依赖,不判断一个名字到底是 schema 字段、运行时系统值还是非法输入,这个分类交给 caller 去做。依赖分析和语义校验解耦,让引擎层保持纯粹。

Function Chain 核心的设计是分层。链有一个 stage 字段,首版支持 L0 和 L2,两个 stage 在引擎里是两个完全不同的执行点。
L0 是 QueryNode 上的 early rescore,发生在 segment 级、合并之前。 每个 segment 的数据进来,先跑一遍链,把候选量压下去,再往上送。执行器在 internal/querynodev2/tasks/l0_function_chain.go,约束很严:只允许 map 算子,系统输入只允许 $id/$score,系统输出只允许 $score。它还会自动追加一个 Sort($score desc, $id) 作为 reduce contract,保证每个 segment 送出去的结果已经按新分数排好序。
每个 segment 一个 DataFrame:单 segment 直接执行,多 segment 用 errgroup 并发跑,避免跨 segment 共享算子或函数状态。它和旧的 boost score 互斥(boost score 是同一位置的旧打分入口,两个系统不能在同一个 segment 上同时改写分数),报错信息是 boost score and L0 rerank function chain cannot be used together。
L2 是 Proxy 上的 post-reduction rerank,发生在所有 shard 结果合并之后。 它支持完整的 map/sort/limit,走的是 search_pipeline.go 里的 rerankOperator。普通 SearchRequest.function_chains 首版走的就是这条路径。stage 的取值也有限制:首版普通 Search 只支持 L2_RERANK,L1 会直接报 not supported yet。
维度 | L0 | L2 |
|---|---|---|
执行位置 | QueryNode,segment 级 | Proxy,合并后 |
时机 | 合并前 early rescore | 合并后 post-reduction |
允许算子 | 仅 map | map/sort/limit |
排序 | 自动追加 Sort($score desc, $id) | 链内显式 sort |
典型用途 | XGBoost 轻量裁剪 | 模型重排、最终排序 |
为什么要拆两层?一句话:L0 牺牲精度换规模,L2 精度优先。 L0 在数据还没合并的时候就用轻量打分把明显不行的候选裁掉,减少网络传输和后续计算量;L2 拿到全量结果后做最终排序,保证排序的全局正确性。两者的裁剪语义和打分语义完全不同,不能混在一个执行点里。
从源码约束看,L0 只允许 map 也有它的道理:排序被 reduce contract 统一接管,如果允许用户在 segment 级自己写 sort 甚至 limit,每个 segment 各自裁剪,合并后的全局顺序和候选集就没法保证了。所以 L0 把排序权收走,只留打分这一个自由度。
维护者在社区讨论里也确认过这个方向(issue #44658):计算尽量下推到 segment 层,proxy 层适合做模型重排。这和源码里 L0/L2 的分工是一致的。首版的 L0 只有 xgboost 表达式跑通,rerank_model 是 L2 only,这个不对称后面细说。

XGBoost 是 L0 目前能跑的模型入口。它的设计文档(issue #51192 对应的 20260708-xgboost-function-chain.md)把一个调库就能做的事情拆成了四层,每一层都有明确理由。表达式层(xgboost_expr.go)的校验相当严格:model_resource 必填,output 只能是 raw/default,feature_names、objective 这类参数直接拒绝;至少要有一个特征列,不支持字面量参数;特征列数必须和模型的 num_feature 对上。
它的 IsRunnable 只声明 StageL0Rerank,这个表达式在 L2 上根本不可运行。
资源层:模型即资源。 模型注册成 FileResource,执行端按 model_resource 名字解析本地文件路径。只认 .ubj 扩展名,UBJ 是 JSON 的二进制版,JSON 和 legacy binary 格式都不支持。
缓存层:Go 侧管理生命周期。 xgboost_cache.go 里,模型按 {resource.ID}:{resource.Path} 做 key 缓存。加载是惰性的,搜索请求第一次用到才加载;并发首装用 singleflight 合并,避免多个请求同时加载同一个模型;生命周期用 lease/refcount 管理,预测持 lease,逐出时先 markClosing,等 refs 归零才真正 close,防止在途预测被关掉。
FileResource sync 之后,不在 activeKeys 里的模型会被逐出。这层缓存倒是没有 LRU、没有容量上限,设计文档明确把它列为 Non-goal,留作后续优化。
桥接层:CGO 过墙。 Go 层通过 Arrow C Data Interface 把 Arrow 数组导出给 C++,cgo 关闭时返回明确错误,提示用 CGO_ENABLED=1 重建。
到了 C++ 这边,预测逻辑在 xgboost_model_c.cpp:用 nlohmann 的 from_ubjson 解析模型,深度限制 128。模型约束列得很死:objective 只支持 reg:squarederror 和 binary:logistic,booster 只支持 gbtree,拒绝 gblinear/dart/multiclass,num_parallel_tree 必须为 1,categorical split 拒绝。
预测时用 base_score 初始化,逐树累加 leaf value,null 特征按 default_left 走,非 null 走 value < split_conditions。output=default 时 binary:logistic 做 sigmoid,output=raw 返回原始 tree margin。还有两个细节:模型句柄不可变,并发预测是安全的;预测过程不持有 Arrow 输入指针,避免生命周期纠缠。
为什么是 L0 先做、L2 留作 follow-up?设计文档给的理由很实在:L0 的执行端 QueryNode 有 FileResource sync 和本地模型文件管理,而 L2 的执行端 Proxy 还没有 FileResource sync、本地管理、resolver 支持。先做的那一层,是基础设施已经就绪的那一层,而不是功能上更需要的那一层。
作为对照,L2 的模型重排走的是 rerank_model 表达式,走外部 provider,queries 数量必须匹配 query chunk 数,凭据由服务端 provider 配置解析,SDK 不该携带 API keys。本地模型打分和外部模型重排,在首版里被刻意分到了两个 stage,各走各的基础设施。

新链不是凭空长出来的,它和旧的 function_score、legacy reranker 挤在同一条执行路径里。L2 的执行流是完整的一条链:FromSearchResultData 把合并后的结果建成 DataFrame,buildChainFromMeta 按 meta 类型分发,ExecuteWithOptions 执行时开了 EnableColumnPruning 做列裁剪,最后 ToSearchResultDataWithOptions 转回 SearchResultData。
列裁剪的效果很直接:链用到哪些列就保留哪些列,减少不必要的字段搬运。
分发逻辑上,functionChainRerankMeta 走 FuncChainFromReprWithContext,legacyRerankMeta 走 BuildRerankChainWithLegacy,function score 走 legacy 路径。旧入口没有被删除,而是被翻译成链的内部表示,和新链共用同一套 DataFrame 执行引擎。
Proxy 侧的校验规则写在 function_chain_validator.go 里:
function_score 和 function_chains 互斥,同一请求里不能同时用$id/$score,系统输出只允许 $score,其他 $xxx 系统名直接拒绝输入字段规划也做得比较细:planL2FunctionChainInputs 遍历 RequiredInputs,系统名跳过,非系统名查 schema 拿 fieldID;临时变量是前一个算子写出来的,不会从 schema 拉取。内部拉取的字段只流入 rerank metadata,最终投影仍按用户 output_fields 来,不外露内部字段。
这里有个容易混淆的点:reranker 参数(PyMilvus model 库的客户端 rerankers,BGE/CrossEncoder 那些)和 function_chains 是两回事。前者是客户端预定义入口,后者是服务端可编排打分流水线。设计文档明确提醒,不要把 reranker 语言误当成新 Function Chain 自身。
顺带说一句,官方 user guide 里 rerankers 的篇幅主要给了客户端预定义入口,Function Chain 的可编排流水线在文档里反而单薄,GA release notes 里宣传得多。想深入只能翻设计文档和源码。
边界也体现在冲突规则上:order_by 和 function rerank 互斥,因为它们指定了冲突的排序标准;search iterator 和 function rerank 互斥,就是开头那个 bug 的官方答案。
首版的 Non-Goals 也值得扫一眼:hybrid search 不支持 function_chains,insert/upsert/ingestion 链不支持,不返回中间变量,不替换 function_score 和 legacy rank。这些边界决定了首版能干什么、不能干什么。另外要提醒的是,这两个设计文档目前都是 Draft 状态(截至 2026 年 8 月),社区讨论里提到的自定义 UDF 方向(expr-lang、Python UDF、WASM UDF)在源码里未见实现依据,别当成已发布功能。
回头看 Function Chain 的设计取舍,主线其实很清楚:精排从客户端搬进了引擎。 typed chain 让执行语义可预测,L0/L2 分层兼顾规模与精度,FileResource 把模型变成一等资源。
官方 Release Notes 的大意是:一次 search 请求内执行有序、typed 的流水线,组合 L0 early rescore 与 L2 post-reduction rerank,支持分数转换与组合、模型重排、排序和候选裁剪,不需要客户端编排。源码和宣传口径在这里是对得上的。
横向对比一下,2025-2026 年 two-stage retrieval 已经是各向量库的标配:Qdrant 用 prefetch 嵌套子查询加主查询 rescore,Elasticsearch 用 retrievers 框架和 ES|QL 的 FORK/FUSE/RERANK 管道,Weaviate 用 reranker modules 做检索后附加阶段。
Milvus 的差异点在于编排粒度:链式 map/sort/limit 加服务端本地 XGBoost 打分,把精排从外部服务拉回引擎内。
至于这套设计能不能撑住,我的判断是:L0/L2 分层的方向是对的,但首版的边界暴露了不少待补的洞。XGBoost 只支持 gbtree booster(num_parallel_tree=1,拒绝并行树),L2 的 xgboost 还停在设计文档里,search iterator 和 function rerank 直接互斥而不是修好续页,缓存没有容量管理。
这些都不是缺陷,是明确的取舍。等 Draft 转正式、UDF 方向落地,再回来看这篇,应该能验证不少判断。
说明:本文内容基于 Milvus 3.0.0 源码(zilliztech/milvus)、官方设计文档(
20260624-function-chain-api.md、20260708-xgboost-function-chain.md,均为 Draft 状态)和官方文档整理而成,尚未在生产环境中完成全场景验证。文中的配置模板和参数建议仅供参考,实际效果请以你的业务数据和环境测试结果为准。如果有实际使用经验,欢迎在评论区分享交流。
好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。