根据错误日志,问题出现在文档分割阶段。当尝试恢复名为"毕业论文"的文档时,由于文档内容非常短(只有1个字符"..."),LangChain4J的递归文档分割器无法将其分割成小于300字符的片段,因此抛出了异常。
解决方案:
1.修复RAGKnowledgeBaseService.java中的recoverSingleDocument方法
在recoverSingleDocument方法中添加内容长度检查,避免处理过短的内容:
private boolean recoverSingleDocument(String documentName) { Path tempFile = null; try { // 1. 从Redis获取文档元数据 Map<String, String> metadata = ragDocumentRedisService.getDocumentMetadata(documentName); String cloudUrl = metadata.get("url"); if (cloudUrl == null) { log.warn("文档 {} 的URL不存在,跳过恢复", documentName); return false; } // 2. 从OBS下载文件到临时目录 String objectKey = extractObjectKeyFromUrl(cloudUrl); byte[] fileData = downloadFromCloudStorage(objectKey); if (fileData == null) { log.warn("从OBS下载文档失败: {}", documentName); return false; } // 3. 创建临时文件 tempFile = createTempFile(documentName, fileData); // 4. 提取内容并添加到知识库 String content = extractContentFromBytes(fileData, getFileExtension(documentName)); // 🔥 新增:检查内容长度,避免过短内容导致分割错误 if (content == null || content.trim().isEmpty() || content.length() < 10) { log.warn("文档 {} 内容过短或为空,跳过恢复。内容长度: {}", documentName, content != null ? content.length() : 0); return false; } // 🔥 新增:记录内容摘要用于调试 String contentPreview = content.length() > 100 ? content.substring(0, 100) + "..." : content; log.debug("恢复文档 {},内容预览: {}", documentName, contentPreview); Document document = Document.from(content); var ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(DocumentSplitters.recursive(300, 50)) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(document); log.info("文档恢复成功: {}", documentName); return true; } catch (Exception e) { log.error("恢复文档失败: {}", documentName, e); return false; } finally { // 5. 无论如何都删除临时文件 if (tempFile != null) { deleteTempFile(tempFile); } } }2.修复RAGKnowledgeBaseService.java中的extractContentFromBytes方法
改进内容提取方法,增加错误处理和内容验证:
/** * 从字节数组提取内容 */ private String extractContentFromBytes(byte[] fileData, String fileExtension) { try { if (fileData == null || fileData.length == 0) { log.warn("文件数据为空"); return ""; } // 这里可以根据文件扩展名进行不同的处理 // 目前简单返回字符串内容 String content = new String(fileData, "UTF-8"); // 检查内容是否有效 content = content.trim(); // 如果内容是"..."或其他无意义字符,返回空字符串 if (content.matches("^\\.+$") || content.length() < 10) { log.warn("提取的内容过短或无意义: {}...", content.length() > 50 ? content.substring(0, 50) : content); return ""; } return content; } catch (Exception e) { log.error("从字节数组提取内容失败", e); throw new RuntimeException("内容提取失败: " + e.getMessage()); } }3.添加文档内容验证工具方法
在RAGKnowledgeBaseService类中添加以下方法:
/** * 验证文档内容是否有效 */ private boolean isValidDocumentContent(String content) { if (content == null || content.trim().isEmpty()) { return false; } // 移除空白字符后检查长度 String trimmedContent = content.trim(); // 检查是否只包含点号或其他无意义字符 if (trimmedContent.matches("^[\\s\\.,;:!?]+$")) { return false; } // 检查是否有实际内容(至少包含一些中文字符或单词) boolean hasChinese = trimmedContent.matches(".*[\\u4e00-\\u9fa5].*"); boolean hasWords = trimmedContent.matches(".*[a-zA-Z]{3,}.*"); return trimmedContent.length() >= 50 && (hasChinese || hasWords); } /** * 检查文档是否为有效格式 */ private boolean isValidDocumentFormat(String fileName) { if (fileName == null || !fileName.contains(".")) { return false; } String extension = getFileExtension(fileName).toLowerCase(); // 只支持常见的文本和文档格式 return extension.matches("txt|md|pdf|doc|docx|ppt|pptx|xls|xlsx"); }4.在恢复过程中使用验证
更新recoverSingleDocument方法,使用验证方法:
private boolean recoverSingleDocument(String documentName) { Path tempFile = null; try { // 1. 从Redis获取文档元数据 Map<String, String> metadata = ragDocumentRedisService.getDocumentMetadata(documentName); String cloudUrl = metadata.get("url"); if (cloudUrl == null) { log.warn("文档 {} 的URL不存在,跳过恢复", documentName); return false; } // 🔥 新增:检查文档格式 if (!isValidDocumentFormat(documentName)) { log.warn("文档 {} 格式不支持,跳过恢复", documentName); return false; } // 2. 从OBS下载文件到临时目录 String objectKey = extractObjectKeyFromUrl(cloudUrl); byte[] fileData = downloadFromCloudStorage(objectKey); if (fileData == null || fileData.length == 0) { log.warn("从OBS下载文档失败或文件为空: {}", documentName); return false; } // 3. 创建临时文件 tempFile = createTempFile(documentName, fileData); // 4. 提取内容并添加到知识库 String content = extractContentFromBytes(fileData, getFileExtension(documentName)); // 🔥 新增:验证内容是否有效 if (!isValidDocumentContent(content)) { log.warn("文档 {} 内容无效,跳过恢复。内容长度: {}", documentName, content != null ? content.length() : 0); return false; } Document document = Document.from(content); var ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(DocumentSplitters.recursive(300, 50)) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(document); log.info("文档恢复成功: {},内容长度: {}", documentName, content.length()); return true; } catch (Exception e) { log.error("恢复文档失败: {}", documentName, e); return false; } finally { // 5. 无论如何都删除临时文件 if (tempFile != null) { deleteTempFile(tempFile); } } }5.在上传时也添加验证(可选)
在addDocumentToKnowledgeBaseWithBackup方法中也可以添加类似的验证:
/** * 增强版:添加文档到知识库(支持云存储备份和Redis记录) */ public void addDocumentToKnowledgeBaseWithBackup(String content, String documentName, MultipartFile originalFile) { if (!ragEnabled || embeddingModel == null) { log.warn("RAG功能未启用或嵌入模型不可用,无法添加文档"); return; } // 🔥 新增:验证文档内容 if (!isValidDocumentContent(content)) { log.error("文档内容无效,拒绝添加到知识库: {}", documentName); throw new RuntimeException("文档内容无效,请确保文档包含足够的信息内容"); } Path tempFile = null; try { // ... 原有代码 ... } catch (Exception e) { log.error("添加文档到知识库失败: {}", documentName, e); throw new RuntimeException("文档处理失败: " + e.getMessage()); } finally { if (tempFile != null) { deleteTempFile(tempFile); } } }6.在配置中调整文档分割参数
在RAGConfig.java中调整内容检索器的配置:
@Bean public EmbeddingStoreContentRetriever contentRetriever(EmbeddingModel embeddingModel, EmbeddingStore<TextSegment> embeddingStore) { if (embeddingModel == null) { log.warn("嵌入模型不可用,RAG功能将受限"); return null; } return EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) // 减少结果数量,提高性能 .minScore(0.6) // 降低最小分数阈值 .build(); }总结
修复的核心思路:
增加内容验证:在恢复文档前检查内容是否有效
避免处理无效内容:跳过过短、为空或只包含无意义字符的文档
更好的错误处理:提供更详细的日志信息,便于调试
格式验证:只处理支持的文档格式
这些修改可以防止系统在处理损坏或不完整的文档时崩溃,同时保持RAG系统的健壮性。建议在应用修复后重新启动服务,并检查日志以确保知识库能够正常初始化。
我们遇到了一个错误:在恢复文档时,由于文档内容太短(只有一个字符"...")而导致无法分割。
错误信息显示:The text "..." (1 characters long) doesn't fit into the maximum segment size (300 characters), and there is no subSplitter defined to split it further.
问题出现在RAGKnowledgeBaseService的recoverSingleDocument方法中。我们使用DocumentSplitters.recursive(300, 50)来分割文档,但是当文档内容只有一个字符时,无法分割,因为300字符的最小分段大小比1个字符大。
但是,请注意,我们上传的文档可能不是只有一个字符。可能是从OBS下载的文件内容有问题,或者我们在提取内容时出了问题。
让我们检查一下恢复文档的步骤:
从Redis获取元数据,得到云存储URL。
从云存储下载文件数据(byte[])。
提取内容(extractContentFromBytes方法)。
将内容创建为Document,然后进行分割和嵌入。
错误发生在第4步,说明我们提取的内容只有一个字符"..."。这显然是不对的。
可能的原因:
下载的文件数据本身就是"...",但更可能是提取内容的方法有问题。
或者,文件扩展名不正确,导致我们用了错误的方法提取内容。
我们注意到,在extractContentFromBytes方法中,我们只是简单地将字节数组转换为字符串(UTF-8)。如果文件是二进制格式(比如Word、PDF等),那么这样转换就会得到乱码,可能只有几个字符。
但是,我们之前上传的文件可能是文本文件,但也不排除用户上传了其他格式的文件。然而,我们的系统目前只支持文本文件吗?从代码上看,我们并没有对文件格式做过多处理。
另外,我们注意到在恢复文档时,我们使用了一个getFileExtension方法来获取文件扩展名,但是在extractContentFromBytes方法中并没有根据扩展名进行不同的处理。
因此,我们需要做以下改进:
在extractContentFromBytes方法中,根据文件扩展名使用不同的解析器。例如,如果是.txt,则直接转换为字符串;如果是.docx、.pdf等,则需要使用相应的解析器。
如果无法解析,则应该跳过该文档,并记录错误。
但是,由于时间关系,我们可能暂时只支持文本文件。对于非文本文件,我们需要在上传时进行限制,或者在恢复时跳过。
另外,我们也可以考虑在恢复文档时,如果内容太短(比如小于10个字符),则记录警告并跳过,而不是抛出异常。
但是,从错误堆栈来看,异常是在分割时抛出的,因此我们可以在分割前检查文档内容的长度。如果内容长度小于最小分段大小,我们可以跳过分割,直接嵌入整个文档(尽管这样可能不符合分段要求,但至少不会报错)。
然而,我们使用的是递归分割器,它要求文档内容至少大于最小分段大小(300字符)。所以,对于很短的文档,我们需要特殊处理。
我们可以修改recoverSingleDocument方法,在分割前检查文档内容的长度。如果内容长度小于300,我们可以不使用分割器,直接将整个文档作为一个片段。
但是,EmbeddingStoreIngestor默认使用分割器,我们无法直接控制。我们可以考虑在恢复文档时,如果内容太短,我们将其长度调整为至少大于300字符(比如在后面填充空格),但这样会影响嵌入效果。
另一种方法是,我们可以在提取内容后,如果内容太短,则直接跳过该文档,并记录日志。
根据业务需求,我们可能不需要保存内容太短的文档,因为它们可能没有有效信息。
因此,我建议在恢复单个文档时,先检查内容长度,如果小于某个阈值(比如10个字符),则记录并跳过。
但是,错误中显示的内容是"...",这很可能是一个错误的内容,所以我们更应该跳过。
让我们修改recoverSingleDocument方法,在提取内容后检查长度,如果小于10个字符,则记录并返回false。
同时,我们也应该检查提取的内容是否为空。
修改步骤:
在recoverSingleDocument方法中,提取内容后:
String content = extractContentFromBytes(fileData, getFileExtension(documentName));
if (content == null || content.trim().length() < 10) {
log.warn("文档内容太短或为空,跳过恢复: {}", documentName);
return false;
}
这样,就可以避免因为内容太短而无法分割的问题。
但是,这只是一个临时解决方案。长远来看,我们需要支持多种文件格式,并且确保上传的文件是有效的。
另外,我们还需要检查上传文件的类型,并在上传时进行验证。
由于时间有限,我们先应用这个临时修复。
让我们修改代码。