生命不息,折腾不止。前四课把 RAG 做成了「能查、能测、能信」的一问一答机,但你一问「那它的增长率呢」,它当场装死——今天给链路装上「多轮记忆 + query 改写」,让知识库从问答机升级成记得住上下文的对话助手。

上一课收尾时留了个话头:检索、精排、生成三层其实已经闭环了,但有一个更贴生产的高频场景一直没碰——多轮对话。用户第一句问「承德奇聚 2024 年的营收是多少」,第二句顺嘴追问「那它的增长率呢」。第一句还好,第二句里那个「它」指的啥?没有任何上下文的话,纯靠字面根本没法检索。这种「残缺追问」,恰好是现实里最常见、也最让单轮 RAG 翻车的场景。

今天这一课,就在前四课手搓的那条管线上,加两块新零件:一个多轮记忆(把每轮对话攒起来),一个 query 改写(把「它」补全成完整 query)。还是老规矩,不引框架,纯手写 Python,代码复制下来就能跑。

一、先看清死穴:追问进来的那一刻,就翻车了

回想前四课的检索链路,本质是「用户问题 → 向量化 → 相似度检索 → 喂给大模型」。这套链路默认一个前提:用户每次问进来的,都是一句完整、自洽的话。可现实里的对话根本不是这么聊的。

来看两组真实的对话:

1
2
3
用户:承德奇聚 2024 年的营收是多少?
助手:XXX 万元。
用户:那它的增长率呢? ← 这句单独拎出来,检索器一脸懵
1
2
3
用户:帮我找找 Sub2API 的定价规则。
助手:……(答了一堆)
用户:那第二个呢? ← 「第二个」是啥?只有上文知道

第二个追问一旦被单独丢进检索器,「它的增长率」「第二个」这些字眼在向量空间里跟任何文档都对不上,retriever 要么召回一堆无关 chunk,要么啥也捞不着,大模型就只能睁眼说瞎话。病根不在检索器、不在模型,在 query 本身被「掐头去尾」了。

要治,思路也朴素:给它「接上头」。具体两条路,下一节掰开讲。

二、两条路:简单拼接 vs LLM 改写,先想清楚要哪个

业界处理多轮检索,主流就两个方案,代价和效果差得挺远(这个对比在 NVIDIA 的 RAG Blueprint 文档里也写得很清楚):

方案 怎么干 优点 缺点
简单拼接 把历史里几个用户 query 直接用「.」连起来,和当前问题一起拿去检索 零额外成本、零延迟 指代没消解,复杂追问照样检索不准
LLM 改写 让一个便宜模型把「它/这个/那个」补全成独立 query,再拿去检索 准确率高,复杂指代也能解 多一次 LLM 调用,有几十到几百毫秒延迟

简单拼接长这样:历史 ["承德奇聚 2024 年的营收是多少?"] + 当前 "那它的增长率呢?",拼成一句 "承德奇聚 2024 年的营收是多少?那它的增长率呢?" 再检索。好处是真省事,但「它」还是那个「它」,语义模型依然不知道「它」是谁,只能瞎猜。

所以正经场景,还是得上 LLM 改写。说白了就是在「用户输入」和「检索器」之间,插一层便宜的模型调用,把追问「去上下文化(decontextualize)」成一句能独立检索的话:

1
2
原文追问:那它的增长率呢?
改写成:承德奇聚 2024 年的营收增长率是多少?

改完之后,这句就能正常检索了。下一节开始手搓,先把「记忆」这块补上。

三、手搓「记忆」:把每轮对话攒进一个 session

多轮记忆,说白了就是一个按会话(session)存的列表,每一轮往里追加一条 {q, a}(问题、回答)。代码不复杂:

1
2
3
4
5
6
7
# 一个 session 的记忆:按轮存 [{"q": ..., "a": ...}, ...]
session = {"history": []}

def add_turn(session, question, answer):
session["history"].append({"q": question, "a": answer})
# 关键:只保留最近 10 轮,防止对话无限变长、token 爆炸
session["history"] = session["history"][-10:]

这里有两个要点得拎出来:

  1. 只存「问题 + 回答」,别存检索中间态。改写只要知道「上一轮聊了啥」,用不着把当时检索到的 chunk、rerank 分数全塞进去。
  2. 一定要截断。不截断的话,聊个几十轮,历史能把上下文窗口吃光,还白白烧 token。留最近 10 轮([-10:])足够覆盖绝大多数追问。

再写一个把历史格式化成纯文本的辅助函数,改写和生成都要用它:

1
2
3
4
5
6
def render_history(history, n=3):
lines = []
for turn in history[-n:]:
lines.append(f"用户:{turn['q']}")
lines.append(f"助手:{turn['a']}")
return "\n".join(lines)

写成「用户:/助手:」这种带角色的文本,LLM 一眼就能分清谁说了啥。

四、手搓「改写」:一个 prompt 把「它」补全

记忆有了,接下来就是最核心的一块——query 改写。思路是:把「最近几轮历史 + 当前追问」一起丢给一个便宜模型,让它回吐出独立的、适合检索的查询句。prompt 这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
REWRITE_PROMPT = """你负责把用户的追问改写成一句独立、完整、适合检索的查询语句。

规则:
1. 结合对话历史,把「它」「他」「这个」「那个」等指代补全成具体内容。
2. 如果问题里包含多个不同的诉求,拆成多个查询,一行一个。
3. 去掉寒暄和口语填充,只保留信息内容。
4. 只输出改写后的查询,不要解释、不要多余文字。

对话历史:
{history}

用户当前追问:{question}

改写结果:"""

改写函数本身,就是一个普通的补全调用。这里我用 DeepSeek 举例(走 OpenAI 兼容接口,换个便宜模型也一样):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from openai import OpenAI

client = OpenAI(
base_url="https://ai.aklibk.com/v1", # 中转站,OpenAI 兼容;直连官方也可
api_key="你的 key",
)

def rewrite_query(history, question):
history_text = render_history(history) or "(无历史)"
resp = client.chat.completions.create(
model="deepseek-chat", # 改写这种小活,用最便宜的档就够
messages=[{"role": "user", "content": REWRITE_PROMPT.format(
history=history_text, question=question)}],
temperature=0, # 改写要稳,temperature 设 0,别让它发挥
)
return resp.choices[0].message.content.strip()

几个经验值直接给你:temperature 设 0,改写要的是确定性,不是创意;模型用最便宜的就行(deepseek-chat 这一档,国产模型走中转,一次改写成本按厘算);prompt 里强调「只输出改写结果」,免得它啰嗦。

五、少走弯路:先用规则筛一道,再决定要不要调 LLM

上一节的改写看着挺好,但有个实际问题:不是每句话都要改写。用户要是问了一句全新的「帮我找找 XX 的定价」,它压根没有指代,你再调一次 LLM 改写,纯属浪费一次调用、白加几百毫秒延迟(那句「Route only vague queries through the rewriter」说的就是这个道理)。

所以加一道便宜的「筛子」:先看当前问题是新话题还是追问,新话题直接原样检索,追问才走改写。判断不用再调模型,一条规则就够用:

1
2
3
4
5
6
7
8
9
10
11
12
# 中文里常见的追问/指代信号,命中就走改写
FOLLOWUP_HINTS = [
"它", "他", "她", "这个", "那个", "这", "那", "呢",
"也", "还", "再", "第二个", "另一个", "上述", "上面", "刚才",
]

def maybe_rewrite(history, question):
if not history: # 第一句,没历史可参考,直接检索
return question
if any(h in question for h in FOLLOWUP_HINTS) or len(question) <= 8:
return rewrite_query(history, question) # 有指代或太短 → 改写
return question # 新话题 → 原样检索

这套规则不是银弹(复杂的指代还得靠 LLM),但能帮你挡掉一大半用不着的调用,省下来的就是真金白银的延迟和 token。

六、生成也别忘了历史,让回答接得上话

改写好 query、检索到 chunk,最后一步生成时,别忘了把历史也喂给大模型——不然它检索对了、答得也对,就是不知道「你这句是在接着上一句问」,口吻生硬得像每次都在重新开场。

把整个流程拆成四个动作:改写 → 检索 → 带着历史生成 → 记进记忆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def chat_with_memory(session, question):
# 1) 需要的话先改写
search_query = maybe_rewrite(session["history"], question)

# 2) 检索:复用前几课的 hybrid_search + rerank(此处简写)
chunks = retrieve(search_query)

# 3) 生成:历史 + 检索上下文 + 当前问题一起喂
answer = generate(session["history"], chunks, question)

# 4) 记进记忆,下一轮才能接着用
add_turn(session, question, answer)
return answer


def generate(history, chunks, question):
context = "\n\n".join(f"[{i+1}] {c['text']}" for i, c in enumerate(chunks))
messages = [
{"role": "system", "content": "你是知识库问答助手,严格依据【参考资料】回答,资料里没有的就说不知道,别编造。"},
]
# 把最近几轮历史拼进去,让回答接得上话
for turn in history[-3:]:
messages.append({"role": "user", "content": turn["q"]})
messages.append({"role": "assistant", "content": turn["a"]})
messages.append({"role": "user", "content":
f"【参考资料】\n{context}\n\n【问题】{question}"})

resp = client.chat.completions.create(
model="deepseek-chat",
messages=messages,
temperature=0.3,
)
return resp.choices[0].message.content.strip()

串起来跑个效果,还是拿开头的例子:

1
2
3
4
5
6
7
用户:承德奇聚 2024 年的营收是多少?
→ maybe_rewrite:没历史,直接检索;查得「2024 年营收 XXX 万元」。

用户:那它的增长率呢?
→ maybe_rewrite:命中「它」+「呢」→ 改写;
→ 改写成「承德奇聚 2024 年的营收增长率是多少?」
→ 检索命中「同比增长 XX%」,生成时带上上一轮,回答自然接住。

前后一对比就清楚了:不装改写,第二句大概率答非所问;装上了,它就是一台「记得住上下文」的对话助手。

七、小结 + 这几个坑别踩

到这,五课下来你的 RAG 从「手搓一条链路」,一路补齐了混合检索、精排评测、引用出处,今天又加上了多轮记忆和 query 改写,已经是个能进生产陪聊的小东西了。收尾前把今天的坑点一遍:

  • 改写结果是给检索用的,不是给用户看的。改完直接进 retriever,别把它当回复的一部分拼进答案。
  • history 不截断必翻车。几十轮后 token 爆炸、成本飙升,还会把模型带偏,[-10:] 这个截断别省。
  • temperature 设 0。改写求稳,别让它「有创意」地把「它」理解错。
  • 能用规则挡的就别调 LLM。新话题没指代,硬走改写纯属浪费,先过一遍 FOLLOWUP_HINTS
  • 别指望规则扛所有指代。复杂指代(「上上轮提到的那个供应商,再往上那家的报价」)规则肯定兜不住,这时候老老实实走 LLM 改写。
  • 改写和生成可以用两个不同档的模型。改写用小排量便宜模型低成本跑,生成再用主力模型,别一视同仁全上贵的。

生命不息,折腾不止。多轮对话搞定后,你的知识库已经是「会聊天的助手」了,但还差最后一道坎——它现在还只活在一个 Python 脚本里,离线了、重启了就没了。下一篇 RAG 实战第六课把这条 RAG 链路打包成 API 服务上线,用 FastAPI 把它变成对外可调的 HTTP 接口(带 session 管理、流式输出),让你的知识库真正脱离「本地 run 一下」的 demo 阶段,能被前端、飞书机器人甚至另一个 Agent 直接调起来。回见。