Browse Source

feat(rag): FAQ 匹配支持分类隔离并在闲聊意图前精准匹配

FaqMatchEngine 三级匹配(精确/关键词/语义)新增 categoryIds 分类过滤,授权分类与未分类 FAQ 可见;ChatPipeline 闲聊(CHITCHAT)意图前先尝试 FAQ 精准匹配,命中则短路返回标准答案;同步更新 PipelineFlow 架构图
Spring-AI-1.1.2
wanghanlin 2 weeks ago
parent
commit
a2dff86923
  1. 6
      frontend/src/views/PipelineFlow.vue
  2. 10
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  3. 18
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  4. 71
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

6
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["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
@ -97,7 +97,7 @@ flowchart TD
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"]
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
@ -111,6 +111,8 @@ flowchart TD
M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end
CHK -. "❌ 未命中 → 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- "✅ 命中标准答案" --> T
I --> T
S --> T
E --> T

10
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<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message());
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
Optional<String> 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<FaqMatchResult> 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);
}

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

@ -109,7 +109,7 @@ public class RagPipeline {
*/
public RagContext retrieve(ChatContext ctx) {
// 1. FAQ 优先匹配命中则直接返回标准答案跳过检索与生成
Optional<FaqMatchResult> faqMatch = tryFaqMatchResult(ctx.message());
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();
@ -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<FaqMatchResult> tryFaqMatchResult(String message) {
public Optional<FaqMatchResult> tryFaqMatchResult(String message, List<Long> 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<String> tryFaqMatch(String message) {
return tryFaqMatchResult(message).map(result -> result.getFaq().getAnswer());
public Optional<String> tryFaqMatch(String message, List<Long> categoryIds) {
return tryFaqMatchResult(message, categoryIds).map(result -> result.getFaq().getAnswer());
}
/**

71
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<FaqMatchResult> match(String question) {
public Optional<FaqMatchResult> match(String question, List<Long> categoryIds) {
if (question == null || question.isBlank()) {
return Optional.empty();
}
String trimmedQuestion = question.trim();
// 第一级精确匹配
Optional<FaqMatchResult> exactResult = exactMatch(trimmedQuestion);
Optional<FaqMatchResult> exactResult = exactMatch(trimmedQuestion, categoryIds);
if (exactResult.isPresent()) {
log.info("FAQ 精确匹配命中: question={}", trimmedQuestion);
return exactResult;
}
// 第二级关键词匹配
Optional<FaqMatchResult> keywordResult = keywordMatch(trimmedQuestion);
Optional<FaqMatchResult> keywordResult = keywordMatch(trimmedQuestion, categoryIds);
if (keywordResult.isPresent()) {
log.info("FAQ 关键词匹配命中: question={}", trimmedQuestion);
return keywordResult;
}
// 第三级语义匹配
Optional<FaqMatchResult> semanticResult = semanticMatch(trimmedQuestion);
Optional<FaqMatchResult> 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<FaqMatchResult> exactMatch(String question) {
private Optional<FaqMatchResult> exactMatch(String question, List<Long> categoryIds) {
try {
List<Object> params = new ArrayList<>();
params.add(question);
String categorySql = buildCategoryFilter(categoryIds, params, "category_id");
List<KnowledgeFaq> 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<FaqMatchResult> keywordMatch(String question) {
private Optional<FaqMatchResult> keywordMatch(String question, List<Long> categoryIds) {
try {
List<String> 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<KnowledgeFaq> results = jdbcTemplate.query(
sqlBuilder.toString(),
@ -188,7 +199,7 @@ public class FaqMatchEngine {
/**
* 语义匹配计算问题向量 faq_embedding 表中做余弦距离查询
*/
private Optional<FaqMatchResult> semanticMatch(String question) {
private Optional<FaqMatchResult> semanticMatch(String question, List<Long> 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<Object> params = new ArrayList<>();
params.add(vectorStr);
String categorySql = buildCategoryFilter(categoryIds, params, "kf.category_id");
// 余弦距离查询<=> 运算符返回余弦距离相似度 = 1 - distance
List<Map<String, Object>> 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<Long> categoryIds, List<Object> params, String column) {
List<String> 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]
*/

Loading…
Cancel
Save