|
|
|
@ -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; |
|
|
|
@ -69,30 +73,31 @@ public class FaqMatchEngine { |
|
|
|
* 对用户问题进行三级匹配 |
|
|
|
* |
|
|
|
* @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, " + |
|
|
|
@ -205,8 +220,9 @@ public class FaqMatchEngine { |
|
|
|
"FROM faq_embedding fe " + |
|
|
|
"JOIN knowledge_faq kf ON fe.faq_id = kf.id " + |
|
|
|
"WHERE kf.status = 'ENABLED' AND kf.is_delete = false" + |
|
|
|
categorySql + |
|
|
|
" ORDER BY distance ASC LIMIT 5", |
|
|
|
vectorStr |
|
|
|
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] |
|
|
|
*/ |
|
|
|
|