用户说的话和该搜的词往往不是同一回事
开篇引言
上一篇把记忆系统的最后一块拼图补上了——摘要压缩。滑动窗口保留最近 8 轮原文,更早的对话浓缩成一段摘要,水位线保证 不重复压缩,异步执行不阻塞主流程。到这里,ctx.history 已经装好了完整的对话记忆,Pipeline 的阶段 1 结束。
阶段 2 要做什么?来看一个场景。
假设你在一家电商公司做智能客服助手,接入了 3C 数码、家电、服装等多个品类的商品知识库。有个用户第一轮问了 iPhone 16 Pro 的退货政策是什么,系统从 3C 数码知识库检索到退货政策文档,回答得很好。第二轮,用户追问了一句:
那它的保修期呢?
记忆系统让模型知道 它 是 iPhone 16 Pro。但检索引擎呢?检索引擎拿到的查询就是这句原话——那它的保修期呢。拿 它 去向量数据库里搜,能搜到什么?大概率搜到的是各种产品的保修通用条款,可能不是 iPhone 16 Pro 的保修政策。
模型有记忆,但检索没有。这个问题在基础系列的 Query 改写篇已经讲过了——解法是在检索之前把原始问题改写成对检索友好的形式。基础系列讲了五种改写策略的原理,本篇不重复那些概念,聚焦 Ragent 项目里的工程实现。
Ragent 的查询改写有一个特别的设计:一次 LLM 调用同时完成两件事——改写和拆分。 改写解决检索精度问题(指代消解、去噪),拆分解决另一个问题——复合问题的意图覆盖。
什么意思?再看一个场景。用户问了一句:
iPhone 16 Pro 的退货政策是什么?AirPods Pro 的保修期呢?
这一句话里包含两个完全不同方向的问题。如果只改写不拆分,改写后变成 iPhone 16 Pro 退货政策和 AirPods Pro 保修期,拿这一整句话去做意图分类,LLM 可能只匹配到退货政策方向,AirPods Pro 的保修期被忽略了。拆分成两个子问题后,每个子问题独立做意图分类,两个方向都不丢。
本篇就围绕这两件事展开:改写怎么做,拆分怎么做,以及两者怎么在一次调用里同时搞定。
改写 + 拆分的双输出设计
1. 为什么要拆分
改写的价值在基础系列已经讲透了:指代消解、上下文补全、口语化转正式,让检索查询独立、完整、精确。这里重点说拆分。
用具体例子感受一下。用户问 iPhone 16 Pro 的退货政策是什么?AirPods Pro 的保修期呢:
不拆分的情况:
改写后的完整问题 → "iPhone 16 Pro 退货政策和 AirPods Pro 保修期"(作为唯一子问题)
→ 意图分类 → 可能只命中 kb-return-policy(退货政策节点),AirPods Pro 保修方向被弱化
→ 检索 → 只去退货政策知识库搜相关内容
→ 结果:AirPods Pro 的保修期没人管了
拆分后的情况:
子问题 1 → "iPhone 16 Pro 的退货政策是什么"
→ 意图分类 → 命中 kb-return-policy(score=0.92)
子问题 2 → "AirPods Pro 的保修期"
→ 意图分类 → 命中 kb-warranty(score=0.90)
→ 两个方向都命中,后续分别去对应知识库检索
打个比方,改写像是把一封字迹潦草的信重新写清楚,拆分像是把一封信里问了两件事的信拆成两封,分别交给不同部门处理。
2. 什么时候该拆、什么时候不该拆
不是所有包含多个关键词的问题都该拆。 核心判断标准是:子问题能不能独立回答。
| 场景 | 是否拆分 | 原因 |
|---|---|---|
| iPhone 16 Pro 的退货政策是什么?AirPods Pro 的保修期呢? | 拆分 | 两个独立问题,指向不同产品的不同主题 |
| iPhone 16 Pro 和 iPhone 16 Plus 的退货政策分别是什么? | 拆分 | 话题相同但指向不同产品,需要分别检索 |
| iPhone 16 Pro 和 iPhone 16 Plus 有什么区别? | 不拆分 | 对比型问题,两个产品放在一起才能回答 |
| iPhone 16 Pro 从哪些方面考虑? | 不拆分 | 笼统询问,没有明确列举子问题 |
| 你好 | 不拆分 | 问候语,保持原样 |
对比型问题是最容易误拆的。iPhone 16 Pro 和 iPhone 16 Plus 有什么区别?如果拆成 iPhone 16 Pro 的优缺点和 iPhone 16 Plus 的优缺点两个子问题,分别检索后各自拿到一堆文档,但用户要的是对比,不是两个独立介绍。拆了反而更差。
3. RewriteResult:一个 Record 承载两个输出
改写和拆分的结果统一封装在一个 Java Record 里:
public record RewriteResult(String rewrittenQuestion, List<String> subQuestions) {
}
两个字段各有分工:
rewrittenQuestion:改写后的完整问题。用于歧义引导阶段(阶段 4)展示给用户看,也在不拆分时作为检索查询subQuestions:子问题列表。每个子问题会独立送去做意图分类(阶段 3)
两者的关系有一个约定:不拆分时,subQuestions 只有一个元素,内容与 rewrittenQuestion 一致。 这样下游的 IntentResolver 不管拆不拆,都从 subQuestions 取数据,逻辑统一。
术语归一化:LLM 改写前的预处理
在调 LLM 改写之前,Ragent 先做了一步轻量级的规则处理——术语归一化。
1. 解决什么问题
电商平台的产品和品类有很多口语化的叫法。用户习惯说苹果手机,但知识库里的文档标题写的是 iPhone 系列;用户说降噪豆,指的其实是 AirPods Pro。如果不归一化,LLM 改写后可能保留苹果手机这个叫法,后续在 iPhone 相关的知识库里搜苹果手机,向量距离可能偏远,检索精度打折扣。
术语归一化就是在 LLM 改写之前,先用规则把这些口语化叫法映射为标准产品名。
2. QueryTermMappingService 的实现
@Service
@RequiredArgsConstructor
public class QueryTermMappingService {
private final QueryTermMappingMapper mappingMapper;
private final QueryTermMappingCacheManager cacheManager;
public String normalize(String text) {
if (text == null || text.isEmpty()) {
return text;
}
List<QueryTermMappingDO> mappings = loadMappings();
String result = text;
for (QueryTermMappingDO mapping : mappings) {
result = QueryTermMappingUtil.applyMapping(
result, mapping.getSourceTerm(), mapping.getTargetTerm());
}
return result;
}
private List<QueryTermMappingDO> loadMappings() {
// 优先从 Redis 缓存读取
List<QueryTermMappingDO> cached = cacheManager.getMappingsFromCache();
if (CollUtil.isNotEmpty(cached)) {
return cached;
}
// 缓存未命中,从数据库加载
List<QueryTermMappingDO> dbList = mappingMapper.selectList(
Wrappers.lambdaQuery(QueryTermMappingDO.class)
.eq(QueryTermMappingDO::getEnabled, 1));
// 按优先级降序 + 源词长度降序排序
dbList.sort(Comparator
.comparing(QueryTermMappingDO::getPriority, Comparator.nullsLast(Integer::compareTo)).reversed()
.thenComparing(m -> m.getSourceTerm().length(), Comparator.reverseOrder()));
// 回填 Redis 缓存
cacheManager.saveMappingsToCache(dbList);
return dbList;
}
}
管理后台增删改映射规则时,Admin 接口先更新数据库,再删除 Redis 缓存:
// QueryTermMappingAdminServiceImpl.java
queryTermMappingMapper.insert(record); // 先写数据库
queryTermMappingCacheManager.clearCache(); // 再删缓存,下次读自动从 DB 加载
这是经典的 Cache-Aside 模式——读时加载、写时失效。和意图树的缓存策略一致,集群部署时任意一台实例的 Admin 操作都会删除 Redis 缓存,所有实例下次 normalize 时发现缓存为空,各自从数据库重新加载,天然保持一致。
几个设计细节:
映射规则缓存在 Redis,而不是本地内存。 管理后台可以在线增删改映射规则(比如新增一条 苹果手机 → iPhone),修改后删除 Redis 缓存,所有实例下次读取时自动加载最新规则,不需要重启应用,也不存在集群节点间数据不一致的问题。
排序策略:优先级高的先替换,同优先级下长词优先。 为什么长词优先?假设有两条规则:苹果 → Apple 和 苹果手机 → iPhone。如果短词苹果先被替换了,原文里的苹果手机就变成了 Apple 手机,长词规则就匹配不到了。长词优先替换,避免这种截断问题。
归一化是纯文本替换,不依赖 LLM。 速度快,零 LLM 开销。相当于在 LLM 改写之前帮它做好功课——把术语问题先解决掉,LLM 只需要专注于指代消解和语义改写。
举个具体的例子:用户输入苹果手机的降噪豆能退吗,归一化后变成 iPhone 的 AirPods Pro 能退吗。这个归一化后的问题再送给 LLM 做进一步改写(去掉口语化表达、补全上下文等)。
一次 LLM 调用搞定改写 + 拆分
1. 核心入口:rewriteWithSplit
先看 Pipeline 怎么调的:
// StreamChatPipeline.java 阶段 2
private void rewriteQuery(StreamChatContext ctx) {
RewriteResult rewriteResult = queryRewriteService.rewriteWithSplit(
ctx.getQuestion(), ctx.getHistory()
);
ctx.setRewriteResult(rewriteResult);
}
输入是用户原始问题 + 对话历史(阶段 1 的输出),输出是 RewriteResult,存入 Context 供下游消费。
再看 MultiQuestionRewriteService 的核心实现:
@Override
@RagTraceNode(name = "query-rewrite-and-split", type = "REWRITE")
public RewriteResult rewriteWithSplit(String userQuestion, List<ChatMessage> history) {
// 开关关闭 → 术语归一化 + 规则拆分
if (!ragConfigProperties.getQueryRewriteEnabled()) {
String normalized = queryTermMappingService.normalize(userQuestion);
List<String> subs = ruleBasedSplit(normalized);
return new RewriteResult(normalized, subs);
}
// 先做术语归一化
String normalizedQuestion = queryTermMappingService.normalize(userQuestion);
// 再调 LLM 改写 + 拆分
return callLLMRewriteAndSplit(normalizedQuestion, userQuestion, history);
}
逻辑很清晰:
- 不管开关开不开,先过一遍术语归一化
- 开关关闭 → 走纯规则路径(归一化 + 按标点拆分),不调 LLM
- 开关打开 → 归一化后的问题送去 LLM 改写 + 拆分