本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1044 lines
41 KiB

package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.KnowledgeCategoryMapper;
import com.wok.supportbot.dao.KnowledgeDocumentMapper;
import com.wok.supportbot.document.extract.JsonDocumentLoader;
import com.wok.supportbot.document.extract.MarkdownDocumentLoader;
import com.wok.supportbot.document.extract.SimpleStringDocumentReader;
import com.wok.supportbot.document.extract.TikaDocumentReader;
import com.wok.supportbot.document.transform.MyKeywordEnricher;
import com.wok.supportbot.document.transform.MyTokenTextSplitter;
import com.wok.supportbot.entity.CategoryNode;
import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument;
import com.wok.supportbot.entity.SearchResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.stream.Collectors;
/**
* 知识库文档管理服务
* 统一管理文档的上传、删除、搜索、统计、分类等操作
*/
@Service
@Slf4j
public class DocumentService {
@Autowired
private KnowledgeDocumentMapper documentMapper;
@Autowired
private KnowledgeCategoryMapper categoryMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private VectorStore pgVectorVectorStore;
@Autowired
private MyTokenTextSplitter myTokenTextSplitter;
@Autowired
private MyKeywordEnricher myKeywordEnricher;
@Autowired
private TikaDocumentReader tikaDocumentReader;
@Autowired
private SimpleStringDocumentReader simpleStringDocumentReader;
@Autowired
private MarkdownDocumentLoader markdownDocumentLoader;
@Autowired
private JsonDocumentLoader jsonDocumentLoader;
@Autowired
private DocumentProcessingService documentProcessingService;
// ==================== 文档上传 ====================
/**
* 统一文档上传流程:创建记录(PROCESSING)→ 触发异步处理
* 同步阶段:内容去重检查 + 创建 PROCESSING 记录
* 异步阶段:分块 → 关键词提取 → 向量化 → 更新状态为 READY
*
* @param documents 解析后的文档列表
* @param title 文档标题
* @param sourceName 源文件名
* @param fileType 文件类型
* @param fileSize 文件大小
* @param content 原文内容(截断预览)
* @param categoryId 分类ID
* @param tags 标签列表
* @param chunkSize 分块大小(可选,覆盖全局配置)
* @param overlap 重叠大小(可选,覆盖全局配置)
* @return 创建完成的文档记录(status=PROCESSING)
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName,
String fileType, Long fileSize, String content,
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
// 0. 内容去重检查
String contentHash = computeContentHash(content);
if (contentHash != null) {
String duplicateTitle = checkContentDuplicate(contentHash);
if (duplicateTitle != null) {
throw new RuntimeException("文档内容重复,已有同名文档: " + duplicateTitle);
}
}
// 存储 per-doc 分块参数到 extraConfig(便于重新处理时复用)
Map<String, Object> extraConfig = new HashMap<>();
if (chunkSize != null) extraConfig.put("chunkSize", chunkSize);
if (overlap != null) extraConfig.put("overlap", overlap);
// 1. 创建文档记录(状态 PROCESSING)
KnowledgeDocument docRecord = KnowledgeDocument.builder()
.title(title != null ? title : sourceName)
.sourceName(sourceName)
.fileType(fileType)
.fileSize(fileSize != null ? fileSize : 0L)
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.categoryId(categoryId != null ? categoryId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash)
.enabled(true)
.extraConfig(extraConfig.isEmpty() ? null : extraConfig)
.status("PROCESSING")
.chunkCount(0)
.build();
documentMapper.insert(docRecord);
// 2. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态)
documentProcessingService.processDocumentAsync(
docRecord.getId(), documents, sourceName, title, categoryId, tags,
chunkSize, overlap);
log.info("文档已创建,后台处理中: id={}, title={}", docRecord.getId(), docRecord.getTitle());
return docRecord;
}
/**
* 解析文件并上传
*/
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = tikaDocumentReader.read(file);
String fileType = getFileExtension(file.getOriginalFilename());
return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
fileType,
file.getSize(),
documents.get(0).getText(),
categoryId,
tags, chunkSize, overlap);
}
/**
* 解析字符串并上传
*/
public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = simpleStringDocumentReader.read(content);
return uploadDocument(documents, title, title, "txt",
(long) content.length(), content, categoryId, tags, chunkSize, overlap);
}
/**
* 解析 Markdown 文件并上传
*/
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
"md",
file.getSize(),
content,
categoryId,
tags, chunkSize, overlap);
}
/**
* 解析 JSON 文件(基本方式)并上传
*/
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadBasicJson(file);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
"json",
file.getSize(),
content,
categoryId,
tags, chunkSize, overlap);
}
/**
* 解析 JSON 文件(按字段)并上传
*/
public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title,
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0]));
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
"json",
file.getSize(),
content,
categoryId,
tags, chunkSize, overlap);
}
/**
* 解析 JSON 文件(按指针)并上传
*/
public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title,
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
"json",
file.getSize(),
content,
categoryId,
tags, chunkSize, overlap);
}
// ==================== 文档管理 ====================
/**
* 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选)
*/
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status, String keyword, String tag) {
// 参数安全校验
if (page < 1) page = 1;
if (size < 1 || size > 100) size = 10;
// LIKE 通配符转义
String kw = (keyword != null && !keyword.trim().isEmpty())
? keyword.trim().replace("%", "\\%").replace("_", "\\_") : null;
// 构建基础条件(用于 count 和 list)
QueryWrapper<KnowledgeDocument> countWrapper = new QueryWrapper<>();
if (categoryId != null && categoryId > 0) {
countWrapper.eq("category_id", categoryId);
}
if (status != null && !status.isEmpty()) {
countWrapper.eq("status", status);
}
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);
// 构建列表查询条件
QueryWrapper<KnowledgeDocument> listWrapper = new QueryWrapper<>();
if (categoryId != null && categoryId > 0) {
listWrapper.eq("category_id", categoryId);
}
if (status != null && !status.isEmpty()) {
listWrapper.eq("status", status);
}
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);
}
}
listWrapper.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeDocument> records = documentMapper.selectList(listWrapper);
Map<String, Object> result = new HashMap<>();
result.put("records", records);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
return result;
}
/**
* 获取文档详情
*/
public KnowledgeDocument getDocumentDetail(Long id) {
return documentMapper.selectById(id);
}
/**
* 获取文档的所有分块
*/
public List<Map<String, Object>> getDocumentChunks(Long id) {
String documentId = String.valueOf(id);
String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " +
"WHERE metadata->>'documentId' = ? " +
" OR metadata->>'document_id' = ? " +
" OR metadata->>'docId' = ? " +
"ORDER BY " + chunkOrderSql();
List<Map<String, Object>> chunks = jdbcTemplate.queryForList(sql, documentId, documentId, documentId);
if (!chunks.isEmpty()) {
return chunks;
}
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
return chunks;
}
return getDocumentChunksByTitleFallback(doc);
}
private List<Map<String, Object>> getDocumentChunksByTitleFallback(KnowledgeDocument doc) {
List<String> filters = new ArrayList<>();
List<Object> params = new ArrayList<>();
if (doc.getSourceName() != null && !doc.getSourceName().isBlank()) {
filters.add("metadata->>'sourceName' = ?");
params.add(doc.getSourceName());
}
if (doc.getTitle() != null && !doc.getTitle().isBlank()) {
filters.add("metadata->>'title' = ?");
params.add(doc.getTitle());
}
if (filters.isEmpty()) {
return List.of();
}
String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " +
"WHERE " + String.join(" OR ", filters) + " " +
"ORDER BY " + chunkOrderSql();
if (doc.getChunkCount() != null && doc.getChunkCount() > 0) {
sql += " LIMIT ?";
params.add(doc.getChunkCount());
}
return jdbcTemplate.queryForList(sql, params.toArray());
}
private String chunkOrderSql() {
return "CASE WHEN metadata->>'chunkIndex' ~ '^[0-9]+$' " +
"THEN (metadata->>'chunkIndex')::int ELSE 0 END ASC, create_time ASC, id::text ASC";
}
/**
* 删除文档(逻辑删除 + 级联删除向量)
*/
@Transactional(rollbackFor = Exception.class)
public int deleteDocument(Long id) {
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
throw new RuntimeException("文档不存在");
}
// 删除关联的向量
int vectorCount = deleteVectorsByDocumentId(String.valueOf(id));
// 逻辑删除文档记录
documentMapper.deleteById(id);
log.info("删除文档: id={}, title={}, 删除向量数={}", id, doc.getTitle(), vectorCount);
return vectorCount;
}
/**
* 批量删除文档
*
* @param ids 文档ID列表
* @return 批量操作结果
*/
public Map<String, Object> batchDeleteDocuments(List<Long> ids) {
int successCount = 0;
int failCount = 0;
List<Map<String, Object>> details = new ArrayList<>();
for (Long id : ids) {
try {
int vectorCount = deleteDocument(id);
successCount++;
details.add(Map.of("id", id.toString(), "success", true, "deletedVectors", vectorCount));
} catch (Exception e) {
failCount++;
details.add(Map.of("id", id.toString(), "success", false, "message", e.getMessage()));
log.warn("批量删除文档失败: id={}", id, e);
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("details", details);
return result;
}
/**
* 批量重新处理文档
*
* @param ids 文档ID列表
* @return 批量操作结果
*/
public Map<String, Object> batchReprocessDocuments(List<Long> ids) {
int successCount = 0;
int failCount = 0;
List<Map<String, Object>> details = new ArrayList<>();
for (Long id : ids) {
try {
reprocessDocument(id);
successCount++;
details.add(Map.of("id", id.toString(), "success", true));
} catch (Exception e) {
failCount++;
details.add(Map.of("id", id.toString(), "success", false, "message", e.getMessage()));
log.warn("批量重新处理文档失败: id={}", id, e);
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("details", details);
return result;
}
/**
* 重新处理文档(异步:先标记为 PROCESSING,后台重新分块 + 向量化)
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeDocument reprocessDocument(Long id) {
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
throw new RuntimeException("文档不存在");
}
if (doc.getContent() == null || doc.getContent().isEmpty()) {
throw new RuntimeException("文档无内容,无法重新处理");
}
// 标记为 PROCESSING,清空旧状态
doc.setStatus("PROCESSING");
doc.setChunkCount(0);
doc.setErrorMessage(null);
documentMapper.updateById(doc);
// 解析内容并触发异步处理
List<Document> documents = simpleStringDocumentReader.read(doc.getContent());
documentProcessingService.reprocessDocumentAsync(id, documents);
log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle());
return doc;
}
/**
* 更新文档元信息
*/
public void updateDocumentMetadata(Long id, String title, Long categoryId, List<String> tags) {
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
throw new RuntimeException("文档不存在");
}
if (title != null && !title.isEmpty()) {
doc.setTitle(title);
}
if (categoryId != null) {
doc.setCategoryId(categoryId);
}
if (tags != null) {
doc.setTags(Map.of("tags", tags));
}
documentMapper.updateById(doc);
// 同步更新 vector_store 中对应的 metadata
// 注意:Spring AI 当前没有直接更新 metadata 的 API
// 这里我们先更新文档记录,metadata 的同步留到后续优化
log.info("更新文档元信息: id={}, title={}", id, doc.getTitle());
}
// ==================== 启用/禁用 ====================
/**
* P1-2.1: 切换文档启用/禁用状态
* 禁用后不参与 RAG 检索但保留数据,同步更新 vector_store metadata
* 使用原子翻转 SQL 避免并发竞态
*/
public KnowledgeDocument toggleDocument(Long id) {
// 原子翻转:直接在 DB 层 NOT enabled,避免先 SELECT 再 UPDATE 的竞态问题
int updated = jdbcTemplate.update(
"UPDATE knowledge_document SET enabled = NOT enabled WHERE id = ? AND is_delete = false",
id);
if (updated == 0) {
throw new RuntimeException("文档不存在或已被删除");
}
// 查询最新状态
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
throw new RuntimeException("文档不存在");
}
// 同步更新 vector_store 中的 enabled metadata
syncVectorEnabledMetadata(String.valueOf(id), Boolean.TRUE.equals(doc.getEnabled()));
log.info("文档状态切换: id={}, enabled={}", id, doc.getEnabled());
return doc;
}
/**
* P1-2.1: 批量启用/禁用文档
*/
public Map<String, Object> batchToggleDocuments(List<Long> ids, boolean enabled) {
int successCount = 0;
int failCount = 0;
for (Long id : ids) {
try {
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
failCount++;
continue;
}
doc.setEnabled(enabled);
documentMapper.updateById(doc);
syncVectorEnabledMetadata(String.valueOf(id), enabled);
successCount++;
} catch (Exception e) {
failCount++;
log.warn("批量切换文档状态失败: id={}", id, e);
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
return result;
}
/**
* 同步更新 vector_store 中指定文档所有分块的 enabled metadata
* 使用 JdbcTemplate 直接 UPDATE JSON 字段
* 注意:Spring AI 的 PgVectorStore 自动建表时 metadata 是 json 类型,不是 jsonb
* 统一写入字符串类型 "true"/"false",与 searchDocuments 的过滤条件保持一致
*/
private void syncVectorEnabledMetadata(String documentId, boolean enabled) {
// 使用 to_jsonb(?::text) 将字符串转换为 JSONB 字符串值,与 searchDocuments 过滤条件一致
String sql = "UPDATE vector_store SET metadata = (jsonb_set(metadata::jsonb, '{enabled}', " +
"to_jsonb(?::text)))::json WHERE metadata->>'documentId' = ?";
jdbcTemplate.update(sql, enabled ? "true" : "false", documentId);
}
// ==================== 批量移动分类 ====================
/**
* P1-2.2: 批量移动文档到目标分类
* 仅更新 category_id,不影响向量数据
*/
@Transactional(rollbackFor = Exception.class)
public int batchMoveDocuments(List<Long> ids, Long categoryId) {
Long targetCategoryId = categoryId != null ? categoryId : 0L;
int updated = 0;
for (Long id : ids) {
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc != null) {
doc.setCategoryId(targetCategoryId);
documentMapper.updateById(doc);
updated++;
}
}
log.info("批量移动文档分类: ids={}, categoryId={}, 实际更新={}", ids, targetCategoryId, updated);
return updated;
}
// ==================== 标签管理 ====================
/**
* 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();
}
}
// ==================== 分块管理 ====================
/**
* P1-2.5: 更新单个分块内容(同步更新 vector_store 并重新向量化)
*/
@Transactional(rollbackFor = Exception.class)
public void updateChunk(Long docId, int chunkIndex, String newContent) {
KnowledgeDocument doc = documentMapper.selectById(docId);
if (doc == null) {
throw new RuntimeException("文档不存在");
}
String docIdStr = String.valueOf(docId);
// 查找对应的向量记录 ID
String selectSql = "SELECT id::text FROM vector_store " +
"WHERE (metadata->>'documentId' = ? OR metadata->>'document_id' = ?) " +
"AND metadata->>'chunkIndex' = ? " +
"LIMIT 1";
List<String> vectorIds = jdbcTemplate.queryForList(selectSql, String.class,
docIdStr, docIdStr, String.valueOf(chunkIndex));
if (vectorIds.isEmpty()) {
throw new RuntimeException("未找到对应的分块向量记录");
}
String vectorId = vectorIds.get(0);
// 删除旧向量
pgVectorVectorStore.delete(List.of(vectorId));
// 构建新文档并重新向量化
Map<String, Object> meta = new HashMap<>();
meta.put("documentId", docIdStr);
meta.put("chunkIndex", chunkIndex);
meta.put("sourceName", doc.getSourceName());
meta.put("title", doc.getTitle());
if (doc.getCategoryId() != null && doc.getCategoryId() > 0) {
meta.put("categoryId", String.valueOf(doc.getCategoryId()));
}
if (doc.getTags() != null && doc.getTags().containsKey("tags")) {
meta.put("tags", doc.getTags().get("tags"));
}
// 标记启用状态,与文档当前状态保持一致
meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled())));
Document newDoc = new Document(vectorId, newContent, meta);
// 关键词提取
List<Document> enriched = myKeywordEnricher.enrichDocuments(List.of(newDoc));
pgVectorVectorStore.add(enriched);
log.info("更新分块: docId={}, chunkIndex={}, vectorId={}", docId, chunkIndex, vectorId);
}
/**
* P1-2.5: 删除单个分块(同步从 vector_store 移除向量)
*/
@Transactional(rollbackFor = Exception.class)
public void deleteChunk(Long docId, int chunkIndex) {
KnowledgeDocument doc = documentMapper.selectById(docId);
if (doc == null) {
throw new RuntimeException("文档不存在");
}
String docIdStr = String.valueOf(docId);
// 查找对应的向量记录 ID
String selectSql = "SELECT id::text FROM vector_store " +
"WHERE (metadata->>'documentId' = ? OR metadata->>'document_id' = ?) " +
"AND metadata->>'chunkIndex' = ?";
List<String> vectorIds = jdbcTemplate.queryForList(selectSql, String.class,
docIdStr, docIdStr, String.valueOf(chunkIndex));
if (!vectorIds.isEmpty()) {
pgVectorVectorStore.delete(vectorIds);
}
// 更新文档的分块计数
doc.setChunkCount(Math.max(0, (doc.getChunkCount() != null ? doc.getChunkCount() : 1) - vectorIds.size()));
documentMapper.updateById(doc);
log.info("删除分块: docId={}, chunkIndex={}, 删除向量数={}", docId, chunkIndex, vectorIds.size());
}
// ==================== 语义搜索 ====================
/**
* 语义搜索
*/
public List<SearchResult> searchDocuments(String query, int topK, double similarityThreshold, Long categoryId) {
List<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
return searchDocuments(query, topK, similarityThreshold, categoryIds);
}
/**
* 语义搜索(支持多个知识库分类过滤)
*/
public List<SearchResult> searchDocuments(String query, int topK, double similarityThreshold, List<Long> categoryIds) {
SearchRequest.Builder searchBuilder = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(similarityThreshold);
FilterExpressionBuilder builder = new FilterExpressionBuilder();
// P1-2.1: 始终过滤禁用的文档,只检索启用的
var enabledFilter = builder.eq("enabled", "true");
List<String> categoryIdStrings = normalizeCategoryIds(categoryIds);
if (!categoryIdStrings.isEmpty()) {
List<Object> values = categoryIdStrings.stream()
.map(value -> (Object) value)
.collect(Collectors.toList());
var categoryFilter = builder.in("categoryId", values);
searchBuilder.filterExpression(builder.and(enabledFilter, categoryFilter).build());
} else {
searchBuilder.filterExpression(enabledFilter.build());
}
List<Document> results;
try {
results = pgVectorVectorStore.similaritySearch(searchBuilder.build());
} catch (Exception e) {
if (categoryIdStrings.isEmpty()) {
throw e;
}
log.warn("向量检索分类过滤失败,改用本地 metadata 过滤兜底: categoryIds={}", categoryIdStrings, e);
results = pgVectorVectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.topK(Math.max(topK * 5, 20))
.similarityThreshold(similarityThreshold)
.build())
.stream()
.filter(doc -> categoryIdStrings.contains(getStringFromMetadata(doc.getMetadata(), "categoryId")))
.filter(doc -> "true".equals(getStringFromMetadata(doc.getMetadata(), "enabled")))
.limit(topK)
.collect(Collectors.toList());
}
List<SearchResult> searchResults = new ArrayList<>();
for (Document doc : results) {
Map<String, Object> metadata = doc.getMetadata();
SearchResult result = SearchResult.builder()
.id(doc.getId())
.content(doc.getText())
.score(metadata.containsKey("distance") ? ((Number) metadata.get("distance")).doubleValue() : null)
.sourceName(getStringFromMetadata(metadata, "sourceName"))
.title(getStringFromMetadata(metadata, "title"))
.chunkIndex(getIntegerFromMetadata(metadata, "chunkIndex"))
.documentId(getStringFromMetadata(metadata, "documentId"))
.metadata(metadata)
.build();
searchResults.add(result);
}
return searchResults;
}
/**
* 多模式搜索(P0-001: 支持向量/关键词/混合检索)
*
* @param searchMode 检索模式:VECTOR(默认)/ KEYWORD / HYBRID
*/
public List<SearchResult> searchDocuments(String query, int topK, double similarityThreshold,
List<Long> categoryIds, String searchMode) {
if (searchMode == null || searchMode.isEmpty() || "VECTOR".equalsIgnoreCase(searchMode)) {
// 默认向量检索,向后兼容
List<SearchResult> results = searchDocuments(query, topK, similarityThreshold, categoryIds);
results.forEach(r -> r.setSearchMode("VECTOR"));
return results;
}
// 委托给 HybridSearchService 处理 KEYWORD 和 HYBRID 模式
if (hybridSearchService != null) {
com.wok.supportbot.rag.SearchMode mode;
try {
mode = com.wok.supportbot.rag.SearchMode.valueOf(searchMode.toUpperCase());
} catch (IllegalArgumentException e) {
mode = com.wok.supportbot.rag.SearchMode.VECTOR;
}
return hybridSearchService.search(query, mode, topK, similarityThreshold, categoryIds);
}
// HybridSearchService 不可用时降级到向量检索
log.warn("HybridSearchService 不可用,降级为向量检索");
return searchDocuments(query, topK, similarityThreshold, categoryIds);
}
@Autowired(required = false)
private com.wok.supportbot.rag.HybridSearchService hybridSearchService;
private List<String> normalizeCategoryIds(List<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return Collections.emptyList();
}
return categoryIds.stream()
.filter(Objects::nonNull)
.filter(id -> id > 0)
.map(String::valueOf)
.distinct()
.collect(Collectors.toList());
}
// ==================== 统计 ====================
/**
* 获取知识库统计信息
*/
public Map<String, Object> getStats() {
// 文档统计
Long totalDocuments = documentMapper.selectCount(null);
// 按文件类型统计
String typeSql = "SELECT file_type, COUNT(*) as count FROM knowledge_document WHERE is_delete = false GROUP BY file_type";
List<Map<String, Object>> typeStats = jdbcTemplate.queryForList(typeSql);
Map<String, Long> byFileType = typeStats.stream()
.collect(Collectors.toMap(
r -> (String) r.get("file_type"),
r -> ((Number) r.get("count")).longValue()
));
// 按分类统计
String catSql = "SELECT c.name, COUNT(d.id) as count FROM knowledge_document d " +
"LEFT JOIN knowledge_category c ON d.category_id = c.id " +
"WHERE d.is_delete = false GROUP BY c.name";
List<Map<String, Object>> catStats;
try {
catStats = jdbcTemplate.queryForList(catSql);
} catch (Exception e) {
catStats = new ArrayList<>();
}
// 向量总数
String vectorSql = "SELECT COUNT(*) FROM vector_store";
Long totalVectors;
try {
totalVectors = jdbcTemplate.queryForObject(vectorSql, Long.class);
} catch (Exception e) {
totalVectors = 0L;
}
// 最近上传时间
String lastUploadSql = "SELECT MAX(create_time) FROM knowledge_document WHERE is_delete = false";
Date lastUploadTime = jdbcTemplate.queryForObject(lastUploadSql, Date.class);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalDocuments", totalDocuments);
stats.put("totalVectors", totalVectors);
stats.put("lastUploadTime", lastUploadTime);
stats.put("byFileType", byFileType);
stats.put("byCategory", catStats);
return stats;
}
// ==================== 分类管理 ====================
/**
* 获取分类树
*/
public List<CategoryNode> getCategoryTree() {
List<KnowledgeCategory> categories = categoryMapper.selectList(
new QueryWrapper<KnowledgeCategory>().orderByAsc("sort_order"));
Map<Long, CategoryNode> nodeMap = new LinkedHashMap<>();
List<CategoryNode> rootNodes = new ArrayList<>();
for (KnowledgeCategory cat : categories) {
CategoryNode node = CategoryNode.builder()
.id(cat.getId())
.name(cat.getName())
.description(cat.getDescription())
.parentId(cat.getParentId())
.sortOrder(cat.getSortOrder())
.documentCount(cat.getDocumentCount())
.children(new ArrayList<>())
.build();
nodeMap.put(cat.getId(), node);
}
for (CategoryNode node : nodeMap.values()) {
if (node.getParentId() == null || node.getParentId() == 0) {
rootNodes.add(node);
} else {
CategoryNode parent = nodeMap.get(node.getParentId());
if (parent != null) {
parent.getChildren().add(node);
} else {
rootNodes.add(node);
}
}
}
return rootNodes;
}
/**
* 获取分类列表
*/
public List<KnowledgeCategory> listCategories() {
return categoryMapper.selectList(
new QueryWrapper<KnowledgeCategory>().orderByAsc("sort_order"));
}
/**
* 创建分类
*/
public KnowledgeCategory createCategory(String name, String description, Long parentId, Integer sortOrder) {
KnowledgeCategory category = KnowledgeCategory.builder()
.name(name)
.description(description)
.parentId(parentId != null ? parentId : 0L)
.sortOrder(sortOrder != null ? sortOrder : 0)
.documentCount(0)
.build();
categoryMapper.insert(category);
return category;
}
/**
* 更新分类
*/
public void updateCategory(Long id, String name, String description, Integer sortOrder) {
KnowledgeCategory category = categoryMapper.selectById(id);
if (category == null) {
throw new RuntimeException("分类不存在");
}
if (name != null && !name.isEmpty()) {
category.setName(name);
}
if (description != null) {
category.setDescription(description);
}
if (sortOrder != null) {
category.setSortOrder(sortOrder);
}
categoryMapper.updateById(category);
}
/**
* 删除分类(不删除文档,仅清空关联)
*/
@Transactional(rollbackFor = Exception.class)
public void deleteCategory(Long id) {
// 将关联的文档 category_id 设为 0
KnowledgeDocument updateDoc = new KnowledgeDocument();
updateDoc.setCategoryId(0L);
documentMapper.update(updateDoc, new QueryWrapper<KnowledgeDocument>().eq("category_id", id));
// 逻辑删除分类
categoryMapper.deleteById(id);
}
// ==================== 内部方法 ====================
/**
* 计算文本内容的 SHA-256 哈希值
*/
private String computeContentHash(String content) {
if (content == null || content.isEmpty()) {
return null;
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
log.error("SHA-256 算法不可用", e);
return null;
}
}
/**
* 检查内容是否重复
* @param contentHash 内容哈希值
* @return 重复文档的标题,如果不存在重复则返回 null
*/
private String checkContentDuplicate(String contentHash) {
if (contentHash == null) {
return null;
}
QueryWrapper<KnowledgeDocument> wrapper = new QueryWrapper<>();
wrapper.eq("content_hash", contentHash);
wrapper.select("title");
KnowledgeDocument existing = documentMapper.selectOne(wrapper);
return existing != null ? existing.getTitle() : null;
}
/**
* 根据文档ID删除 vector_store 中关联的所有向量
*/
private int deleteVectorsByDocumentId(String documentId) {
String sql = "SELECT id::text FROM vector_store WHERE metadata->>'documentId' = ?";
List<String> ids = jdbcTemplate.queryForList(sql, String.class, documentId);
if (!ids.isEmpty()) {
pgVectorVectorStore.delete(ids);
log.debug("删除向量: documentId={}, count={}", documentId, ids.size());
}
return ids.size();
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "unknown";
}
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
/**
* 从 metadata 中安全获取字符串值
*/
private String getStringFromMetadata(Map<String, Object> metadata, String key) {
Object value = metadata.get(key);
return value != null ? value.toString() : null;
}
/**
* 从 metadata 中安全获取整数值
*/
private Integer getIntegerFromMetadata(Map<String, Object> metadata, String key) {
Object value = metadata.get(key);
if (value == null) return null;
if (value instanceof Number) {
return ((Number) value).intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
}