对话式 RAG 的预处理链路:意图识别与查询重写的工程取舍
Zhongjun Qiu 元婴开发者

在对话式 RAG 系统中,用户的输入往往不能直接拿去检索——“它怎么做”指代不明,“好的”根本不需要检索。本文记录了 CookHero 项目中意图识别和查询重写模块的设计过程,包括我们在 Prompt 工程上的反复迭代,以及一些”看起来简单但容易做错”的细节。

问题:用户说的不是检索要用的

先看几个真实的用户输入:

1
2
3
4
5
用户:红烧肉怎么做
用户:这个要放多少盐?
用户:有点太油腻了,有什么替代方案吗
用户:好的,我试试
用户:谢谢

如果把这些原封不动送去检索:

  • “红烧肉怎么做” → ✅ 可以检索
  • “这个要放多少盐” → ❌ “这个”是什么?检索会失败
  • “有点太油腻了…” → ❓ 能检索,但需要明确是哪道菜的替代方案
  • “好的” → ❌ 完全不需要检索,检索了也是噪声
  • “谢谢” → ❌ 同上

直接把用户输入扔给检索模块,一半以上会出问题

我们需要两个预处理步骤:

  1. 意图识别:判断是否需要检索(过滤掉”好的”“谢谢”)
  2. 查询重写:把指代词解析、上下文补全(“这个” → “红烧肉”)

意图识别:减少不必要的检索

设计目标

意图识别的核心目标是回答一个问题:这个查询需要检索知识库吗?

如果不需要,就跳过检索,直接让 LLM 回答。这样做有两个好处:

  1. 降低延迟:省掉检索步骤,响应更快
  2. 避免噪声:不该检索的检索了,可能返回不相关内容,影响 LLM 生成质量

分类体系

我们定义了五种意图:

1
2
3
4
5
6
class QueryIntent(Enum):
RECIPE_SEARCH = "recipe_search" # 菜谱查询
COOKING_TIPS = "cooking_tips" # 烹饪技巧
INGREDIENT_INFO = "ingredient_info" # 食材信息
RECOMMENDATION = "recommendation" # 推荐请求
GENERAL_CHAT = "general_chat" # 闲聊/确认

其中只有 GENERAL_CHAT 不需要 RAG,其他四种都需要。

Prompt 的迭代

意图识别用 LLM 实现。最初的 Prompt 很简单:

1
2
判断用户问题的意图,输出 JSON:
{"need_rag": true/false, "intent": "..."}

上线后发现问题:

  1. 边界 case 判断不准:“这个可以用微波炉吗” → 被判成不需要 RAG
  2. 过度检索:“好的我知道了” → 被判成需要 RAG

反复迭代后,我们总结出 Prompt 设计的几个要点:

要点 1:明确定义 need_rag=true 的条件

不能让 LLM 自己”理解”什么时候需要检索,要显式列出:

1
2
3
4
5
6
7
8
9
10
11
12
13
【need_rag = true 的典型情况】

1. 菜谱 / 做法查询
- 明确询问某道菜怎么做、步骤、火候
2. 基于条件的可执行建议
- "有 A、B、C 能做什么菜"
- "减脂期间晚餐推荐做什么"
3. 烹饪技巧与操作问题
- 处理方法、口味调整、失败补救
- 时间、温度、器具、流程相关问题
4. 承接式问题(需结合上下文)
- 使用指代或省略:"这个怎么做""第二种呢"
- 基于之前推荐继续追问细节

要点 2:显式排除不需要检索的情况

同样地,也要列出 need_rag=false 的边界:

1
2
3
4
5
6
7
8
9
【need_rag = false 的典型情况】

1. 纯对话或流程控制
- 闲聊、感谢、寒暄
- "好的""明白了""继续"
2. 确认 / 澄清 / 选择类问题
- "这个可以吗?"
- "就用第一个方案"
3. 与烹饪无关的内容

要点 3:强调结合对话历史判断

孤立地看”这个可以吗”无法判断,必须结合上下文。我们在 Prompt 中显式要求:

1
2
3
你的判断必须结合:
- 用户的【当前问题】
- 已压缩/整理后的【对话历史上下文】

并且在调用时把历史文本一并传入:

1
2
template = INTENT_DETECTION_PROMPT.format_prompt(history=history_text + f"\n用户: {query}")
response = await llm.ainvoke(template.messages)

输出格式约束

LLM 有时会输出不规范的 JSON。我们用了几个手段:

  1. Prompt 中强调:“你必须且只能输出以下 JSON,不得输出任何多余文本”
  2. 解析时做容错:用正则提取 JSON,处理多余的 markdown 标记
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def extract_first_valid_json(content: str) -> dict:
"""从 LLM 输出中提取第一个有效的 JSON 对象"""
# 处理 ```json ... ``` 包装
content = re.sub(r'^```json\s*', '', content)
content = re.sub(r'\s*```$', '', content)

# 尝试解析
try:
return json.loads(content)
except:
# fallback: 用正则找 {...}
match = re.search(r'\{[^{}]*\}', content)
if match:
return json.loads(match.group())
return {}

选择 Fast 模型

意图识别调用 LLM,会增加延迟。为了控制成本和延迟,我们用的是 fast 模型(更小更快的模型),而不是主生成用的 normal 模型:

1
2
3
class IntentDetector:
def __init__(self, llm_type: LLMType = LLMType.FAST):
self._llm = provider.create_base_llm(llm_type, temperature=0.0)

实测 fast 模型在意图识别这种简单分类任务上,准确率和 normal 模型差不多,但延迟低 3-4 倍。

查询重写:消除指代、补全上下文

为什么需要重写?

即使判断出需要 RAG,原始查询也可能不适合直接检索:

1
2
3
用户:红烧肉怎么做
助手:红烧肉的做法是... [详细步骤]
用户:第二步要多长时间?

“第二步要多长时间”如果直接检索,会匹配到各种菜的”第二步”,而不是红烧肉的。

查询重写的目标是:把用户的当前问题转换为一个独立、完整、可直接检索的查询

重写后应该是:“红烧肉烹制的第二步需要多长时间”。

Prompt 设计的核心规则

规则 1:指代消解

最重要的规则。把”它”“这个”“上一道”等指代词替换为具体对象:

1
2
3
【指代消解】
- 将"它/这个/那个/第一个"等指代词,替换为对话历史中已明确出现的具体菜品、食材
- 禁止猜测或引入未出现的信息

“禁止猜测”很重要——LLM 很喜欢”帮忙”补充信息,但这会引入幻觉。

规则 2:上下文补全

有时候用户省略了主语,需要从历史中补充:

1
2
3
用户:我想减脂
助手:推荐低卡沙拉、蒸鸡胸...
用户:第一个怎么做

重写应该补全主语:“低卡沙拉怎么做”。

但补全要克制,只补充对检索必要的信息

1
2
3
【上下文补全】
- 若当前问题无法独立理解,补充对检索必要且直接相关的历史信息
- 只补充与"做什么/怎么做/推荐什么"直接相关的内容

规则 3:幻觉防护

这是我们迭代多次才意识到的重点。LLM 重写时经常”加戏”:

  • 用户说”推荐个菜”,重写成”推荐一道简单快手的家常菜” ❌
  • 用户说”有什么素菜”,重写成”推荐一道健康低脂的素菜” ❌

这些额外的限定条件不是用户说的,是 LLM 脑补的,会导致检索结果偏离用户真实需求。

我们在 Prompt 中显式禁止:

1
2
3
【幻觉与扩展限制】
- 对话中未出现的信息一律不得添加
- 不得擅自加入"简单/快速/健康/低脂/辣"等描述

规则 4:处理模糊问题

用户说”我饿了”或”吃点啥”怎么办?

强行指代消解会出错(没有可消解的对象),强行补充上下文会引入幻觉。

我们的策略是保持模糊

1
2
3
【模糊问题处理】
- 若问题本身无法确定具体对象(如"我饿了""吃点啥")
- 重写为不设限、不假设条件的通用菜谱请求

重写结果可能是”推荐一些菜品”,虽然宽泛,但至少不会出错。

输出格式

和意图识别一样,输出 JSON:

1
{"query": "重写后的一句话查询"}

同样需要做解析容错。

跳过重写的情况

如果没有历史上下文,重写就没意义。我们直接跳过:

1
2
3
4
5
async def rewrite(self, current_query: str, history_text: str) -> str:
if not history_text.strip():
return current_query # 直接返回原查询

# 执行重写...

这避免了无意义的 LLM 调用。

串联:预处理链路的完整流程

把意图识别和查询重写串起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async def handle_query(query: str, conversation_id: str):
# 1. 获取对话历史
history = await repository.get_history(conversation_id)
history_text = context_manager.build_history_text(history)

# 2. 意图识别
intent_result = await intent_detector.detect(history_text + f"\n用户: {query}")

if not intent_result.need_rag:
# 不需要检索,直接生成
return await llm_generate(query, history)

# 3. 查询重写
rewritten_query = await query_rewriter.rewrite(query, history_text)

# 4. 检索
rag_result = await rag_service.retrieve(rewritten_query)

# 5. 生成
return await llm_generate(query, history, rag_context=rag_result.context)

注意:

  • 意图识别的输入是 history + 当前 query
  • 查询重写的输入是 当前 query + history(history 作为上下文参考)
  • 检索用的是 rewritten_query,不是原始 query

遇到的几个坑

1. 重写结果变成了多句话

最初我们没限制输出格式,LLM 有时输出:

1
红烧肉的做法。特别是第二步需要多长时间。

这不是一个可检索的查询。加了约束后解决:

1
2
输出必须是一整句通顺、自然的中文
禁止列表、标签、关键词堆砌

2. 意图识别和重写的顺序问题

最初我们先重写再识别意图,逻辑是”重写后的查询更清晰,意图更好判断”。

但实际上这样做有问题:

  • 重写也需要调用 LLM,浪费了一次调用
  • 如果不需要检索,重写完全是无用功

改成先意图识别后,不需要 RAG 的情况直接跳过重写,省掉一次 LLM 调用。

3. 历史上下文太长

历史文本太长会:

  1. 超过 LLM 上下文限制
  2. 增加 token 成本
  3. 降低 LLM 处理速度

我们做了截断,只取最近的历史:

1
2
3
def build_history_text(self, history, limit=10):
recent = history[-limit:]
return "\n".join([f"{h['role']}: {h['content']}" for h in recent])

另外,如果使用了上下文压缩(见我们的另一篇博客),这里传入的是压缩摘要 + 未压缩消息,进一步控制长度。

4. temperature 设置

意图识别和查询重写都用 temperature=0.0

1
base_llm = provider.create_base_llm(llm_type, temperature=0.0)

这不是最佳实践的通用建议,而是针对这两个任务的选择:

  • 意图识别是分类任务,需要确定性输出
  • 查询重写需要保守改写,不要创造性发挥

如果是需要多样性的任务(如生成推荐理由),temperature 应该调高。

还在思考的问题

  1. 意图识别可以不用 LLM 吗?

    对于简单 case(“好的”“谢谢”),规则就能判断。可以先过规则,再用 LLM 处理复杂 case。

  2. 重写可以更激进吗?

    目前的策略很保守,“宁可不改也不错改”。但有时候激进改写效果更好(比如把口语化表达转成书面语)。

  3. 两步可以合成一步吗?

    意图识别和查询重写都用 LLM,理论上可以合并成一次调用。但分开做更清晰,失败也更容易定位。这是复杂度 vs 性能的取舍。

总结

对话式 RAG 的预处理链路,核心设计要点:

  1. 先判断再检索:意图识别过滤掉不需要 RAG 的查询
  2. Prompt 要显式:边界条件、禁止行为都要写清楚,不要靠 LLM “理解”
  3. 防止幻觉:查询重写最容易引入幻觉,要显式禁止”加戏”
  4. 用 fast 模型:预处理任务对模型能力要求不高,用小模型降低延迟
 REWARD AUTHOR
 Comments
Comment plugin failed to load
Loading comment plugin