Browse Source

perf(rag): MULTI_QUERY 多路检索并行化并避免 FAQ 重复语义匹配

- 新增 ragRetrieveExecutor 有界线程池 + knowledge.rag.multiquery.parallel 开关:
  多路检索默认并行执行(各路 embedding HTTP + PG 查询相互独立,可把 N 路串行降为 1 段最慢路),
  按原查询顺序合并去重,结果与串行路径一致;单路异常降级为空不拖垮整次检索;
  饱和时退回请求线程串行(CallerRuns),供高并发/embedding 侧限流时置 false 回退
- ChatPipeline/RagPipeline:FAQ 高置信已「干净跑完」完整三级匹配仍未命中时,进入 RAG 检索跳过
  重复的 FAQ 匹配(FaqMatchOutcome 携带 completedCleanly 标记),避免同一请求两次 FAQ 语义 embedding;
  异常降级的 miss 不跳过,防止误跳
- FaqMatchEngine:语义匹配前加 faq_embedding 门控,作用域内无向量候选时直接返回未命中,
  省掉一次注定失败的语义 embedding HTTP
Spring-AI-1.1.2
wanghanlin 1 week ago
parent
commit
90754d15b2
  1. 18
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  2. 18
      src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
  3. 112
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  4. 12
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
  5. 5
      src/main/resources/application.yml

18
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -85,17 +85,21 @@ public class ChatPipeline {
// 意图路由先用 IntentRouter 做细粒度分类 // 意图路由先用 IntentRouter 做细粒度分类
IntentRouter.IntentResult intent = routeIntent(ctx.message()); IntentRouter.IntentResult intent = routeIntent(ctx.message());
// FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示
// FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示
// 若此处已干净跑完完整 FAQ 三级匹配仍未命中进入 RAG 检索时跳过重复的 FAQ 匹配避免同一请求两次 FAQ 语义 embedding
boolean faqSkippableInRetrieve = false;
if (intent != null && "FAQ".equals(intent.getIntent()) if (intent != null && "FAQ".equals(intent.getIntent())
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) { && intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
RagPipeline.FaqMatchOutcome faqOutcome = ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds());
if (faqOutcome.result().isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId()); log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
Optional<String> faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer());
Optional<String> faqAnswer = Optional.ofNullable(faqOutcome.result().get().getFaq().getAnswer());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer, return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer,
globalPrompt, null, null, "FAQ", null, null, faqMatch.get());
globalPrompt, null, null, "FAQ", null, null, faqOutcome.result().get());
} }
log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId()); log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
// 仅当第一次 FAQ 匹配干净完成才允许后续检索跳过第二次 FAQ异常降级的 miss 不跳过避免误跳
faqSkippableInRetrieve = faqOutcome.completedCleanly();
} }
// 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索 // 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索
@ -113,8 +117,8 @@ public class ChatPipeline {
globalPrompt, null, null, "CHITCHAT", null, null, null); globalPrompt, null, null, "CHITCHAT", null, null, null);
} }
// RAG 检索 FAQ 优先匹配
RagContext rag = ragPipeline.retrieve(ctx);
// RAG 检索 FAQ 优先匹配FAQ 高置信已完整匹配过则跳过二次 FAQ
RagContext rag = ragPipeline.retrieve(ctx, faqSkippableInRetrieve);
// 记录 RAG 检索日志到 rag_hit_log 供知识库分析看板使用 // 记录 RAG 检索日志到 rag_hit_log 供知识库分析看板使用
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) { if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {

18
src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java

@ -51,4 +51,22 @@ public class AsyncExecutorConfig {
executor.initialize(); executor.initialize();
return executor; return executor;
} }
/**
* RAG MULTI_QUERY 多路向量检索扇出线程池性能优化
* 各路检索相互独立embedding HTTP + PG 向量查询并行执行可将N 路串行降为1 段最慢
* 显著降低首 token 前的检索耗时有界队列 + CallerRunsPolicy饱和时退回请求线程串行天然降级
* traceExecutor/documentExecutor 分离避免与日志/文档向量化任务互相挤占
*/
@Bean("ragRetrieveExecutor")
public ThreadPoolTaskExecutor ragRetrieveExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(32);
executor.setThreadNamePrefix("rag-retrieve-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
} }

112
src/main/java/com/wok/supportbot/rag/RagPipeline.java

@ -17,6 +17,8 @@ import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -26,6 +28,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -93,6 +96,14 @@ public class RagPipeline {
@Resource @Resource
private MultiQueryExpanderRewriter multiQueryExpanderRewriter; private MultiQueryExpanderRewriter multiQueryExpanderRewriter;
/** MULTI_QUERY 多路检索扇出线程池:并行执行各路向量检索,饱和时退回请求线程串行(CallerRuns) */
@Resource(name = "ragRetrieveExecutor")
private ThreadPoolTaskExecutor ragRetrieveExecutor;
/** MULTI_QUERY 多路检索是否并行执行(false 回退串行,供高并发/embedding 侧限流时降级) */
@Value("${knowledge.rag.multiquery.parallel:true}")
private boolean multiQueryParallel;
private final DatabaseChatMemory chatMemory; private final DatabaseChatMemory chatMemory;
public RagPipeline(DatabaseChatMemory chatMemory) { public RagPipeline(DatabaseChatMemory chatMemory) {
@ -108,13 +119,29 @@ public class RagPipeline {
* @return 检索结果FAQ 命中时 documents contextText 为空rewrittenQuery 为原始 message * @return 检索结果FAQ 命中时 documents contextText 为空rewrittenQuery 为原始 message
*/ */
public RagContext retrieve(ChatContext ctx) { public RagContext retrieve(ChatContext ctx) {
// 1. FAQ 优先匹配命中则直接返回标准答案跳过检索与生成
Optional<FaqMatchResult> faqMatch = tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
log.info("FAQ 命中,跳过知识库检索: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
String answer = faqMatch.get().getFaq().getAnswer();
return new RagContext(Optional.ofNullable(answer), Collections.emptyList(), "", ctx.message(),
currentSearchMode(), faqMatch.get());
return retrieve(ctx, false);
}
/**
* 执行一次统一的 RAG 检索可跳过前序已做过的 FAQ 匹配
* <p>
* 流程FAQ 优先未匹配过时 查询重写/扩展 统一检索 拼接资料文本
*
* @param ctx 对话上下文使用 {@code message / chatId / rewriteStrategy / categoryIds}
* @param faqAlreadyMatched 编排层是否已在前序阶段FAQ 高置信未命中降级干净跑过完整 FAQ 三级匹配
* true 时跳过 retrieve 内重复的 FAQ 匹配避免同一请求重复做 FAQ 语义 embedding
* @return 检索结果FAQ 命中时 documents contextText 为空rewrittenQuery 为原始 message
*/
public RagContext retrieve(ChatContext ctx, boolean faqAlreadyMatched) {
// 1. FAQ 优先匹配命中则直接返回标准答案跳过检索与生成已匹配过则跳过防止重复 embedding
if (!faqAlreadyMatched) {
Optional<FaqMatchResult> faqMatch = tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
log.info("FAQ 命中,跳过知识库检索: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
String answer = faqMatch.get().getFaq().getAnswer();
return new RagContext(Optional.ofNullable(answer), Collections.emptyList(), "", ctx.message(),
currentSearchMode(), faqMatch.get());
}
} }
// 2. 统一检索 + 组装结果 // 2. 统一检索 + 组装结果
@ -190,6 +217,27 @@ public class RagPipeline {
} }
} }
/**
* FAQ 匹配结果 + 是否完整跑完三级匹配后仍未命中区别于异常降级
* 供编排层判定前序已干净跑过 FAQ 三级匹配且未命中时可在后续检索中跳过重复的 FAQ 匹配
*/
public record FaqMatchOutcome(Optional<FaqMatchResult> result, boolean completedCleanly) {
}
/**
* 尝试 FAQ 三级匹配并携带是否干净完成标记
* match 内部各子级已吞掉各自异常并返回 empty此处仅在 match 整体抛出未捕获异常时置 completedCleanly=false
* 避免异常降级被误当作已匹配未命中而在后续检索中跳过第二次 FAQ可能本可命中
*/
public FaqMatchOutcome tryFaqMatchClean(String message, List<Long> categoryIds) {
try {
return new FaqMatchOutcome(faqMatchEngine.match(message, categoryIds), true);
} catch (Exception e) {
log.warn("FAQ 匹配异常(clean 标记),降级到 RAG: {}", e.getMessage());
return new FaqMatchOutcome(Optional.empty(), false);
}
}
/** /**
* 尝试 FAQ 三级匹配命中返回标准答案仅答案文本 * 尝试 FAQ 三级匹配命中返回标准答案仅答案文本
* 异常时降级为未命中供仅需答案的调用方使用 * 异常时降级为未命中供仅需答案的调用方使用
@ -246,6 +294,7 @@ public class RagPipeline {
/** /**
* 多路查询扩展 + 分别检索 + 按文档 ID 去重合并 * 多路查询扩展 + 分别检索 + 按文档 ID 去重合并
* 扩展失败时退回原问题检索避免整次 RAG 不可用 * 扩展失败时退回原问题检索避免整次 RAG 不可用
* 多路检索默认并行执行每路 embedding HTTP + PG 查询相互独立关闭或异常时回退串行
*/ */
private List<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) { private List<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) {
List<String> expandedQueries; List<String> expandedQueries;
@ -260,6 +309,18 @@ public class RagPipeline {
} }
log.info("多路查询扩展结果: {}", expandedQueries); log.info("多路查询扩展结果: {}", expandedQueries);
// 多路检索默认并行执行每路 embedding HTTP + PG 查询相互独立可把 N 路串行降为 1 段最慢路
// 单路或关闭并行开关时回退串行保证结果与旧版一致
boolean useParallel = multiQueryParallel && expandedQueries.size() > 1;
return useParallel
? retrieveMultiQueryParallel(expandedQueries, categoryIds)
: retrieveMultiQuerySequential(expandedQueries, categoryIds);
}
/**
* 串行执行多路检索并行开关关闭 / 单路 / 异常回退路径行为与旧版一致
*/
private List<Document> retrieveMultiQuerySequential(List<String> expandedQueries, List<Long> categoryIds) {
Map<String, Document> merged = new LinkedHashMap<>(); Map<String, Document> merged = new LinkedHashMap<>();
for (String query : expandedQueries) { for (String query : expandedQueries) {
if (!StringUtils.hasText(query) || merged.size() >= MAX_DOCS) { if (!StringUtils.hasText(query) || merged.size() >= MAX_DOCS) {
@ -276,6 +337,43 @@ public class RagPipeline {
return new ArrayList<>(merged.values()); return new ArrayList<>(merged.values());
} }
/**
* 并行执行多路检索各路相互独立embedding HTTP + PG 查询可把 N 路串行降为 1 段最慢路
* join 后仍按原 query 顺序合并去重LinkedHashMap + MAX_DOCS 封顶保证文档集合与串行结果一致
* 任一路异常降级为空列表不拖垮整次检索
*/
private List<Document> retrieveMultiQueryParallel(List<String> expandedQueries, List<Long> categoryIds) {
List<CompletableFuture<List<Document>>> futures = new ArrayList<>(expandedQueries.size());
for (String query : expandedQueries) {
if (!StringUtils.hasText(query)) {
futures.add(CompletableFuture.completedFuture(Collections.emptyList()));
continue;
}
futures.add(CompletableFuture
.supplyAsync(() -> similaritySearch(query, categoryIds), ragRetrieveExecutor)
.exceptionally(ex -> {
log.warn("多路并行检索单路失败,该路降级为空: error={}", ex.getMessage());
return Collections.emptyList();
}));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 按原 query 顺序合并去重/封顶规则与串行路径一致不按完成先后保证结果确定
Map<String, Document> merged = new LinkedHashMap<>();
for (int i = 0; i < expandedQueries.size() && merged.size() < MAX_DOCS; i++) {
if (!StringUtils.hasText(expandedQueries.get(i))) {
continue;
}
for (Document doc : futures.get(i).join()) {
if (merged.size() >= MAX_DOCS) {
break;
}
merged.putIfAbsent(doc.getId(), doc);
}
}
return new ArrayList<>(merged.values());
}
// ==================== 底层检索 ==================== // ==================== 底层检索 ====================
/** /**

12
src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

@ -201,6 +201,18 @@ public class FaqMatchEngine {
*/ */
private Optional<FaqMatchResult> semanticMatch(String question, List<Long> categoryIds) { private Optional<FaqMatchResult> semanticMatch(String question, List<Long> categoryIds) {
try { try {
// P2 门控先确认作用域内确有可语义匹配的 FAQfaq_embedding 有向量记录
// 为空直接返回未命中省掉一次注定失败的语义 embedding HTTP豆包多模态模型逐条调用较慢
List<Object> gateParams = new ArrayList<>();
String gateSql = "SELECT 1 FROM faq_embedding fe " +
"JOIN knowledge_faq kf ON fe.faq_id = kf.id " +
"WHERE kf.status = 'ENABLED' AND kf.is_delete = false" +
buildCategoryFilter(categoryIds, gateParams, "kf.category_id") + " LIMIT 1";
if (jdbcTemplate.queryForList(gateSql, gateParams.toArray()).isEmpty()) {
log.debug("FAQ 语义候选为空,跳过语义 embedding: question={}", question);
return Optional.empty();
}
EmbeddingModel embeddingModel = embeddingModelFactory.getEmbeddingModel(); EmbeddingModel embeddingModel = embeddingModelFactory.getEmbeddingModel();
float[] embedding = embeddingModel.call(new EmbeddingRequest(List.of(question), null)) float[] embedding = embeddingModel.call(new EmbeddingRequest(List.of(question), null))
.getResult().getOutput(); .getResult().getOutput();

5
src/main/resources/application.yml

@ -76,6 +76,11 @@ knowledge:
- 来源对应:回答中每个要点都应能在【知识库资料】中找到对应来源;不得让回答内容与下方引用来源不一致,也不得将多步流程压缩到只剩一两条。 - 来源对应:回答中每个要点都应能在【知识库资料】中找到对应来源;不得让回答内容与下方引用来源不一致,也不得将多步流程压缩到只剩一两条。
- 资料不足:如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。 - 资料不足:如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。
- 排版规范:使用 Markdown 结构化输出——分点用有序或无序列表(`1.` / `-`),不同要点之间空一行分段,涉及多来源或多角度时可用二级标题(`##`)区分;严禁把所有内容挤在一段内。 - 排版规范:使用 Markdown 结构化输出——分点用有序或无序列表(`1.` / `-`),不同要点之间空一行分段,涉及多来源或多角度时可用二级标题(`##`)区分;严禁把所有内容挤在一段内。
# MULTI_QUERY 多路检索并行开关(性能优化):扩展出的多路查询是否并行执行向量检索。
# 并行可将多路串行(≈N×(embedding HTTP + PG 查询))降为 1 段,显著降低首 token 前耗时。
# 注意 embedding 多为逐条调用无法批量(如豆包多模态 vision 模型);高并发/embedding 侧限流时置 false 回退串行。
multiquery:
parallel: true
storage: storage:
# 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads) # 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads)
# 各环境可在 application-{env}.yml 中覆盖 # 各环境可在 application-{env}.yml 中覆盖

Loading…
Cancel
Save