diff --git a/frontend/src/views/PipelineFlow.vue b/frontend/src/views/PipelineFlow.vue index cbaacd3..c9a28e0 100644 --- a/frontend/src/views/PipelineFlow.vue +++ b/frontend/src/views/PipelineFlow.vue @@ -78,7 +78,7 @@ mermaid.initialize({ // Mermaid 流程图 DSL 定义 // 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统 -// %%graph-meta: { updated: "2026-08-04", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" } +// %%graph-meta: { updated: "2026-08-27", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" } const GRAPH_DEFINITION = ` flowchart TD A["用户请求
message + roleId + accountId + chatId"] @@ -97,7 +97,7 @@ flowchart TD G -- "FAQ
confidence ≧ 0.8" --> H["FaqMatchEngine
三级匹配策略
精确 → 关键词 → 向量语义"] - G -- "CHITCHAT
confidence ≧ 0.6" --> I["模式: 纯对话
跳过知识库检索
不注入资料块"] + G -- "CHITCHAT
confidence ≧ 0.6" --> CHK["闲聊前 FAQ 精准匹配
先试 FaqMatchEngine
命中则短路返回"] G -- "RAG / 降级
其余情况" --> J["RagPipeline.retrieve
RAG 检索流水线入口"] @@ -111,6 +111,8 @@ flowchart TD M --> S["4. 构建资料块
拼接检索文档
注入 system prompt 末尾"] end + CHK -. "❌ 未命中 → 纯对话" .-> I["模式: 纯对话
跳过知识库检索
不注入资料块"] + CHK -- "✅ 命中标准答案" --> T I --> T S --> T E --> T diff --git a/src/main/java/com/wok/supportbot/app/ChatPipeline.java b/src/main/java/com/wok/supportbot/app/ChatPipeline.java index 9c7b792..302f8ea 100644 --- a/src/main/java/com/wok/supportbot/app/ChatPipeline.java +++ b/src/main/java/com/wok/supportbot/app/ChatPipeline.java @@ -88,7 +88,7 @@ public class ChatPipeline { // FAQ 高置信度:优先匹配标准答案;未命中时降级到 RAG 检索,避免知识库中已有答案却返回兜底提示 if (intent != null && "FAQ".equals(intent.getIntent()) && intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) { - Optional faqMatch = ragPipeline.tryFaqMatchResult(ctx.message()); + Optional faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds()); if (faqMatch.isPresent()) { log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId()); Optional faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer()); @@ -101,6 +101,14 @@ public class ChatPipeline { // 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索 if (intent != null && "CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) { + // 闲聊前先尝试 FAQ 精准匹配,避免"你是谁"等被配置成 FAQ 后命中不了 + Optional faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds()); + if (faqMatch.isPresent()) { + log.info("闲聊意图但 FAQ 命中标准答案: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType()); + return new ChatRequest(ctx, ctx.message(), baseSystem, + Optional.ofNullable(faqMatch.get().getFaq().getAnswer()), + globalPrompt, null, null, "FAQ", null, null, faqMatch.get()); + } return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(), globalPrompt, null, null, "CHITCHAT", null, null, null); } diff --git a/src/main/java/com/wok/supportbot/rag/RagPipeline.java b/src/main/java/com/wok/supportbot/rag/RagPipeline.java index 8767b4a..c6f4b23 100644 --- a/src/main/java/com/wok/supportbot/rag/RagPipeline.java +++ b/src/main/java/com/wok/supportbot/rag/RagPipeline.java @@ -109,7 +109,7 @@ public class RagPipeline { */ public RagContext retrieve(ChatContext ctx) { // 1. FAQ 优先匹配:命中则直接返回标准答案,跳过检索与生成 - Optional faqMatch = tryFaqMatchResult(ctx.message()); + 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(); @@ -150,7 +150,7 @@ public class RagPipeline { } // RAG 命中日志:记录本次检索的命中/未命中情况 - logRagHit(ctx.chatId(), ctx.message(), docs, rewrittenQuery); + logRagHit(ctx.chatId(), ctx.message(), docs, currentSearchMode()); String contextText = joinContext(docs); return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery, currentSearchMode(), null); @@ -177,10 +177,13 @@ public class RagPipeline { /** * 尝试 FAQ 三级匹配(精确→关键词→语义),命中返回完整匹配结果(含 matchType/score)。 * 异常时降级为未命中。 + * + * @param message 用户问题 + * @param categoryIds 角色授权分类 ID 列表(null/空表示不限制) */ - public Optional tryFaqMatchResult(String message) { + public Optional tryFaqMatchResult(String message, List categoryIds) { try { - return faqMatchEngine.match(message); + return faqMatchEngine.match(message, categoryIds); } catch (Exception e) { log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage()); return Optional.empty(); @@ -190,9 +193,12 @@ public class RagPipeline { /** * 尝试 FAQ 三级匹配,命中返回标准答案(仅答案文本)。 * 异常时降级为未命中,供仅需答案的调用方使用。 + * + * @param message 用户问题 + * @param categoryIds 角色授权分类 ID 列表(null/空表示不限制) */ - public Optional tryFaqMatch(String message) { - return tryFaqMatchResult(message).map(result -> result.getFaq().getAnswer()); + public Optional tryFaqMatch(String message, List categoryIds) { + return tryFaqMatchResult(message, categoryIds).map(result -> result.getFaq().getAnswer()); } /** diff --git a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java index cc180fe..bc1c3b4 100644 --- a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java +++ b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.wok.supportbot.config.EmbeddingModelFactory; import com.wok.supportbot.dao.KnowledgeFaqMapper; import com.wok.supportbot.entity.KnowledgeFaq; +import com.wok.supportbot.rag.CategoryFilter; import lombok.AllArgsConstructor; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -37,6 +38,9 @@ public class FaqMatchEngine { @Autowired private EmbeddingModelFactory embeddingModelFactory; + @Autowired + private CategoryFilter categoryFilter; + /** 向量维度,与 PgVectorStore 保持一致 */ @Value("${knowledge.vector.dimension:1024}") private int vectorDimension; @@ -68,31 +72,32 @@ public class FaqMatchEngine { /** * 对用户问题进行三级匹配 * - * @param question 用户问题 + * @param question 用户问题 + * @param categoryIds 角色授权分类 ID 列表(null/空表示不限制),未设置分类的 FAQ 一并可见 * @return 匹配结果(可能为空) */ - public Optional match(String question) { + public Optional match(String question, List categoryIds) { if (question == null || question.isBlank()) { return Optional.empty(); } String trimmedQuestion = question.trim(); // 第一级:精确匹配 - Optional exactResult = exactMatch(trimmedQuestion); + Optional exactResult = exactMatch(trimmedQuestion, categoryIds); if (exactResult.isPresent()) { log.info("FAQ 精确匹配命中: question={}", trimmedQuestion); return exactResult; } // 第二级:关键词匹配 - Optional keywordResult = keywordMatch(trimmedQuestion); + Optional keywordResult = keywordMatch(trimmedQuestion, categoryIds); if (keywordResult.isPresent()) { log.info("FAQ 关键词匹配命中: question={}", trimmedQuestion); return keywordResult; } // 第三级:语义匹配 - Optional semanticResult = semanticMatch(trimmedQuestion); + Optional semanticResult = semanticMatch(trimmedQuestion, categoryIds); if (semanticResult.isPresent()) { log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore()); return semanticResult; @@ -107,12 +112,16 @@ public class FaqMatchEngine { /** * 精确匹配:问题文本完全一致 */ - private Optional exactMatch(String question) { + private Optional exactMatch(String question, List categoryIds) { try { + List params = new ArrayList<>(); + params.add(question); + String categorySql = buildCategoryFilter(categoryIds, params, "category_id"); List results = jdbcTemplate.query( - "SELECT * FROM knowledge_faq WHERE question = ? AND status = 'ENABLED' AND is_delete = false ORDER BY priority DESC LIMIT 1", + "SELECT * FROM knowledge_faq WHERE question = ? AND status = 'ENABLED' AND is_delete = false" + + categorySql + " ORDER BY priority DESC LIMIT 1", (rs, rowNum) -> mapRowToFaq(rs), - question + params.toArray() ); if (!results.isEmpty()) { KnowledgeFaq faq = results.get(0); @@ -130,7 +139,7 @@ public class FaqMatchEngine { /** * 关键词匹配:从用户问题中提取关键词,检查 similar_questions 是否包含 */ - private Optional keywordMatch(String question) { + private Optional keywordMatch(String question, List categoryIds) { try { List keywords = extractKeywords(question); if (keywords.isEmpty()) { @@ -149,7 +158,9 @@ public class FaqMatchEngine { sqlBuilder.append("similar_questions ILIKE ?"); params.add("%" + keywords.get(i) + "%"); } - sqlBuilder.append(") ORDER BY priority DESC LIMIT 5"); + sqlBuilder.append(")"); + sqlBuilder.append(buildCategoryFilter(categoryIds, params, "category_id")); + sqlBuilder.append(" ORDER BY priority DESC LIMIT 5"); List results = jdbcTemplate.query( sqlBuilder.toString(), @@ -188,7 +199,7 @@ public class FaqMatchEngine { /** * 语义匹配:计算问题向量,在 faq_embedding 表中做余弦距离查询 */ - private Optional semanticMatch(String question) { + private Optional semanticMatch(String question, List categoryIds) { try { EmbeddingModel embeddingModel = embeddingModelFactory.getEmbeddingModel(); float[] embedding = embeddingModel.call(new EmbeddingRequest(List.of(question), null)) @@ -197,6 +208,10 @@ public class FaqMatchEngine { // 将向量转为 PGVector 格式字符串 String vectorStr = toPgVectorFormat(embedding); + List params = new ArrayList<>(); + params.add(vectorStr); + String categorySql = buildCategoryFilter(categoryIds, params, "kf.category_id"); + // 余弦距离查询:<=> 运算符返回余弦距离,相似度 = 1 - distance List> results = jdbcTemplate.queryForList( "SELECT fe.faq_id, fe.embedding <=> ?::vector AS distance, " + @@ -204,9 +219,10 @@ public class FaqMatchEngine { "kf.status, kf.priority, kf.hit_count, kf.source, kf.create_time, kf.update_time, kf.is_delete " + "FROM faq_embedding fe " + "JOIN knowledge_faq kf ON fe.faq_id = kf.id " + - "WHERE kf.status = 'ENABLED' AND kf.is_delete = false " + - "ORDER BY distance ASC LIMIT 5", - vectorStr + "WHERE kf.status = 'ENABLED' AND kf.is_delete = false" + + categorySql + + " ORDER BY distance ASC LIMIT 5", + params.toArray() ); if (!results.isEmpty()) { @@ -295,6 +311,33 @@ public class FaqMatchEngine { // ==================== 工具方法 ==================== + /** + * 构建 FAQ 分类过滤 SQL 片段。 + * 规则:授权分类(category_id IN ...)与未设置分类(category_id IS NULL 或 0)均可见, + * 其余分类被隔离;分类列表为空时不加过滤(检索全部)。 + * + * @param categoryIds 角色授权分类 ID 列表,可为 null/空 + * @param params 参数集合,本方法追加分类占位符参数 + * @param column 分类列名(单表为 category_id,联表为 kf.category_id) + * @return SQL 片段(分类为空时返回空串) + */ + private String buildCategoryFilter(List categoryIds, List params, String column) { + List ids = categoryFilter.normalize(categoryIds); + if (ids.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(" AND (").append(column).append(" IN ("); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append("?"); + params.add(Long.valueOf(ids.get(i))); + } + sb.append(") OR ").append(column).append(" IS NULL OR ").append(column).append(" = 0)"); + return sb.toString(); + } + /** * 将 float[] 转为 PGVector 格式字符串: [0.1,0.2,0.3] */