Browse Source

fix(doc): 修复重新处理文档会按 2000 字截断预览重建导致内容丢失

问题
- knowledge_document.content 上传时被截断到 2000 字符(有意的预览设计),
  但 reprocessDocument 把它当原文重建分块,而 DocumentProcessingService 以
  cleanBeforeFirstAttempt=true 运行(先删光旧向量)——对超过 2000 字符的文档
  执行 POST /document/batch/reprocess 会永久丢失其余内容

修复
- content 改存原文全文(列已是 TEXT,无需 DDL),并写 extra_config.contentComplete
  作为完整性标记
- 实体 content 加 @JsonIgnore:全文任何接口都不返回(否则 GET /document/list
  无字段投影,每行都会带上整篇正文);文档详情接口单独组装响应,返回 2000 字预览
  (键名仍是 content)与 contentTruncated 标志,前端据此提示截断
- reprocessDocument 的数据源按优先级解析:
  1) 有 contentComplete 标记 → 用库内全文(语义最忠实,JSON 的 fields/pointer
     模式不受影响,也不依赖文件是否还在)
  2) 历史遗留文档(无标记,content 是截断预览)→ 按 fileType 从原始文件重解析
     (md → MarkdownDocumentLoader,json → JsonDocumentLoader,其余 → Tika),
     成功后回填全文并打标记,此后不再依赖原始文件
  3) 两者都不可用 → 明确报错拒绝,绝不静默按截断预览重建
  无法完整还原时在标记 PROCESSING 之前抛错,事务回滚,文档状态与已有向量都不受影响
- contentTruncated 判据覆盖历史数据:长度恰好等于上限且无 contentComplete 标记时
  同样判定为已截断,避免把截断预览误报成完整内容
- 同步 content 列注释(DatabaseInitConfig / init-database.sql / knowledge-base.sql),
  该注释会在下次启动幂等刷新到库

验证(真实库 + 557 篇存量文档环境)
- 上传 4891 字符正文:库内存全文,详情接口返回 2000 字预览 + contentTruncated=true
- 重新处理:chunk_count 与首次完全一致(修复前会降为个位数),内容无丢失
- 模拟历史数据(截断 + 无标记):有文件时从文件重解析成功并回填全文;
  文件也丢失时明确拒绝,且文档状态与 chunk_count 保持不变(向量未被破坏)
- 回填后再删文件重跑仍成功(已自愈);GET /document/list 不再含 content
- RAG 对话回归正常

已知降级
- JSON 的 basic/fields/pointer 解析模式上传时未持久化,历史 JSON 文档从文件重解析
  只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)
- 存量中疑似被截断的文档需重跑一次才能回填全文(均已保留原始文件,均可恢复)
Spring-AI-1.1.2
wanghanlin 2 days ago
parent
commit
2001dc3a0f
  1. 12
      CLAUDE.md
  2. 2
      README.md
  3. 2
      frontend/src/views/DocDetail.vue
  4. 2
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  5. 31
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  6. 8
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  7. 87
      src/main/java/com/wok/supportbot/service/DocumentService.java
  8. 2
      src/main/resources/init-database.sql
  9. 2
      src/main/resources/knowledge-base.sql

12
CLAUDE.md

@ -71,6 +71,18 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
**向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。
**`content` 列存全文 + 重新处理的数据源规则**(`DocumentService`,修复了「重跑丢内容」缺陷):
| 项 | 规则 |
|---|---|
| 存储 | `knowledge_document.content` 存**原文全文**(不再截断),并写 `extra_config.contentComplete = true` 作为完整性标记 |
| 序列化 | 实体 `content``@JsonIgnore`,**任何接口都不返回全文**(否则列表接口每行都带整篇正文)。仅文档详情接口(`DocumentController.toDetailMap`)返回 2000 字预览(键名仍是 `content`)+ `contentTruncated` 布尔标志 |
| 重新处理的数据源 | ① 有 `contentComplete` 标记 → 用库内全文(语义最忠实,如 JSON 的 fields/pointer 模式)② 历史遗留文档(无标记,content 是 2000 字截断预览)→ 按 `fileType` 从原始文件重解析,成功后**回填全文并打标记**(此后不再依赖文件)③ 两者都不可用 → **明确报错拒绝** |
**踩坑记录**:`reprocessDocument` 原先直接 `simpleStringDocumentReader.read(doc.getContent())`,而 `content` 是 2000 字截断预览,且 `DocumentProcessingService``cleanBeforeFirstAttempt=true` 运行(**先删光旧向量**)—— 对超过 2000 字符的文档重跑会永久丢失其余内容。修复前**不要**对存量文档执行 `POST /document/batch/reprocess`。判断遗留文档:`extra_config->>'contentComplete' IS NULL`;其中 `length(content) = 2000` 的才是真正被截断的。
**JSON 解析模式的已知降级**:JSON 的 basic/fields/pointer 三种模式上传时未持久化,历史 JSON 文档从文件重解析只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)。新文档走库内全文分支,语义不变。
## 关键配置

2
README.md

@ -94,7 +94,7 @@ CREATE TABLE knowledge_document (
source_name VARCHAR(500), -- 原始文件名
file_type VARCHAR(20) NOT NULL, -- 文件类型
file_size BIGINT DEFAULT 0, -- 文件大小(字节)
content TEXT, -- 原文内容(截断预览)
content TEXT, -- 原文全文(重处理用;接口仅返回 2000 字预览)
category_id BIGINT DEFAULT 0, -- 所属分类ID
tags JSONB DEFAULT '{}', -- 标签(JSON对象)
chunk_count INTEGER DEFAULT 0, -- 分块数量

2
frontend/src/views/DocDetail.vue

@ -26,7 +26,7 @@
<div v-if="doc.content" class="raw-content">
<div class="raw-header">
<span class="raw-title">{{ doc.sourceName || doc.title || '原文' }}</span>
<t-tag v-if="doc.content && doc.content.length >= 2000" size="small" theme="warning" variant="light">内容已截断仅显示前2000字符</t-tag>
<t-tag v-if="doc.contentTruncated" size="small" theme="warning" variant="light">内容已截断仅显示前2000字符</t-tag>
</div>
<pre class="raw-body">{{ doc.content }}</pre>
</div>

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

@ -1500,7 +1500,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.source_name", "原始文件名");
executeComment("COLUMN knowledge_document.file_type", "文件类型: pdf / md / json / txt / word / excel 等");
executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)");
executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)");
executeComment("COLUMN knowledge_document.content", "原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)");
executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)");
executeComment("COLUMN knowledge_document.folder_id", "所属目录 ID(0 表示未指定目录,直接挂分类根)");
executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)");

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

@ -1,5 +1,7 @@
package com.wok.supportbot.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.entity.CategoryNode;
import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument;
@ -30,6 +32,11 @@ public class DocumentController {
private DocumentService documentService;
@Autowired
private CategoryFilter categoryFilter;
@Autowired
private ObjectMapper objectMapper;
/** 文档详情接口返回的原文预览上限(字符)。仅影响传输展示,不影响存库与重新处理 */
private static final int CONTENT_PREVIEW_LIMIT = 2000;
// ==================== 上传校验常量 ====================
@ -384,7 +391,7 @@ public class DocumentController {
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", doc
"data", toDetailMap(doc)
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
@ -394,6 +401,28 @@ public class DocumentController {
}
}
/**
* 组装文档详情响应
* <p>
* 实体上的 {@code content} {@code @JsonIgnore}存的是全文可达数十万字符不能直接返回给前端
* 此处显式补回键名仍为 {@code content} **截断预览**并附 {@code contentTruncated} 标志供前端提示
* 预览仅用于展示与存库内容与重新处理的数据源无关
*/
private Map<String, Object> toDetailMap(KnowledgeDocument doc) {
Map<String, Object> data = objectMapper.convertValue(doc, new TypeReference<Map<String, Object>>() {});
String content = doc.getContent();
boolean contentComplete = doc.getExtraConfig() != null
&& Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete"));
// 历史遗留文档没有 contentComplete 标记 content 2000 字截断预览
// 长度恰好等于上限时也必须提示已截断否则会把截断预览误报成完整内容
boolean truncated = content != null
&& (content.length() > CONTENT_PREVIEW_LIMIT
|| (content.length() == CONTENT_PREVIEW_LIMIT && !contentComplete));
data.put("content", truncated ? content.substring(0, CONTENT_PREVIEW_LIMIT) : content);
data.put("contentTruncated", truncated);
return data;
}
/**
* 获取文档的所有分块
*/

8
src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java

@ -1,6 +1,7 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.wok.supportbot.handler.PostgresJsonTypeHandler;
@ -63,9 +64,14 @@ public class KnowledgeDocument implements Serializable {
private String filePath;
/**
* 原文内容(截断预览)
* 原文全文用于重新处理时重建分块
* <p>
* {@code @JsonIgnore}全文可达数十万字符**任何接口都不序列化**避免列表/回显接口携带整篇正文
* 文档详情接口DocumentController.getDocumentDetail单独组装响应返回截断后的预览
* 键名仍为 {@code content} {@code contentTruncated} 标志
*/
@TableField("content")
@JsonIgnore
private String content;
/**

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

@ -22,6 +22,7 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -122,6 +123,9 @@ public class DocumentService {
Map<String, Object> extraConfig = new HashMap<>();
if (chunkSize != null) extraConfig.put("chunkSize", chunkSize);
if (overlap != null) extraConfig.put("overlap", overlap);
// 完整性标记本字段起 content 存全文不再截断可安全用于 reprocess
// 历史数据无此标记 content 2000 字截断预览reprocess 据此走从原始文件重解析或明确拒绝
extraConfig.put("contentComplete", true);
// 2. 创建文档记录状态 PROCESSING
KnowledgeDocument docRecord = KnowledgeDocument.builder()
@ -130,13 +134,13 @@ public class DocumentService {
.fileType(fileType)
.fileSize(fileSize != null ? fileSize : 0L)
.filePath(filePath)
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.content(content)
.categoryId(categoryId != null ? categoryId : 0L)
.folderId(folderId != null ? folderId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash)
.enabled(true)
.extraConfig(extraConfig.isEmpty() ? null : extraConfig)
.extraConfig(extraConfig)
.status("PROCESSING")
.chunkCount(0)
.build();
@ -663,6 +667,9 @@ public class DocumentService {
/**
* 重新处理文档异步先标记为 PROCESSING后台重新分块 + 向量化
* <p>
* 数据源解析见 {@link #loadSourceDocuments}优先用库内全文历史遗留文档回退到原始文件重解析
* 两者都不可用时明确报错拒绝 绝不静默按截断预览重建会先删旧向量属于不可逆的数据丢失
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeDocument reprocessDocument(Long id) {
@ -670,8 +677,20 @@ public class DocumentService {
if (doc == null) {
throw new RuntimeException("文档不存在");
}
if (doc.getContent() == null || doc.getContent().isEmpty()) {
throw new RuntimeException("文档无内容,无法重新处理");
// 先解析数据源无法完整还原时直接抛错事务回滚不会破坏已有向量与文档状态
SourceLoad loaded = loadSourceDocuments(doc);
List<Document> documents = loaded.documents();
// 历史遗留文档从原始文件重解析成功后把全文回填进 content 并打上完整性标记
// 该文档此后不再依赖原始文件文件丢失也能重跑详情接口的 contentTruncated 也恢复准确
if (loaded.reparsedFromFile()) {
doc.setContent(documents.stream().map(Document::getText).collect(Collectors.joining("\n")));
Map<String, Object> extra = doc.getExtraConfig() != null
? new HashMap<>(doc.getExtraConfig())
: new HashMap<>();
extra.put("contentComplete", true);
doc.setExtraConfig(extra);
}
// 标记为 PROCESSING清空旧状态
@ -680,14 +699,70 @@ public class DocumentService {
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;
}
/** 重新处理的数据源解析结果 */
private record SourceLoad(List<Document> documents, boolean reparsedFromFile) {}
/**
* 解析重新处理所需的数据源
* <ol>
* <li><b>库内全文优先</b>新文档 content 存的是全文 extraConfig.contentComplete 标记
* 用它可保持与首次上传完全一致的抽取语义例如 JSON fields/pointer 模式且不依赖文件是否还在</li>
* <li><b>回退原始文件</b>历史遗留文档的 content 2000 字截断预览此时按 fileType 重新解析原始文件</li>
* <li><b>明确拒绝</b>两者都不可用时抛错绝不静默用截断预览重建reprocess 会先删旧向量属不可逆丢失</li>
* </ol>
*/
private SourceLoad loadSourceDocuments(KnowledgeDocument doc) {
String content = doc.getContent();
boolean contentComplete = doc.getExtraConfig() != null
&& Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete"));
// 1) 库内全文优先
if (contentComplete && StringUtils.hasText(content)) {
return new SourceLoad(simpleStringDocumentReader.read(content), false);
}
// 2) 历史遗留文档从原始文件按 fileType 重解析
if (StringUtils.hasText(doc.getFilePath())) {
java.io.File file = fileStorageConfig.getFilePath(doc.getFilePath()).toFile();
if (file.exists()) {
log.warn("库内 content 为历史截断数据,改为从原始文件重解析: id={}, fileType={}, path={}",
doc.getId(), doc.getFileType(), doc.getFilePath());
return new SourceLoad(parseFromFile(doc.getFileType(), file), true);
}
log.warn("原始文件不存在,无法完整重解析: id={}, path={}", doc.getId(), doc.getFilePath());
}
// 3) 无法完整还原
throw new RuntimeException("该文档为历史遗留数据(库内仅存截断预览,原始文件也已丢失),"
+ "无法完整重新处理,请重新上传该文档");
}
/**
* 按上传时的文件类型选择读取器与各 uploadXxx 保持同一条解析路径
* <p>
* 注意 fileType 是原始扩展名pdf/docx/csv/html/故用 default 兜到 Tika md/json 单独分支
* 已知降级JSON 的三种解析模式basic/fields/pointer上传时未持久化历史文档只能按 basic 还原
* 不丢数据仅抽取口径可能变化日志会 WARN 提示
*/
private List<Document> parseFromFile(String fileType, java.io.File file) {
String type = fileType == null ? "" : fileType.toLowerCase();
return switch (type) {
case "md", "markdown" -> markdownDocumentLoader.loadMarkdownFromFile(file);
case "json" -> {
log.warn("历史 JSON 文档未持久化解析模式(basic/fields/pointer),按 basic 重解析: {}", file.getName());
yield jsonDocumentLoader.loadBasicJsonFromFile(file);
}
default -> new TikaDocumentReader(new FileSystemResource(file)).get();
};
}
/**
* 更新文档元信息
*/

2
src/main/resources/init-database.sql

@ -154,7 +154,7 @@ COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID';
COMMENT ON COLUMN knowledge_document.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)';
COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)';

2
src/main/resources/knowledge-base.sql

@ -64,7 +64,7 @@ COMMENT ON COLUMN knowledge_document.title IS '文档标题';
COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型 - pdf/md/json/txt/word/excel 等';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID - 0表示未分类';
COMMENT ON COLUMN knowledge_document.tags IS '标签列表(JSON数组)';
COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量';

Loading…
Cancel
Save