15|RAG 优化:为什么检索到了,回答仍然不对?
《Eino 实战:用 Go 构建 AI 应用与智能体》· 第 15 篇
关键词:召回质量、MultiQuery、检索路由、重排序、评测
前置条件:已完成 13|知识入库 与 14|RAG 入门。
第 14 篇的流程跑通了,但上线后常见的情况是:检索确实返回了资料,回答却不对。
这可能发生在四个不同的环节。如果不先定位是哪一环,调参就只是在碰运气。这一篇按顺序给出排查路径,并介绍 Eino 提供的优化手段。
一、先定位:问题出在哪一环
RAG 有四个环节,每个环节都有各自的失效方式:
用户问题 ↓ ① 查询理解:问题表达与资料措辞不一致 检索 ↓ ② 召回质量:该被召回的没召回,或召回了无关的 重排与筛选 ↓ ③ 上下文组装:关键信息被淹没、被截断、被冲突内容干扰 生成 ↓ ④ 生成与引用:模型误读资料,或引用不支持结论 回答排查顺序建议从后往前,或者从最容易验证的环节开始:
- 打印最终发给模型的完整消息(第 14 篇示例里的做法)。这一步能立刻区分是“资料没进去”还是“资料进去了但模型没用好”。
- 打印召回结果和分数。看该有的资料是否在里面。
- 打印切分后的片段。看关键信息是否被切碎或丢失。
下面这段程序模拟了“检索命中但召回质量不稳定”的情况——这正是最常见的一种(实机运行):
packagemainimport("context""fmt""log""sort""strings""github.com/cloudwego/eino/schema")// chunk 表示一个已入库的知识片段。typechunkstruct{IDstringContentstringSourcestring}varcorpus=[]chunk{{ID:"printer#0",Content:"打印机无法联网时,先检查网线是否插紧,再确认网段是否允许办公设备接入。",Source:"handbook/printer.md"},{ID:"vpn#0",Content:"远程访问需要先申请 VPN 账号,审批通过后由网络组开通。",Source:"handbook/vpn.md"},{ID:"ticket#0",Content:"工单超过 24 小时未分派,可申请催办。",Source:"handbook/ticket.md"},}// bigrams 生成字符二元组,用于中文的粗略相似度计算。funcbigrams(sstring)map[string]struct{}{r:=[]rune(s)set:=make(map[string]struct{})fori:=0;i+1<len(r);i++{set[string(r[i:i+2])]=struct{}{}}returnset}// retrieve 用 bigram 重叠度做模拟检索,便于在无向量库时观察召回质量。funcretrieve(querystring,kint)[]chunk{q:=bigrams(query)typescoredstruct{c chunk scorefloat64}varall[]scoredfor_,c:=rangecorpus{d:=bigrams(c.Content)iflen(d)==0{continue}hit:=0forg:=rangeq{if_,ok:=d[g];ok{hit++}}all=append(all,scored{c:c,score:float64(hit)/float64(len(d))})}sort.Slice(all,func(i,jint)bool{returnall[i].score>all[j].score})varhits[]chunkfor_,s:=rangeall{ifs.score>0&&len(hits)<k{hits=append(hits,s.c)}}returnhits}funcmain(){iferr:=run();err!=nil{log.Fatal(err)}}funcrun()error{_=context.Background()query:="打印机离线怎么处理"hits:=retrieve(query,3)fmt.Printf("查询: %q 命中 %d 条\n",query,len(hits))for_,h:=rangehits{fmt.Printf(" 来源=%s 内容=%s\n",h.Source,h.Content)}// 组装上下文:带上来源编号,便于回答引用。varb strings.Builderfori,h:=rangehits{fmt.Fprintf(&b,"[%d] 来源:%s\n%s\n",i+1,h.Source,h.Content)}systemPrompt:="你是工单助手。只依据参考资料回答,结论后标注来源编号;资料不足时明确说明。"messages:=[]*schema.Message{schema.SystemMessage(systemPrompt),schema.UserMessage("参考资料:\n"+b.String()+"\n问题:"+query),}fmt.Println("--- 最终发送给模型的用户消息 ---")fmt.Println(messages[len(messages)-1].Content)// 反例:无相关资料时必须显式处理,否则模型容易自由发挥。empty:=retrieve("报销流程是什么",3)fmt.Printf("\n无相关资料时命中数: %d\n",len(empty))iflen(empty)==0{fmt.Println("正确做法:明确说明没有依据,并转人工或补充信息,而不是让模型猜测。")}returnnil}实机输出:
查询: "打印机离线怎么处理" 命中 1 条 来源=handbook/printer.md 内容=打印机无法联网时,先检查网线是否插紧,再确认网段是否允许办公设备接入。 --- 最终发送给模型的用户消息 --- 参考资料: [1] 来源:handbook/printer.md 打印机无法联网时,先检查网线是否插紧,再确认网段是否允许办公设备接入。 问题:打印机离线怎么处理 无相关资料时命中数: 0 正确做法:明确说明没有依据,并转人工或补充信息,而不是让模型猜测。这里能直接看出一个典型问题:用户说的是“离线”,资料里写的是“无法联网”。在简单相似度算法下,这种同义不同词的差距会显著拉低召回分数。真实向量检索对这类问题更健壮,但依然会受影响——这是查询理解要解决的问题。
二、① 查询理解:用户的说法和资料的写法不一样
用户不会用资料的措辞提问。常见的差距:
| 用户说法 | 资料写法 |
|---|---|
| 打印机离线 | 打印机无法联网 |
| 网断了 | 网络连接异常 |
| 怎么催 | 申请催办流程 |
改善手段:
多查询检索(MultiQuery):用模型把一个问题改写成多个表达,分别检索后合并结果。这样能覆盖更多措辞。
// 概念示意:把原始问题扩展成多个查询变体。// 实现方式可能是:// - 调用模型生成改写(推荐,可指定数量的变体)// - 或用同义词规则扩展queries:=[]string{"打印机离线怎么处理","打印机无法联网 排查步骤","办公设备 网络连接失败 处理",}for_,q:=rangequeries{docs,err:=r.Retrieve(ctx,q,retriever.WithTopK(3))// 合并、去重}Eino 的示例仓库里有MultiQueryRetriever的实现(基于模型生成查询变体),可以直接参考其结构。
检索路由:不同类型的问题走不同的检索源。比如“工单进度”查业务数据库,“操作规范”查文档库。路由错误会导致在一个错误的语料里搜索。
三、② 召回质量:该来的没来,不该来的来了
| 症状 | 可能原因 | 排查 |
|---|---|---|
| 该有的资料没被召回 | 切分不当、Embedding 不匹配、TopK 太小 | 打印召回列表;检查入库与检索用的嵌入模型是否一致 |
| 召回了大量无关内容 | 阈值过低、TopK 过大、资料本身有问题 | 提高阈值;减小 TopK;人工检查片段质量 |
| 只召回半句话 | 切分切断了上下文 | 检查切分粒度和重叠(第 13 篇) |
| 总是召回同一篇 | 该文档片段重复度高、索引存在重复 | 去重;检查入库逻辑 |
最容易被忽略的是切分问题。第 13 篇实测出的那个只有句号的碎片就是例子:它可能因“太短所以什么都像”被召回,挤占上下文。
重排序
检索返回的结果,顺序未必可靠。重排序(Rerank)是对候选结果重新打分的独立步骤:先多召回一些(比如 20 条),再用更精细(也更贵)的模型筛出最相关的几条。
这样做的好处是把“召回”和“排序”解耦:召回阶段追求不漏,排序阶段追求精准。
// 概念示意:先宽召回,再精排。candidates,_:=r.Retrieve(ctx,query,retriever.WithTopK(20))top:=rerank(candidates,query,3)// 用更强的相关性判断挑出前 3 条四、③ 上下文组装:资料对了,但被淹没了
即使检索正确,组装方式也会毁掉效果:
| 问题 | 后果 | 对策 |
|---|---|---|
| 塞入太多片段 | 关键信息被稀释,Token 浪费 | 控制数量,优先高相关片段 |
| 片段无分隔 | 两段内容被连读 | 明确分段,带来源标注 |
| 来源缺失 | 无法核验 | 始终带来源 |
| 冲突内容未标注 | 模型自行选一个 | 提示词要求列出冲突 |
| 无关内容混入 | 干扰判断 | 用阈值过滤 |
一个实用技巧:在片段前标注它来自哪个文档和章节,给模型更多判断依据:
[1] 来源:handbook/printer.md(第 2 章 网络排查) 打印机无法联网时……五、④ 生成与引用:资料对,回答还是错
到这一步,资料已经正确进入上下文,但模型仍可能出错:
| 现象 | 原因 | 对策 |
|---|---|---|
| 张冠李戴 | 把 A 文档的内容说成 B 文档的 | 强调查看来源标注;减小片段数量 |
| 过度推断 | 资料只说了一半,模型自行补全 | 提示词要求“只陈述资料明确写出的内容” |
| 引用不支持结论 | 标了来源,但来源其实没这么说 | 需要评测验证,不能只信“有引用” |
| 该拒答却硬答 | 缺乏“不知道”的出口 | 明确允许说明资料不足 |
“有引用”不等于“引用正确”。这一点必须靠评测来发现,不能靠抽查几个好例子。
六、评测:没有它就只能凭感觉
RAG 优化最大的风险是改一处、坏一处。没有固定测试集,你无法判断改动是进步还是退步。
准备测试集
不需要很大,几十条真实问题就很有用:
问题 期望命中文档 要点 打印机离线怎么办 handbook/printer.md 先查网线再查网段 怎么申请 VPN handbook/vpn.md 需审批 工单多久没处理可以催办 handbook/ticket.md 24 小时 报销流程是什么 (无相关资料) 应明确说明无法回答关注指标
| 指标 | 测什么 | 怎么测 |
|---|---|---|
| 召回命中率 | 该命中的是否命中 | 对比期望文档与实际召回 |
| 拒答正确率 | 无资料时是否正确说明 | 用无答案的问题测试 |
| 引用正确率 | 标注的来源是否支持结论 | 人工或模型辅助核对 |
| 答案要点覆盖 | 关键点是否说出 | 对照要点清单 |
| 延迟与成本 | 每次问答的开销 | 记录耗时与 Token 用量 |
最后一行的“无资料”用例尤其重要——它测试的是系统会不会编,而这个问题比“答得好不好”更关键。
改动前后都要跑
每次调整切分、TopK、阈值、提示词,都用同一套测试集对比。凭印象判断优化效果,几乎一定会走弯路。
七、优化的优先级建议
按投入产出比排序,建议的排查与优化顺序是:
- 打印实际发送的消息。零成本,能立刻区分是数据问题还是生成问题。
- 检查切分质量。人工读几十个片段,往往能发现大量问题。
- 准备最小测试集。没有它,后面所有改动都无法评估。
- 调 TopK 与阈值。低成本,见效快。
- 加入重排序。需要额外模型调用,但通常明显提升排序质量。
- 多查询与路由。针对特定类型的查询失效。
- 提示词与引用格式优化。最后再做。
不要一上来就换更大的模型。多数 RAG 问题的根因在数据侧。
八、常见问题
召回为空但资料里明明有
检查 Embedding 模型是否一致、切分是否把关键信息切碎、查询与资料是否措辞差异过大。
召回很多但都不相关
提高阈值、减小 TopK,检查片段是否过长混入无关内容。
答案引用了错误来源
先减小上下文中的片段数量,再考虑是否需要更强的排序。评测时把“引用正确率”单独度量。
改了提示词,效果时好时坏
很可能没有固定评测方式,只在看个例。建立测试集后对比。
优化之后某些问题反而变差了
这在 RAG 里很常见(比如提高阈值提高了精度但降低了召回)。用测试集量化取舍,而不是凭感觉。
九、小结
- 回答不对可能来自四个环节:查询理解、召回质量、上下文组装、生成与引用,先定位再优化。
- 打印最终消息是最有效的第一步排查手段。
- 切分质量是常见根因,第 13 篇的碎片例子就是典型。
- 多查询、检索路由改善查询理解;重排序解耦召回与排序。
- 必须建立测试集,重点覆盖“无资料时是否正确拒答”。
- 优先修数据侧,而不是换更大的模型。
第三篇《工具与知识》到此完成。下一篇进入第四篇,开始用 ADK 构建真正会自主决策的 Agent。
参考资料与版本说明
资料核对日期为2026 年 10 月 9 日:
- Retriever 使用说明
- Eino 官方示例仓库(检索相关示例)
- Eino 主仓库
本文示例按Eino v0.9.21编写,检索与相似度计算为自定义实现,已实机运行,正文输出为真实结果。多查询与重排序部分为概念示意与优化方向,未在本篇实机运行。