系列「企业级 AI Agent 实现拆解」补充篇。前面 E8 给 Agent 装了「脑」(ChatModel),E9 给它装了「手」(Tool,能动手干活)。这一篇装第三样:给它一个「资料库」,让它回答前先翻资料。
这就是 RAG(检索增强生成)。光靠模型自己「记」的知识,又旧又容易错。RAG 的做法是:用户问问题 → 先去资料库里把相关段落捞出来 → 把这些段落连同问题一起喂给模型 → 模型「看着资料」回答。开卷考试,比闭卷靠谱。
「去资料库里捞相关段落」这一步,在 Eino 里有个专门的名字:Retriever(检索器)。
读完这篇你会知道:
Retriever接口长什么样(一个方法,返回一堆文档)- 向量检索到底在干嘛:为什么是「按意思找」而不是「按关键字找」
- 相似度过滤是什么------以及它为什么不是一个独立组件,只是一个参数
- 多查询检索器:一个问题换几种问法去搜,把结果合起来
- 路由检索器:不同问题去不同的资料库搜,用 RRF 算法合起来
- 为什么这两个「高级检索器」自己也是个
Retriever(可以套娃)
一、先看一个能跑的检索器
讲接口之前,先看检索器实际怎么用。下面这段来自 Eino 官方示例(components/retriever/multiquery/main.go:128,这是构造一个真实的向量库检索器):
go
vk, err := volc_vikingdb.NewRetriever(ctx, &volc_vikingdb.RetrieverConfig{
Host: host,
Region: region,
AK: ak,
SK: sk,
EmbeddingConfig: volc_vikingdb.EmbeddingConfig{
UseBuiltin: true, // 用向量库自带的 embedding 模型
},
Index: "3", // 索引名
TopK: &baseTopK, // 返回几条
})
// 用起来就一句话:
docs, err := vk.Retrieve(ctx, "tourist attraction")
// docs 就是 []相关文档,按相关性从高到低排好
不管底下是火山引擎的 VikingDB、Milvus、Elasticsearch 还是 Redis,用法都是这个样子:造一个检索器,调一句 Retrieve(ctx, 问题),拿回一堆相关文档。 底层换了,调用代码不动------这就是「统一接口」要解决的问题。
VikingDB、Milvus、ES、Qdrant、Redis 等十几个具体实现都在 eino-ext/components/retriever/ 下,每个都是 retriever.Retriever 接口的一个实现。
二、Retriever 接口是什么
接口本身极其克制(components/retriever/interface.go:48):
go
type Retriever interface {
Retrieve(ctx context.Context, query string, opts ...Option) ([]*schema.Document, error)
}
就一个方法:输入一句自然语言 query,输出一组 schema.Document,按相关性从高到低排好序。
这里有个配套概念必须一起讲:Indexer(索引器) 。它俩是 RAG 的一对门神(components/retriever/doc.go:31):
| 组件 | 方向 | 干什么 |
|---|---|---|
| Indexer | 写 | 把文档(和它的向量)存进资料库 |
| Retriever | 读 | 拿 query 去资料库里捞相关文档 |
先存(Indexer)才能捞(Retriever)。而且有一个铁律 :存的时候用什么模型把文字变成向量,捞的时候就必须用同一个模型 ------不然向量维度对不上,相似度算出来全是乱的(doc.go:37)。这点和 E9 讲 Tool 时的「JSON Schema 是共同语言」是一回事:检索这一侧,向量是文字和资料库之间的共同语言。
三、向量检索到底在干嘛
技术小白卡住 RAG 的第一个坎,就是「向量检索」。其实就三步:
第一步:把句子变成一串数字。 这叫 embedding(嵌入)。一个 Embedder 模型吃进一句话,吐出一串数字(比如 1024 个浮点数),这串数字就是这句话的「意思坐标」。
第二步:资料库里的每段文档,早就存好了自己的坐标。 存的时候(Indexer)就用同一个 Embedder 算好了。
第三步:算距离。 把用户问题的坐标,和资料库里每段文档的坐标算一个「距离」(最常用的是余弦相似度)。距离越近 = 意思越像。按距离排序,取最像的几段,就是检索结果。
关键在第二步用的是「坐标」不是「字面」。所以「我想找个住的地方」能搜到「酒店预订」「民宿」「租房」------尽管没有共同关键词,但意思坐标挨得近。这就是「按意思找」比「按关键字找」强的原因。
Embedder 怎么传给检索器?在 Eino 里它是个 Option(option.go:31):
go
docs, _ := retriever.Retrieve(ctx, "我想找个住的地方",
retriever.WithEmbedding(myEmbedder), // 用这个模型把 query 变向量
)
四、相似度过滤:它只是个参数,不是独立组件
很多人以为「相似度过滤」是个单独的东西,其实不是 。它是 Retrieve 调用时的一个 Option(option.go:66):
go
type Options struct {
TopK *int // 最多返回几条
ScoreThreshold *float64 // 相似度过滤:低于这个分的直接扔
Embedding embedding.Embedder // query 向量化用的模型
DSLInfo map[string]any // 后端特定的过滤条件(如按 metadata 筛)
Index *string // 用哪个索引
}
这里有两件容易搞混的事,官方注释特意点明了(interface.go:38):
ScoreThresholdis a filter, not a sort.
翻译:
WithScoreThreshold(0.5):相似度低于 0.5 的文档直接剔除,不要了。这是「扔」,不是用来排序的。排序一直按相似度从高到低。WithTopK(5):最终最多返回 5 条。这是数量上限。
举个直觉例子:资料库里查到 20 段相关文档,其中只有 8 段相似度 > 0.5,你只要 5 段。
- 不设阈值、设
TopK=5:返回相似度最高的 5 段(哪怕第 5 段相似度只有 0.1,很不像也要)。 - 设
ScoreThreshold=0.5+TopK=5:先把 < 0.5 的扔掉剩 8 段,再取前 5 段。
为什么要设阈值?因为「检索器永远会返回 TopK 条」是很多向量库的默认行为------哪怕资料库里根本没有相关内容,它也会硬塞几条最不相关的给你。设了阈值,不沾边的就别凑数了,避免把模型带歪。这是 RAG 质量的一个关键旋钮。
DSLInfo是个补充:当你想「只在某个租户的文档里搜」「只搜 2024 年的文档」时,靠它传后端特定的过滤条件(metadata filter)。语义随具体向量库而定。
五、多查询检索器:一个问题,换几种问法去搜
单个 query 经常召不全。比如用户问「怎么部署 Agent」,资料库里相关段落可能写的是「应用上线流程」「模型服务化」「推理服务发布」------意思相近,措辞不同。一个 query 命中不了这么多表达。
解法:让 LLM 把一个问题改写成好几个 ,分别去搜,再把结果合起来。这就是多查询检索器(flow/retriever/multiquery/multi_query.go)。
go
mqr, _ := multiquery.NewRetriever(ctx, &multiquery.Config{
RewriteHandler: func(ctx context.Context, query string) ([]string, error) {
// 用 LLM 把一个问题改写成多个(这里简化,实际会调 llm.Generate)
return []string{"怎么部署 Agent", "应用上线流程", "推理服务发布"}, nil
},
MaxQueriesNum: 3, // 最多改写几个,默认 5
OrigRetriever: vk, // 底层真实检索器
FusionFunc: nil, // 融合函数,nil=默认按文档 ID 去重
})
docs, _ := mqr.Retrieve(ctx, "怎么部署 Agent")
// 内部:3 个 query 各自检索 → 合并去重 → 返回
它内部的工作流(multi_query.go:161)很清晰:
- 改写 :把 1 个 query 变成 N 个(默认用 LLM,也可以自定义
RewriteHandler)。不改写时它甚至内置了一个默认 prompt(multi_query.go:36),让模型「从不同角度生成 3 个版本」。 - 并发检索 :N 个 query 同时丢给底层检索器(用 goroutine 并发,utils.go:44),各拿回一堆文档。
- 融合 :把 N 堆文档合成一堆。默认策略是
deduplicateFusion------按文档 ID 去重 (multi_query.go:45),同一个文档被多个 query 召回只留一份。你也可以换自己的融合策略。
比喻:同一个图书管理员,你换三种问法去问,把他三次递过来的书去个重。能比一次问召全得多。
六、路由检索器:同一个问题,去不同的资料库搜
和多查询相反的场景:你有好几个资料库 (比如技术文档库、财务制度库、HR 政策库),一个问题该去哪个库搜?或者干脆都搜一遍再合起来?
这就是路由检索器(flow/retriever/router/router.go):
go
rr, _ := router.NewRetriever(ctx, &router.Config{
Retrievers: map[string]retriever.Retriever{
"tech": techRetriever,
"finance": financeRetriever,
"hr": hrRetriever,
},
Router: func(ctx context.Context, query string) ([]string, error) {
// 根据 query 决定走哪些库。返回的是上面 map 的 key
if strings.Contains(query, "报销") {
return []string{"finance"}, nil
}
return []string{"tech"}, nil
},
FusionFunc: nil, // 融合函数,nil=默认 RRF
})
docs, _ := rr.Retrieve(ctx, "差旅报销流程")
// 内部:Router 选出 finance 库 → 去 finance 库搜 → 返回
它的工作流(router.go:122):
- 路由 :
Router函数看一眼 query,决定去哪些库(返回库的名字列表)。不传Router时,默认所有库都走一遍。 - 并发检索:选中的库同时搜(同一个 query)。
- 融合:把多个库的结果合成一份。
重点:RRF------把多份排名合成一份的算法
多查询的融合是「去重」就行(因为同一个库,文档 ID 全局唯一)。路由的融合复杂些:不同库的文档分数不可比(VikingDB 给 0.8,Milvus 可能给 0.9,不是一个量纲),不能直接比大小。
所以路由默认用 RRF(Reciprocal Rank Fusion,倒数排名融合) (router.go:33)。它不看绝对分数,只看排名:
go
docRankMap[v[i].ID] += 1.0 / float64(i+60)
一句话:一份文档在某个库里排第 i 名,就给它加 1/(i+60) 分。在多个库里都排名靠前,分数就累加------说明它真的相关。最后按累加分数排序。
这个 +60 是业界常用的平滑常数(避免排名靠后的文档权重过大,来自 RRF 原始论文的推荐值)。不用纠结,记住思想:「被多个来源都排在前面」比「在一个来源排第一」更可信。
多查询 vs 路由,别搞混:
- 多查询:同一个库,多个 query(解决「一种问法召不全」)。
- 路由:同一个 query,多个库(解决「资料分散在不同库」)。
- 比喻:多查询是「换几种问法问同一个管理员」;路由是「同一问题问不同科室的管理员」。
七、它们自己也是 Retriever(可以套娃)
注意上面两个 NewRetriever 的返回值类型 ------都是 retriever.Retriever(multi_query.go:69、router.go:77)。
这不是巧合,是 Eino 一以贯之的设计:多查询、路由这些「高级检索器」本身就实现了 Retriever 接口。 于是它们可以任意嵌套组合:
- 一个路由检索器 ,它的几个子库可以是多查询检索器(每个库内部再做多查询扩召回)。
- 反过来,多查询的
OrigRetriever也可以是一个路由检索器。 - 套几层都行,对外仍然是同一个
Retrieve(ctx, query)接口。
这就是「组合优于继承」在检索层的落地。和 E9 里 Tool 的统一接口、E3 里 Graph 的节点编排是同一种哲学:定一个最小接口,让实现可以无限组合。 上层(Graph、Agent)完全不需要知道底下套了几层检索器。
顺带一提,
flow/retriever/下还有一个parent(父文档检索器):检索时用小切片去匹配(更精准),返回时换成它所属的大文档(上下文更全)。同样是Retriever,同样可嵌套。RAG 进阶时用得上。
八、串进 RAG:检索器只是个 Graph 节点
最后,一个检索器怎么被用起来?官方文档给的最小写法(interface.go:42):
go
retriever, _ := redis.NewRetriever(ctx, cfg)
docs, _ := retriever.Retrieve(ctx, "what is eino?", retriever.WithTopK(5))
// 或者,把它编进 Graph:
graph.AddRetrieverNode("retriever", retriever)
在 RAG 的完整 Graph 里,检索器通常夹在中间:
css
用户问题 → [检索器] → 相关文档 → [拼进 prompt] → [ChatModel] → 回答
检索器把「相关文档」这一筐资料递给后面的模板和模型,模型开卷答题。一个最小可用的 RAG,就是这么把 Retriever 节点 + ChatModel 节点连起来。脑(ChatModel)、手(Tool)、资料库(Retriever)三件齐了,Agent 就从「只会聊天」变成「会查资料、会动手、能讲道理」。
要点回顾
Retriever= 一个方法 :Retrieve(ctx, query, opts) → []*Document,是 RAG 的读路径;Indexer是写路径。两者用的 Embedder 必须是同一个,向量才能对得上。- 向量检索 = 按意思找:句子→向量→算距离→排序。比关键字匹配更懂语义。
- 相似度过滤不是独立组件,是
ScoreThreshold这个 Option :低于阈值的直接扔(filter 不是 sort);TopK是返回数量上限;DSLInfo做元数据过滤。 - 多查询检索器:1 个 query 让 LLM 改写成 N 个,并发检索,按文档 ID 去重融合。解决「一种问法召不全」。
- 路由检索器 :1 个 query 按 Router 函数分流到多个库,并发检索,用 RRF(倒数排名融合)合并。解决「资料分散在多库」。
- 两者自己都是
Retriever,可任意嵌套组合,对外接口不变------这是 Eino「最小接口 + 无限组合」设计哲学的又一次体现。
这套设计的妙处,和 E9 的 Tool 一脉相承:别为每种向量库、每种检索策略单独造一套上层代码。 定一个 Retriever 接口,让 VikingDB、Milvus、ES 都来实现它;再把多查询、路由这些通用策略做成「本身也是 Retriever」的组合件------上层 Agent 只认接口,底下怎么换、怎么套,它一概不关心。这也是 DeepFlux 把「多租户独立 collection」「按租户路由检索」做成可插拔层的基础。