系列「企业级 AI Agent 实现拆解」E46 篇,Part 10 生产工程篇第四章。上一篇 讲了 Agent 的可观测性。这篇深入知识库检索:Eino 把 RAG 拆成四个组件接口,如何串成流水线,Document 如何在各阶段流转。
读完这篇你会知道
- Eino RAG 的四个核心接口:
Loader/Transformer/Indexer/RetrieverDocument是什么------MetaData 里藏着什么,DenseVector/SparseVector/Score 怎么读写Options模式:通用参数(TopK/ScoreThreshold/Embedding)和实现特定参数怎么共存- Retriever 怎么在 Graph 里接线,以及把 Retriever 包成 Tool 的常见生产模式
- 完整的 LLM-scored RAG 工作流:BatchNode 并行打分 + 过滤 + 合成回答
为什么 RAG 需要四个接口
一次完整的知识库问答分两个阶段:
构建期 (一次性):文档从外部来源进来 → 切块 → 生成向量 → 存到向量库 查询期(每次问答):用户问题 → 生成向量 → 向量库检索 → 召回相关片段 → LLM 合成回答
Eino 把这两个阶段的四个步骤各自抽象成一个接口:
构建期:Loader → Transformer → Indexer
查询期:Retriever
四个接口都以 schema.Document 为货币,在 Graph 节点之间传递。
Document:RAG 的基本单位
go
type Document struct {
ID string // 文档唯一标识
Content string // 文本内容(向量化的原始材料)
MetaData map[string]any // 开放的 KV 元数据
}
MetaData 虽然是 map[string]any,但 Eino 提供了类型安全的访问方法,避免直接操作 key string:
go
// 读写向量(Indexer 存储前写入,Retriever 可以读取)
doc.WithDenseVector([]float64{0.1, 0.2, 0.3, ...})
doc.DenseVector() // → []float64
// 稀疏向量(混合检索用,key=词项ID, value=权重)
doc.WithSparseVector(map[int]float64{123: 0.8, 456: 0.3})
doc.SparseVector() // → map[int]float64
// 相关性分数(Retriever 检索后由实现写入)
doc.WithScore(0.87)
doc.Score() // → float64
// 子索引(让 Indexer 把文档同时写入多个分区)
doc.WithSubIndexes([]string{"zh", "product-manual"})
doc.SubIndexes() // → []string
// DSL 过滤器(携带 backend-specific 过滤表达式)
doc.WithDSLInfo(map[string]any{"tenant_id": "t-123"})
doc.DSLInfo() // → map[string]any
重要约定 :Transformer 实现应当保留现有 MetaData 并 merge,而不是替换整个 map------前一个 stage 写入的元数据(比如来源文件名)不应该被下一个 stage 清掉。
Loader:从外部读文档
go
type Loader interface {
Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error)
}
type Source struct {
URI string // 本地文件路径 or 远程 URL
}
Loader 只负责拿到原始内容并转成 Document,不做切分。常见实现:
- 文件 Loader:读 PDF/DOCX/Markdown,内部会调 parser.Parser
- URL Loader:HTTP 抓取页面
- 对象存储 Loader:读 MinIO/S3 里的文件
最简单的自定义 Loader:
go
func loadFile(ctx context.Context, src document.Source, opts ...document.LoaderOption) ([]*schema.Document, error) {
data, err := os.ReadFile(src.URI)
if err != nil {
return nil, fmt.Errorf("read %q: %w", src.URI, err)
}
return []*schema.Document{{
Content: string(data),
MetaData: map[string]any{"source": src.URI},
}}, nil
}
Transformer:切块和过滤
go
type Transformer interface {
Transform(ctx context.Context, src []*schema.Document, opts ...TransformerOption) ([]*schema.Document, error)
}
最常见的 Transformer 是文本分块器(Splitter):把一篇长文档切成多个小 chunk,便于向量化和召回。
分块策略的核心矛盾:
- chunk 太大 → 向量混杂了太多概念,相关性下降
- chunk 太小 → 单个 chunk 缺乏上下文,LLM 无法合成完整回答
eino-examples 里的 splitIntoChunks 用的是段落边界优先 + 行边界兜底的策略:
go
func splitIntoChunks(text string, chunkSize int) []*schema.Document {
// 优先在 \n\n(段落)处切分
// 段落超过 chunkSize 时按 \n 行切
// 保留各 chunk 的 MetaData(来源、分块序号等)
}
Transformer 也可以用来做重排序(Reranker):先向量召回 top-30,再用 Cross-Encoder 打分,只保留 top-5。
Indexer:写入向量库
go
type Indexer interface {
Store(ctx context.Context, docs []*schema.Document, opts ...Option) (ids []string, err error)
}
Store 接受一批 Document,返回存储后分配的 ID 列表。
当 Options.Embedding 提供时,Indexer 在存储前自动把 doc.Content 转为向量------入库和查询必须用同一个 Embedding 模型,否则向量空间不对齐,召回结果会乱。
用 WithSubIndexes 可以把同一篇文档同时写入多个分区,适合多语言或多业务线的知识库:
go
doc.WithSubIndexes([]string{"zh", "en"}) // 同时存进中文分区和英文分区
indexer.Store(ctx, []*schema.Document{doc})
Retriever:查询接口
go
type Retriever interface {
Retrieve(ctx context.Context, query string, opts ...Option) ([]*schema.Document, error)
}
接口极简,只有一个方法。所有行为通过 Options 控制:
go
type Options struct {
Index *string // 查哪个索引
SubIndex *string // 查哪个子分区
TopK *int // 最多返回多少个
ScoreThreshold *float64 // 分数低于此值的过滤掉(不是排序,是截止)
Embedding embedding.Embedder // 查询向量化用的模型(需与入库一致)
DSLInfo map[string]any // backend-specific 过滤表达式(如租户过滤)
}
通用参数 + 实现特定参数共存:
go
// 通用参数用标准 Option 函数
retriever.WithTopK(5)
retriever.WithScoreThreshold(0.7)
retriever.WithDSLInfo(map[string]any{"tenant_id": tenantID})
// Redis 特有参数(格式和语义 Redis 自己定义)
redis.WithFilterQuery("@category:{product}")
// Retriever 实现里的处理方式:
func (r *Retriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) {
co := retriever.GetCommonOptions(&retriever.Options{TopK: &r.defaultTopK}, opts...)
io := retriever.GetImplSpecificOptions(&implOptions{}, opts...) // Redis 特有选项
// 用 co.TopK, co.Embedding, io.FilterQuery...
}
GetImplSpecificOptions 用泛型从 Option 列表里提取特定类型的选项,不同 Retriever 实现互不干扰。
实战:Redis 向量检索的完整执行路径
以 Redis Retriever 为例,Retrieve 内部做了什么:
go
func (r *Retriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) (docs []*schema.Document, err error) {
// 1. 提取 Options
co := retriever.GetCommonOptions(&retriever.Options{...}, opts...)
// 2. 触发 Callback(通知 OTel/Metrics)
ctx = callbacks.EnsureRunInfo(ctx, r.GetType(), components.ComponentOfRetriever)
ctx = callbacks.OnStart(ctx, &retriever.CallbackInput{
Query: query, TopK: *co.TopK,
})
// 3. 把 query 转为向量
vectors, _ := co.Embedding.EmbedStrings(ctx, []string{query})
// 4. 构造 Redis FT.SEARCH 查询
// 有 DistanceThreshold → Range Search(过滤掉不够像的)
// 没有 → KNN Search(返回最近的 K 个)
searchQuery := fmt.Sprintf("(*)=>[KNN %d @vector $vec AS dist]", *co.TopK)
// 5. 执行搜索,把结果转为 []*schema.Document
result, _ := r.config.Client.FTSearchWithArgs(ctx, index, searchQuery, opts)
for _, doc := range result.Docs {
d := DocumentConverter(ctx, doc) // 转换 + 写入 d.WithScore(1 - distance)
docs = append(docs, d)
}
// 6. 触发 OnEnd Callback
ctx = callbacks.OnEnd(ctx, &retriever.CallbackOutput{Docs: docs})
return docs, nil
}
把 Retriever 接入 Graph
方式一:作为 Graph 节点
go
// graph.AddRetrieverNode 把 Retriever 封装成标准节点
graph.AddRetrieverNode("kb_retriever", retriever)
graph.AddChatModelNode("llm", model)
graph.AddEdge("kb_retriever", "llm")
方式二:包成 Tool(推荐的生产模式)
在 DeepFlux 里,知识库检索作为 Agent 的一个 Tool,而不是 Graph 中的固定节点:
go
kbTool := tool.NewInvokableTool(
func(ctx context.Context, query struct{ Q string `json:"query"` }) (string, error) {
docs, err := retriever.Retrieve(ctx, query.Q,
retriever.WithTopK(5),
retriever.WithScoreThreshold(0.6),
retriever.WithDSLInfo(map[string]any{
"tenant_id": tenantIDFromCtx(ctx),
}),
)
if err != nil { return "", err }
var sb strings.Builder
for i, doc := range docs {
fmt.Fprintf(&sb, "[%d] %s (score=%.2f)\n", i+1, doc.Content, doc.Score())
}
return sb.String(), nil
},
)
agent := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Tools: []tool.BaseTool{kbTool, ...},
})
Agent 自主决定什么时候调用知识库------对话简单时跳过,问题复杂时调用,比固定 RAG 管道更灵活。
LLM 打分的 RAG 工作流
eino-examples 里有一个有意思的完整 RAG 实现,不用向量库,而是用 LLM 直接给 chunk 打相关性分数:
css
START{FilePath, Question}
│
▼
[load] 读文件 → []*Document
▼
[chunk] 按段落切块 → []*Document
│ (Chunks + Question 通过 FieldMapping 传入)
▼
[score] BatchNode(并行 5 个):
每个 chunk → LLM 打分 → {score: 0-10, excerpt: "..."}
▼
[filter] 按分数降序,保留 score≥3 的前 3 个
▼
[answer] 用 top-k chunks + Question 合成最终回答
│
END
BatchNode 是 Eino 的并行批处理节点,核心作用是让 N 个相同任务并发跑:
go
scorer := batch.NewBatchNode(&batch.NodeConfig[scoreTask, scoredChunk]{
Name: "ChunkScorer",
InnerTask: scoreWorkflow, // 单个 chunk 的打分工作流
MaxConcurrency: 5, // 最多同时跑 5 个
})
// [score] 节点调用它
func(ctx context.Context, in scoreIn) ([]scoredChunk, error) {
tasks := make([]scoreTask, len(in.Chunks))
for i, c := range in.Chunks {
tasks[i] = scoreTask{Text: c.Content, Question: in.Question}
}
return scorer.Invoke(ctx, tasks) // 并行打分,等全部完成
}
这个方案的优点是不需要向量数据库,适合"文件已上传、按需召回"场景。代价是 Token 消耗随 chunk 数线性增长。
小结
Eino 的 RAG 组件体系核心是接口极简 + Document 作为统一货币:
| 组件 | 接口方法 | 核心职责 |
|---|---|---|
Loader |
Load(ctx, Source) |
从外部读原始内容,转成 Document |
Transformer |
Transform(ctx, docs) |
切块、过滤、重排序 |
Indexer |
Store(ctx, docs) |
向量化 + 写入向量库 |
Retriever |
Retrieve(ctx, query) |
向量化查询 + 召回相关文档 |
Document 的 MetaData 是整个流水线的信息传递通道------DenseVector、SparseVector、Score、SubIndexes、DSLInfo 都通过类型安全的 accessor 方法管理,不用关心底层 key string。
在 DeepFlux 里,Retriever 作为 Agent 的一个 Tool 暴露,结合 RLS 租户过滤(通过 WithDSLInfo({"tenant_id": ...})),实现了每个租户只能检索自己的知识库。
代码来源:eino/components/retriever/interface.go · eino/schema/document.go · eino-ext/components/retriever/redis/retriever.go · eino-examples/quickstart/chatwitheino/rag/rag.go