前言        

1. 为什么需要 RAG

2. RAG 的整体流程

(1) 先跑通 ChatModel

(2) 使用 Prompt 模板控制模型输入

(3) Embedding:让文本可以被语义检索

(4) Indexer:把知识写入向量数据库

(5) Retriever:从向量数据库检索相关知识

(6) Transformer:让文档更适合入库和生成

完整 RAG 问答流程

3. 总结


前言        

        最近在学习Eino这类 AI 应用开发框架, 刚开始只是跑一些简单 demo,比如调用大模型、流式输出、使用Prompt模板。往后学习自然接触到Embedding、Indexer、Retriever、Transform这些概念。这些组件单独看都不复杂,但如果把它们串起来,其实就是一个最小RAG应用的核心流程。这篇文章不是从理论角度完整介绍RAG,而是基于我学习Eino的几个小demo,回顾一下自己是如何一步步理解 RAG 的。

1. 为什么需要 RAG

        RAG(Retrieval Augmented Generation)检索增强生成, 通过将外部知识库与大模型能力结合,在回答问题前先进行知识检索,再基于检索结果生成答案,从而有效提升回答的准确性、时效性和可解释性。

        平常我们使用通用大模型时, 其都是通过获取互联网已有的内容进行回复生成所以针对一些私有非公开的数据我们需要提供足够的上下文告诉大模型, 它才能生成我们想要的答案。将大模型应用于实际业务场景时会发现存在以下几个方面问题:

  • 知识的局限性:大模型自身的知识完全源于训练数据,而现有的主流大模型(deepseek、gpt)的训练集基本都是构建于网络公开的数据,对于一些实时性的、非公开的或私域的数据是没有。
  • 幻觉问题:所有的深度学习模型的底层原理都是基于数学概率,模型输出实质上是一系列数值运算,大模型也不例外,所以它经常会一本正经地胡说八道,尤其是在大模型自身不具备某一方面的知识或不擅长的任务场景。
  • 数据安全性:对于企业来说,数据安全至关重要,没有企业愿意承担数据泄露的风险,尤其是大公司,没有人将私域数据上传第三方平台进行训练会推理。这也导致完全依赖通用大模型自身能力的应用方案不得不在数据安全和效果方面进行取舍。

当我们希望模型回答公司内部文档、项目知识库、用户手册里的内容,单纯依赖模型本身是不够的。而RAG就是解决上述问题的有效方案。

RAG 的思路是:

不要直接让模型凭空回答,而是先检索相关资料,再让模型基于资料回答

它的核心不是让大模型变得无所不知,而是在生成答案之前,先给它补充一批和问题相关的外部知识。

2. RAG 的整体流程

一个最基础的 RAG 系统通常可以拆成两条链路。

第一条是知识入库链路:

原始文档 -> Transformer 清洗/切分 -> Embedding 向量化 -> Indexer 写入向量数据库

第二条是问答检索链路:

用户问题 -> Retriever 检索文档 -> Transformer 整理检索结果 -> Prompt 拼接上下文 -> ChatModel 生成回答

如果对应到 Eino 里的组件,可以大概这样理解:

组件 作用
ChatModel 调用大模型生成回答
Prompt Template 组织模型输入
Embedding 把文本转换成向量
Indexer 把文档写入向量数据库
Retriever 从向量数据库检索相关文档
Transformer 对文档进行切分、转换或清洗

我目前的学习路径也基本是沿着这个顺序展开的。

(1) 先跑通 ChatModel

学习 Eino 的第一步,是先跑通大模型调用

model, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
    APIKey:  os.Getenv("ARK_API_KEY"),
    Model:   os.Getenv("MODEL"),
    Timeout: &timeout,
})

messages := []*schema.Message{
    schema.SystemMessage("你是一个助手"),
    schema.UserMessage("请介绍一下你自己"),
}

response, err := model.Generate(ctx, messages)

这一步是先确认模型可以正常被调用。

在 RAG 里,ChatModel 是最后负责生成答案的组件。前面的检索、向量化、文档召回,最终都是为了给模型提供更可靠的上下文。

除了普通生成外还有流式输出:

reader, err := model.Stream(ctx, messages)

流式输出在实际问答产品中很常见,因为用户不用等完整答案生成完,模型可以边生成边返回,体验更接近我们平时使用的大模型应用。

(2) 使用 Prompt 模板控制模型输入

在能调用模型之后,下一步就是学习如何组织 Prompt。

如果只是简单问答,可以直接构造 schema.UserMessage。但在真实应用里,Prompt 往往不是固定文本,而是由多个变量动态组成。

比如:

  • 用户角色
  • 用户问题
  • 检索到的上下文
  • 回答规则
  • 输出格式要求

Eino 提供了 prompt.FromMessages 来定义模板:

template := prompt.FromMessages(schema.FString,
    schema.SystemMessage("你是一个{role}"),
    &schema.Message{
        Role:    schema.User,
        Content: "{task}",
    },
)

params := map[string]any{
    "role": "问答助手",
    "task": "请根据资料回答问题",
}

message, err := template.Format(ctx, params)

一个典型的RAG Prompt可能长这样:

你是一个严谨的问答助手。
请只根据下面的参考资料回答用户问题。
如果参考资料中没有答案,请说明无法从资料中得到结论。

参考资料:
{context}

用户问题:
{question}

这里的 {context} 占位符就是 Retriever 检索出来的文档内容,{question} 是用户的原始问题。

所以 Prompt模板在 RAG 中就是把用户问题和外部知识组合成稳定的模型输入。

(3) Embedding:让文本可以被语义检索

理解 RAG 时,Embedding向量化是绕不开的一步。

传统搜索更多依赖关键词匹配,比如用户搜iPhone手机,系统可能根据"iPhone":和"手机"这两个词去匹配内容。但语义搜索更关注文本含义。即使两个句子没有完全相同的关键词,只要语义接近,也应该能被检索出来。Embedding 组件是一个用于将文本转换为向量表示的组件。它的主要作用是将文本内容映射到向量空间,使得语义相似的文本在向量空间中的距离较近。

Embedding 的作用就是把文本转换成向量:

文本 -> 一组浮点数

语义相近的文本,在向量空间中的距离也会更近。

embedder, err := ark.NewEmbedder(ctx, &ark.EmbeddingConfig{
    APIKey:  os.Getenv("ARK_API_KEY"),
    Model:   os.Getenv("EMBEDDER"),
    Timeout: &timeout,
})

texts := []string{
    "第一段文本",
    "第二段文本",
}

embeddings, err := embedder.EmbedStrings(ctx, texts)

然后可以打印每个向量的维度:

for i, embedding := range embeddings {
    fmt.Println("文本", i+1, "的向量维度", len(embedding))
}

(4) Indexer:把知识写入向量数据库

有了 Embedding 之后,下一步就是把文档存起来,  这就需要向量数据库。我这里使用的是 Milvus。

在 Milvus 里,需要先定义 Collection 的字段结构。比如:

fields := []*entity.Field{
    {
        Name:       "id",
        DataType:   entity.FieldTypeVarChar,
        PrimaryKey: true,
    },
    {
        Name:     "vector",
        DataType: entity.FieldTypeFloatVector,
        TypeParams: map[string]string{
            "dim": "1024",
        },
    },
    {
        Name:     "content",
        DataType: entity.FieldTypeVarChar,
    },
    {
        Name:     "metadata",
        DataType: entity.FieldTypeJSON,
    },
}

这几个字段分别表示:

字段 含义
id 文档唯一 ID
vector 文档内容对应的向量
content 原始文本内容
metadata 额外信息,比如作者、来源、分类

然后使用 Eino 的 Milvus Indexer:

indexer, err := milvus.NewIndexer(ctx, &milvus.IndexerConfig{
    Client:     milvusCli,
    Collection: collection,
    Fields:     fields,
    Embedding:  embedder,
})

接着构造文档并写入:

docs := []*schema.Document{
    {
        ID:      "1",
        Content: "这里是一段需要入库的知识内容",
        MetaData: map[string]any{
            "author": "Ryne",
        },
    },
}

ids, err := indexer.Store(ctx, docs)

Indexer 实际做了两件事:

1. 调用 Embedding,把文档内容转换成向量

2. 把文档内容、向量和元数据写入 Milvus

这就完成了 RAG 的知识入库阶段。

(5) Retriever:从向量数据库检索相关知识

Indexer 负责把知识放进去,Retriever 则负责把知识取出来。

当用户提出一个问题时,Retriever 的流程通常是:

用户问题 -> 问题向量化 -> 向量数据库相似度搜索 -> 返回相关文档

比如用户问:

原神是什么?

系统会先把这个问题转换成向量,然后去 Milvus 里查找语义最接近的文档内容。

返回的结果可能是:

原神是一款二次元开放世界冒险游戏...

这个过程不是简单的关键词搜索,而是基于向量相似度的语义搜索。

下面是检索的基础案例, milvus客户端Client需要自己配置, 这里不占篇幅了

retriever, err := milvus.NewRetriever(ctx, &milvus.RetrieverConfig{
	Client:      MilvusCli,
	Collection:  "test",
	Partition:   nil,
	VectorField: "vector",
	OutputFields: []string{
		"id",
		"content",
		"metadata",
	},
	TopK:      1,
	Embedding: embedder,
})

这一部分补齐之后,RAG 的核心闭环就完整了。

(6) Transformer:让文档更适合入库和生成

这一节重点写:

Transformer 并不只属于入库阶段,它的本质是对 Document 做转换处理

可以分两种情况:

入库前: 原始文档 -> Transformer -> Embedding -> Indexer

检索后: Retriever -> Transformer -> Prompt -> ChatModel

入库前主要做:

  • 文档切分
  • 文本清洗
  • 格式统一
  • metadata 补充

检索后主要做:

  • 合并多个文档片段
  • 截断过长内容
  • 去重
  • 过滤低质量结果
  • 转成适合 Prompt 的上下文文本
splitter, err := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
		Headers: map[string]string{
			"#":   "h1",
			"##":  "h2",
			"###": "h3",
		},
		TrimHeaders: false,
	})
	if err != nil {
		panic(err)
	}

	// 准备要分割的文档
	content, err := os.OpenFile("./document.md", os.O_CREATE|os.O_RDWR, 0755)
	if err != nil {
		panic(err)
	}
	defer content.Close()
	bs, err := os.ReadFile("./document.md")
	if err != nil {
		panic(err)
	}
	docs := []*schema.Document{
		{
			ID:      "doc1",
			Content: string(bs),
		},
	}

	// 执行分割
	results, err := splitter.Transform(ctx, docs)

完整 RAG 问答流程

当 ChatModel、Prompt、Embedding、Indexer、Retriever Transform都理解之后,就可以把它们串成完整 RAG 流程。

整体逻辑可以概括为:

用户提问
  ↓
Retriever 检索相关文档
  ↓
Transformer 整理检索结果
  ↓
Prompt 拼接上下文
  ↓
ChatModel 基于上下文生成回答
  ↓
返回最终答案

用伪代码表示:

question := "用户的问题"

docs, err := retriever.Retrieve(ctx, question)

message, err := template.Format(ctx, map[string]any{
    "context": docs,
    "question": question,
})

answer, err := chatModel.Generate(ctx, message)

所以RAG不是某一个单独组件,而是一条由多个组件组成的工程链路, 

其中:

  • Embedding 解决如何表示语义
  • Indexer 解决如何存储知识
  • Retriever 解决如何找回知识
  • Transform 解决文本如何转换处理
  • Prompt 解决如何组织上下文
  • ChatModel 解决如何生成自然语言答案

只有这些组件配合起来,才是一个完整的 RAG 应用。

3. 总结

第一:  RAG 的重点不只是大模型。刚开始很容易把注意力都放在模型调用上,但真正做 RAG 时,检索链路同样重要。模型生成答案只是最后一步,前面能不能找到正确资料,直接决定回答质量。

第二:  Embedding 是语义检索的基础。如果没有 Embedding,系统很难理解“意思相近但表达不同”的文本。向量化之后,语义搜索才成为可能。

第三:  Indexer 和 Retriever 是一进一出。Indexer 负责把文档写入向量数据库,Retriever 负责根据问题从数据库中找回相关文档。一个负责存,一个负责取。

第四:  Prompt 决定模型如何使用检索结果。即使检索到了正确资料,如果 Prompt 写得不好,模型也可能没有正确引用上下文,甚至继续自由发挥。

第五:  最小 RAG 系统并不复杂。真正复杂的是后续优化,比如文档切分、召回准确率、重排序、多轮上下文、权限控制、引用来源等。

通过这几个 Eino demo可以简单总结为:

先把知识向量化并存入数据库; 用户提问时,再检索相关知识; 最后把知识交给大模型生成回答。对应Eino:  Embedding + Indexer 负责知识入库 Retriever 负责知识召回 Prompt 负责组织上下文 ChatModel 负责生成答案

        RAG 的核心价值,不是替代大模型,而是为大模型补充可靠、可更新、可追溯的外部知识。对我来说:AI 应用开发并不是只会调用模型接口就够了,更重要的是理解模型周围的工程链路。RAG 正是一个很好的切入点,它把模型调用、Prompt、向量化、向量数据库和检索流程都串在了一起。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐