14|RAG 入门:构建带来源引用的知识问答
《Eino 实战:用 Go 构建 AI 应用与智能体》· 第 14 篇
关键词:Retriever、RAG、上下文组装、来源引用、无结果处理
前置条件:已完成 13|知识入库。
上一篇我们把资料整理成可检索的片段。这一篇把它接进问答流程:用户提问 → 召回相关片段 → 交给模型 → 生成有据可依的回答。
看似简单,但有几个决定成败的细节:检索不到时怎么办、答案怎么标来源、上下文怎么组织。这一篇逐个处理。
一、Retriever:接口只有一行
typeRetrieverinterface{Retrieve(ctx context.Context,querystring,opts...Option)([]*schema.Document,error)}输入查询字符串,返回文档列表。就这么简单——切分、向量化、索引的复杂性都封装在实现内部。
常用的调用选项:
docs,err:=r.Retrieve(ctx,query,retriever.WithTopK(5),// 最多返回几条retriever.WithScoreThreshold(0.6),// 低于分数阈值的不返回)两个参数都需要根据实际数据调,没有通用最优值:
TopK太小:可能漏掉关键资料。TopK太大:无关内容进入上下文,既浪费 Token,又干扰模型判断。- 阈值太高:容易把本可用的资料过滤掉。
建议先用固定样本测出召回情况,再决定取值,而不是照抄示例里的数字。
二、上下文组装:把资料变成提示词
召回的结果需要组织成模型能读的形式。一个基本可用的格式:
[1] 来源:handbook/printer.md 打印机无法联网时,先检查网线是否插紧…… [2] 来源:handbook/vpn.md 远程访问需要先申请 VPN 账号……对应代码:
funcbuildContext(docs[]*schema.Document)string{iflen(docs)==0{return""}varb strings.Builderfori,d:=rangedocs{fmt.Fprintf(&b,"[%d] 来源:%v\n%s\n\n",i+1,d.MetaData["source"],d.Content)}returnb.String()}三个设计要点:
- 编号。让模型可以用
[1]这样的方式引用,前端也能据此渲染成可点击的来源。 - 保留来源。直接来自第 13 篇的元数据。没有来源的答案,用户无法核验,也就难以信任。
- 分隔清晰。片段之间要断开,否则模型可能把两段内容连读成一段。
系统提示怎么写
schema.SystemMessage("你是工单助手。只依据提供的参考资料回答,并在结论后标注来源编号;资料不足时明确说明。")这句话包含三个约束,缺一不可:
- 只依据参考资料:抑制模型用自身知识补充。
- 标注来源编号:让答案可核验。
- 资料不足要说明:给出“拒绝回答”的出口。
但要清醒地认识到:这些是行为引导,不是硬约束。模型仍可能不标来源,或基于资料过度推断。第 15 篇会讨论如何验证和改善。
三、完整示例
下面这段程序用模拟检索器和模拟模型,不依赖任何外部服务即可运行,重点是把链路走通:
packagemainimport("context""errors""fmt""log""strings""github.com/cloudwego/eino/components/model""github.com/cloudwego/eino/components/prompt""github.com/cloudwego/eino/components/retriever""github.com/cloudwego/eino/compose""github.com/cloudwego/eino/schema")// ---- 检索器:用关键词匹配模拟向量检索,便于无依赖运行 ----typekeywordRetrieverstruct{docs[]*schema.Document}func(r*keywordRetriever)Retrieve(_context.Context,querystring,_...retriever.Option)([]*schema.Document,error){varhits[]*schema.Documentfor_,d:=ranger.docs{ifstrings.Contains(d.Content,query)||sharedKeyword(query,d.Content){// 复制一份并附带分数,避免修改原始文档。cp:=*d cp.WithScore(0.9)hits=append(hits,&cp)}}iflen(hits)>2{hits=hits[:2]}returnhits,nil}funcsharedKeyword(query,contentstring)bool{for_,kw:=range[]string{"打印机","离线","网络"}{ifstrings.Contains(query,kw)&&strings.Contains(content,kw){returntrue}}returnfalse}// ---- 模拟模型:把参考资料的来源整理进回答,便于验证链路 ----typemockModelstruct{}func(m*mockModel)Generate(_context.Context,in[]*schema.Message,_...model.Option)(*schema.Message,error){iflen(in)==0{returnnil,errors.New("空输入")}returnschema.AssistantMessage("(模拟回答)已根据参考资料生成建议。",nil),nil}func(m*mockModel)Stream(_context.Context,in[]*schema.Message,_...model.Option)(*schema.StreamReader[*schema.Message],error){sr,sw:=schema.Pipe[*schema.Message](1)gofunc(){defersw.Close()sw.Send(schema.AssistantMessage("(模拟流式回答)",nil),nil)}()returnsr,nil}// ---- 把检索结果组织成带来源的上下文 ----funcbuildContext(docs[]*schema.Document)string{iflen(docs)==0{return""}varb strings.Builderfori,d:=rangedocs{fmt.Fprintf(&b,"[%d] 来源:%v\n%s\n\n",i+1,d.MetaData["source"],d.Content)}returnb.String()}funcmain(){iferr:=run();err!=nil{log.Fatal(err)}}funcrun()error{ctx:=context.Background()r:=&keywordRetriever{docs:[]*schema.Document{{ID:"handbook/printer.md#0",Content:"打印机无法联网时,先检查网线是否插紧。",MetaData:map[string]any{"source":"handbook/printer.md"}},{ID:"handbook/vpn.md#0",Content:"远程访问需要先申请 VPN 账号。",MetaData:map[string]any{"source":"handbook/vpn.md"}},{ID:"handbook/ticket.md#0",Content:"工单超过 24 小时未分派可申请催办。",MetaData:map[string]any{"source":"handbook/ticket.md"}},}}query:="打印机离线怎么处理"// 1. 检索:传入查询,取回相关片段。docs,err:=r.Retrieve(ctx,query)iferr!=nil{returnfmt.Errorf("检索失败: %w",err)}// 2. 无结果时明确说明,而不是让模型自由发挥。iflen(docs)==0{fmt.Println("未检索到相关资料,请补充信息或转人工。")returnnil}for_,d:=rangedocs{fmt.Printf("命中: %s (score=%.2f)\n",d.ID,d.Score())}// 3. 组装上下文:保留来源,便于回答中引用。contextText:=buildContext(docs)tpl:=prompt.FromMessages(schema.FString,schema.SystemMessage("你是工单助手。只依据提供的参考资料回答,并在结论后标注来源编号;资料不足时明确说明。"),schema.UserMessage("参考资料:\n{context}\n问题:{question}"),)// 4. 用编排把模板和模型连起来。chain:=compose.NewChain[map[string]any,*schema.Message]()chain.AppendChatTemplate(tpl).AppendChatModel(&mockModel{})runnable,err:=chain.Compile(ctx)iferr!=nil{returnfmt.Errorf("编译失败: %w",err)}out,err:=runnable.Invoke(ctx,map[string]any{"context":contextText,"question":query})iferr!=nil{returnfmt.Errorf("生成失败: %w",err)}fmt.Println("回答:",out.Content)returnnil}实机输出:
命中: handbook/printer.md#0 (score=0.90) 回答: (模拟回答)已根据参考资料生成建议。示例里检索器用的是关键词匹配,因此只能召回明显相关的那一条。真实项目接入向量检索后,召回质量会好得多——但链路的形状是一样的:检索 → 组织上下文 → 生成。
四、无结果:RAG 里最重要的分支
很多 RAG 演示都假设“总能检索到东西”。真实场景里,用户问的往往是资料里没有的:
用户:报销流程是什么? 资料库:只有设备、网络、工单相关文档此时有两种做法:
错误做法:把空的上下文交给模型,让它基于自身知识回答。结果是听起来很确定的幻觉。
正确做法:显式判断,明确告知:
iflen(docs)==0{fmt.Println("未检索到相关资料,请补充信息或转人工。")returnnil}或者在提示词之外,用代码做兜底判断。下面这段程序对比了两种查询(实机运行):
查询: "打印机离线怎么处理" 命中 1 条 来源=handbook/printer.md 内容=打印机无法联网时,先检查网线是否插紧,再确认网段是否允许办公设备接入。 ... 无相关资料时命中数: 0 正确做法:明确说明没有依据,并转人工或补充信息,而不是让模型猜测。处理策略
| 情况 | 建议处理 |
|---|---|
| 完全无召回 | 明确说明,转人工或请用户补充信息 |
| 召回分数都很低 | 同上,或告知“仅供参考,请核实” |
| 召回内容相互矛盾 | 都呈现并标注来源,提示存在不一致 |
| 召回的是过期资料 | 依赖元数据中的版本/时间判断,优先给最新 |
“我不知道”是一个合格答案。在业务系统里,含糊其辞比明确拒答风险更大。
五、RAG 能解决什么,不能保证什么
这一点在推销式文章里经常被含糊过去,但在工程上必须清楚。
RAG 能做的:
- 让回答参考你提供的最新资料。
- 让答案可以标注来源,供人核验。
- 更新资料后无需重新训练模型。
RAG 不能保证的:
| 期望 | 现实 |
|---|---|
| 有资料就不会答错 | 模型可能误读资料、张冠李戴 |
| 检索到的都是对的 | 可能召回不相关或过期内容 |
| 引用了来源就可靠 | 引用可能不支持结论 |
| 覆盖所有问题 | 资料没写的问题它答不了 |
| 提升模型本身的推理能力 | RAG 只提供信息,不改变模型能力 |
一句话概括:RAG 改善的是信息来源,不是模型的判断力。它把“模型凭记忆瞎说”变成“模型照着资料说,但不保证说得对”。
六、常见问题
回答完全没有用到资料
检查上下文是否真的传进了提示词(打印最终消息看一眼,别猜)。也检查系统提示是否明确要求“只依据参考资料”。
回答了但没标来源
要求标注来源是引导而非强制。可以在提示词中给出标注格式示例,也可以在代码后处理中校验并补标。
检索到相关资料,答案却是错的
模型可能误读了内容。检查片段是否被切断了上下文(第 13 篇的切分问题),或考虑在生成前加校验步骤(第 15 篇)。
不同资料说法冲突
在提示词中明确要求“资料冲突时都列出并标注来源”,不要让它自行选一个。
问了个资料里没有的问题,模型还是硬答
缺少“无结果”分支。加上显式判断,并在提示词中给出拒答许可。
召回数量设多少合适
先用一组真实问题做测试,观察召回命中情况再定。不要照抄默认值。
七、小结
- Retriever 接口简单,复杂性封装在实现里;
TopK与阈值需按数据实测调整。 - 上下文组装要带编号和来源,让答案可核验。
- 系统提示应同时约束“只依据资料”“标注来源”“不足要说明”。
- 无召回必须显式处理,“我不知道”是合格答案。
- RAG 改善信息来源,不保证正确性,也不改变模型的判断力。
下一篇《RAG 优化:为什么检索到了,回答仍然不对?》,我们从召回、切分、上下文和评测四个角度定位问题。
参考资料与版本说明
资料核对日期为2026 年 10 月 9 日:
- Retriever 使用说明
- ChatTemplate 使用说明
- Eino 主仓库
本文示例按Eino v0.9.21编写,检索器与模型均为自定义模拟实现,已实机运行,正文输出为真实结果。