系列「企业级 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)
}
注意 Retrievers 是 map[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 省下来的成本。三个库变一到两个,省的是真金白银的向量检索调用。
生产上路由函数的三种写法,按成本从低到高:
- 关键词 / 正则:零成本,零延迟,但维护规则表很烦,且改口径要改代码
- 一个小分类模型:几毫秒,比关键词准,需要标注数据训练
- 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-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 的顺序毫无关系。相关性排序就这么丢了。
写
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 篇,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 就是配套的索引器:
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)只做了源码阅读和配置项引用,没有实际跑过索引流程。