From 2001dc3a0fce7ce0eacf7234d0b1b051f83eceb0 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 11 Sep 2026 08:37:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(doc):=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E5=A4=84=E7=90=86=E6=96=87=E6=A1=A3=E4=BC=9A=E6=8C=89?= =?UTF-8?q?=202000=20=E5=AD=97=E6=88=AA=E6=96=AD=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E5=AF=BC=E8=87=B4=E5=86=85=E5=AE=B9=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 - 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) - 存量中疑似被截断的文档需重跑一次才能回填全文(均已保留原始文件,均可恢复) --- CLAUDE.md | 12 +++ README.md | 2 +- frontend/src/views/DocDetail.vue | 2 +- .../supportbot/config/DatabaseInitConfig.java | 2 +- .../controller/DocumentController.java | 31 ++++++- .../supportbot/entity/KnowledgeDocument.java | 8 +- .../supportbot/service/DocumentService.java | 87 +++++++++++++++++-- src/main/resources/init-database.sql | 2 +- src/main/resources/knowledge-base.sql | 2 +- 9 files changed, 135 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87558f0..5e36709 100644 --- a/CLAUDE.md +++ b/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)。新文档走库内全文分支,语义不变。 + ## 关键配置 diff --git a/README.md b/README.md index c475ef0..8680556 100644 --- a/README.md +++ b/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, -- 分块数量 diff --git a/frontend/src/views/DocDetail.vue b/frontend/src/views/DocDetail.vue index d3be730..6ba682d 100644 --- a/frontend/src/views/DocDetail.vue +++ b/frontend/src/views/DocDetail.vue @@ -26,7 +26,7 @@
{{ doc.sourceName || doc.title || '原文' }} - 内容已截断(仅显示前2000字符) + 内容已截断(仅显示前2000字符)
{{ doc.content }}
diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 4e69124..9e6afe3 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/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 格式)"); diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index 8e61063..938797a 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/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 { } } + /** + * 组装文档详情响应。 + *

+ * 实体上的 {@code content} 带 {@code @JsonIgnore}(存的是全文,可达数十万字符,不能直接返回给前端), + * 此处显式补回键名仍为 {@code content} 的**截断预览**,并附 {@code contentTruncated} 标志供前端提示。 + * 预览仅用于展示,与存库内容、与重新处理的数据源无关。 + */ + private Map toDetailMap(KnowledgeDocument doc) { + Map data = objectMapper.convertValue(doc, new TypeReference>() {}); + 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; + } + /** * 获取文档的所有分块 */ diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java index f6653f9..ca65dbc 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java +++ b/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; /** - * 原文内容(截断预览) + * 原文全文(用于重新处理时重建分块)。 + *

+ * {@code @JsonIgnore}:全文可达数十万字符,**任何接口都不序列化**,避免列表/回显接口携带整篇正文。 + * 文档详情接口(DocumentController.getDocumentDetail)单独组装响应,返回截断后的预览 + * (键名仍为 {@code content})与 {@code contentTruncated} 标志。 */ @TableField("content") + @JsonIgnore private String content; /** diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 92a762c..da5df8e 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/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 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,后台重新分块 + 向量化) + *

+ * 数据源解析见 {@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 documents = loaded.documents(); + + // 历史遗留文档从原始文件重解析成功后,把全文回填进 content 并打上完整性标记: + // 该文档此后不再依赖原始文件(文件丢失也能重跑),详情接口的 contentTruncated 也恢复准确 + if (loaded.reparsedFromFile()) { + doc.setContent(documents.stream().map(Document::getText).collect(Collectors.joining("\n"))); + Map 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 documents = simpleStringDocumentReader.read(doc.getContent()); + // 触发异步处理 documentProcessingService.reprocessDocumentAsync(id, documents); log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle()); return doc; } + /** 重新处理的数据源解析结果 */ + private record SourceLoad(List documents, boolean reparsedFromFile) {} + + /** + * 解析重新处理所需的数据源。 + *

    + *
  1. 库内全文优先:新文档 content 存的是全文(含 extraConfig.contentComplete 标记), + * 用它可保持与首次上传完全一致的抽取语义(例如 JSON 的 fields/pointer 模式),且不依赖文件是否还在。
  2. + *
  3. 回退原始文件:历史遗留文档的 content 是 2000 字截断预览,此时按 fileType 重新解析原始文件。
  4. + *
  5. 明确拒绝:两者都不可用时抛错,绝不静默用截断预览重建(reprocess 会先删旧向量,属不可逆丢失)。
  6. + *
+ */ + 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 保持同一条解析路径。 + *

+ * 注意 fileType 是原始扩展名(pdf/docx/csv/html/…),故用 default 兜到 Tika,仅 md/json 单独分支。 + * 已知降级:JSON 的三种解析模式(basic/fields/pointer)上传时未持久化,历史文档只能按 basic 还原 + * (不丢数据,仅抽取口径可能变化),日志会 WARN 提示。 + */ + private List 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(); + }; + } + /** * 更新文档元信息 */ diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 4806813..a54be88 100644 --- a/src/main/resources/init-database.sql +++ b/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)'; diff --git a/src/main/resources/knowledge-base.sql b/src/main/resources/knowledge-base.sql index 2436dfe..5ffff21 100644 --- a/src/main/resources/knowledge-base.sql +++ b/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 '分块数量';