三个通道返回30条结果,最终只给模型5条
开篇引言
上一篇拆解了多通道并行检索的内部实现——SearchChannel 接口抽象、两级线程池隔离、AbstractParallelRetriever 模板方法模式、CompletableFuture 并行调度与容错。读者已经知道怎么把一个用户问题同时丢给多个通道、怎么让通道之间互不阻塞、怎么在某个通道挂掉时返回空结果而不拖垮全局。
文章最后留了一个问题:定向检索从两个 Collection 搜回了 14 条,全局检索从 5 个 Collection 搜回了 15 条,加起来 29 条 Chunk——能直接塞进 LLM 的 Prompt 吗?
V1 版本 Ragent AI 代码未接入关键词检索,但是文章预留了关键词检索通道进行讲解。
答案是不能。把场景拉回电商客服。用户问了一句“AirPods Pro 怎么退货?”,经过意图识别和多通道并行检索后,三个通道各自返回了结果:
- 意图定向通道(
INTENT_DIRECTED):命中 3C 数码退货政策 Collection,返回 10 条 Chunk,分数范围 0.82~0.45 - 关键词检索通道(
KEYWORD_ES):用 BM25 在全局搜到包含 AirPods、退货关键词的 8 条 Chunk,分数范围 12.5~3.2 - 全局向量检索通道(
VECTOR_GLOBAL):在所有 Collection 中做向量检索,返回 10 条 Chunk,分数范围 0.78~0.35
三个通道合计 28 条 Chunk。这 28 条有三个问题:
- 有重复:同一篇退货政策文档在意图定向和全局向量两个通道都被捞到了,不去重就会在 Prompt 里出现两次,白白浪费 Token
- 分数不可比:向量通道返回的分数是余弦相似度(0~1),ES 通道返回的是 BM25 分数(理论上无上界),意图定向的 0.82 和 ES 的 12.5 放在一起排序毫无意义
- 数量太多:28 条 Chunk 的文本量可能上万 Token,全塞进 Prompt 会挤压生成空间,核心信息被大量低相关内容稀释
后处理流水线要解决的就是这三个问题:去重 → 精排 → 截断,最终只留下最相关的 topK 条喂给大模型。
后处理流水线全景
1. 一张图看懂漏斗
先用一张图看看后处理流水线的整体结构。三个通道的原始结果经过两个处理器,逐步收窄到最终 5 条:
漏斗的逻辑很直观:28 条原始 Chunk 经过去重缩减到约 21 条,再经过 Cross-Encoder 精排只保留最相关的 5 条。从数量上看是 28 → 21 → 5,从质量上看是粗糙的多源结果 → 无重复的候选集 → 精选的高质量上下文。
2. 两阶段入口:retrieveKnowledgeChannels
后处理流水线并不是独立运行的,它是 MultiChannelRetrievalEngine 两阶段架构的第二阶段。来看入口方法:
@RagTraceNode(name = "multi-channel-retrieval", type = "RETRIEVE_CHANNEL")
public List<RetrievedChunk> retrieveKnowledgeChannels(List<SubQuestionIntent> subIntents, int topK) {
// 构建检索上下文
SearchContext context = buildSearchContext(subIntents, topK);
// 【阶段1:多通道并行检索】
List<SearchChannelResult> channelResults = executeSearchChannels(context);
if (CollUtil.isEmpty(channelResults)) {
return List.of();
}
// 【阶段2:后置处理器链】
return executePostProcessors(channelResults, context);
}
两阶段的职责很清楚:
- 阶段 1:
executeSearchChannels()——并行执行所有启用的 Channel,收集每个通道的原始结果(第 10 篇已经讲过) - 阶段 2:
executePostProcessors()——串行执行后处理器链,做去重、精排、截断(本篇的重点)
阶段 1 的输出 List<SearchChannelResult> 就是阶段 2 的输入。每个 SearchChannelResult 封装了一个通道的检索结果,包含通道类型、通道名称、Chunk 列表和耗时:
@Data
@Builder
public class SearchChannelResult {
private SearchChannelType channelType; // 通道类型(INTENT_DIRECTED / KEYWORD_ES / VECTOR_GLOBAL)
private String channelName; // 通道名称(用于日志)
private List<RetrievedChunk> chunks; // 检 索到的 Chunk 列表
private long latencyMs; // 检索耗时(毫秒)
}
后处理器接口:SearchResultPostProcessor
1. 四个方法各管什么
后处理流水线的核心抽象是 SearchResultPostProcessor 接口,它定义了每个处理器必须实现的四个方法:
public interface SearchResultPostProcessor {
/** 处理器名称 */
String getName();
/** 处理器优先级(数字越小越先执行) */
int getOrder();
/** 是否启用该处理器 */
boolean isEnabled(SearchContext context);
/** 处理检索结果 */
List<RetrievedChunk> process(List<RetrievedChunk> chunks,
List<SearchChannelResult> results,
SearchContext context);
}
四个方法各司其职:
| 方法 | 职责 | 举例 |
|---|---|---|
getName() | 返回处理器名称,用于日志和监控 | Deduplication、Rerank |
getOrder() | 返回执行优先级,数字越小越先执行 | 去重返回 1,精排返回 10 |
isEnabled() | 根据上下文决定是否启用该处理器 | 当前两个处理器始终返回 true |
process() | 核心处理逻辑,输入 Chunk 列表,输出处理后的 Chunk 列表 | 去重、精排 + 截断 |
2. process() 的入参设计
process() 方法同时接收三个参数,这个设计值得注意:
chunks:上一个处理器的输出,是流水线的传递对象。第一个处理器拿到的是所有通道的 Chunk 合并后的列表,后续处理器拿到的是前一个处理器处理过的列表results:原始的多通道检索结果,只读引用。去重处理器需要按通道分组处理(先处理高优先级通道的结果),所以需要访问原始的通道结构,而不能只看打平后的chunkscontext:全局检索上下文,携带topK、用户问题等参数。精排处理器需要context.getMainQuestion()作为 Rerank 的 query,需要context.getTopK()决定截断数量
打个比方:chunks 是流水线上正在加工的半成品,results 是工艺卡片(记录了原料来自哪个车间),context 是生产工单(写着最终要交付多少件)。不是每道工序都需要看工艺卡片,但去重工序需要——因为它要根据原料来源(通道优先级)决定保留哪一份。
3. 返回值设计:输入一个列表,输出一个列表
每个处理器的返回值是一个新的 List<RetrievedChunk>。不修改输入列表,返回新列表——和 Java Stream 的 .filter() 或 .map() 思路一样 。上一个处理器的输出直接变成下一个处理器的输入,链式传递自然成立。
第一步:去重——DeduplicationPostProcessor
1. 为什么去重是第一步
去重处理器的 getOrder() 返回 1,是流水线中最先执行的。原因很直接:如果不先去重就精排,同一条 Chunk 在意图定向和全局向量两个通道各出现一次,Rerank 模型要给它打两次分——浪费 API 调用和算力,而且重复 Chunk 还会占据 topK 的名额。
2. 通道优先级排序
2.1 三个通道的优先级
去重不是简单地遍历所有 Chunk 然后去掉重复的。它先按通道优先级排序,再依次处理每个通道的结果:
private int getChannelPriority(SearchChannelType type) {
return switch (type) {
case INTENT_DIRECTED -> 1; // 意图检索优先级最高
case KEYWORD_ES -> 2; // 关键词检索次之
case VECTOR_GLOBAL -> 3; // 全局检索最低
default -> 99;
};
}
优先级的设计逻辑:
| 通道类型 | 优先级 | 理由 |
|---|---|---|
INTENT_DIRECTED | 1(最高) | 根据意图精准定位到特定 Collection,命中最靠谱 |
KEYWORD_ES | 2 | 关键词精确匹配,召回精度高 |
VECTOR_GLOBAL | 3(最低) | 全局兜底搜索,语义粗匹配,精度相对低 |