单轮 RAG 的隐性假设
前面十七篇都在处理同一种问题:给一个独立的问题,返回一个答案。
但真实的对话不是这样的。
用户在问完"RAGAS 是什么"之后,自然会接着问:
yaml
Turn 1: RAGAS 是什么?
Turn 2: 它有哪四个核心指标?
Turn 3: 其中哪个最难提升?为什么?
Turn 1 没有问题。Turn 2 的"它"指的是 RAGAS,Turn 3 的"其中"指的是上一轮提到的四个指标。这在人类对话里显而易见------但对检索系统来说,"它有哪四个核心指标"是一个没有主语的查询,向量搜索会拿着这句话去找最相似的文档,找到的结果和 RAGAS 完全无关。
这是单轮 RAG 的隐性假设:每一个问题都是独立完整的。一旦遇到追问,这个假设就失效了。
History-Aware Retriever:在检索前先改写
解决思路很直接:在检索之前,先用一个 LLM 调用把当前问题 + 对话历史合并,改写成一个独立完整的问题,再用改写后的问题做检索。
yaml
Turn 1: RAGAS 是什么? → 直接检索(无历史)
Turn 2: 它有哪四个核心指标?
↓ 结合 Turn 1 历史
"RAGAS 框架的四个核心指标是什么?"
↓ 用改写后的问题检索
Turn 3: 其中哪个最难提升?
↓ 结合 Turn 1 + Turn 2 历史
"RAGAS 的四个核心指标中,哪一个最难提升?为什么?"
↓ 用改写后的问题检索
LangChain 把这个模式封装成了 create_history_aware_retriever,但为了防止 LLM 输出冗长内容触发嵌入模型 token 限制,本文手动构建了这条链并加了截断保护:
python
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableBranch, RunnableLambda
def _extract_standalone_question(text: str) -> str:
"""只取第一行,防止 LLM 输出冗余内容超出嵌入模型 512 token 限制。"""
lines = [l.strip() for l in text.strip().split("\n") if l.strip()]
question = lines[0] if lines else text
return question[:400] # 硬截断保护
_contextualize_chain = (
CONTEXTUALIZE_PROMPT
| llm
| StrOutputParser()
| RunnableLambda(_extract_standalone_question)
)
# 无历史时直接检索,有历史时先改写再检索
history_aware_retriever = RunnableBranch(
(
lambda x: not x.get("chat_history"),
(lambda x: x["input"]) | retriever,
),
_contextualize_chain | retriever,
)
核心架构
Contextualize Prompt:改写的关键
python
CONTEXTUALIZE_PROMPT = ChatPromptTemplate.from_messages([
("system",
"根据对话历史和最新问题,将最新问题改写为一个独立完整的问题。\n"
"要求:\n"
"- 替换所有代词(它、这个、这些、其中等)为具体名词\n"
"- 补全省略的主语或宾语\n"
"- 只输出改写后的问题,不加任何解释\n"
"如果问题本身已经完整独立,原样返回。"),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
三个设计要点:
- 明确"只输出问题":否则 LLM 会解释改写原因,生成的内容远超嵌入模型限制
- 对话历史放在 system 和 human 之间 :
MessagesPlaceholder("chat_history")会展开为完整的消息列表 - 原样返回条件:Turn 1 或语义完整的问题不需要改写,给 LLM 一个退路
完整 ConvRAG 链
python
# Step 1: 检索(含问题改写)
history_aware_retriever = ... # 见上文
# Step 2: 基于历史和检索结果生成答案
ANSWER_PROMPT = ChatPromptTemplate.from_messages([
("system",
"你是一个RAG技术专家。根据以下参考资料回答问题。\n"
"参考资料:\n{context}"),
MessagesPlaceholder("chat_history"), # 历史也参与生成
("human", "{input}"),
])
qa_chain = create_stuff_documents_chain(llm, ANSWER_PROMPT)
rag_chain = create_retrieval_chain(history_aware_retriever, qa_chain)
# Step 3: 用 Session 管理多轮历史
store: dict[str, ChatMessageHistory] = {}
def get_session_history(session_id: str) -> ChatMessageHistory:
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
conv_rag = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key="input",
history_messages_key="chat_history",
output_messages_key="answer",
)
每个 session_id 对应一个独立的对话历史。RunnableWithMessageHistory 会在每次 invoke 前自动把历史注入 chat_history,调用结束后自动把本轮的 Q&A 追加进去。
问题改写效果
三组追问对话,Turn 2 的问题改写结果:
ini
[RAGAS追问]
原始问题: 它有哪四个核心指标?
改写后: RAGAS有哪四个核心指标?
[向量数据库追问]
原始问题: 其中哪个最适合生产环境?
改写后: 在常见的向量数据库(Chroma、Pinecone、Milvus、Qdrant)中,
哪个最适合生产环境?
[高级RAG追问]
原始问题: Graph RAG和Agentic RAG又分别解决什么?
改写后: Graph RAG和Agentic RAG各自解决了RAG系统中的哪些问题?
"它"被替换为"RAGAS","其中"被展开为具体的数据库名列表------代词消歧后,这两个问题才有意义去检索。
检索对比:Turn 2 的关键差异
用"它有哪四个核心指标"直接检索 vs 用改写后的"RAGAS 框架的四个核心指标是什么"检索:
erlang
Baseline 检索到(原始问题"它有哪四个核心指标?"):
doc1: RAG的核心流程:检索→增强→生成。RAG由Meta AI在2020年提出...
doc2: 文档分块策略影响RAG检索质量:固定大小分块适合通用场景...
ConvRAG 检索到(改写问题"RAGAS框架的四个核心指标是什么?"):
doc1: RAGAS是专为RAG系统设计的评估框架,由Es等人在2023年提出。
RAGAS的四个核心指标:1. context_recall ... 2. context_precision ...
doc2: Embedding模型将文本转换为向量,决定了语义检索的质量上限...
Baseline 拿着"它有哪四个"去检索,"它"没有指向,搜到的是 RAG 基础介绍和分块策略------完全无关。ConvRAG 改写后明确了主语,第一篇文档就是 RAGAS 的四个指标。
这个差距是质性的,不是量的。
RAGAS 指标:一个有趣的反转
diff
======================================================================
RAGAS 指标对比(Baseline vs Conversational RAG)
======================================================================
指标 Baseline ConvRAG 变化
──────────────────────────────────────────────────────────────
context_recall 0.667 0.400 ↓-0.267 ◀
context_precision 0.880 0.870 →-0.010
faithfulness 1.000 1.000 →+0.000
answer_relevancy 0.432 0.430 →-0.002
======================================================================
注:评估对象为每轮对话的最后一轮(Turn 3)
ConvRAG 的 context_recall 反而低了 0.267。这看起来很奇怪------为什么"更好的检索"反而拿到更少的相关内容?
原因在于 RAGAS 评估的是什么。
本次实验评估的是 Turn 3,也就是每组对话的最后一轮:
- "其中哪个最难提升?为什么?"
- "如果我的团队刚开始做RAG,应该选哪个?"
- "这四种技术的演进关系是什么?"
这三个 Turn 3 问题语义本身就是完整的------即使没有对话历史,直接用它们去检索也能找到相关文档。Baseline 在这三个问题上直接检索,反而精准命中了 ground truth 需要的内容。
ConvRAG 做了什么?它把 Turn 3 的问题结合前两轮历史做了改写,比如"这四种技术的演进关系是什么"可能被改写为"Self-RAG、CRAG、Graph RAG 和 Agentic RAG 这四种技术的演进关系是什么"------语义上更完整,但检索结果的覆盖范围可能因为措辞变化而有所偏移,导致 context_recall 反而更低。
结论:RAGAS 指标没有捕捉到 Conversational RAG 的核心价值。
核心价值体现在 Turn 2------代词消歧让检索从"找到无关内容"变为"直接命中"。但 RAGAS 评估的是 Turn 3,而 Turn 3 的问题刚好不需要历史就能正确检索。实验设计偏向于 Baseline 的场景,从而掩盖了 ConvRAG 的真实价值。
什么时候用 Conversational RAG?
| 场景 | Baseline RAG | Conversational RAG |
|---|---|---|
| 每个问题独立完整 | ✅ 直接检索,成本低 | ⚠️ 改写增加延迟和 cost |
| 追问含代词("它"、"其中") | ❌ 检索失效 | ✅ 改写后正确检索 |
| 追问省略主语 | ❌ 检索失效 | ✅ 补全后正确检索 |
| 多轮深入探讨同一主题 | ⚠️ 每轮独立,无上下文积累 | ✅ 历史积累,答案更连贯 |
额外成本:每轮多 1 次 LLM 调用(问题改写)。如果问题类型单一且每次都是独立完整的问题(比如搜索引擎式查询),Baseline RAG 的成本优势明显。如果是对话场景,追问是常态,ConvRAG 的改写才真正值回这次调用。
记忆管理的权衡:本文使用完整历史(所有轮次都保留),优点是信息完整,缺点是随对话轮次增加 token 消耗增大。生产系统常见的替代方案:
- 滑动窗口:只保留最近 N 轮历史
- 摘要记忆:用 LLM 将历史压缩为摘要,再加上最近 1 轮详情
完整代码
代码已开源:
核心文件:
conversational_rag.py--- 完整实现,含两条对比流水线和 RAGAS 评估
运行方式:
bash
git clone https://github.com/chendongqi/llm-in-action
cd 18-conversational-rag
cp .env.example .env
pip install -r requirements.txt
python conversational_rag.py
小结
本文实现了 Conversational RAG,核心发现:
- 代词消歧是刚需:追问中的"它""其中""这些"在没有历史时会让检索完全失效,Turn 2 的检索对比清楚展示了这一点
- 问题改写效果显著:GLM-4-flash 能准确把"它有哪四个核心指标"改写为"RAGAS 框架的四个核心指标是什么",消歧质量令人满意
- RAGAS 指标出现了反转:ConvRAG 的 context_recall 低于 Baseline(0.400 vs 0.667)------原因是 Turn 3 的测试问题语义本身足够完整,不需要历史辅助也能正确检索;而 RAGAS 评估的恰好是这一轮
- 指标无法覆盖场景价值:ConvRAG 的价值在"代词追问失效"这个场景,RAGAS 没有测到这个场景,所以数字不反映真实优势
这是整个系列里 RAGAS 和实际价值偏差最明显的一次。指标是工具,不是答案------每次都要想清楚"这个指标在当前实验设计里,测到了什么,没测到什么"。