Retriever 组件:让 Agent 学会「翻资料」的统一接口

系列「企业级 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):

ScoreThreshold is 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. 改写 :把 1 个 query 变成 N 个(默认用 LLM,也可以自定义 RewriteHandler)。不改写时它甚至内置了一个默认 prompt(multi_query.go:36),让模型「从不同角度生成 3 个版本」。
  2. 并发检索 :N 个 query 同时丢给底层检索器(用 goroutine 并发,utils.go:44),各拿回一堆文档。
  3. 融合 :把 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):

  1. 路由Router 函数看一眼 query,决定去哪些库(返回库的名字列表)。不传 Router 时,默认所有库都走一遍
  2. 并发检索:选中的库同时搜(同一个 query)。
  3. 融合:把多个库的结果合成一份。

重点: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.Retrievermulti_query.go:69router.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」「按租户路由检索」做成可插拔层的基础。

相关推荐
贵慜_Derek1 小时前
《从零实现 Agent 系统》连载 29|多 Agent 研究 Harness:Lead、Worker 与 Spawn
人工智能·架构·agent
枫子有风1 小时前
AI编程-Vibe coding(大厂常问问题)
人工智能·ai编程
枫叶林FYL1 小时前
BRIDGE:多模态查询的强化学习对齐与文本检索重构
人工智能·语言模型
Sam09271 小时前
Loop Engineering 是什么:让 AI Agent 从一次性回答变成可迭代执行
人工智能·ai
一个做软件开发的牛马1 小时前
MyBatis 从零实战:完整搭建可运行 Demo,注解与 XML 双模式开发详解
java·后端
TCW11211 小时前
AI底层系列:用C++实现线性代数的公式推导与算法设计-6.线性方程组的解集
c++·人工智能·算法
古城小栈2 小时前
Python 的主流Ai框架为什么优先适配 Linux 系统?
linux·人工智能·python
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年6月15日
大数据·人工智能·python·ai·信息可视化·自然语言处理·灵砚智能
暮云星影2 小时前
瑞芯微rk3588利用Rockchip NPU运行大语言模型(LLM)
arm开发·人工智能·语言模型·自然语言处理