跳到主要内容Retriever 不是向量库的糖衣,而是 Eino 的读侧统一协议 | 极客日志Go / GolangAI算法
Retriever 不是向量库的糖衣,而是 Eino 的读侧统一协议
Eino 的 Retriever 组件不只是对搜索接口的简单封装,它定义了一套统一的读侧检索协议,将查询、向量化、过滤、结果规范化及运行时回调整合在一起,让 RAG 链路中的检索步骤可以干净地进入 Chain 和 Graph 编排。文章从常见误区入手,拆解了 Retrieve 接口的核心边界、公共 Option 的真实作用,并通过 VikingDB 示例和自定义实现骨架展示了如何正确对接后端、管理 metadata 并接入 Callback 排障。
为什么很多人用了 Retriever,却没看懂它真正的边界
很多人第一眼看到 Retriever,下意识觉得:这不就是调一下向量库或搜索引擎的 search,把最像的几条文档捞出来吗?代码看上去也确实是这么回事。
但只要往工程里多走两步,问题马上冒出来:
- query 到底在哪里做 embedding?
- 多知识库、多子索引怎么切?
TopK 和相似度阈值该放配置里,还是放运行时?
- 过滤条件写在 SDK 调用里,还是写在组件 option 里?
- 一次检索怎么进入
Chain、Graph、Callback 这条正式运行时链路?
这些事如果都散在业务代码里,不是不能跑,而是跑不久就会乱。
之前聊 Indexer 的时候,我强调它解决的是写入侧如何统一。现在换到 Retriever,重心就要转到读侧:
Retriever 是 Eino 在读侧给出的统一检索协议,不是某家向量库 SDK 的语法糖。
不只是'搜一下'
先别看 Retrieve(ctx, query, opts...) 这个方法,怕你直接把它定位成'检索调用的统一壳子'。
真正让 Retriever 发挥作用的是读侧这几件事:
把 query 变成标准检索入口。 上层只需要给查询字符串,后面是关键词、向量、混合检索还是带过滤的召回,组件内部去接。
把结果统一成 []*schema.Document。 不管底层是 VikingDB、Milvus、ES 还是 OpenSearch,最后交上来的都不是某家 SDK 的 hit 结构,而是标准文档协议。
把检索纳入运行时链路。 这里要认清:Retriever 不是工具函数,是能进 Chain、Graph、挂 Callback 的正式组件。
放到 RAG 里看,这层价值更清楚:
Embedding 把文本变成向量
Indexer 把文档写成可检索对象
Retriever 把 query 变成召回动作
ChatModel 基于召回结果生成答案
Rerank 通常放在 Retriever 之后,对候选结果做重排,那不是 Retriever 本体的事。
所以别把它理解成'搜索函数封装'。更准确的说法是:
Retriever 解决的是'查询如何以统一协议进入检索系统,并把结果以统一协议返回出来'。
Retrieve 动作的核心
官方接口其实很短:
type Retriever interface {
Retrieve(ctx context.Context, query string, opts ...Option) ([]*schema.Document, error)
}
比 Indexer 还简单。但真正要看的是它画的边界。
retriever.Retriever 说明 Eino 在组件层明确区分了写入协议和读取协议。前面有 Indexer 负责 Store,这里再单独给 Retrieve 一层抽象,意图明显:写进去怎么做,和查出来怎么做,是两条边界。
再看签名里四个要点:
在 Eino 里,它还承担请求级信息和 callback manager 的传递。检索这一步从一开始就被当成正式运行时行为,不是藏在工具函数里的黑盒调用。
1. ctx 不只是取消信号。
2. 输入是 query string,不是某家后端的专属请求结构。 上层调用姿势压得很统一。query 要不要向量化、怎么向量化、要不要混合检索,是组件内部的事。
3. 返回的是 []*schema.Document,不是原始 hit。 如果返回后端自己的结果结构,这层抽象就基本失效了。现在统一返回 Document,说明它抽象的不是'某种数据库搜索请求',而是'统一读侧输出协议'。
4. opts ...Option 把运行时可变能力单独挂了出来。 索引、子索引、TopK、阈值、embedding、过滤 DSL,都可以在调用时覆写,而不是全写死在初始化配置里。
type Document struct {
ID string
Content string
MetaData map[string]any
}
很多人只盯着 Content,但检索场景里不能轻视的往往是 MetaData。结果除了正文,还会带上分数、来源、业务标签、命中的索引或分区、后端返回的上下文字段。这些信息不一定在 Content 里,却可能被后续节点继续用到。
所以一次 Retrieve 调用,背后可能同时发生:query 预处理、向量生成、后端检索、结果解析、metadata 注入、callback 生命周期触发。这已经不是一句'搜一下'能说清的了。
公共 Option 不是几个小参数
官方给 Retriever 的公共 option 长这样:
type Options struct {
Index *string
SubIndex *string
TopK *int
ScoreThreshold *float64
Embedding embedding.Embedder
DSLInfo map[string]any
}
Index 决定这次检索落到哪个可检索空间。在多知识库、多业务库、多环境隔离里很常见,别只理解成'数据库里的索引名',不同实现里含义可能不一样。
SubIndex 是更细的逻辑分流。同一套物理存储,可能按租户、业务线、数据域、时间分区做检索路由。这就是为什么 Eino 不把它粗暴合并进 Index——层级不一样。
TopK 看起来普通,但直接影响召回范围、下游模型上下文长度、延迟和成本。FAQ 检索、知识库问答、长文档辅助分析需要的 TopK 根本不是一个数,不该写死在初始化配置里。
ScoreThreshold 是过滤条件,不是排序开关。它的意思是'低于阈值的文档直接不要',而不是'把低分文档往后排'。如果召回结果'明明命中了,但又没返回',除了看 TopK,还得看这里是不是把结果滤掉了。
Embedding 给 query 做向量化。这说明 Retriever 虽然吃自然语言 query,但内部可以把它变成向量再做相似度检索。官方特别强调:检索时用的 embedder 应该和索引写入时用的模型保持一致,否则很容易出现'文档都在,索引建好了,召回效果就是不对'的线上问题——写入和查询不在一个向量空间里。
不止公共 option,具体实现还能继续扩展。 官方提供了实现级 option 的包装方式。自定义 Retriever 时,既要支持 GetCommonOptions(...),也可以保留自己那套专属参数,不必为了兼容框架把所有细节都塞进公共层。公共 option 负责定义'所有 Retriever 都该听得懂的话',实现级 option 保留'这一家后端自己的方言'。
Retriever 的使命:把查询送进检索系统
很多人理解 RAG,脑子里只有一句话:'把文档切块,做 embedding,丢向量库,查询的时候搜出来。' 这话不算错,但落到 Eino 组件边界上,还得再拆清楚一点。
Loader / Parser -> Indexer -> Retriever -> ChatModel
原始资料 -> Loader / Parser -> []*schema.Document -> 切块 / 清洗 -> Indexer.Store -> 可检索后端 -> Retriever.Retrieve(query) -> []*schema.Document -> ChatModel
重要的不是流程图,而是边界与规范。写入侧关心文档标准化、向量什么时候生成、元数据怎么落库、写进哪个索引或分区;读取侧关心 query 怎么解释、查哪个索引或子索引、召回多少条、分数阈值怎么设、过滤条件怎么下发、返回什么 metadata 给下游。
上一篇讲 Indexer 时我强调'写入侧协议统一',这一篇换到 Retriever,重点就必须换成:
Retriever 解决的是'查询如何进入可检索系统',不是'文档如何写进去'。
如果这两层边界不拆开,最常见的结果就是写入逻辑和检索逻辑缠在一起,业务代码里到处散落后端 SDK 细节,一旦要换存储后端、换索引策略、加 callback,改动面会非常大。所以 Retriever 站在 RAG 链路里的位置,不是'后面随便补一层的 search helper',它就是读侧入口。
用 VikingDB 走一遍最小检索闭环
光讲抽象容易飘,最好走一个完整实现。官方给了这样一个示例:
package main
import (
"context"
"log"
"github.com/cloudwego/eino-ext/components/retriever/volc_vikingdb"
)
func ptr[T any](v T) *T {
return &v
}
func main() {
ctx := context.Background()
cfg := &volc_vikingdb.RetrieverConfig{
Host: "api-vikingdb.volces.com",
Region: "cn-beijing",
AK: "your-ak",
SK: "your-sk",
Scheme: "https",
ConnectionTimeout: 0,
Collection: "eino_test",
Index: "test_index_1",
EmbeddingConfig: volc_vikingdb.EmbeddingConfig{
UseBuiltin: true,
ModelName: "bge-m3",
UseSparse: true,
DenseWeight: 0.4,
},
Partition: "",
TopK: ptr(10),
ScoreThreshold: ptr(0.1),
FilterDSL: nil,
}
r, err := volc_vikingdb.NewRetriever(ctx, cfg)
if err != nil {
log.Fatal(err)
}
docs, err := r.Retrieve(ctx, "怎么申请退款")
if err != nil {
log.Fatal(err)
}
for _, doc := range docs {
log.Printf("id=%s metadata=%v content=%s", doc.ID, doc.MetaData, doc.Content)
}
}
Collection 是文档所在的数据集,可以理解成'更大一级的检索容器'。Index 对应检索时真正使用的索引,是 Indexer 的镜像面:Indexer 决定内容怎么写进去,Retriever 决定查的时候落到哪个索引上。Partition 对应子索引划分字段,如果知识库按租户、业务、区域、版本再做细分,这层就有用了。FilterDSL 对应标量过滤字段,很多场景你不只是'找最像的内容',还要先满足一层业务过滤,比如只看某个知识库、某个状态的数据、某个时间范围。EmbeddingConfig 这块很有代表性:它说明 query 不一定非得由你先手工转成向量再传进去,像这里 UseBuiltin: true 就是让检索器直接使用 VikingDB 的内置 embedding 完成向量化。TopK 和 ScoreThreshold 一个控制'最多拿多少',一个控制'低于多少不要',别混用。如果后面想在单次调用时临时覆盖,通过公共 option 去改就行,不必把默认值写死在初始化配置里。
Milvus、Elasticsearch、OpenSearch 这些实现,初始化参数和搜索模式都不一样,但最后都会收口到同一条调用协议上:
docs, err := retriever.Retrieve(ctx, query, opts...)
这说明 Retriever 抽象的不是某一家后端,而是读侧检索动作本身。
为什么它能直接进 Chain、Graph 和 Callback
如果 Retriever 只是一个普通 SDK 包装层,它没必要出现在编排系统里。但官方文档明确给出了两种挂法:
chain := compose.NewChain[string, []*schema.Document]()
chain.AppendRetriever(retriever)
graph := compose.NewGraph[string, []*schema.Document]()
graph.AddRetrieverNode("retriever_node", retriever)
这已经说明:Retriever 是正式运行时节点,不是藏在代码角落里的工具函数。
再看 callback。官方示例里,Retriever 这层可以直接挂 retriever.CallbackInput 和 retriever.CallbackOutput:
handler := &callbacksHelper.RetrieverCallbackHandler{
OnStart: func(ctx context.Context, info *callbacks.RunInfo, input *retriever.CallbackInput) context.Context {
log.Printf("query=%s topK=%d", input.Query, input.TopK)
return ctx
},
OnEnd: func(ctx context.Context, info *callbacks.RunInfo, output *retriever.CallbackOutput) context.Context {
log.Printf("docs=%d", len(output.Docs))
return ctx
},
}
helper := callbacksHelper.NewHandlerHelper().Retriever(handler).Handler()
chain := compose.NewChain[string, []*schema.Document]()
chain.AppendRetriever(retriever)
runner, _ := chain.Compile(ctx)
docs, _ := runner.Invoke(ctx, "怎么申请退款", compose.WithCallbacks(helper))
_ = docs
这段代码最值得盯住的不是日志打印,而是暴露出来的事实:你能在 OnStart 里看到 query 和运行时参数,能在 OnEnd 里拿到检索结果,检索过程本身可以进入统一追踪和观测链路。这对排障非常重要,因为 RAG 项目里最难受的问题之一就是:'答案不对,到底是模型幻觉,还是前面的召回就错了?' 如果 Retriever 没进入 callback 链路,这个问题会很难查,最后只能在业务层加一堆散乱日志,既不整洁也不稳定。
自己实现一个 Retriever 时,哪些细节不能省
如果要接一个新的检索后端,官方文档给出了清晰的骨架。真正要守住的顺序大致是:
retriever.GetCommonOptions
callbacks.ManagerFromContext
OnStart
doRetrieve
OnError
OnEnd
type MyRetriever struct {
index string
topK int
embedder embedding.Embedder
}
func (r *MyRetriever) Retrieve(
ctx context.Context,
query string,
opts ...retriever.Option,
) ([]*schema.Document, error) {
commonOpts := retriever.GetCommonOptions(&retriever.Options{
Index: &r.index,
TopK: &r.topK,
Embedding: r.embedder,
}, opts...)
cm := callbacks.ManagerFromContext(ctx)
runInfo := &callbacks.RunInfo{}
ctx = cm.OnStart(ctx, runInfo, &retriever.CallbackInput{
Query: query,
TopK: *commonOpts.TopK,
ScoreThreshold: commonOpts.ScoreThreshold,
Extra: map[string]any{
"index": commonOpts.Index,
"sub_index": commonOpts.SubIndex,
"dsl": commonOpts.DSLInfo,
},
})
docs, err := r.doRetrieve(ctx, query, commonOpts)
if err != nil {
ctx = cm.OnError(ctx, runInfo, err)
return nil, err
}
ctx = cm.OnEnd(ctx, runInfo, &retriever.CallbackOutput{
Docs: docs,
})
return docs, nil
}
func (r *MyRetriever) doRetrieve(
ctx context.Context,
query string,
opts *retriever.Options,
) ([]*schema.Document, error) {
var queryVector []float64
if opts.Embedding != nil {
vectors, err := opts.Embedding.EmbedStrings(ctx, []string{query})
if err != nil {
return nil, err
}
queryVector = vectors[0]
}
_ = queryVector
docs := []*schema.Document{{
ID: "doc_1",
Content: "退款申请一般需要先提交订单号和支付凭证。",
MetaData: map[string]any{
"score": 0.92,
"source": "faq/refund.md",
"backend": "my_store",
},
}}
return docs, nil
}
第一,Embedding 只在需要时调用。 不是所有检索后端都要求你在组件里自己生成 query 向量。有的后端支持内置 embedding,有的走关键词或混合检索。正确的姿势是:调用前先看 opts.Embedding,有就用,没有就按实现自己的检索模式走。
第二,要把后续节点可能会用到的 metadata 补齐。 很多人自己实现 Retriever 时只想着把正文查出来,这当然能跑,但后面一接真实业务就会发现不够用。至少召回分数、来源标识、后端文档 ID、命中的索引或分区这些信息值得带出去。因为后续节点不一定只看 Content,它可能要做来源展示、结果解释、问题排查,甚至还要继续做 rerank 或引用标注。如果 metadata 在这里丢了,后面再补就会很别扭。
5 个最容易把 Retriever 用浅的坑
把 Retriever 当成 SDK 薄封装。 一旦这么理解,代码里就会到处散落后端专属请求结构、过滤逻辑和日志逻辑。最后不是 Eino 在帮你统一边界,而是你自己把边界重新打碎了。
不看 MetaData,后面就追不动来源和分数。 只拿正文,不看 metadata,短 demo 没什么感觉。可一到线上,你很快就会遇到这些问题:这段答案是从哪篇文档来的?这条结果分数到底高不高?它命中了哪个索引或分区?这些都离不开 metadata。
TopK 和阈值写死。 很多项目最开始为了省事,直接把 TopK=5、threshold=0.3 固定死。问题是不同场景需要的召回范围并不一样。而且阈值本身还是过滤条件,不是排序条件。一旦写死,后面要调优效果就会非常别扭。
查询 embedding 和底库向量配置不匹配。 这是检索效果异常里非常高频的一类问题。写入时用了一种模型,查询时换了另一种模型,或者维度根本对不上,最直观的表现就是:'库里明明有内容,可就是召不准。' 别一上来就怀疑数据脏了,先看 query embedding 和底库配置是不是同一套。
不接 callback,召回问题很难排。 RAG 项目里,很多问题不是'功能坏了',而是'效果不稳定'。这类问题如果没有 callback,你很难快速判断:这次 query 进来时到底用了什么参数,检索结果到底返回了几条,是前面没召回到还是后面模型没用好。所以 callback 不是锦上添花,它在检索层经常就是排障入口。
总结
Retrieve 的本质不是'调一次 search',而是'让 query 以统一协议进入读侧检索系统'。
Retriever 解决的是读侧协议统一,不是某家后端 SDK 的简单包一层
Retrieve 里可能同时发生向量化、过滤、召回、结果解析和 callback 触发
Indexer 管'怎么写进去',Retriever 管'怎么查出来',两层边界不能混
参考资料
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- 随机西班牙地址生成器
随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online