3 Commits

Author SHA1 Message Date
wanghanlin 90754d15b2 perf(rag): MULTI_QUERY 多路检索并行化并避免 FAQ 重复语义匹配 2 months ago
wanghanlin a7d0ad87c9 feat(doc): 文档列表目录树默认收起并移除标签筛选 2 months ago
wanghanlin 6c35aa0ff6 style(frontend): 上传页让「选择文件夹上传」与「选择文件」按钮同行显示 2 months ago
  1. 10
      frontend/src/api/document.ts
  2. 1
      frontend/src/router/index.ts
  3. 14
      frontend/src/stores/document.ts
  4. 6
      frontend/src/views/DocList.vue
  5. 32
      frontend/src/views/DocUpload.vue
  6. 18
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  7. 18
      src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
  8. 29
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  9. 100
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  10. 43
      src/main/java/com/wok/supportbot/service/DocumentService.java
  11. 12
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
  12. 5
      src/main/resources/application.yml

10
frontend/src/api/document.ts

@ -12,13 +12,12 @@ function authHeaders(): Record<string, string> {
// ==================== 文档 CRUD ====================
/** 文档列表(分页 + 过滤 + 搜索) */
export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/document/list?page=${page}&size=${size}`
if (categoryId) path += `&categoryId=${categoryId}`
if (folderId != null) path += `&folderId=${folderId}`
if (status) path += `&status=${status}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (tag) path += `&tag=${encodeURIComponent(tag)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
@ -128,10 +127,3 @@ export function searchDocuments(query: string, topK?: number, similarityThreshol
export function getStats(): Promise<ApiResponse> {
return request.get('/document/stats').then(r => r.data)
}
// ==================== 标签 ====================
/** 获取标签列表 */
export function getTagList(): Promise<ApiResponse> {
return request.get('/tag/list').then(r => r.data)
}

1
frontend/src/router/index.ts

@ -62,7 +62,6 @@ router.beforeEach(async (to, _from, next) => {
await Promise.all([
categoryStore.loadCategories(),
documentStore.loadStats(),
documentStore.loadTags(),
])
} catch { /* 预加载失败不阻塞导航 */ }
break

14
frontend/src/stores/document.ts

@ -1,13 +1,12 @@
/**
* 文档状态管理 —— 替代旧 store.js 中 stats / tags 部分
* 文档状态管理 —— 替代旧 store.js 中 stats 部分
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getStats, getTagList } from '@/api/document'
import { getStats } from '@/api/document'
export const useDocumentStore = defineStore('document', () => {
const stats = ref<any>(null)
const tags = ref<any[]>([])
async function loadStats(): Promise<void> {
try {
@ -16,12 +15,5 @@ export const useDocumentStore = defineStore('document', () => {
} catch (e) { console.error('加载统计失败', e) }
}
async function loadTags(): Promise<void> {
try {
const res = await getTagList()
if (res.success) tags.value = res.data || []
} catch (e) { console.error('加载标签列表失败', e) }
}
return { stats, tags, loadStats, loadTags }
return { stats, loadStats }
})

6
frontend/src/views/DocList.vue

@ -15,7 +15,6 @@
:activable="true"
:active-multiple="false"
hover
expand-all
empty="暂无分类与目录"
v-model:actived="activedKeys"
@active="onTreeActive"
@ -44,7 +43,6 @@
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" />
<t-select v-model="filterCategory" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:140px;" @change="onCategoryChange" />
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:110px;" @change="load()" />
<t-select v-model="filterTag" :options="tagOptions" placeholder="全部标签" clearable size="small" style="width:140px;" @change="load()" />
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
<t-tag v-if="hasProcessing" theme="warning" size="small">⏳ 有文档处理中...</t-tag>
<t-tag v-if="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter">📁 目录筛选</t-tag>
@ -172,7 +170,6 @@ const loading = ref(false)
const filterCategory = ref('')
const filterStatus = ref('')
const keyword = ref('')
const filterTag = ref('')
const selectedIds = ref(new Set<string>())
const hasProcessing = ref(false)
@ -247,7 +244,6 @@ const columns = [
// ==================== 选项 ====================
const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))])
const statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }]
const tagOptions = computed(() => [{ label: '全部标签', value: '' }, ...(documentStore.tags || []).map((t: any) => ({ label: `${t.tag} (${t.count})`, value: t.tag }))])
// 移动弹窗目录树:确保有「未分类」节点,保留移出到未分类的能力
const moveTreeData = computed(() => {
const hasUnclassified = treeData.value.some(n => n.value === 'cat:0')
@ -519,7 +515,7 @@ async function loadData(p = page.value) {
page.value = p
loading.value = true
try {
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterTag.value || undefined, filterFolder.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterFolder.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (json.success) {
documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false }))
total.value = json.total || 0

32
frontend/src/views/DocUpload.vue

@ -27,14 +27,19 @@
<!-- 上传模式 Tab -->
<t-tabs v-model="uploadMode">
<t-tab-panel value="file" label="📄 文件上传">
<div class="folder-upload-bar">
<t-button variant="outline" size="small" :loading="folderUploading" @click="pickFolder">📁 选择文件夹上传</t-button>
<t-upload ref="uploadRef" :auto-upload="false" theme="file-flow" multiple :before-upload="beforeFileUpload"
:request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf"
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }">
<template #trigger>
<div class="upload-trigger-row">
<t-button variant="outline" :loading="folderUploading" @click.stop.prevent="pickFolder">📁 选择文件夹上传</t-button>
<t-button variant="outline" @click.stop.prevent="pickFile">📄 选择文件</t-button>
<span v-if="folderUploading" class="folder-progress">上传中 {{ folderProgress ?? 0 }}%</span>
<input ref="folderInput" type="file" webkitdirectory style="display:none;" @change="onFolderSelected" />
</div>
<t-upload :auto-upload="false" theme="file-flow" multiple :before-upload="beforeFileUpload"
:request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf"
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" />
</template>
</t-upload>
<!-- 独立的文件夹选择隐藏 input(不随 t-upload 的 trigger 打开) -->
<input ref="folderInput" type="file" webkitdirectory style="display:none;" @change="onFolderSelected" />
</t-tab-panel>
<t-tab-panel value="text" label="📝 文本">
<t-textarea v-model="textContent" placeholder="粘贴文本内容..." :autosize="{ minRows: 6, maxRows: 12 }" />
@ -54,7 +59,7 @@
<script setup lang="ts">
import { ref, computed, watch, h } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
import type { UploadFile } from 'tdesign-vue-next'
import type { UploadFile, UploadInstanceFunctions } from 'tdesign-vue-next'
import { useCategoryStore } from '@/stores/category'
import { useDocumentStore } from '@/stores/document'
import { uploadFile, uploadMarkdown, uploadJsonBasic, uploadFolder } from '@/api/upload'
@ -68,6 +73,9 @@ const uploadMode = ref('file')
const uploadCategory = ref('')
const uploading = ref(false)
// t-upload 实例(用于自定义 trigger 时打开文件选择器)
const uploadRef = ref<UploadInstanceFunctions>()
// 目标目录(value 形如 'folder:xxx',空 = 分类根)
const uploadFolderId = ref('')
const folderTreeData = ref<any[]>([])
@ -224,7 +232,13 @@ function onFolderChange(_value: any, ctx: any) {
}
}
// ==================== 文件夹上传 ====================
// ==================== 上传触发(自定义 trigger 行的两个入口) ====================
/** 选择文件:调用 t-upload 实例方法打开文件选择器 */
function pickFile() {
uploadRef.value?.triggerUpload?.()
}
/** 选择文件夹:触发独立的 webkitdirectory input */
function pickFolder() {
folderInput.value?.click()
}
@ -332,6 +346,6 @@ async function onFolderSelected(e: Event) {
<style scoped>
.upload-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px; background: var(--color-bg-subtle); border-radius: 8px; border: 1px solid var(--color-border); margin-bottom: 16px; }
.tag-input-wrapper { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; flex: 1; padding: 4px 8px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--td-bg-color-container); }
.folder-upload-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.upload-trigger-row { display: flex; align-items: center; gap: 8px; }
.folder-progress { font-size: 12px; color: var(--td-text-color-secondary); }
</style>

18
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<FaqMatchResult> 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<String> faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer());
Optional<String> 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()) {

18
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;
}
}

29
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -329,7 +329,7 @@ public class DocumentController {
}
/**
* 查询文档列表(分页 + 过滤 + 关键词搜索 + 标签筛选)
* 查询文档列表(分页 + 过滤 + 关键词搜索)
*
* @param page 页码(默认1)
* @param size 每页大小(默认10)
@ -337,7 +337,6 @@ public class DocumentController {
* @param folderId 目录ID过滤(可选,0 表示只看根目录)
* @param status 状态过滤(PROCESSING/READY/FAILED,可选)
* @param keyword 关键词搜索(模糊匹配标题和文件名,可选)
* @param tag 标签筛选(精确匹配,可选)
* @return 分页文档列表
*/
@GetMapping("/document/list")
@ -349,11 +348,10 @@ public class DocumentController {
@RequestParam(required = false) Long folderId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, tag, sortField, sortOrder);
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, sortField, sortOrder);
Map<String, Object> data = new HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
@ -727,29 +725,6 @@ public class DocumentController {
return categoryFilter.parse(rawCategoryIds);
}
// ==================== 标签管理 ====================
/**
* P1-2.3: 获取标签列表(从所有文档的 tags 聚合去重,含使用次数)
*/
@GetMapping("/tag/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getTagList() {
try {
List<Map<String, Object>> tags = documentService.getTagList();
return ResponseEntity.ok(Map.of(
"success", true,
"data", tags,
"total", tags.size()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取标签列表失败:" + e.getMessage()
));
}
}
// ==================== 分块管理 ====================
/**

100
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,7 +119,22 @@ public class RagPipeline {
* @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message
*/
public RagContext retrieve(ChatContext ctx) {
// 1. FAQ 优先匹配:命中则直接返回标准答案,跳过检索与生成
return retrieve(ctx, false);
}
/**
* 执行一次统一的 RAG 检索(可跳过前序已做过的 FAQ 匹配)。
* <p>
* 流程: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<FaqMatchResult> faqMatch = tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
log.info("FAQ 命中,跳过知识库检索: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
@ -116,6 +142,7 @@ public class RagPipeline {
return new RagContext(Optional.ofNullable(answer), Collections.emptyList(), "", ctx.message(),
currentSearchMode(), faqMatch.get());
}
}
// 2. 统一检索 + 组装结果
return retrieveDocumentsAndContext(ctx);
@ -190,6 +217,27 @@ public class RagPipeline {
}
}
/**
* FAQ 匹配结果 + 是否「完整跑完三级匹配后仍未命中」(区别于异常降级)。
* 供编排层判定:前序已干净跑过 FAQ 三级匹配且未命中时,可在后续检索中跳过重复的 FAQ 匹配。
*/
public record FaqMatchOutcome(Optional<FaqMatchResult> result, boolean completedCleanly) {
}
/**
* 尝试 FAQ 三级匹配并携带「是否干净完成」标记。
* match 内部各子级已吞掉各自异常并返回 empty,此处仅在 match 整体抛出未捕获异常时置 completedCleanly=false,
* 避免异常降级被误当作「已匹配未命中」而在后续检索中跳过第二次 FAQ(可能本可命中)。
*/
public FaqMatchOutcome tryFaqMatchClean(String message, List<Long> 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<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) {
List<String> 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<Document> retrieveMultiQuerySequential(List<String> expandedQueries, List<Long> categoryIds) {
Map<String, Document> 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<Document> retrieveMultiQueryParallel(List<String> expandedQueries, List<Long> categoryIds) {
List<CompletableFuture<List<Document>>> 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<String, Document> 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());
}
// ==================== 底层检索 ====================
/**

43
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -457,10 +457,10 @@ public class DocumentService {
// ==================== 文档管理 ====================
/**
* 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选 + 目录筛选)
* 分页查询文档列表(手动分页,支持关键词搜索 + 目录筛选)
*/
public Map<String, Object> listDocuments(int page, int size, Long categoryId, Long folderId, String status,
String keyword, String tag, String sortField, String sortOrder) {
String keyword, String sortField, String sortOrder) {
// 参数安全校验
if (page < 1) page = 1;
if (size < 1 || size > 100) size = 10;
@ -482,16 +482,6 @@ public class DocumentService {
if (kw != null) {
countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw));
}
// P1-2.3: 标签筛选(JSONB 包含查询,使用 ObjectMapper 构建合法 JSON)
if (tag != null && !tag.trim().isEmpty()) {
try {
ObjectMapper om = new ObjectMapper();
String jsonArray = om.writeValueAsString(List.of(tag.trim()));
countWrapper.apply("tags->'tags' @> {0}::jsonb", jsonArray);
} catch (Exception e) {
log.warn("构建标签筛选 JSON 失败: tag={}", tag, e);
}
}
// 先查询总数(不加 ORDER BY)
Long total = documentMapper.selectCount(countWrapper);
@ -510,15 +500,6 @@ public class DocumentService {
if (kw != null) {
listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw));
}
if (tag != null && !tag.trim().isEmpty()) {
try {
ObjectMapper om = new ObjectMapper();
String jsonArray = om.writeValueAsString(List.of(tag.trim()));
listWrapper.apply("tags->'tags' @> {0}::jsonb", jsonArray);
} catch (Exception e) {
log.warn("构建标签筛选 JSON 失败: tag={}", tag, e);
}
}
// 排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入
Map<String, String> sortColumns = Map.of(
"title", "title",
@ -867,26 +848,6 @@ public class DocumentService {
}
}
// ==================== 标签管理 ====================
/**
* P1-2.3: 获取标签列表(从所有文档的 tags JSONB 聚合去重,含使用次数)
*
* @return [{tag: "标签名", count: 使用次数}]
*/
public List<Map<String, Object>> getTagList() {
String sql = "SELECT tag_value AS tag, COUNT(*) AS count " +
"FROM knowledge_document, jsonb_array_elements(tags->'tags') AS tag_value " +
"WHERE is_delete = false AND tags IS NOT NULL AND tags->'tags' IS NOT NULL " +
"GROUP BY tag_value ORDER BY count DESC, tag_value ASC";
try {
return jdbcTemplate.queryForList(sql);
} catch (Exception e) {
log.warn("获取标签列表失败", e);
return List.of();
}
}
// ==================== 分块管理 ====================
/**

12
src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

@ -201,6 +201,18 @@ public class FaqMatchEngine {
*/
private Optional<FaqMatchResult> semanticMatch(String question, List<Long> categoryIds) {
try {
// P2 门控:先确认作用域内确有可语义匹配的 FAQ(faq_embedding 有向量记录)。
// 为空直接返回未命中,省掉一次注定失败的语义 embedding HTTP(豆包多模态模型逐条调用较慢)。
List<Object> 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();

5
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 中覆盖

Loading…
Cancel
Save