09 · 知识库问答:RAG 完整链路(老板要的东西终于来了)
开头的现象
小林把零件都备齐了:能回答的模型、能切块的刀、能按意思检索的向量库。他以为自己马上能交货,结果老板发来一条消息:“小林,手册我更新了,以后每年更新一次。你要让机器人永远答最新版,不能我改完了它还念旧版本。” 小林心想:“这怎么可能?模型是训练出来的,我又不能重新训练它。” 他抱着这个想法,去查了一个他早有耳闻但一直没搞懂的词——RAG。第一幕:小林的”死记硬背”幻想破灭了
小林一开始的设想是”把手册背给模型听”——他试了把手册全文塞进 prompt,模型答得驴唇不对马嘴(第 7 篇已经栽过)。他又想”微调模型”——查了查价格和工期,直接放弃。 他沮丧地坐在工位上,突然看到了一个词:RAG(检索增强生成,Retrieval-Augmented Generation)。文档里写了一句让他豁然开朗的话:RAG 不改变模型,改变模型”看到什么”。每次用户提问,先从你的文档里检索最相关的段落,把段落塞进 prompt 让模型基于它回答。小林一拍大腿:“我不是要让模型记住手册,我是要让模型在回答的时候’翻手册’!” 他脑海里浮现出一个画面:模型是个新来的实习生,不懂公司规矩,但每次被问到”年假怎么算”,你就把员工手册翻到对应那页递给他看——他照着念,永远是对的,手册更新了他就念新版。 这就是 RAG 的灵魂:检索(翻到哪页)+ 生成(照着念)。
想验证”翻手册”vs”背手册”的区别吗?跑 09_rag.py 问它”工资什么时候发”——它的回答里带着手册原文的影子,而且手册里没有的信息它会说”手册里没有相关信息”(不编造)。这就是”照着念”的表现。
第二幕:小林画出了 RAG 的”流水线”——四条链串起来
小林把 RAG 拆成四步,画了一张图贴在显示器上:|)把这四步串成了一条”流水线”:
| 就像流水线上的传送带,左边输出接到右边输入。{"context": ..., "question": ...} 是并行两条管道——一个去检索资料,一个原样递问题,最后汇合填进模板。
他问了一个手册里没有的问题:“公司食堂中午有什么菜?“——模型回答:“手册里没有相关信息。” 小林满意地点头:“它没编!它知道资料里没有就不瞎说!”
想验证这条流水线吗?跑 09_rag.py,四个问题轮流问:有的命中手册(年假、工资、迟到),有的是手册没有的(食堂菜单)——模型会诚实地说”手册里没有相关信息”。这就是 RAG 的”不说谎”。
第三幕:小林发现了检索的”最后一公里”问题——粗排 vs 精排
小林用了一天,发现了一个让他皱眉的现象:检索器返回的 top-3,有时候不是最相关的 3 块。 他做了个实验:问”年假怎么算?“,让检索器返回 top-8,他一个个看过去:BAAI/bge-reranker-base(和 embedding 的 bge 是同一个家族),配合 sentence_transformers 的 CrossEncoder:
想验证吗?下载BAAI/bge-reranker-base模型(约 1.1GB,放models/bge-reranker-base),跑09_rag_rerank.py——你会看到同一批 top-8 粗排结果,重排后顺序变了,且每块带一个 relevance 分数。想验证”向量检索会犯错”吗? 在粗排结果里故意找一块”提到年假但实际讲别的”的资料,看它精排后被压到多后面。
第四幕:小林解剖了链的”中间产物”——模型到底看到了什么
小林的好奇心又来了:这条链最后StrOutputParser() 剥出纯文本,那剥壳之前模型返回的到底是什么?
他拆了一截”只到模型为止”的链:
mid 的完整结构,发现:模型在 RAG 里返回的还是一个 AIMessage——content 是最终回答,response_metadata.stop_reason 是 end_turn(直接回答完毕,没调工具),usage_metadata 记着 token。
他恍然大悟:StrOutputParser 只做一件事——把 AIMessage.content 剥出来变成字符串。 整条链的”最后一环”,就是个拆包装的。
想验证”剥壳前长什么样”吗?把09_rag.py里链尾的StrOutputParser()去掉,直接invoke——你拿到的不是一个字符串,而是一个完整的 AIMessage(content 是回答,response_metadata 里躺着 stop_reason 和 usage)。加上StrOutputParser(),就只剩文本了。
🔧 技术细节:这一章涉及的关键类和签名
①BaseRetriever——检索器接口(langchain_core.retrievers)
| 背后的类型
RunnablePassthrough——透传(RAG 里”原样递问题”)
想验证吗?⑤ 重排序(第三幕)——rag_chain.invoke("年假怎么算?")返回字符串;去掉StrOutputParser()直接 invoke,返回 AIMessage——看两者的 type 区别。
CrossEncoder 与两段式检索
第五幕:边界——RAG 不是银弹
小林用了一天,摸清了 RAG 的边界: 它能干的:让模型基于”你的文档”回答问题、自动跟进文档更新(换库内容就行,不用重新训练)、控制模型”不乱编”(资料里没有就说不知道)、给回答标出处(块里的 metadata)。 它不能干的:- 检索错了,答案就错——RAG 的上限取决于检索(第 8 篇那些边界:否定、数字、专有名词)。检索返回了不相关的块,模型再聪明也答不对。重排序(第三幕)能缓解但治不了本:它只能把”已捞到的块”重新排序,捞漏了的(粗排没召回)它也无能为力。
- 资料本身有错,模型照样念——RAG 不校验资料对错,只保证”照着念”。
- 不适合”开放式创意问题”——问”帮我想个宣传语”,RAG 的”翻手册”反而碍事。
- 多轮对话没接上——这个版本的 RAG 是无状态的,每次提问独立检索。要让它”记得上一轮”,得接上第 4 篇的历史机制(或直接上第 08、09 篇的 Agent / LangGraph)。
想验证边界吗?问 09_rag.py 一个手册里根本没有答案的问题,比如”食堂菜单”——它老实说”手册里没有”。但如果你在手册里写一句错误信息(比如故意把年假天数写错),RAG 会照错念——它不校验内容对错。
结论(小林用一天换来的)
RAG = 检索 + 生成:每次提问先”翻手册”(从向量库找最相关的 3 块),再把资料 + 问题填进模板让模型”照着念”。 LangChain 用| 把 检索器 → 格式化 → 模板 → 模型 → 解析器 串成一条链,StrOutputParser 负责剥壳取文本。检索分两段:粗排(向量相似度,快、捞 top-N)→ 重排序(CrossEncoder 精排,准、挑最相关)——粗排负责”别漏”,精排负责”别错”。边界:RAG 的上限=检索的上限,检索错就答错(重排序只能缓解不能根治);它不校验资料对错,也不自带记忆。 但这一刻,小林终于把老板要的”基于文档回答问题”跑通了——虽然它还只是个”无状态问答机”。
复现信号:什么时候你会想起这一章
- 要让模型回答”你的文档里的问题”——别微调,用 RAG:切块 → 向量化 → 粗排检索 → 重排序 → 生成。
- 模型总在编造”手册里没有的信息”——RAG 的 system 提示”没有就说不知道”能压住它。
- 文档经常更新——RAG 只要换库内容,模型不用动。
- 回答要”有理有据”——检索块的 metadata 存好来源,回答就能标出处。
- 检索返回 top-3 但答案还是不对——可能是粗排把”提到关键词但不相关”的块排前面了:加大粗排 k(比如 top-20),再加重排序精排到 top-3。