RAG 流水线设计:Eino 的 Loader → Transformer → Indexer → Retriever(第60篇-E46)

系列「企业级 AI Agent 实现拆解」E46 篇,Part 10 生产工程篇第四章。上一篇 讲了 Agent 的可观测性。这篇深入知识库检索:Eino 把 RAG 拆成四个组件接口,如何串成流水线,Document 如何在各阶段流转。

读完这篇你会知道

  • Eino RAG 的四个核心接口:Loader / Transformer / Indexer / Retriever
  • Document 是什么------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 是整个流水线的信息传递通道------DenseVectorSparseVectorScoreSubIndexesDSLInfo 都通过类型安全的 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

相关推荐
9i编程1 小时前
国内直连 Claude Code 本地部署完整实操手册 ——DeepSeek 兼容接口版
ai编程·claude
liuliuqiqirr1 小时前
2026最新两款AI编程工具深度对比实测
大数据·ai编程
AI程序员1 小时前
万字长文详解 Agent 的评测机制:从任务、环境、轨迹到验证器、统计与持续回归
人工智能·agent
用户216753009731 小时前
Superpowers 卸载潮背后,我做了个轻量替代方案(开源)
ai编程
ch8561 小时前
别再只会 similaritySearch 了!RAG 在线阶段的 6 道鬼门关
agent
ch8561 小时前
别再调 prompt 了!RAG 的天花板,在文档进向量库那刻就焊死了
agent
卡卡罗特AI1 小时前
AI编程入门教程01-VibeCoding前的正确姿势是,先问AI去Github上找项目
chatgpt·ai编程
ch8562 小时前
RAG 一上线就翻车?因为你缺这张全景地图
agent