4 Commits

Author SHA1 Message Date
wanghanlin 1986500321 style(frontend): API Key 角色绑定弹窗简化角色名展示 2 months ago
wanghanlin cbbb588c69 chore: 清理角色表误加的 sort_order 孤儿列 2 months ago
wanghanlin 1e768a7409 fix(service): 修复角色创建主键获取异常 2 months ago
wanghanlin a2dff86923 feat(rag): FAQ 匹配支持分类隔离并在闲聊意图前精准匹配 2 months ago
  1. 2
      frontend/src/views/ApiKeyManager.vue
  2. 6
      frontend/src/views/PipelineFlow.vue
  3. 10
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  4. 13
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  5. 18
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  6. 25
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  7. 69
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

2
frontend/src/views/ApiKeyManager.vue

@ -67,7 +67,7 @@
<!-- 角色绑定弹窗 --> <!-- 角色绑定弹窗 -->
<t-dialog v-model:visible="showRoleDialog" :header="'绑定角色 — '+bindingKeyName" width="480px" :footer="false"> <t-dialog v-model:visible="showRoleDialog" :header="'绑定角色 — '+bindingKeyName" width="480px" :footer="false">
<t-checkbox-group v-model="selectedRoleIds"> <t-checkbox-group v-model="selectedRoleIds">
<t-checkbox v-for="r in allRoles" :key="r.id" :value="String(r.id)">{{ r.name }} ({{ r.roleKey }})</t-checkbox>
<t-checkbox v-for="r in allRoles" :key="r.id" :value="String(r.id)">{{ r.name }}</t-checkbox>
</t-checkbox-group> </t-checkbox-group>
<div class="dialog-footer"> <div class="dialog-footer">
<t-button variant="outline" @click="showRoleDialog=false">取消</t-button> <t-button variant="outline" @click="showRoleDialog=false">取消</t-button>

6
frontend/src/views/PipelineFlow.vue

@ -78,7 +78,7 @@ mermaid.initialize({
// Mermaid 流程图 DSL 定义 // Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统 // 节点类型: [矩形]=处理步骤, {菱形}=决策分支, 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 = ` const GRAPH_DEFINITION = `
flowchart TD flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"] 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 -- "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 检索流水线入口"] 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 末尾"] M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end end
CHK -. "❌ 未命中 → 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- "✅ 命中标准答案" --> T
I --> T I --> T
S --> T S --> T
E --> T E --> T

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

@ -88,7 +88,7 @@ public class ChatPipeline {
// FAQ 高置信度:优先匹配标准答案;未命中时降级到 RAG 检索,避免知识库中已有答案却返回兜底提示 // FAQ 高置信度:优先匹配标准答案;未命中时降级到 RAG 检索,避免知识库中已有答案却返回兜底提示
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());
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) { if (faqMatch.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(faqMatch.get().getFaq().getAnswer());
@ -101,6 +101,14 @@ public class ChatPipeline {
// 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索 // 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索
if (intent != null && "CHITCHAT".equals(intent.getIntent()) if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) { && 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(), return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
globalPrompt, null, null, "CHITCHAT", null, null, null); globalPrompt, null, null, "CHITCHAT", null, null, null);
} }

13
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -67,6 +67,7 @@ public class DatabaseInitConfig {
createCustomerServiceRoleTable(); createCustomerServiceRoleTable();
} else { } else {
dropRoleModelColumn(); dropRoleModelColumn();
dropRoleSortOrderColumn();
} }
}); });
@ -604,6 +605,18 @@ public class DatabaseInitConfig {
} }
} }
/**
* 清理误加的 customer_service_role.sort_order 列(角色表无排序权重需求,按 id 排序,
* 该列无任何代码引用,系误加/遗留的孤儿列)。幂等:DROP COLUMN IF EXISTS,列不存在时不做任何事。
*/
private void dropRoleSortOrderColumn() {
try {
jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS sort_order");
} catch (Exception e) {
log.warn("清理 sort_order 列时出错", e);
}
}
/** /**
* 自动添加 content_hash 列(二期去重功能新增字段) * 自动添加 content_hash 列(二期去重功能新增字段)
*/ */

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

@ -109,7 +109,7 @@ public class RagPipeline {
*/ */
public RagContext retrieve(ChatContext ctx) { public RagContext retrieve(ChatContext ctx) {
// 1. FAQ 优先匹配:命中则直接返回标准答案,跳过检索与生成 // 1. FAQ 优先匹配:命中则直接返回标准答案,跳过检索与生成
Optional<FaqMatchResult> faqMatch = tryFaqMatchResult(ctx.message());
Optional<FaqMatchResult> faqMatch = tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) { if (faqMatch.isPresent()) {
log.info("FAQ 命中,跳过知识库检索: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType()); log.info("FAQ 命中,跳过知识库检索: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
String answer = faqMatch.get().getFaq().getAnswer(); String answer = faqMatch.get().getFaq().getAnswer();
@ -150,7 +150,7 @@ public class RagPipeline {
} }
// RAG 命中日志:记录本次检索的命中/未命中情况 // RAG 命中日志:记录本次检索的命中/未命中情况
logRagHit(ctx.chatId(), ctx.message(), docs, rewrittenQuery);
logRagHit(ctx.chatId(), ctx.message(), docs, currentSearchMode());
String contextText = joinContext(docs); String contextText = joinContext(docs);
return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery, currentSearchMode(), null); return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery, currentSearchMode(), null);
@ -177,10 +177,13 @@ public class RagPipeline {
/** /**
* 尝试 FAQ 三级匹配(精确→关键词→语义),命中返回完整匹配结果(含 matchType/score)。 * 尝试 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 { try {
return faqMatchEngine.match(message);
return faqMatchEngine.match(message, categoryIds);
} catch (Exception e) { } catch (Exception e) {
log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage()); log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage());
return Optional.empty(); return Optional.empty();
@ -190,9 +193,12 @@ public class RagPipeline {
/** /**
* 尝试 FAQ 三级匹配,命中返回标准答案(仅答案文本)。 * 尝试 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());
} }
/** /**

25
src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

@ -5,13 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@ -114,23 +110,16 @@ public class CustomerServiceRoleService {
String key = (roleKey == null || roleKey.trim().isEmpty()) String key = (roleKey == null || roleKey.trim().isEmpty())
? "role_" + System.currentTimeMillis() ? "role_" + System.currentTimeMillis()
: roleKey.trim(); : roleKey.trim();
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO customer_service_role (role_key, name, description, prompt, enabled) VALUES (?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS);
ps.setString(1, key);
ps.setString(2, name.trim());
ps.setString(3, description);
ps.setString(4, prompt);
ps.setBoolean(5, enabled != null ? enabled : true);
return ps;
}, keyHolder);
Number generatedId = keyHolder.getKey();
// 使用 RETURNING id 显式取回主键,避免 PostgreSQL JDBC 对 RETURN_GENERATED_KEYS
// 追加 RETURNING *(返回整行多列),导致 GeneratedKeyHolder.getKey() 抛"multiple keys"异常
Long generatedId = jdbcTemplate.queryForObject(
"INSERT INTO customer_service_role (role_key, name, description, prompt, enabled) VALUES (?, ?, ?, ?, ?) RETURNING id",
Long.class,
key, name.trim(), description, prompt, enabled != null ? enabled : true);
if (generatedId == null) { if (generatedId == null) {
throw new RuntimeException("创建角色失败:无法获取自动生成的 ID"); throw new RuntimeException("创建角色失败:无法获取自动生成的 ID");
} }
return generatedId.longValue();
return generatedId;
} }
/** /**

69
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.config.EmbeddingModelFactory;
import com.wok.supportbot.dao.KnowledgeFaqMapper; import com.wok.supportbot.dao.KnowledgeFaqMapper;
import com.wok.supportbot.entity.KnowledgeFaq; import com.wok.supportbot.entity.KnowledgeFaq;
import com.wok.supportbot.rag.CategoryFilter;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -37,6 +38,9 @@ public class FaqMatchEngine {
@Autowired @Autowired
private EmbeddingModelFactory embeddingModelFactory; private EmbeddingModelFactory embeddingModelFactory;
@Autowired
private CategoryFilter categoryFilter;
/** 向量维度,与 PgVectorStore 保持一致 */ /** 向量维度,与 PgVectorStore 保持一致 */
@Value("${knowledge.vector.dimension:1024}") @Value("${knowledge.vector.dimension:1024}")
private int vectorDimension; private int vectorDimension;
@ -69,30 +73,31 @@ public class FaqMatchEngine {
* 对用户问题进行三级匹配 * 对用户问题进行三级匹配
* *
* @param question 用户问题 * @param question 用户问题
* @param categoryIds 角色授权分类 ID 列表(null/空表示不限制),未设置分类的 FAQ 一并可见
* @return 匹配结果(可能为空) * @return 匹配结果(可能为空)
*/ */
public Optional<FaqMatchResult> match(String question) {
public Optional<FaqMatchResult> match(String question, List<Long> categoryIds) {
if (question == null || question.isBlank()) { if (question == null || question.isBlank()) {
return Optional.empty(); return Optional.empty();
} }
String trimmedQuestion = question.trim(); String trimmedQuestion = question.trim();
// 第一级:精确匹配 // 第一级:精确匹配
Optional<FaqMatchResult> exactResult = exactMatch(trimmedQuestion);
Optional<FaqMatchResult> exactResult = exactMatch(trimmedQuestion, categoryIds);
if (exactResult.isPresent()) { if (exactResult.isPresent()) {
log.info("FAQ 精确匹配命中: question={}", trimmedQuestion); log.info("FAQ 精确匹配命中: question={}", trimmedQuestion);
return exactResult; return exactResult;
} }
// 第二级:关键词匹配 // 第二级:关键词匹配
Optional<FaqMatchResult> keywordResult = keywordMatch(trimmedQuestion);
Optional<FaqMatchResult> keywordResult = keywordMatch(trimmedQuestion, categoryIds);
if (keywordResult.isPresent()) { if (keywordResult.isPresent()) {
log.info("FAQ 关键词匹配命中: question={}", trimmedQuestion); log.info("FAQ 关键词匹配命中: question={}", trimmedQuestion);
return keywordResult; return keywordResult;
} }
// 第三级:语义匹配 // 第三级:语义匹配
Optional<FaqMatchResult> semanticResult = semanticMatch(trimmedQuestion);
Optional<FaqMatchResult> semanticResult = semanticMatch(trimmedQuestion, categoryIds);
if (semanticResult.isPresent()) { if (semanticResult.isPresent()) {
log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore()); log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore());
return semanticResult; 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 { try {
List<Object> params = new ArrayList<>();
params.add(question);
String categorySql = buildCategoryFilter(categoryIds, params, "category_id");
List<KnowledgeFaq> results = jdbcTemplate.query( 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), (rs, rowNum) -> mapRowToFaq(rs),
question
params.toArray()
); );
if (!results.isEmpty()) { if (!results.isEmpty()) {
KnowledgeFaq faq = results.get(0); KnowledgeFaq faq = results.get(0);
@ -130,7 +139,7 @@ public class FaqMatchEngine {
/** /**
* 关键词匹配:从用户问题中提取关键词,检查 similar_questions 是否包含 * 关键词匹配:从用户问题中提取关键词,检查 similar_questions 是否包含
*/ */
private Optional<FaqMatchResult> keywordMatch(String question) {
private Optional<FaqMatchResult> keywordMatch(String question, List<Long> categoryIds) {
try { try {
List<String> keywords = extractKeywords(question); List<String> keywords = extractKeywords(question);
if (keywords.isEmpty()) { if (keywords.isEmpty()) {
@ -149,7 +158,9 @@ public class FaqMatchEngine {
sqlBuilder.append("similar_questions ILIKE ?"); sqlBuilder.append("similar_questions ILIKE ?");
params.add("%" + keywords.get(i) + "%"); 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( List<KnowledgeFaq> results = jdbcTemplate.query(
sqlBuilder.toString(), sqlBuilder.toString(),
@ -188,7 +199,7 @@ public class FaqMatchEngine {
/** /**
* 语义匹配:计算问题向量,在 faq_embedding 表中做余弦距离查询 * 语义匹配:计算问题向量,在 faq_embedding 表中做余弦距离查询
*/ */
private Optional<FaqMatchResult> semanticMatch(String question) {
private Optional<FaqMatchResult> semanticMatch(String question, List<Long> categoryIds) {
try { try {
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))
@ -197,6 +208,10 @@ public class FaqMatchEngine {
// 将向量转为 PGVector 格式字符串 // 将向量转为 PGVector 格式字符串
String vectorStr = toPgVectorFormat(embedding); String vectorStr = toPgVectorFormat(embedding);
List<Object> params = new ArrayList<>();
params.add(vectorStr);
String categorySql = buildCategoryFilter(categoryIds, params, "kf.category_id");
// 余弦距离查询:<=> 运算符返回余弦距离,相似度 = 1 - distance // 余弦距离查询:<=> 运算符返回余弦距离,相似度 = 1 - distance
List<Map<String, Object>> results = jdbcTemplate.queryForList( List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT fe.faq_id, fe.embedding <=> ?::vector AS distance, " + "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 " + "kf.status, kf.priority, kf.hit_count, kf.source, kf.create_time, kf.update_time, kf.is_delete " +
"FROM faq_embedding fe " + "FROM faq_embedding fe " +
"JOIN knowledge_faq kf ON fe.faq_id = kf.id " + "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()) { 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] * 将 float[] 转为 PGVector 格式字符串: [0.1,0.2,0.3]
*/ */

Loading…
Cancel
Save