RAG 实战第五课:多轮记忆 + query 改写,追问也能接住
生命不息,折腾不止。前四课把 RAG 做成了「能查、能测、能信」的一问一答机,但你一问「那它的增长率呢」,它当场装死——今天给链路装上「多轮记忆 + query 改写」,让知识库从问答机升级成记得住上下文的对话助手。
上一课收尾时留了个话头:检索、精排、生成三层其实已经闭环了,但有一个更贴生产的高频场景一直没碰——多轮对话。用户第一句问「承德奇聚 2024 年的营收是多少」,第二句顺嘴追问「那它的增长率呢」。第一句还好,第二句里那个「它」指的啥?没有任何上下文的话,纯靠字面根本没法检索。这种「残缺追问」,恰好是现实里最常见、也最让单轮 RAG 翻车的场景。
今天这一课,就在前四课手搓的那条管线上,加两块新零件:一个多轮记忆(把每轮对话攒起来),一个 query 改写(把「它」补全成完整 query)。还是老规矩,不引框架,纯手写 Python,代码复制下来就能跑。
一、先看清死穴:追问进来的那一刻,就翻车了
回想前四课的检索链路,本质是「用户问题 → 向量化 → 相似度检索 → 喂给大模型」。这套链路默认一个前提:用户每次问进来的,都是一句完整、自洽的话。可现实里的对话根本不是这么聊的。
来看两组真实的对话:
1 | 用户:承德奇聚 2024 年的营收是多少? |
1 | 用户:帮我找找 Sub2API 的定价规则。 |
第二个追问一旦被单独丢进检索器,「它的增长率」「第二个」这些字眼在向量空间里跟任何文档都对不上,retriever 要么召回一堆无关 chunk,要么啥也捞不着,大模型就只能睁眼说瞎话。病根不在检索器、不在模型,在 query 本身被「掐头去尾」了。
要治,思路也朴素:给它「接上头」。具体两条路,下一节掰开讲。
二、两条路:简单拼接 vs LLM 改写,先想清楚要哪个
业界处理多轮检索,主流就两个方案,代价和效果差得挺远(这个对比在 NVIDIA 的 RAG Blueprint 文档里也写得很清楚):
| 方案 | 怎么干 | 优点 | 缺点 |
|---|---|---|---|
| 简单拼接 | 把历史里几个用户 query 直接用「.」连起来,和当前问题一起拿去检索 | 零额外成本、零延迟 | 指代没消解,复杂追问照样检索不准 |
| LLM 改写 | 让一个便宜模型把「它/这个/那个」补全成独立 query,再拿去检索 | 准确率高,复杂指代也能解 | 多一次 LLM 调用,有几十到几百毫秒延迟 |
简单拼接长这样:历史 ["承德奇聚 2024 年的营收是多少?"] + 当前 "那它的增长率呢?",拼成一句 "承德奇聚 2024 年的营收是多少?那它的增长率呢?" 再检索。好处是真省事,但「它」还是那个「它」,语义模型依然不知道「它」是谁,只能瞎猜。
所以正经场景,还是得上 LLM 改写。说白了就是在「用户输入」和「检索器」之间,插一层便宜的模型调用,把追问「去上下文化(decontextualize)」成一句能独立检索的话:
1 | 原文追问:那它的增长率呢? |
改完之后,这句就能正常检索了。下一节开始手搓,先把「记忆」这块补上。
三、手搓「记忆」:把每轮对话攒进一个 session
多轮记忆,说白了就是一个按会话(session)存的列表,每一轮往里追加一条 {q, a}(问题、回答)。代码不复杂:
1 | # 一个 session 的记忆:按轮存 [{"q": ..., "a": ...}, ...] |
这里有两个要点得拎出来:
- 只存「问题 + 回答」,别存检索中间态。改写只要知道「上一轮聊了啥」,用不着把当时检索到的 chunk、rerank 分数全塞进去。
- 一定要截断。不截断的话,聊个几十轮,历史能把上下文窗口吃光,还白白烧 token。留最近 10 轮(
[-10:])足够覆盖绝大多数追问。
再写一个把历史格式化成纯文本的辅助函数,改写和生成都要用它:
1 | def render_history(history, n=3): |
写成「用户:/助手:」这种带角色的文本,LLM 一眼就能分清谁说了啥。
四、手搓「改写」:一个 prompt 把「它」补全
记忆有了,接下来就是最核心的一块——query 改写。思路是:把「最近几轮历史 + 当前追问」一起丢给一个便宜模型,让它回吐出独立的、适合检索的查询句。prompt 这样写:
1 | REWRITE_PROMPT = """你负责把用户的追问改写成一句独立、完整、适合检索的查询语句。 |
改写函数本身,就是一个普通的补全调用。这里我用 DeepSeek 举例(走 OpenAI 兼容接口,换个便宜模型也一样):
1 | from openai import OpenAI |
几个经验值直接给你:temperature 设 0,改写要的是确定性,不是创意;模型用最便宜的就行(deepseek-chat 这一档,国产模型走中转,一次改写成本按厘算);prompt 里强调「只输出改写结果」,免得它啰嗦。
五、少走弯路:先用规则筛一道,再决定要不要调 LLM
上一节的改写看着挺好,但有个实际问题:不是每句话都要改写。用户要是问了一句全新的「帮我找找 XX 的定价」,它压根没有指代,你再调一次 LLM 改写,纯属浪费一次调用、白加几百毫秒延迟(那句「Route only vague queries through the rewriter」说的就是这个道理)。
所以加一道便宜的「筛子」:先看当前问题是新话题还是追问,新话题直接原样检索,追问才走改写。判断不用再调模型,一条规则就够用:
1 | # 中文里常见的追问/指代信号,命中就走改写 |
这套规则不是银弹(复杂的指代还得靠 LLM),但能帮你挡掉一大半用不着的调用,省下来的就是真金白银的延迟和 token。
六、生成也别忘了历史,让回答接得上话
改写好 query、检索到 chunk,最后一步生成时,别忘了把历史也喂给大模型——不然它检索对了、答得也对,就是不知道「你这句是在接着上一句问」,口吻生硬得像每次都在重新开场。
把整个流程拆成四个动作:改写 → 检索 → 带着历史生成 → 记进记忆:
1 | def chat_with_memory(session, question): |
串起来跑个效果,还是拿开头的例子:
1 | 用户:承德奇聚 2024 年的营收是多少? |
前后一对比就清楚了:不装改写,第二句大概率答非所问;装上了,它就是一台「记得住上下文」的对话助手。
七、小结 + 这几个坑别踩
到这,五课下来你的 RAG 从「手搓一条链路」,一路补齐了混合检索、精排评测、引用出处,今天又加上了多轮记忆和 query 改写,已经是个能进生产陪聊的小东西了。收尾前把今天的坑点一遍:
- 改写结果是给检索用的,不是给用户看的。改完直接进 retriever,别把它当回复的一部分拼进答案。
- history 不截断必翻车。几十轮后 token 爆炸、成本飙升,还会把模型带偏,
[-10:]这个截断别省。 - temperature 设 0。改写求稳,别让它「有创意」地把「它」理解错。
- 能用规则挡的就别调 LLM。新话题没指代,硬走改写纯属浪费,先过一遍
FOLLOWUP_HINTS。 - 别指望规则扛所有指代。复杂指代(「上上轮提到的那个供应商,再往上那家的报价」)规则肯定兜不住,这时候老老实实走 LLM 改写。
- 改写和生成可以用两个不同档的模型。改写用小排量便宜模型低成本跑,生成再用主力模型,别一视同仁全上贵的。
生命不息,折腾不止。多轮对话搞定后,你的知识库已经是「会聊天的助手」了,但还差最后一道坎——它现在还只活在一个 Python 脚本里,离线了、重启了就没了。下一篇 RAG 实战第六课:把这条 RAG 链路打包成 API 服务上线,用 FastAPI 把它变成对外可调的 HTTP 接口(带 session 管理、流式输出),让你的知识库真正脱离「本地 run 一下」的 demo 阶段,能被前端、飞书机器人甚至另一个 Agent 直接调起来。回见。