Skip to main content

三个通道返回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 条有三个问题:

  1. 有重复:同一篇退货政策文档在意图定向和全局向量两个通道都被捞到了,不去重就会在 Prompt 里出现两次,白白浪费 Token
  2. 分数不可比:向量通道返回的分数是余弦相似度(0~1),ES 通道返回的是 BM25 分数(理论上无上界),意图定向的 0.82 和 ES 的 12.5 放在一起排序毫无意义
  3. 数量太多: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);
}

两阶段的职责很清楚:

  • 阶段 1executeSearchChannels()——并行执行所有启用的 Channel,收集每个通道的原始结果(第 10 篇已经讲过)
  • 阶段 2executePostProcessors()——串行执行后处理器链,做去重、精排、截断(本篇的重点)

阶段 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()返回处理器名称,用于日志和监控DeduplicationRerank
getOrder()返回执行优先级,数字越小越先执行去重返回 1,精排返回 10
isEnabled()根据上下文决定是否启用该处理器当前两个处理器始终返回 true
process()核心处理逻辑,输入 Chunk 列表,输出处理后的 Chunk 列表去重、精排 + 截断

2. process() 的入参设计

process() 方法同时接收三个参数,这个设计值得注意:

  • chunks:上一个处理器的输出,是流水线的传递对象。第一个处理器拿到的是所有通道的 Chunk 合并后的列表,后续处理器拿到的是前一个处理器处理过的列表
  • results:原始的多通道检索结果,只读引用。去重处理器需要按通道分组处理(先处理高优先级通道的结果),所以需要访问原始的通道结构,而不能只看打平后的 chunks
  • context:全局检索上下文,携带 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_DIRECTED1(最高)根据意图精准定位到特定 Collection,命中最靠谱
KEYWORD_ES2关键词精确匹配,召回精度高
VECTOR_GLOBAL3(最低)全局兜底搜索,语义粗匹配,精度相对低

2.2 优先级影响什么

解锁付费内容,👉 戳