Router / Parent 源码:多个知识库如何查,切太碎怎么补上下文(第75篇-E61)
先对号入座,看看你是不是有这两个问题:

| 症状 | 组件 | 一句话原理 |
|---|---|---|
| HR 库、技术库、法务库分开建的,问一句话总不知道该查哪个;全查一遍又慢又贵 | Router | 先判断该查哪几个库,只查那几个 |
| 切片切小了检索很准,但捞上来那一小段前后文没了,LLM 答不完整;切大了又检索不准 | Parent | 用小片去检索,命中后返回它所属的大片 |
读完这篇你会知道
一、Router:先决定查哪个库,再去查
企业里知识库通常是分开建的:HR 制度一个库,技术文档一个库,法务合同一个库。原因很实际——权限不同、更新频率不同、embedding 模型可能都不同。
问题来了:用户问一句「合同审批要走什么流程」,你查哪个?
- 全查一遍:三倍的成本和延迟,而且 HR 库里那些"审批"字样的文档会来干扰结果
- 只查一个:猜错就全完了
Router 的做法是:先用一个函数判断该查哪几个,再并发查那几个,最后融合。
配置只有三个字段:
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)}
注意 Retrievers 是 map[string],名字很重要——路由函数返回的就是这些名字,对不上会直接报错:
r, ok := e.retrievers[retrieverNames[i]]if !ok {returnnil, fmt.Errorf("router output[%s] has not registered", retrieverNames[i])}
如果你的路由函数是 LLM 驱动的(让模型判断该查哪个库),这个错误会很常见——LLM 很容易输出一个你没注册过的名字,比如把 legal 说成 法务。用 LLM 做路由时,记得在返回前做一次白名单校验。
路由函数怎么写
最简单的是关键词判断,实测:
Router: func(ctx context.Context, query string) ([]string, error) {if strings.Contains(query, "合同") {return []string{"legal"}, nil}return []string{"hr", "tech"}, nil},
跑起来:
query="审批" 实际查了: [hr tech]结果: tech-1(2.0000)tech-2(2.0000)query="合同审批" 实际查了: [legal]结果: legal-1(6.0000)
法务库在第一个查询里完全没被访问——这就是 Router 省下来的成本。三个库变一到两个,省的是真金白银的向量检索调用。
生产上路由函数的三种写法,按成本从低到高:
- 关键词 / 正则:零成本,零延迟,但维护规则表很烦,且改口径要改代码
- 一个小分类模型:几毫秒,比关键词准,需要标注数据训练
- LLM 判断:最灵活,能理解「我上个月报的那笔钱怎么还没到账」该去财务库,但每次查询多几百毫秒和一次调用费
多数场景第一种就够了。只有当你的库多到十几个、且用户问法千奇百怪时,才值得上第三种。
二、一个会 panic 的默认值
Router 字段看起来是可以不填的——不填就查全部。源码里也确实这么写了:
router := config.Routerif router == nil {var retrieverSet []stringfor k := range config.Retrievers {retrieverSet = append(retrieverSet, k)}router = func(ctx context.Context, query string) ([]string, error) {return retrieverSet, nil// 默认:查全部}}
逻辑没问题:没填就构造一个「返回所有名字」的函数。
但看它是怎么用这个变量的:
fusion := config.FusionFuncif fusion == nil {fusion = rrf}return &routerRetriever{retrievers: config.Retrievers,router: config.Router, // ← 用的是 config.RouterfusionFunc: fusion,// ← 用的是局部变量 fusion}, nil
两行并排放着,一个对一个错。
fusionFunc 拿的是处理过的局部变量 fusion(正确),router 拿的却是原始的 config.Router(错误)。上面辛辛苦苦构造出来的默认路由函数,赋值给了局部变量 router,然后再也没被用到。
于是不填 Router 时,存进结构体的是 nil。而 Retrieve 第一件事就是调它:
retrieverNames, err := e.router(routeCtx, query)
实测:
════ 实验 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 分支里被赋值)。
(版本是 eino v0.9.13。将来可能修,但依赖默认值本来就不是好习惯——构造时不报错、运行时才炸的默认值,是最难排查的一类问题,因为你的单元测试如果只测了「构造成功」就会全绿。)
三、Router 的 RRF:排序用了,分数没写回
不填 FusionFunc 时,Router 默认用 RRF。它自己带了一份实现:
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() 还是各个库自己算出来的原始分。
实测输出里能直接看到:
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(030),混在一个列表里的 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 就是这个矛盾的标准解法:索引时切小片,检索时用小片匹配,命中后把它所属的大片整篇返回。
检索准确性由小片保证,上下文完整性由大片保证,两头都要。
实现只有十几行:
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 {returnnil, err}ids := make([]string, 0, len(subDocs))for _, subDoc := range subDocs {if k, ok := subDoc.MetaData[p.parentIDKey]; ok {// ② 取父 IDif 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 篇出
三个子片,其中两片属于同一篇父文档:
════ 实验 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-1 和 chunk-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 的顺序毫无关系。相关性排序就这么丢了。
五、Parent 最阴的失败模式:静默返回空
看那个取父 ID 的分支:
if k, ok := subDoc.MetaData[p.parentIDKey]; ok {if s, okk := k.(string); okk && !inList(s, ids) {ids = append(ids, s)}}
MetaData 里没有 parent_id 的子片,直接被跳过,不报错、不打日志。配置注释里其实写明了:
// 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.
后果实测:
════ 实验 4:忘了写 parent_id 会怎样 ════子片确实命中了,但没有 parent_id返回 0 篇, err=<nil>, getter 被调用=true→ 检索不到任何东西,而且不报错
检索明明命中了,最终返回 0 篇,err 是 nil。
(注意 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 就是配套的索引器:
type Config struct {Indexerindexer.Indexer // 底层索引器(向量库)Transformerdocument.Transformer// 切片器:大片 → 小片ParentIDKeystring// 跟检索侧必须一致SubIDGenerator func(ctx context.Context, parentID string, num int) ([]string, error)}
它把「切片 → 给每个子片生成唯一 ID → 写上 parent_id → 索引」这套流程包起来了。SubIDGenerator 的典型实现就是拼接:
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,并把每一步该配什么值、怎么验证,列成一张可以照着抄的表。
-
09.01
通过使用 MCP 创建日程和待办怎么做-执行顺序和关键限制
-
09.01
SpringBoot使用WebSocket(二)
-
09.01
IONIQ艾尼氪V成都车展亮相:楔形车身吸睛,科技续航双在线
-
09.01
在Excel中轻松抓取其他表格数据的做法与技巧分享
-
09.01
袋里IP架构设计:采集Shopify / BigCommerce 公开数据时的袋里策略差异
-
09.01
[057][调度模块]分布式环境下定时任务的防重复执行方案
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏