diff --git a/src/main/java/com/wok/supportbot/app/ChatPipeline.java b/src/main/java/com/wok/supportbot/app/ChatPipeline.java index 302f8ea..2e02a05 100644 --- a/src/main/java/com/wok/supportbot/app/ChatPipeline.java +++ b/src/main/java/com/wok/supportbot/app/ChatPipeline.java @@ -85,17 +85,21 @@ public class ChatPipeline { // 意图路由:先用 IntentRouter 做细粒度分类 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()) && intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) { - Optional 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()); - Optional faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer()); + Optional faqAnswer = Optional.ofNullable(faqOutcome.result().get().getFaq().getAnswer()); 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()); + // 仅当第一次 FAQ 匹配「干净完成」才允许后续检索跳过第二次 FAQ(异常降级的 miss 不跳过,避免误跳) + faqSkippableInRetrieve = faqOutcome.completedCleanly(); } // 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索 @@ -113,8 +117,8 @@ public class ChatPipeline { 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 表(供知识库分析看板使用) if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) { diff --git a/src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java b/src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java index 0c5c68d..3749b42 100644 --- a/src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java +++ b/src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java @@ -51,4 +51,22 @@ public class AsyncExecutorConfig { executor.initialize(); 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; + } } diff --git a/src/main/java/com/wok/supportbot/rag/RagPipeline.java b/src/main/java/com/wok/supportbot/rag/RagPipeline.java index c6f4b23..bd663fa 100644 --- a/src/main/java/com/wok/supportbot/rag/RagPipeline.java +++ b/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.VectorStore; 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.util.StringUtils; @@ -26,6 +28,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; /** @@ -93,6 +96,14 @@ public class RagPipeline { @Resource 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; public RagPipeline(DatabaseChatMemory chatMemory) { @@ -108,13 +119,29 @@ public class RagPipeline { * @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message */ public RagContext retrieve(ChatContext ctx) { - // 1. FAQ 优先匹配:命中则直接返回标准答案,跳过检索与生成 - Optional 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 匹配)。 + *

+ * 流程: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 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. 统一检索 + 组装结果 @@ -190,6 +217,27 @@ public class RagPipeline { } } + /** + * FAQ 匹配结果 + 是否「完整跑完三级匹配后仍未命中」(区别于异常降级)。 + * 供编排层判定:前序已干净跑过 FAQ 三级匹配且未命中时,可在后续检索中跳过重复的 FAQ 匹配。 + */ + public record FaqMatchOutcome(Optional result, boolean completedCleanly) { + } + + /** + * 尝试 FAQ 三级匹配并携带「是否干净完成」标记。 + * match 内部各子级已吞掉各自异常并返回 empty,此处仅在 match 整体抛出未捕获异常时置 completedCleanly=false, + * 避免异常降级被误当作「已匹配未命中」而在后续检索中跳过第二次 FAQ(可能本可命中)。 + */ + public FaqMatchOutcome tryFaqMatchClean(String message, List 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 三级匹配,命中返回标准答案(仅答案文本)。 * 异常时降级为未命中,供仅需答案的调用方使用。 @@ -246,6 +294,7 @@ public class RagPipeline { /** * 多路查询扩展 + 分别检索 + 按文档 ID 去重合并。 * 扩展失败时退回原问题检索,避免整次 RAG 不可用。 + * 多路检索默认并行执行(每路 embedding HTTP + PG 查询相互独立),关闭或异常时回退串行。 */ private List retrieveMultiQueryDocs(String message, List categoryIds) { List expandedQueries; @@ -260,6 +309,18 @@ public class RagPipeline { } log.info("多路查询扩展结果: {}", expandedQueries); + // 多路检索默认并行执行(每路 embedding HTTP + PG 查询相互独立,可把 N 路串行降为 1 段最慢路); + // 单路或关闭并行开关时回退串行,保证结果与旧版一致 + boolean useParallel = multiQueryParallel && expandedQueries.size() > 1; + return useParallel + ? retrieveMultiQueryParallel(expandedQueries, categoryIds) + : retrieveMultiQuerySequential(expandedQueries, categoryIds); + } + + /** + * 串行执行多路检索(并行开关关闭 / 单路 / 异常回退路径,行为与旧版一致)。 + */ + private List retrieveMultiQuerySequential(List expandedQueries, List categoryIds) { Map merged = new LinkedHashMap<>(); for (String query : expandedQueries) { if (!StringUtils.hasText(query) || merged.size() >= MAX_DOCS) { @@ -276,6 +337,43 @@ public class RagPipeline { return new ArrayList<>(merged.values()); } + /** + * 并行执行多路检索:各路相互独立(embedding HTTP + PG 查询),可把 N 路串行降为 1 段最慢路。 + * join 后仍按原 query 顺序合并去重(LinkedHashMap + MAX_DOCS 封顶),保证文档集合与串行结果一致; + * 任一路异常降级为空列表,不拖垮整次检索。 + */ + private List retrieveMultiQueryParallel(List expandedQueries, List categoryIds) { + List>> 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 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()); + } + // ==================== 底层检索 ==================== /** diff --git a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java index bc1c3b4..a1a4878 100644 --- a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java +++ b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java @@ -201,6 +201,18 @@ public class FaqMatchEngine { */ private Optional semanticMatch(String question, List categoryIds) { try { + // P2 门控:先确认作用域内确有可语义匹配的 FAQ(faq_embedding 有向量记录)。 + // 为空直接返回未命中,省掉一次注定失败的语义 embedding HTTP(豆包多模态模型逐条调用较慢)。 + List 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(); float[] embedding = embeddingModel.call(new EmbeddingRequest(List.of(question), null)) .getResult().getOutput(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5f01f00..8172a20 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -76,6 +76,11 @@ knowledge: - 来源对应:回答中每个要点都应能在【知识库资料】中找到对应来源;不得让回答内容与下方引用来源不一致,也不得将多步流程压缩到只剩一两条。 - 资料不足:如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。 - 排版规范:使用 Markdown 结构化输出——分点用有序或无序列表(`1.` / `-`),不同要点之间空一行分段,涉及多来源或多角度时可用二级标题(`##`)区分;严禁把所有内容挤在一段内。 + # MULTI_QUERY 多路检索并行开关(性能优化):扩展出的多路查询是否并行执行向量检索。 + # 并行可将多路串行(≈N×(embedding HTTP + PG 查询))降为 1 段,显著降低首 token 前耗时。 + # 注意 embedding 多为逐条调用无法批量(如豆包多模态 vision 模型);高并发/embedding 侧限流时置 false 回退串行。 + multiquery: + parallel: true storage: # 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads) # 各环境可在 application-{env}.yml 中覆盖