在对话式 RAG 系统中,用户的输入往往不能直接拿去检索——“它怎么做”指代不明,“好的”根本不需要检索。本文记录了 CookHero 项目中意图识别和查询重写模块的设计过程,包括我们在 Prompt 工程上的反复迭代,以及一些”看起来简单但容易做错”的细节。
问题:用户说的不是检索要用的
先看几个真实的用户输入:
1 | 用户:红烧肉怎么做 |
如果把这些原封不动送去检索:
- “红烧肉怎么做” → ✅ 可以检索
- “这个要放多少盐” → ❌ “这个”是什么?检索会失败
- “有点太油腻了…” → ❓ 能检索,但需要明确是哪道菜的替代方案
- “好的” → ❌ 完全不需要检索,检索了也是噪声
- “谢谢” → ❌ 同上
直接把用户输入扔给检索模块,一半以上会出问题。
我们需要两个预处理步骤:
- 意图识别:判断是否需要检索(过滤掉”好的”“谢谢”)
- 查询重写:把指代词解析、上下文补全(“这个” → “红烧肉”)
意图识别:减少不必要的检索
设计目标
意图识别的核心目标是回答一个问题:这个查询需要检索知识库吗?
如果不需要,就跳过检索,直接让 LLM 回答。这样做有两个好处:
- 降低延迟:省掉检索步骤,响应更快
- 避免噪声:不该检索的检索了,可能返回不相关内容,影响 LLM 生成质量
分类体系
我们定义了五种意图:
1 | class QueryIntent(Enum): |
其中只有 GENERAL_CHAT 不需要 RAG,其他四种都需要。
Prompt 的迭代
意图识别用 LLM 实现。最初的 Prompt 很简单:
1 | 判断用户问题的意图,输出 JSON: |
上线后发现问题:
- 边界 case 判断不准:“这个可以用微波炉吗” → 被判成不需要 RAG
- 过度检索:“好的我知道了” → 被判成需要 RAG
反复迭代后,我们总结出 Prompt 设计的几个要点:
要点 1:明确定义 need_rag=true 的条件
不能让 LLM 自己”理解”什么时候需要检索,要显式列出:
1 | 【need_rag = true 的典型情况】 |
要点 2:显式排除不需要检索的情况
同样地,也要列出 need_rag=false 的边界:
1 | 【need_rag = false 的典型情况】 |
要点 3:强调结合对话历史判断
孤立地看”这个可以吗”无法判断,必须结合上下文。我们在 Prompt 中显式要求:
1 | 你的判断必须结合: |
并且在调用时把历史文本一并传入:
1 | template = INTENT_DETECTION_PROMPT.format_prompt(history=history_text + f"\n用户: {query}") |
输出格式约束
LLM 有时会输出不规范的 JSON。我们用了几个手段:
- Prompt 中强调:“你必须且只能输出以下 JSON,不得输出任何多余文本”
- 解析时做容错:用正则提取 JSON,处理多余的 markdown 标记
1 | def extract_first_valid_json(content: str) -> dict: |
选择 Fast 模型
意图识别调用 LLM,会增加延迟。为了控制成本和延迟,我们用的是 fast 模型(更小更快的模型),而不是主生成用的 normal 模型:
1 | class IntentDetector: |
实测 fast 模型在意图识别这种简单分类任务上,准确率和 normal 模型差不多,但延迟低 3-4 倍。
查询重写:消除指代、补全上下文
为什么需要重写?
即使判断出需要 RAG,原始查询也可能不适合直接检索:
1 | 用户:红烧肉怎么做 |
“第二步要多长时间”如果直接检索,会匹配到各种菜的”第二步”,而不是红烧肉的。
查询重写的目标是:把用户的当前问题转换为一个独立、完整、可直接检索的查询。
重写后应该是:“红烧肉烹制的第二步需要多长时间”。
Prompt 设计的核心规则
规则 1:指代消解
最重要的规则。把”它”“这个”“上一道”等指代词替换为具体对象:
1 | 【指代消解】 |
“禁止猜测”很重要——LLM 很喜欢”帮忙”补充信息,但这会引入幻觉。
规则 2:上下文补全
有时候用户省略了主语,需要从历史中补充:
1 | 用户:我想减脂 |
重写应该补全主语:“低卡沙拉怎么做”。
但补全要克制,只补充对检索必要的信息:
1 | 【上下文补全】 |
规则 3:幻觉防护
这是我们迭代多次才意识到的重点。LLM 重写时经常”加戏”:
- 用户说”推荐个菜”,重写成”推荐一道简单快手的家常菜” ❌
- 用户说”有什么素菜”,重写成”推荐一道健康低脂的素菜” ❌
这些额外的限定条件不是用户说的,是 LLM 脑补的,会导致检索结果偏离用户真实需求。
我们在 Prompt 中显式禁止:
1 | 【幻觉与扩展限制】 |
规则 4:处理模糊问题
用户说”我饿了”或”吃点啥”怎么办?
强行指代消解会出错(没有可消解的对象),强行补充上下文会引入幻觉。
我们的策略是保持模糊:
1 | 【模糊问题处理】 |
重写结果可能是”推荐一些菜品”,虽然宽泛,但至少不会出错。
输出格式
和意图识别一样,输出 JSON:
1 | {"query": "重写后的一句话查询"} |
同样需要做解析容错。
跳过重写的情况
如果没有历史上下文,重写就没意义。我们直接跳过:
1 | async def rewrite(self, current_query: str, history_text: str) -> str: |
这避免了无意义的 LLM 调用。
串联:预处理链路的完整流程
把意图识别和查询重写串起来:
1 | async def handle_query(query: str, conversation_id: str): |
注意:
- 意图识别的输入是 history + 当前 query
- 查询重写的输入是 当前 query + history(history 作为上下文参考)
- 检索用的是 rewritten_query,不是原始 query
遇到的几个坑
1. 重写结果变成了多句话
最初我们没限制输出格式,LLM 有时输出:
1 | 红烧肉的做法。特别是第二步需要多长时间。 |
这不是一个可检索的查询。加了约束后解决:
1 | 输出必须是一整句通顺、自然的中文 |
2. 意图识别和重写的顺序问题
最初我们先重写再识别意图,逻辑是”重写后的查询更清晰,意图更好判断”。
但实际上这样做有问题:
- 重写也需要调用 LLM,浪费了一次调用
- 如果不需要检索,重写完全是无用功
改成先意图识别后,不需要 RAG 的情况直接跳过重写,省掉一次 LLM 调用。
3. 历史上下文太长
历史文本太长会:
- 超过 LLM 上下文限制
- 增加 token 成本
- 降低 LLM 处理速度
我们做了截断,只取最近的历史:
1 | def build_history_text(self, history, limit=10): |
另外,如果使用了上下文压缩(见我们的另一篇博客),这里传入的是压缩摘要 + 未压缩消息,进一步控制长度。
4. temperature 设置
意图识别和查询重写都用 temperature=0.0:
1 | base_llm = provider.create_base_llm(llm_type, temperature=0.0) |
这不是最佳实践的通用建议,而是针对这两个任务的选择:
- 意图识别是分类任务,需要确定性输出
- 查询重写需要保守改写,不要创造性发挥
如果是需要多样性的任务(如生成推荐理由),temperature 应该调高。
还在思考的问题
意图识别可以不用 LLM 吗?
对于简单 case(“好的”“谢谢”),规则就能判断。可以先过规则,再用 LLM 处理复杂 case。
重写可以更激进吗?
目前的策略很保守,“宁可不改也不错改”。但有时候激进改写效果更好(比如把口语化表达转成书面语)。
两步可以合成一步吗?
意图识别和查询重写都用 LLM,理论上可以合并成一次调用。但分开做更清晰,失败也更容易定位。这是复杂度 vs 性能的取舍。
总结
对话式 RAG 的预处理链路,核心设计要点:
- 先判断再检索:意图识别过滤掉不需要 RAG 的查询
- Prompt 要显式:边界条件、禁止行为都要写清楚,不要靠 LLM “理解”
- 防止幻觉:查询重写最容易引入幻觉,要显式禁止”加戏”
- 用 fast 模型:预处理任务对模型能力要求不高,用小模型降低延迟