mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 21:13:51 +08:00
fix:实现知识库文档与RAGFlow双向同步,支持远端上传/删除自动对账
This commit is contained in:
@@ -187,4 +187,11 @@ public class RedisKeys {
|
|||||||
public static String getOtaUploadCountKey(Long username) {
|
public static String getOtaUploadCountKey(Long username) {
|
||||||
return "ota:upload:count:" + username;
|
return "ota:upload:count:" + username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档全量同步冷却标记key
|
||||||
|
*/
|
||||||
|
public static String getDocumentSyncKey(String datasetId) {
|
||||||
|
return "knowledge:doc:sync:" + datasetId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -120,4 +120,13 @@ public interface KnowledgeFilesService {
|
|||||||
* 同步所有处于 RUNNING 状态的文档 (供定时任务调用)
|
* 同步所有处于 RUNNING 状态的文档 (供定时任务调用)
|
||||||
*/
|
*/
|
||||||
void syncRunningDocuments();
|
void syncRunningDocuments();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从RAGFlow全量同步文档到本地影子表
|
||||||
|
* 拉取远端所有文档,与本地影子表对比,插入缺失的记录
|
||||||
|
*
|
||||||
|
* @param datasetId 数据集ID
|
||||||
|
* @return 新同步的文档数量
|
||||||
|
*/
|
||||||
|
int syncDocumentsFromRAG(String datasetId);
|
||||||
}
|
}
|
||||||
+205
-2
@@ -4,6 +4,8 @@ import java.util.ArrayList;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
@@ -76,6 +78,27 @@ public class KnowledgeFilesServiceImpl extends BaseServiceImpl<DocumentDao, Docu
|
|||||||
throw new RenException(ErrorCode.RAG_DATASET_ID_AND_MODEL_ID_NOT_NULL);
|
throw new RenException(ErrorCode.RAG_DATASET_ID_AND_MODEL_ID_NOT_NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全量对账同步: 从RAGFlow拉取远端文档,补充本地影子表缺失的记录
|
||||||
|
// 当本地影子表为空时,强制同步(无视冷却),避免删除后重新上传无法感知
|
||||||
|
String syncKey = RedisKeys.getDocumentSyncKey(datasetId);
|
||||||
|
boolean cooldownActive = redisUtils.get(syncKey) != null;
|
||||||
|
boolean localEmpty = false;
|
||||||
|
if (cooldownActive) {
|
||||||
|
Long localCount = documentDao.selectCount(
|
||||||
|
new QueryWrapper<DocumentEntity>().eq("dataset_id", datasetId));
|
||||||
|
localEmpty = localCount == null || localCount == 0;
|
||||||
|
}
|
||||||
|
if (!cooldownActive || localEmpty) {
|
||||||
|
try {
|
||||||
|
self.syncDocumentsFromRAG(datasetId);
|
||||||
|
// 60秒冷却,避免每次翻页都触发远端调用(本地为空时缩短为10秒)
|
||||||
|
int cooldownSec = localEmpty ? 10 : 60;
|
||||||
|
redisUtils.set(syncKey, "1", cooldownSec);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("从RAGFlow全量同步文档失败(不影响本地查询): datasetId={}, error={}", datasetId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 获取本地影子表数据 (MyBatis-Plus 分页)
|
// 1. 获取本地影子表数据 (MyBatis-Plus 分页)
|
||||||
Page<DocumentEntity> pageParams = new Page<>(page, limit);
|
Page<DocumentEntity> pageParams = new Page<>(page, limit);
|
||||||
QueryWrapper<DocumentEntity> queryWrapper = new QueryWrapper<>();
|
QueryWrapper<DocumentEntity> queryWrapper = new QueryWrapper<>();
|
||||||
@@ -512,7 +535,7 @@ public class KnowledgeFilesServiceImpl extends BaseServiceImpl<DocumentDao, Docu
|
|||||||
// 4. 原子化清理本地影子记录并同步统计数据
|
// 4. 原子化清理本地影子记录并同步统计数据
|
||||||
self.deleteDocumentShadows(documentIds, datasetId, totalChunkDelta, totalTokenDelta);
|
self.deleteDocumentShadows(documentIds, datasetId, totalChunkDelta, totalTokenDelta);
|
||||||
|
|
||||||
// 5. 清理缓存
|
// 5. 清理缓存,并清除同步冷却标记以允许立即感知可能的重新上传
|
||||||
try {
|
try {
|
||||||
String cacheKey = RedisKeys.getKnowledgeBaseCacheKey(datasetId);
|
String cacheKey = RedisKeys.getKnowledgeBaseCacheKey(datasetId);
|
||||||
redisUtils.delete(cacheKey);
|
redisUtils.delete(cacheKey);
|
||||||
@@ -520,6 +543,12 @@ public class KnowledgeFilesServiceImpl extends BaseServiceImpl<DocumentDao, Docu
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("驱逐 Redis 缓存失败: {}", e.getMessage());
|
log.warn("驱逐 Redis 缓存失败: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
String syncKey = RedisKeys.getDocumentSyncKey(datasetId);
|
||||||
|
redisUtils.delete(syncKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("清除同步冷却标记失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
log.info("=== 批量文档清理完成 ===");
|
log.info("=== 批量文档清理完成 ===");
|
||||||
}
|
}
|
||||||
@@ -738,6 +767,180 @@ public class KnowledgeFilesServiceImpl extends BaseServiceImpl<DocumentDao, Docu
|
|||||||
this.deleteDocuments(datasetId, req);
|
this.deleteDocuments(datasetId, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int syncDocumentsFromRAG(String datasetId) {
|
||||||
|
log.info("=== 开始从RAGFlow全量同步文档到本地影子表: datasetId={} ===", datasetId);
|
||||||
|
|
||||||
|
// 1. 获取适配器
|
||||||
|
Map<String, Object> ragConfig = knowledgeBaseService.getRAGConfigByDatasetId(datasetId);
|
||||||
|
KnowledgeBaseAdapter adapter = KnowledgeBaseAdapterFactory.getAdapter(extractAdapterType(ragConfig), ragConfig);
|
||||||
|
|
||||||
|
// 2. 分页拉取远端所有文档
|
||||||
|
List<KnowledgeFilesDTO> allRemoteDocs = new ArrayList<>();
|
||||||
|
int pageNum = 1;
|
||||||
|
int pageSize = 100;
|
||||||
|
long totalRemote = Long.MAX_VALUE;
|
||||||
|
|
||||||
|
while ((long) (pageNum - 1) * pageSize < totalRemote) {
|
||||||
|
DocumentDTO.ListReq req = DocumentDTO.ListReq.builder()
|
||||||
|
.page(pageNum)
|
||||||
|
.pageSize(pageSize)
|
||||||
|
.build();
|
||||||
|
PageData<KnowledgeFilesDTO> remotePage = adapter.getDocumentList(datasetId, req);
|
||||||
|
if (remotePage == null || remotePage.getList() == null || remotePage.getList().isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
allRemoteDocs.addAll(remotePage.getList());
|
||||||
|
totalRemote = remotePage.getTotal();
|
||||||
|
pageNum++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取本地已有文档
|
||||||
|
List<DocumentEntity> localDocs = documentDao.selectList(
|
||||||
|
new QueryWrapper<DocumentEntity>().eq("dataset_id", datasetId));
|
||||||
|
Set<String> localDocIds = localDocs.stream()
|
||||||
|
.map(DocumentEntity::getDocumentId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
// 4. 远端文档ID集合
|
||||||
|
Set<String> remoteDocIds = allRemoteDocs.stream()
|
||||||
|
.map(KnowledgeFilesDTO::getDocumentId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
// 5. 补充: 插入远端存在但本地缺失的文档
|
||||||
|
List<KnowledgeFilesDTO> newDocs = allRemoteDocs.stream()
|
||||||
|
.filter(doc -> doc.getDocumentId() != null && !localDocIds.contains(doc.getDocumentId()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
int syncCount = 0;
|
||||||
|
if (!newDocs.isEmpty()) {
|
||||||
|
for (KnowledgeFilesDTO doc : newDocs) {
|
||||||
|
try {
|
||||||
|
self.saveDocumentShadow(datasetId, doc, doc.getName(), doc.getChunkMethod(), doc.getParserConfig());
|
||||||
|
// 同步远端已有的 token/chunk 统计
|
||||||
|
Long tokenCount = doc.getTokenCount() != null ? doc.getTokenCount() : 0L;
|
||||||
|
long chunkCount = doc.getChunkCount() != null ? doc.getChunkCount().longValue() : 0L;
|
||||||
|
if (tokenCount > 0 || chunkCount > 0) {
|
||||||
|
knowledgeBaseService.updateStatistics(datasetId, 0, chunkCount, tokenCount);
|
||||||
|
}
|
||||||
|
syncCount++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("同步单个文档影子记录失败: docId={}, error={}", doc.getDocumentId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("从RAGFlow新增同步 {} 个文档影子记录, datasetId={}", syncCount, datasetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 清理: 删除远端已不存在但本地仍保留的影子记录
|
||||||
|
List<DocumentEntity> deletedDocs = localDocs.stream()
|
||||||
|
.filter(entity -> !remoteDocIds.contains(entity.getDocumentId()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (!deletedDocs.isEmpty()) {
|
||||||
|
List<String> deletedDocIds = new ArrayList<>();
|
||||||
|
long totalChunkDelta = 0;
|
||||||
|
long totalTokenDelta = 0;
|
||||||
|
|
||||||
|
for (DocumentEntity entity : deletedDocs) {
|
||||||
|
deletedDocIds.add(entity.getDocumentId());
|
||||||
|
totalChunkDelta += entity.getChunkCount() != null ? entity.getChunkCount() : 0L;
|
||||||
|
totalTokenDelta += entity.getTokenCount() != null ? entity.getTokenCount() : 0L;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self.deleteDocumentShadows(deletedDocIds, datasetId, totalChunkDelta, totalTokenDelta);
|
||||||
|
log.info("清理远端已删除的影子记录: {} 个, datasetId={}", deletedDocs.size(), datasetId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("清理远端已删除的影子记录失败: datasetId={}, error={}", datasetId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 更新: 远端和本地都存在但状态不一致的文档(处理RAGFlow复用documentId重传场景)
|
||||||
|
// 当文档在RAGFlow被删除后重新上传,RAGFlow可能复用同一个documentId,
|
||||||
|
// 导致本地影子记录仍保留旧的CANCEL/FAIL状态而不会被步骤5/6处理
|
||||||
|
Map<String, KnowledgeFilesDTO> remoteDocMap = allRemoteDocs.stream()
|
||||||
|
.filter(doc -> doc.getDocumentId() != null)
|
||||||
|
.collect(Collectors.toMap(KnowledgeFilesDTO::getDocumentId, doc -> doc, (a, b) -> b));
|
||||||
|
|
||||||
|
Map<String, DocumentEntity> localDocMap = localDocs.stream()
|
||||||
|
.collect(Collectors.toMap(DocumentEntity::getDocumentId, e -> e, (a, b) -> b));
|
||||||
|
|
||||||
|
int updateCount = 0;
|
||||||
|
for (Map.Entry<String, KnowledgeFilesDTO> entry : remoteDocMap.entrySet()) {
|
||||||
|
String docId = entry.getKey();
|
||||||
|
DocumentEntity local = localDocMap.get(docId);
|
||||||
|
if (local == null) {
|
||||||
|
continue; // 不在本地,由步骤5处理
|
||||||
|
}
|
||||||
|
KnowledgeFilesDTO remote = entry.getValue();
|
||||||
|
|
||||||
|
// 判断是否需要更新:run状态或status不同,说明远端有变化
|
||||||
|
boolean runChanged = remote.getRun() != null && !remote.getRun().equals(local.getRun());
|
||||||
|
boolean statusChanged = remote.getStatus() != null && !remote.getStatus().equals(local.getStatus());
|
||||||
|
boolean nameChanged = remote.getName() != null && !remote.getName().equals(local.getName());
|
||||||
|
|
||||||
|
if (runChanged || statusChanged || nameChanged) {
|
||||||
|
log.info("影子更新:检测到远端文档状态变化, docId={}, 本地run={}, 远端run={}, 本地status={}, 远端status={}",
|
||||||
|
docId, local.getRun(), remote.getRun(), local.getStatus(), remote.getStatus());
|
||||||
|
|
||||||
|
UpdateWrapper<DocumentEntity> updateWrapper = new UpdateWrapper<DocumentEntity>()
|
||||||
|
.set("run", remote.getRun())
|
||||||
|
.set("status", remote.getStatus() != null ? remote.getStatus() : local.getStatus())
|
||||||
|
.set("progress", remote.getProgress())
|
||||||
|
.set("chunk_count", remote.getChunkCount())
|
||||||
|
.set("token_count", remote.getTokenCount())
|
||||||
|
.set("error", remote.getError())
|
||||||
|
.set("process_duration", remote.getProcessDuration())
|
||||||
|
.set("updated_at", new Date())
|
||||||
|
.set("last_sync_at", new Date())
|
||||||
|
.eq("document_id", docId)
|
||||||
|
.eq("dataset_id", datasetId);
|
||||||
|
|
||||||
|
if (remote.getName() != null) {
|
||||||
|
updateWrapper.set("name", remote.getName());
|
||||||
|
}
|
||||||
|
if (remote.getThumbnail() != null) {
|
||||||
|
updateWrapper.set("thumbnail", remote.getThumbnail());
|
||||||
|
}
|
||||||
|
if (remote.getMetaFields() != null) {
|
||||||
|
try {
|
||||||
|
updateWrapper.set("meta_fields", objectMapper.writeValueAsString(remote.getMetaFields()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("同步更新元数据序列化失败: docId={}, error={}", docId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
documentDao.update(null, updateWrapper);
|
||||||
|
|
||||||
|
// 如果状态从终态(CANCEL/FAIL)变为活跃态(UNSTART/RUNNING),需要同步token统计
|
||||||
|
boolean wasTerminal = "CANCEL".equals(local.getRun()) || "FAIL".equals(local.getRun());
|
||||||
|
boolean isActive = "RUNNING".equals(remote.getRun()) || "UNSTART".equals(remote.getRun());
|
||||||
|
Long remoteTokenCount = remote.getTokenCount() != null ? remote.getTokenCount() : 0L;
|
||||||
|
Long localTokenCount = local.getTokenCount() != null ? local.getTokenCount() : 0L;
|
||||||
|
long remoteChunkCount = remote.getChunkCount() != null ? remote.getChunkCount().longValue() : 0L;
|
||||||
|
long localChunkCount = local.getChunkCount() != null ? local.getChunkCount().longValue() : 0L;
|
||||||
|
if (wasTerminal && isActive) {
|
||||||
|
long tokenDelta = remoteTokenCount - localTokenCount;
|
||||||
|
long chunkDelta = remoteChunkCount - localChunkCount;
|
||||||
|
if (tokenDelta != 0 || chunkDelta != 0) {
|
||||||
|
knowledgeBaseService.updateStatistics(datasetId, 0, chunkDelta, tokenDelta);
|
||||||
|
log.info("影子更新: 修正知识库统计, docId={}, chunkDelta={}, tokenDelta={}", docId, chunkDelta, tokenDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncCount == 0 && deletedDocs.isEmpty() && updateCount == 0) {
|
||||||
|
log.info("本地影子表已与RAGFlow完全同步, datasetId={}", datasetId);
|
||||||
|
} else {
|
||||||
|
log.info("同步完成: 新增={}, 清理={}, 更新={}, datasetId={}", syncCount, deletedDocs.size(), updateCount, datasetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncCount;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void syncRunningDocuments() {
|
public void syncRunningDocuments() {
|
||||||
// 1. 查询所有 RUNNING 状态的文档
|
// 1. 查询所有 RUNNING 状态的文档
|
||||||
@@ -755,7 +958,7 @@ public class KnowledgeFilesServiceImpl extends BaseServiceImpl<DocumentDao, Docu
|
|||||||
|
|
||||||
// 2. 按 DatasetID 分组,复用 Adapter
|
// 2. 按 DatasetID 分组,复用 Adapter
|
||||||
Map<String, List<DocumentEntity>> groupedDocs = runningDocs.stream()
|
Map<String, List<DocumentEntity>> groupedDocs = runningDocs.stream()
|
||||||
.collect(java.util.stream.Collectors.groupingBy(DocumentEntity::getDatasetId));
|
.collect(Collectors.groupingBy(DocumentEntity::getDatasetId));
|
||||||
|
|
||||||
groupedDocs.forEach((datasetId, docs) -> {
|
groupedDocs.forEach((datasetId, docs) -> {
|
||||||
KnowledgeBaseAdapter adapter = null;
|
KnowledgeBaseAdapter adapter = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user