Router / Parent 源码:多个知识库怎么查,切太碎怎么补上下文(第75篇-E61)

系列「企业级 AI Agent 实现拆解」E61 篇,Part 13 RAG 篇第十章。上一篇把 MultiQuery 和重排序的分工讲清楚了。这篇拆 eino/flow/retriever/ 剩下的两个组件------它们解决的是两个非常具体的工程问题:公司有好几个知识库,一次该查哪几个?切片切得太碎,检索是准了但上下文不够,怎么办?

顺带说:其中一个组件不填某个配置项就会 panic,而源码里明明写了默认实现------本文有完整复现。

先对号入座,看看你是不是有这两个问题:

症状 组件 一句话原理
HR 库、技术库、法务库分开建的,问一句话总不知道该查哪个;全查一遍又慢又贵 Router 先判断该查哪几个库,只查那几个
切片切小了检索很准,但捞上来那一小段前后文没了,LLM 答不完整;切大了又检索不准 Parent 用小片去检索,命中后返回它所属的大片

读完这篇你会知道

  • Router 不填 Router 字段会 panic------源码里构造了默认路由函数却没用上,构造时不报错,调用时才炸(eino v0.9.13 实测)
  • 同一个构造函数里,FusionFunc 的默认值处理是对的,Router 的是错的------两行代码并排放着,一个用局部变量一个用原始配置
  • Router 默认用 RRF 融合,但 RRF 分数不写回文档 ,你拿到的 Score() 还是各库自己的原始分,跨库不可比
  • 同一个框架里,Router 默认 RRF、MultiQuery 默认只去重------两个相似组件的默认行为不一致
  • Parent 实测:命中 3 个子片,最终返回 2 篇父文档,且分数全部变成 0
  • Parent 最阴的失败模式:索引时忘了写 parent_id,检索永远返回空且不报错
  • 用 Parent 必须索引侧、检索侧配套改,flow/indexer/parent 是配套的另一半

一、Router:先决定查哪个库,再去查

企业里知识库通常是分开建的:HR 制度一个库,技术文档一个库,法务合同一个库。原因很实际------权限不同、更新频率不同、embedding 模型可能都不同

问题来了:用户问一句「合同审批要走什么流程」,你查哪个?

  • 全查一遍:三倍的成本和延迟,而且 HR 库里那些"审批"字样的文档会来干扰结果
  • 只查一个:猜错就全完了

Router 的做法是:先用一个函数判断该查哪几个,再并发查那几个,最后融合。

配置只有三个字段:

go 复制代码
type Config struct {
    // 名字 → 检索器
    Retrievers map[string]retriever.Retriever
    // 路由函数:给一个 query,返回该查哪几个名字
    Router func(ctx context.Context, query string) ([]string, error)
    // 融合函数:把多个库的结果合成一个列表
    FusionFunc func(ctx context.Context, result map[string][]*schema.Document) ([]*schema.Document, error)
}

注意 Retrieversmap[string]名字很重要------路由函数返回的就是这些名字,对不上会直接报错:

go 复制代码
r, ok := e.retrievers[retrieverNames[i]]
if !ok {
    return nil, fmt.Errorf("router output[%s] has not registered", retrieverNames[i])
}

如果你的路由函数是 LLM 驱动的(让模型判断该查哪个库),这个错误会很常见------LLM 很容易输出一个你没注册过的名字 ,比如把 legal 说成 法务。用 LLM 做路由时,记得在返回前做一次白名单校验。

路由函数怎么写

最简单的是关键词判断,实测:

go 复制代码
Router: func(ctx context.Context, query string) ([]string, error) {
    if strings.Contains(query, "合同") {
        return []string{"legal"}, nil
    }
    return []string{"hr", "tech"}, nil
},

跑起来:

ini 复制代码
query="审批"   实际查了: [hr tech]
  结果: tech-1(2.0000)  tech-2(2.0000)
query="合同审批" 实际查了: [legal]
  结果: legal-1(6.0000)

法务库在第一个查询里完全没被访问------这就是 Router 省下来的成本。三个库变一到两个,省的是真金白银的向量检索调用。

生产上路由函数的三种写法,按成本从低到高:

  1. 关键词 / 正则:零成本,零延迟,但维护规则表很烦,且改口径要改代码
  2. 一个小分类模型:几毫秒,比关键词准,需要标注数据训练
  3. LLM 判断:最灵活,能理解「我上个月报的那笔钱怎么还没到账」该去财务库,但每次查询多几百毫秒和一次调用费

多数场景第一种就够了。只有当你的库多到十几个、且用户问法千奇百怪时,才值得上第三种。


二、一个会 panic 的默认值

Router 字段看起来是可以不填的------不填就查全部。源码里也确实这么写了:

go 复制代码
router := config.Router
if router == nil {
    var retrieverSet []string
    for k := range config.Retrievers {
        retrieverSet = append(retrieverSet, k)
    }
    router = func(ctx context.Context, query string) ([]string, error) {
        return retrieverSet, nil   // 默认:查全部
    }
}

逻辑没问题:没填就构造一个「返回所有名字」的函数。

但看它是怎么用这个变量的:

go 复制代码
fusion := config.FusionFunc
if fusion == nil {
    fusion = rrf
}

return &routerRetriever{
    retrievers: config.Retrievers,
    router:     config.Router,   // ← 用的是 config.Router
    fusionFunc: fusion,          // ← 用的是局部变量 fusion
}, nil

两行并排放着,一个对一个错。

fusionFunc 拿的是处理过的局部变量 fusion(正确),router 拿的却是原始的 config.Router(错误)。上面辛辛苦苦构造出来的默认路由函数,赋值给了局部变量 router,然后再也没被用到

于是不填 Router 时,存进结构体的是 nil。而 Retrieve 第一件事就是调它:

go 复制代码
retrieverNames, err := e.router(routeCtx, query)

实测:

go 复制代码
════ 实验 1:Router 不填 Router 字段 ════
  NewRetriever err=<nil>(构造这一步是成功的)
  panic: runtime error: invalid memory address or nil pointer dereference
  → 构造时没报错,调用时才炸

构造函数返回 err=nil,一切正常;等到真正检索时才 panic。

这个 bug 的形态很典型------不是逻辑想错了,是复制粘贴时漏改了一处 。写完 router 那段,照着写 fusion 那段,最后组装结构体时把 config. 前缀带进去了一个。Go 编译器不会报错,因为 config.Router 是完全合法的表达式;go vet 也不会报,因为局部变量 router 确实被"使用"过(在 if 分支里被赋值)。

实用结论:用 Router 时永远显式传 Router 字段。 就算你真的想查全部,也自己写一个返回全部名字的函数,别依赖默认值。

(版本是 eino v0.9.13。将来可能修,但依赖默认值本来就不是好习惯------构造时不报错、运行时才炸的默认值,是最难排查的一类问题,因为你的单元测试如果只测了「构造成功」就会全绿。)


三、Router 的 RRF:排序用了,分数没写回

不填 FusionFunc 时,Router 默认用 RRF。它自己带了一份实现:

go 复制代码
var rrf = func(ctx context.Context, result map[string][]*schema.Document) ([]*schema.Document, error) {
    docRankMap := make(map[string]float64)
    docMap := make(map[string]*schema.Document)
    for _, v := range result {
        for i := range v {
            docMap[v[i].ID] = v[i]
            docRankMap[v[i].ID] += 1.0 / float64(i+60)   // 累加
        }
    }
    // ...
    sort.Slice(docList, func(i, j int) bool {
        return docRankMap[docList[i].ID] > docRankMap[docList[j].ID]
    })
    return docList, nil
}

跟上一篇讲的 RRF 是同一个思路,但有三个差异值得注意。

① RRF 分数只活在函数内部。

docRankMap 是个局部 map,排完序就没了。返回的 *schema.Document 里的 Score() 还是各个库自己算出来的原始分

实测输出里能直接看到:

ini 复制代码
query="审批"   结果: tech-1(2.0000)  tech-2(2.0000)
query="合同审批" 结果: legal-1(6.0000)

2.0 和 6.0 都是我那个内存检索器的原始命中计数,不是 RRF 分(RRF 分只会在 0.016 附近)。

这个设计不算错,但要知道 :Router 的输出顺序是按 RRF 排的,可分数字段跟这个顺序无关。如果你下游有「取分数最高的那篇」这种逻辑,它跟 Router 的排序可能给出不同答案。更麻烦的是跨库的分数本来就不可比 ------A 库用余弦相似度(01),B 库用 BM25(0 30),混在一个列表里的 Score() 字段等于一堆量纲不同的数。

上一篇提过 DeepFlux 的做法是反过来的:RRF 分写进独立的 RRFScore 字段,Score 保持原始相似度,两个都留着。多存一个字段,换下游不会误用。

② 用的是 sort.Slice,不是 sort.SliceStable

RRF 分数打平时(多个文档在各库排名相同,这在小结果集上很常见),排序结果不稳定------同样的输入,两次运行的顺序可能不同。第 70 篇讲评测集时强调过:可重复是评测的底线。如果你在 Router 之后接评测,这里会引入抖动。

③ 公式差一个 offset。

1.0/float64(i+60),其中 i 是 0-based 下标,所以第一名是 1/60 ≈ 0.01667。而经典 RRF 公式是 1/(k+rank),rank 从 1 开始,第一名应该是 1/61 ≈ 0.01639

差别微乎其微,不影响排序结果(因为是单调变换)。提这个只是想说明:**"RRF" 这三个字母底下,各家的实现细节是有出入的。**要对比两个系统的融合分数,先确认它们用的是不是同一个公式。

一个不一致:两个组件的默认融合不一样

把上一篇和这篇放一起看:

组件 不填 FusionFunc 后果
MultiQuery deduplicateFusion------只去重,不排序 输出顺序 = 并发完成顺序,不确定
Router rrf------按 RRF 排序 输出有序

同一个 flow/retriever/ 目录下,两个结构几乎一样的组件,默认行为完全不同。

这不是谁对谁错的问题,是你不能凭"上次那个组件是这样"来推断这个组件。用之前把默认值看一遍,或者干脆都显式传------这也是上面那个 panic 教给我们的同一件事。


四、Parent:用小片检索,返回大片

第 68 篇讲切片时留了个没解决的矛盾:

  • 切小了:检索很准(一小段话主题集中,向量表达清晰),但捞上来给 LLM 的上下文太少,答案残缺
  • 切大了:上下文够了,但一大段话里混着好几个主题,向量被"平均"掉,检索反而不准

Parent Retriever 就是这个矛盾的标准解法:索引时切小片,检索时用小片匹配,命中后把它所属的大片整篇返回。

检索准确性由小片保证,上下文完整性由大片保证,两头都要。

实现只有十几行:

go 复制代码
func (p *parentRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) {
    subDocs, err := p.retriever.Retrieve(ctx, query, opts...)   // ① 用小片检索
    if err != nil {
        return nil, err
    }
    ids := make([]string, 0, len(subDocs))
    for _, subDoc := range subDocs {
        if k, ok := subDoc.MetaData[p.parentIDKey]; ok {        // ② 取父 ID
            if s, okk := k.(string); okk && !inList(s, ids) {   //    顺带去重
                ids = append(ids, s)
            }
        }
    }
    return p.origDocGetter(ctx, ids)                            // ③ 换成父文档
}

三步:检索小片、收集父 ID、换成父文档。OrigDocGetter 是你自己实现的------通常就是一句 SELECT * FROM documents WHERE id = ANY($1)

实测:3 片进,2 篇出

三个子片,其中两片属于同一篇父文档:

css 复制代码
════ 实验 3:Parent Retriever 小片检索、大片返回 ════
  子片检索命中 3 片: chunk-1(2.0000)  chunk-2(2.0000)  chunk-3(2.0000)
  OrigDocGetter 收到的 id: [[policy-A policy-B]]
  最终返回 2 篇父文档: policy-A(0.0000)  policy-B(0.0000)

两个必须知道的后果:

① 返回数量跟你设的 TopK 对不上。

TopK=5 检索出 3 片,最后只有 2 篇。因为 chunk-1chunk-2 同属 policy-A,被那句 !inList(s, ids) 去重了。

这个方向是对的 (同一篇文档没必要给 LLM 两遍),但你的下游如果假设「TopK=5 就会拿到 5 条」,那假设不成立。更要紧的是上下文长度 :父文档比子片大得多,5 篇父文档塞进 prompt 可能直接超长。用 Parent 时,TopK 要按父文档的大小重新估算,不能沿用子片时代的值。

② 分数全变成 0。

policy-A(0.0000)------因为返回的父文档是 OrigDocGetter 从你的存储里捞出来的原始对象,它从没参与过向量检索,自然没有分数。子片那个 2.0 的分数在第 ③ 步被整个丢掉了。

于是:

  • 你没法再对结果做 score >= 0.7 这类过滤(要过滤请在子片阶段做)
  • 你没法知道哪篇父文档更相关------顺序完全由 OrigDocGetter 决定

第二点尤其容易翻车。框架把 ids 按子片命中顺序排好了传给你,但如果你的实现是 WHERE id = ANY($1)数据库返回的顺序跟 ids 的顺序毫无关系。相关性排序就这么丢了。

OrigDocGetter 时,记得按传入的 ids 顺序重排结果。 这一步框架不帮你做,而它是相关性排序的最后一道防线。


五、Parent 最阴的失败模式:静默返回空

看那个取父 ID 的分支:

go 复制代码
if k, ok := subDoc.MetaData[p.parentIDKey]; ok {
    if s, okk := k.(string); okk && !inList(s, ids) {
        ids = append(ids, s)
    }
}

MetaData 里没有 parent_id 的子片,直接被跳过,不报错、不打日志。配置注释里其实写明了:

go 复制代码
// ParentIDKey specifies the key used in the sub-document metadata to store the parent document ID.
// Documents without this key will be removed from the recall results.

后果实测:

ini 复制代码
════ 实验 4:忘了写 parent_id 会怎样 ════
  子片确实命中了,但没有 parent_id
  返回 0 篇, err=<nil>, getter 被调用=true
  → 检索不到任何东西,而且不报错

检索明明命中了,最终返回 0 篇,errnil

(注意 getter 被调用=true:它还是被调了,只是传进去一个空 ids。所以你在 OrigDocGetter 里打日志的话,会看到一次"查询 0 个 ID"的空调用------这是排查时唯一的线索。)

这个失败模式之所以阴,是因为它长得跟"知识库里确实没有相关内容"一模一样。你会去检查 embedding 模型、调 ef_search、换切片策略......而真正的原因是索引的时候没写 parent_id

排查口诀:Parent 返回空但子片能检索到,先查 metadata,不是查检索。

还有个类型陷阱:k.(string) 这个断言失败也是静默跳过。如果你的 parent_id 存的是数字(比如 JSON 反序列化出来是 float64),或者从数据库读出来是 []byte,一样会被丢掉。parent_id 一定要用字符串。

索引侧是配套的另一半

Parent 模式必须两侧一起改。eino/flow/indexer/parent 就是配套的索引器:

go 复制代码
type Config struct {
    Indexer        indexer.Indexer         // 底层索引器(向量库)
    Transformer    document.Transformer    // 切片器:大片 → 小片
    ParentIDKey    string                  // 跟检索侧必须一致
    SubIDGenerator func(ctx context.Context, parentID string, num int) ([]string, error)
}

它把「切片 → 给每个子片生成唯一 ID → 写上 parent_id → 索引」这套流程包起来了。SubIDGenerator 的典型实现就是拼接:

go 复制代码
ids[i] = fmt.Sprintf("%s_chunk_%d", parentID, i+1)

两侧的 ParentIDKey 必须一模一样 ------一边写 parent_id、一边读 source_doc_id,就会精确复现上面那个「静默返回空」。这种跨组件的字符串约定,最好抽成一个常量,别在两处各写一遍字面量。

另外注意:父文档本身不进向量库 。它只需要能按 ID 取到,所以放在普通的关系表、KV 存储、甚至对象存储里都行。这也是 Parent 模式的一个附带好处------向量库里只存小片,体积更小,索引更快(第 72 篇算过:1024 维 float32 是 4KB 一片,能少存就少存)。


六、什么时候用哪个

Router 的判断标准很硬:你有没有多个物理隔离的知识库?

  • 有 → 值得上,省的成本立竿见影
  • 只有一个库,只是想按类别过滤 → 别用 Router ,用 metadata 过滤就行(第 72 篇讲过 pgvector 的 metadata @> $1::jsonb),一次查询搞定,不用维护路由函数

Parent 的判断标准是看你的失败模式:

  • 检索能命中,但 LLM 答得残缺(答案被切断在两片之间)→ Parent 正对症
  • 检索压根命不中 → Parent 帮不上,那是召回问题,回去看第 74 篇那张表
  • 文档本身就很短(一篇就几百字)→ 不用切片,也就不需要 Parent

两个可以叠加 ,Router 套 Parent,或者 MultiQuery 套 Parent,因为它们都实现了同一个 retriever.Retriever 接口------这就是第 67 篇讲的接口设计带来的好处,套娃不需要任何胶水代码。

但叠加要算清代价:Router + MultiQuery + Parent 全上,一次用户提问会变成「1 次 LLM 改写 + N×M 次向量检索 + 1 次数据库批量查询」。 每加一层都先跑一遍评测集,确认它真的带来了收益。


小结

  • Router 不填 Router 字段会 panic (eino v0.9.13):构造函数造了默认路由函数却赋值给局部变量,组装时用的是 config.Router。旁边的 fusionFunc: fusion 写法是对的------同一个函数里,两行并排,一个对一个错
  • 构造时不报错、调用时才炸的默认值最难查,因为只测「构造成功」的单测会全绿。用任何组件都优先显式传值
  • Router 的 RRF 分数不写回文档Score() 里还是各库的原始分,跨库不可比;且用的是 sort.Slice 而非 SliceStable,分数打平时顺序不稳定
  • 同框架内两个默认不一致:Router 默认 RRF 排序,MultiQuery 默认只去重不排序。别凭上一个组件的经验推断下一个
  • Parent 用小片检索、大片返回,解决「切小了准但上下文不够」的矛盾;向量库里只存小片,父文档放普通存储即可
  • Parent 的返回数量小于 TopK (同父去重),且分数归零 ------过滤要在子片阶段做,排序要在 OrigDocGetter 里按传入 ids 顺序重排
  • 索引时忘写 parent_id,检索永远返回空且不报错parent_id 必须存字符串,类型不对一样静默跳过。两侧的 ParentIDKey 抽成常量
  • 单库别用 Router ,metadata 过滤更简单;召回不足别指望 Parent,那是另一个问题

下一篇是 Part 13 的收尾:把第 66 篇到这一篇的东西串成一条完整链路------文档进来、切片、向量化、存进 pgvector、检索、融合、重排、喂给 LLM,端到端跑通一个能用的 RAG,并把每一步该配什么值、怎么验证,列成一张可以照着抄的表。


代码状态说明

全部四个实验 都在 eino v0.9.13 下真机运行,输出原样粘贴。检索走内存假库(按字面命中计数打分),路由函数是关键词判断,不需要任何 API Key,可自行复现。语料是我为演示虚构的六条企业文档和两篇父文档。

关于那个 panic :我核对过 flow/retriever/router/router.go 的构造函数,router 局部变量在 if config.Router == nil 分支里被赋值后确实再未被使用,结构体拿的是 config.Router。复现代码见实验 1。这是我读源码时发现的,没有向上游确认过是否为已知问题,也没有查阅 issue 列表------如果你在更高版本上验证到不同行为,以你的版本为准。

第五节的索引侧flow/indexer/parent)只做了源码阅读和配置项引用,没有实际跑过索引流程

相关推荐
Loveyourself1 小时前
🔥 claude code auto compact源码解析
面试·agent
王中阳Go1 小时前
用TRAE Work批量优化学员简历,原来2天的活现在2小时就干完了
后端·面试·agent
安逸sgr1 小时前
Zero-shot、Few-shot 和 One-shot Prompt 有什么区别?
人工智能·ai·大模型·agent·智能体
对象存储与RustFS1 小时前
别再只怪 GPU 了:2026 年 AI 训练/推理的存储瓶颈,以及 7 个被忽视的真相
开源·ai编程·gpu
Idefav1 小时前
让 HermesAgent 拥有真正可控的 Web Search:Camofox Web Search 集成实战
aigc
Bolt1 小时前
一个超级简单的 coding agent,100 行就可以做任何事
llm·agent·bun
月走乂山2 小时前
零依赖 GUI:把 OpenCode CLI 变成可视化 AI 任务流水线
python·自动化·ai编程·tkinter·opencode
imbackneverdie2 小时前
撰写系统性综述/叙述性综述,如何搭建清晰的领域发展脉络?
大数据·人工智能·aigc·论文·科研·ai写作·学术
long3162 小时前
Codex 团队开发入门到精通学习资料
ai·团队开发·个人开发·ai编程