解析结果统计与异步收尾

上一篇讲完了 structureNodeExtractor.extract() 的四阶段流水线。现在回到 TikaDocumentParserService.parse() 方法,继续按执行顺序往下走:拿到结构节点之后,还要做标题计数、段落切分、token 估算、结构等级评估和内容质量评估,最后把所有结果打包返回。

然后再回到 handleParseRoute,看解析完成后的收尾工作。

回到 parse():结构节点之后的统计步骤

回顾一下 parse() 方法中,structureNodeExtractor.extract() 之后的代码:

// 结构节点由专门的提取器负责抽取,这些节点后面会参与导航和切块策略判断。
List<DocumentStructureNodeCandidate> structureNodes = structureNodeExtractor.extract(originalFileName, cleanedText);
int headingCount = countHeadings(cleanedText, structureNodes);

// 段落统计既用于结构判断,也用于后续策略推荐时判断是否适合语义切块。
List<String> paragraphList = extractParagraphs(cleanedText);

int maxParagraphLength = paragraphList.stream().mapToInt(String::length).max().orElse(0);

int charCount = cleanedText.length();

// token 数这里是估算值,不是精确 tokenizer 结果,但足够用于策略判断和粗粒度统计。
int tokenCount = estimateTokenCount(cleanedText);

int structureLevel = evaluateStructureLevel(headingCount, paragraphList.size());

int contentQualityLevel = evaluateContentQuality(cleanedText, charCount);

return new DocumentAnalysisResult(
    cleanedText,
    charCount,
    tokenCount,
    structureLevel,
    contentQualityLevel,
    headingCount,
    paragraphList.size(),
    maxParagraphLength,
    structureNodes
);

我们逐个展开。

countHeadings:标题数量统计

 /**
 * 统计标题数量。
 * <p>
 * 如果结构提取器已经抽到了较可靠的 section 节点,则优先使用结构化结果;
 * 否则退回到逐行分类的启发式标题识别。
 * </p>
 */
private int countHeadings(String text,
                          List<DocumentStructureNodeCandidate> structureNodes) {
    if (structureNodes != null && !structureNodes.isEmpty()) {
        long structuredHeadingCount = structureNodes.stream()
            .filter(node -> node != null
                && DocumentStructureNodeTypeEnum.SECTION.getCode().equals(node.getNodeType())
                && node.getDepth() != null
                && node.getDepth() > 0)
            .count();
        if (structuredHeadingCount > 0) {
            return (int) structuredHeadingCount;
        }
    }
    int count = 0;
    for (String line : text.split("\n")) {
        if (documentLineClassifier.classify(line).isHeading()) {
            count++;
        }
    }
    return count;
}

标题计数有两条路径:

  • 如果结构节点提取器已经识别出了 SECTION 类型的节点(depth > 0),直接用结构化结果,这个更准
  • 如果结构节点没抽到,就退化成逐行扫描,用 DocumentLineClassifier 做启发式标题识别

退化路径里用到的 DocumentLineClassifier 是一个轻量级行分类器,它的 classify() 方法会根据当前行的文本形态给出 HEADING / LIST_ITEM / BODY 三种分类。

具体的正则匹配规则包括 Markdown 标题(# xxx)、多级数字编号(1.2 xxx)、中文章节(第一章 xxx)、中文大纲(一、xxx)等模式。对于单级编号和中文大纲这种有歧义的模式,它还会通过 looksLikeHeadingContent() 做一层启发式判断——如果内容太长(> 24 字符)、包含句中标点(逗号、分号、冒号)、或者以句末标点结尾,就不认为是标题。

extractParagraphs:段落切分

/**
 * 从清洗后的文本中提取非空段落列表。
 */
private List<String> extractParagraphs(String text) {
    List<String> paragraphList = new ArrayList<>();
    for (String paragraph : text.split("\\n\\s*\\n")) {
        String trimmed = paragraph.trim();
        if (StrUtil.isNotBlank(trimmed)) {
            paragraphList.add(trimmed);
        }
    }
    return paragraphList;
}

段落切分的逻辑是:用连续空行(\n\s*\n)作为段落分隔符,过滤掉空段落。这个结果既用于结构等级判断,也用于后续策略推荐时判断是否适合语义切块。

estimateTokenCount:token 估算

/**
 * 粗略估算文本 token 数。
 */
private int estimateTokenCount(String text) {
    int englishWordCount = 0;
    int chineseCharCount = 0;

    for (String word : text.split("\\s+")) {
        if (word.matches(".*[A-Za-z].*")) {
            englishWordCount++;
        }
    }
    for (char current : text.toCharArray()) {
        if (String.valueOf(current).matches("[\\u4e00-\\u9fa5]")) {
            chineseCharCount++;
        }
    }
    return englishWordCount + chineseCharCount
        + Math.max(1, (text.length() - chineseCharCount) / 4);
}

token 估算不是精确的 tokenizer 结果,而是一个粗略公式:中文字符数 + 英文单词数 + 剩余字符数/4。这个精度对策略推荐来说够用了,没必要引入真正的 tokenizer 依赖。

evaluateStructureLevel:结构等级评估

/**
 * 根据标题数量和段落数量评估文档结构化程度。
 */
private int evaluateStructureLevel(int headingCount, int paragraphCount) {
    if (headingCount >= 5) {
        return DocumentStructureLevelEnum.HIGH.getCode();
    }
    if (headingCount >= 2) {
        return DocumentStructureLevelEnum.MEDIUM.getCode();
    }
    if (paragraphCount >= 3) {
        return DocumentStructureLevelEnum.LOW.getCode();
    }
    return DocumentStructureLevelEnum.UNKNOWN.getCode();
}

判断规则:

  • HIGH:标题 >= 5 个,说明文档有清晰的章节结构
  • MEDIUM:标题 >= 2 个,有一定结构但不够丰富
  • LOW:没什么标题但段落 >= 3 个
  • UNKNOWN:啥结构都没有

evaluateContentQuality:内容质量评估

/**
 * 根据文本长度和乱码比例评估内容质量。
 */
private int evaluateContentQuality(String text, int charCount) {
    if (StrUtil.isBlank(text) || charCount < 20) {
        return DocumentContentQualityLevelEnum.LOW.getCode();
    }
    long brokenCharCount = text.chars().filter(value -> value == '�').count();
    double brokenRatio = charCount == 0 ? 1D : (double) brokenCharCount / (double) charCount;
    if (brokenRatio > 0.02D || charCount < 100) {
        return DocumentContentQualityLevelEnum.LOW.getCode();
    }
    if (brokenRatio > 0.005D || charCount < 500) {
        return DocumentContentQualityLevelEnum.MEDIUM.getCode();
    }
    return DocumentContentQualityLevelEnum.HIGH.getCode();
}

判断规则:

  • 文本太短(< 20 字符)→ LOW
  • 乱码比例 > 2% 或文本 < 100 字符 → LOW
  • 乱码比例 > 0.5% 或文本 < 500 字符 → MEDIUM
  • 其他 → HIGH

这两个评估方法是后续策略推荐的重要输入——结构等级决定要不要用结构切块,内容质量决定要不要用 LLM 增强。

tip DocumentAnalysisResult 返回值

所有分析结果打包成 DocumentAnalysisResult 返回,包含:

  • parsedText:清洗后的纯文本
  • charCount / tokenCount:字符数和估算 token 数
  • structureLevel:结构等级(HIGH / MEDIUM / LOW / UNKNOWN)
  • contentQualityLevel:内容质量等级(HIGH / MEDIUM / LOW)
  • headingCount / paragraphCount / maxParagraphLength:标题数、段落数、最长段落长度
  • structureNodes:结构节点候选列表

到这里,parse() 方法就执行完了,控制流回到 handleParseRoute

回到 handleParseRoute:解析后的收尾工作

parse() 返回 DocumentAnalysisResult 之后,handleParseRoute 还要做几件收尾的事情。

上传解析文本 + 结构节点落库

// 解析后的纯文本单独落成 txt,是为了让后续切块与索引构建阶段不必重复解析原始二进制文件。
String parseTextPath = storageService.uploadParsedText(documentId, analysisResult.getParsedText());

// 结构节点采用“替换”方式写入,保证同一文档同一解析任务只保留当前最新结构树。
List<SuperAgentDocumentStructureNode> structureNodes = structureNodeService.replaceDocumentNodes(
    documentId,
    taskId,
    analysisResult.getStructureNodes()
);
int structureNodeCount = structureNodes.size();
// 导航索引、结构图投影和画像都属于解析后的派生产物,这里顺手同步。
syncNavigationArtifacts(documentId, taskId, structureNodes);
documentProfileService.generateProfile(documentId, analysisResult, structureNodes);

用最新候选节点整体替换文档结构节点(DocumentStructureNodeServiceImpl)

/**
 * 用最新候选节点整体替换文档结构节点。
 * <p>
 * 这个方法常用于“文档重新解析完成之后”,其目标不是增量更新,而是整棵树重建。
 * 因此它采用的是“先删旧数据,再插新数据”的整体替换策略。
 * </p>
 * <p>
 * 执行过程可以拆成三步:
 * 1. 删除当前文档已有的结构节点;
 * 2. 先为所有 candidate 的 {@code nodeNo} 预生成数据库主键 ID;
 * 3. 再第二轮遍历 candidate,把 parentNodeNo / prevSiblingNodeNo / nextSiblingNodeNo
 *    从“逻辑编号引用”翻译成“真实数据库 ID 引用”,最后逐条插入。
 * </p>
 * <p>
 * 之所以要分成两轮而不是一边遍历一边插入,是因为节点之间存在前向/后向引用:
 * 某个节点在插入时,可能需要同时知道自己的父节点 ID、前兄弟 ID、后兄弟 ID,
 * 而这些 ID 只有在“所有 nodeNo 对应的真实主键都提前生成完”之后才能稳定回填。
 * </p>
 *
 * @param documentId 文档 ID,表示要替换哪篇文档的结构树
 * @param parseTaskId 解析任务 ID,用于标识这批结构节点来自哪一次解析
 * @param candidates 结构提取链输出的候选节点列表
 * @return 最终落库成功的结构节点实体列表
 */
@Override
public List<SuperAgentDocumentStructureNode> replaceDocumentNodes(Long documentId,
                                                                  Long parseTaskId,
                                                                  List<DocumentStructureNodeCandidate> candidates) {
    // 先删旧结构树,确保当前文档在数据库里只保留“本次解析结果”这一份结构节点。
    deleteByDocumentId(documentId);
    // 任一关键参数缺失时,直接返回空列表;
    // 这意味着“结构树已清空,但没有新的节点需要写入”。
    if (documentId == null || parseTaskId == null || candidates == null || candidates.isEmpty()) {
        return List.of();
    }
    // nodeIdMap 的作用是把候选节点里的逻辑编号 nodeNo,预先映射成数据库真实主键。
    Map<Integer, Long> nodeIdMap = new LinkedHashMap<>();
    List<SuperAgentDocumentStructureNode> entities = new ArrayList<>();
    for (DocumentStructureNodeCandidate candidate : candidates) {
        if (candidate == null || candidate.getNodeNo() == null) {
            continue;
        }
        // 第一轮只做一件事:为每个逻辑节点号分配一个稳定的数据库 ID。
        long id = uidGenerator.getUid();
        nodeIdMap.put(candidate.getNodeNo(), id);
    }
    for (DocumentStructureNodeCandidate candidate : candidates) {
        if (candidate == null || candidate.getNodeNo() == null) {
            continue;
        }
        SuperAgentDocumentStructureNode entity = new SuperAgentDocumentStructureNode();
        // 节点自己的主键直接取第一轮预生成的 ID,确保后续引用关系可闭合。
        entity.setId(nodeIdMap.get(candidate.getNodeNo()));
        entity.setDocumentId(documentId);
        entity.setParseTaskId(parseTaskId);
        entity.setNodeNo(candidate.getNodeNo());
        entity.setNodeType(candidate.getNodeType());
        // 这里最关键:candidate 中的 parent/prev/next 保存的是逻辑 nodeNo,
        // 入库前必须统一翻译成数据库真实主键 ID。
        entity.setParentNodeId(candidate.getParentNodeNo() == null ? null : nodeIdMap.get(candidate.getParentNodeNo()));
        entity.setPrevSiblingNodeId(candidate.getPrevSiblingNodeNo() == null ? null : nodeIdMap.get(candidate.getPrevSiblingNodeNo()));
        entity.setNextSiblingNodeId(candidate.getNextSiblingNodeNo() == null ? null : nodeIdMap.get(candidate.getNextSiblingNodeNo()));
        entity.setDepth(candidate.getDepth());
        entity.setNodeCode(candidate.getNodeCode());
        entity.setTitle(candidate.getTitle());
        entity.setAnchorText(candidate.getAnchorText());
        entity.setCanonicalPath(candidate.getCanonicalPath());
        entity.setSectionPath(candidate.getSectionPath());
        entity.setContentText(candidate.getContentText());
        entity.setItemIndex(candidate.getItemIndex());
        entity.setStatus(BusinessStatus.YES.getCode());
        // 每个节点逐条插入,便于保留完整的树节点信息。
        structureNodeMapper.insert(entity);
        entities.add(entity);
    }
    return entities;
}

上传解析文本 + 结构节点落库 这里做了四件事:

  • 上传解析文本:把清洗后的纯文本以 .txt 格式存到 MinIO,路径格式是 parsed-text/documentId.txt。后续索引构建阶段直接读这个 txt 就行,不用再重新解析原始 PDF/DOCX
  • 替换结构节点:先删除该文档的旧节点,再插入新节点。"替换"而不是"追加",是为了保证每次解析后只保留最新的结构树
  • 同步导航产物:包括导航 ES 索引和结构图投影,这些是可选组件,有就同步,没有就跳过
  • 生成文档画像:基于解析结果和结构节点生成文档的摘要画像

syncNavigationArtifacts:导航产物同步

/**
 * 同步解析后的导航相关产物。
 * <p>
 * 结构节点落库之后,还可以进一步同步到导航索引和结构图投影。
 * 这些能力是可选组件,因此这里会先判空或判 enabled,再决定是否执行。
 * </p>
 */
private void syncNavigationArtifacts(Long documentId,
                                     Long parseTaskId,
                                     List<SuperAgentDocumentStructureNode> structureNodes) {
    log.info("开始同步导航产物: documentId={}, parseTaskId={}, structureNodeCount={}",
        documentId,
        parseTaskId,
        structureNodes == null ? 0 : structureNodes.size());
    DocumentNavigationIndexService navigationIndexService = navigationIndexServiceProvider.getIfAvailable();
    if (navigationIndexService != null) {
        log.info("同步导航 ES 索引: documentId={}, parseTaskId={}", documentId, parseTaskId);
        navigationIndexService.reindexDocumentNodes(documentId, parseTaskId, structureNodes);
    }
    else {
        log.info("跳过导航 ES 索引同步,因为服务未启用: documentId={}, parseTaskId={}", documentId, parseTaskId);
    }
    DocumentStructureGraphProjectionService graphProjectionService = graphProjectionServiceProvider.getIfAvailable();
    if (graphProjectionService != null && graphProjectionService.enabled()) {
        log.info("同步结构图投影: documentId={}, parseTaskId={}", documentId, parseTaskId);
        graphProjectionService.projectToGraph(documentId, parseTaskId);
    }
    else {
        log.info("跳过结构图投影,因为图服务未启用: documentId={}, parseTaskId={}", documentId, parseTaskId);
    }
}

这里用了 Spring 的 ObjectProvider 模式——导航索引服务和图投影服务都是可选依赖,通过 getIfAvailable() 判断是否存在。如果项目没有配置这些组件,就自动跳过,不会报错。

generateProfile:文档画像生成

导航产物同步完之后,紧接着就是 documentProfileService.generateProfile()

这个方法的目标不是解析正文,也不是做切块,而是把解析阶段已经产出的结构化事实压缩成一份更轻量的"画像快照",供检索路由和知识管理界面使用。

 /**
 * 为指定文档生成或更新“文档画像”。
 * <p>
 * 这个方法的目标不是解析正文,也不是做切块,而是把“解析阶段已经产出的结构化事实”
 * 压缩成一份更轻量、更适合检索路由和知识管理界面使用的画像快照。
 * 画像里主要包含几类信息:
 * 1. 文档摘要:帮助后台和检索侧快速理解这份文档大致讲什么;
 * 2. 文档类型:例如 faq、manual、rule、troubleshooting 等,用于后续策略判断;
 * 3. 核心主题与示例问题:帮助推荐可能的问法,也便于知识运营侧快速浏览;
 * 4. 图谱友好性、是否支持目录大纲、是否支持条目定位:用于判断是否适合图谱/导航能力;
 * 5. 知识范围、业务分类、标签:用于知识归档、筛选和后续召回范围控制。
 * </p>
 * <p>
 * 这一步与上传后的异步解析链关系非常紧密:
 * 上游已经拿到了 {@link DocumentAnalysisResult} 和结构节点列表;
 * 当前方法负责把这些信息进一步抽象成“画像”并落库。
 * 同时,它还会尝试把画像里推断出的知识范围、业务分类、标签等元数据
 * 回填到文档主表中,但只会补空,不会覆盖用户已经显式填写过的值。
 * </p>
 * <p>
 * 执行顺序上分成四段:
 * 1. 校验 documentId,并读取文档主记录;
 * 2. 基于 parsedText 与 structureNodes 生成一份临时 draft;
 * 3. 查询是否已有画像记录,如果有则做版本递增更新,没有则新建;
 * 4. 把 draft 中适合回填的文档元数据补回文档主表。
 * </p>
 */
@Override
public SuperAgentDocumentProfile generateProfile(Long documentId,
                                                 DocumentAnalysisResult analysisResult,
                                                 List<SuperAgentDocumentStructureNode> structureNodes) {
    if (documentId == null) {
        throw new IllegalArgumentException("documentId 不能为空");
    }
    // 画像始终依附于真实存在的文档主记录,因此第一步必须确认文档存在。
    SuperAgentDocument document = documentMapper.selectById(documentId);
    if (document == null) {
        throw new IllegalArgumentException("文档不存在: " + documentId);
    }
    // analysisResult 与 structureNodes 在某些补录/重建场景下可能为空,这里统一兜底成安全输入。
    String parsedText = analysisResult == null ? "" : StrUtil.blankToDefault(analysisResult.getParsedText(), "");
    List<SuperAgentDocumentStructureNode> safeNodes = structureNodes == null ? List.of() : structureNodes;
    // draft 是“画像生成过程中的中间态”,先把所有推断结果一次性算出来,后面再决定如何落库与回填。
    DocumentProfileDraft draft = buildDraft(document, parsedText, safeNodes);

    // 一份文档只维护一条有效画像记录;如果已经存在,则在原记录上递增画像版本。
    SuperAgentDocumentProfile profile = documentProfileMapper.selectOne(new LambdaQueryWrapper<SuperAgentDocumentProfile>()
        .eq(SuperAgentDocumentProfile::getDocumentId, documentId)
        .eq(SuperAgentDocumentProfile::getStatus, BusinessStatus.YES.getCode())
        .last("LIMIT 1"));
    boolean creating = profile == null;
    if (creating) {
        // 首次生成画像时创建新记录。
        profile = new SuperAgentDocumentProfile();
        profile.setId(uidGenerator.getUid());
        profile.setDocumentId(documentId);
        profile.setProfileVersion(1);
        profile.setStatus(BusinessStatus.YES.getCode());
    }
    else {
        // 重新生成画像时不新建记录,而是提升版本号,便于后台判断画像是否被重建过。
        profile.setProfileVersion(Optional.ofNullable(profile.getProfileVersion()).orElse(0) + 1);
    }
    // 以下字段都来自 draft,它们共同组成一份对外可消费的“文档能力摘要”。
    profile.setDocumentSummary(draft.documentSummary());
    profile.setDocumentType(draft.documentType());
    profile.setCoreTopics(joinJsonLikeArray(draft.coreTopics()));
    profile.setExampleQuestions(joinJsonLikeArray(draft.exampleQuestions()));
    profile.setGraphFriendly(draft.graphFriendly() ? 1 : 0);
    profile.setSupportsGraphOutline(draft.supportsGraphOutline() ? 1 : 0);
    profile.setSupportsItemLookup(draft.supportsItemLookup() ? 1 : 0);
    profile.setSupportsGraphAssist(draft.supportsGraphAssist() ? 1 : 0);
    profile.setProfileSource("auto");
    profile.setProfileStatus(PROFILE_STATUS_SUCCESS);
    profile.setErrorMsg(null);
    if (creating) {
        documentProfileMapper.insert(profile);
    }
    else {
        documentProfileMapper.updateById(profile);
    }

    // 画像生成出来后,顺手把可推断的知识范围、业务分类、标签等信息补回文档主表,
    // 这样后续列表页、检索配置和知识运营界面就不必只依赖画像表才能看到这些信息。
    backfillDocumentMetadata(document, draft);
    log.info("文档画像生成完成: documentId={}, documentType={}, graphFriendly={}, supportsItemLookup={}, scopeCode='{}', businessCategory='{}', tags='{}'",
        documentId,
        draft.documentType(),
        draft.graphFriendly(),
        draft.supportsItemLookup(),
        draft.knowledgeScopeCode(),
        draft.businessCategory(),
        draft.documentTags());
    return profile;
}

整个方法的执行逻辑分成四段:

  • 校验 + 读取文档主记录:确认文档存在
  • 构建画像草稿(buildDraft):基于解析文本和结构节点,推断出文档类型、核心主题、示例问题、摘要、知识范围、业务分类、标签等
  • 落库:查询是否已有画像记录,有则版本递增更新,没有则新建
  • 回填文档元数据(backfillDocumentMetadata):把画像推断出的知识范围、业务分类、标签等补回文档主表,但只做"补空"不做"覆盖"——如果用户已经显式填过,就保留用户值

其中 buildDraft() 是真正的"画像推断中心":

/**
 * 构建文档画像草稿。
 * <p>
 * 这里是真正的“画像推断中心”,会把原始输入一步步归纳成最终画像字段。
 * 这些字段并不是彼此独立的,而是存在明显依赖关系:
 * 1. 先从结构节点中抽 section 标题;
 * 2. 再根据结构节点判断是否支持条目定位、是否支持图谱目录;
 * 3. 然后基于文档名、正文和标题推断 documentType;
 * 4. 再在 documentType 的基础上生成核心主题、示例问题、摘要、知识范围、业务分类和标签。
 * </p>
 * <p>
 * 之所以集中在一个 draft 里统一产出,是为了保证所有字段使用的是同一批输入快照,
 * 避免“摘要按旧数据生成、标签按新数据生成”这种不一致情况。
 * </p>
 */
private DocumentProfileDraft buildDraft(SuperAgentDocument document,
                                        String parsedText,
                                        List<SuperAgentDocumentStructureNode> structureNodes) {
    // sectionTitles 是后面多个推断逻辑的公共输入:
    // 摘要、核心主题、知识范围判断、图谱大纲判断都会用到。
    List<String> sectionTitles = extractSectionTitles(structureNodes);
    // 只要结构树里出现 step / list_item,就说明这份文档支持更细粒度的“条目定位式问答”。
    boolean supportsItemLookup = structureNodes.stream().anyMatch(node -> node != null
        && (DocumentStructureNodeTypeEnum.STEP.getCode().equals(node.getNodeType())
        || DocumentStructureNodeTypeEnum.LIST_ITEM.getCode().equals(node.getNodeType())));
    // section 标题足够多时,说明可以展示出较完整的目录型导航。
    boolean supportsGraphOutline = sectionTitles.size() >= 2;
    // 图谱友好性不是一个单独推断项,而是“支持大纲”或“支持条目定位”任一满足即可。
    boolean graphFriendly = supportsItemLookup || supportsGraphOutline;
    // 文档类型的判断会影响后面示例问题的问法和业务分类的归类方式。
    String documentType = inferDocumentType(document, parsedText, sectionTitles, supportsItemLookup);
    // coreTopics 会尽量优先取章节标题,再回退到文件名,目的是让主题更贴近用户可理解的领域词。
    List<String> coreTopics = buildCoreTopics(document, sectionTitles);
    // 示例问题并不是任意生成,而是依赖 documentType 对问句模板做差异化选择。
    List<String> exampleQuestions = buildExampleQuestions(documentType, coreTopics);
    String summary = buildSummary(document, sectionTitles, parsedText);
    String knowledgeScopeCode = inferKnowledgeScopeCode(document, sectionTitles, parsedText);
    String knowledgeScopeName = inferKnowledgeScopeName(knowledgeScopeCode);
    String businessCategory = inferBusinessCategory(documentType, parsedText);
    String documentTags = buildDocumentTags(document, knowledgeScopeCode, documentType, coreTopics);
    return new DocumentProfileDraft(
        summary,
        documentType,
        coreTopics,
        exampleQuestions,
        graphFriendly,
        supportsGraphOutline,
        supportsItemLookup,
        true,
        knowledgeScopeCode,
        knowledgeScopeName,
        businessCategory,
        documentTags
    );
}

buildDraft() 内部的推断链条是有依赖顺序的:先抽章节标题 → 再判断图谱能力 → 再推断文档类型 → 最后基于文档类型生成主题、问题、摘要、标签。之所以集中在一个 draft 里统一产出,是为了保证所有字段使用的是同一批输入快照,避免不一致。

其中文档类型推断用的是启发式关键词规则:

 /**
 * 推断文档类型。
 * <p>
 * 这里采用启发式关键词规则,而不是复杂模型判断,目的是保持稳定、可解释、低成本。
 * </p>
 */
private String inferDocumentType(SuperAgentDocument document,
                                 String parsedText,
                                 List<String> sectionTitles,
                                 boolean supportsItemLookup) {
    String combined = combinedText(document, parsedText, sectionTitles);
    if (combined.contains("faq") || combined.contains("常见问题")) {
        return "faq";
    }
    if (combined.contains("故障") || combined.contains("排查") || combined.contains("检查顺序")) {
        return "troubleshooting";
    }
    if (combined.contains("规则") || combined.contains("制度")) {
        return "rule";
    }
    if (combined.contains("规格") || combined.contains("参数")) {
        return "spec";
    }
    if (supportsItemLookup || combined.contains("手册") || combined.contains("指南") || combined.contains("部署")) {
        return "manual";
    }
    return "intro";
}

推断结果会综合文档名、原始文件名、章节标题和正文片段,避免只看正文导致误判。最终产出的文档类型(faq / troubleshooting / rule / spec / manual / intro)会影响后续示例问题的问法模板和业务分类的归类方式。

画像落库之后,backfillDocumentMetadata() 会把推断出的知识范围、业务分类、标签等信息补回文档主表:

 /**
 * 将画像推断出的文档元数据回填到文档主表。
 * <p>
 * 注意这里只做"补空"而不做"覆盖":
 * 如果文档主表中已经有用户显式维护过的知识范围、业务分类或标签,就保留用户值;
 * 只有对应字段为空时,才使用画像结果作为默认值补上。
 * </p>
 */
private void backfillDocumentMetadata(SuperAgentDocument document, DocumentProfileDraft draft) {
    boolean changed = false;
    if (StrUtil.isBlank(document.getKnowledgeScopeCode()) && StrUtil.isNotBlank(draft.knowledgeScopeCode())) {
        document.setKnowledgeScopeCode(draft.knowledgeScopeCode());
        changed = true;
    }
    if (StrUtil.isBlank(document.getKnowledgeScopeName()) && StrUtil.isNotBlank(draft.knowledgeScopeName())) {
        document.setKnowledgeScopeName(draft.knowledgeScopeName());
        changed = true;
    }
    if (StrUtil.isBlank(document.getBusinessCategory()) && StrUtil.isNotBlank(draft.businessCategory())) {
        document.setBusinessCategory(draft.businessCategory());
        changed = true;
    }
    if (StrUtil.isBlank(document.getDocumentTags()) && StrUtil.isNotBlank(draft.documentTags())) {
        document.setDocumentTags(draft.documentTags());
        changed = true;
    }
    if (changed) {
        documentMapper.updateById(document);
    }
}

这个"补空不覆盖"的策略很重要——用户在知识管理界面手动填写的元数据优先级高于系统自动推断的结果。

记录解析完成日志

// 内容解析完成后,先把解析阶段的统计结果记录到任务日志里。
taskLogService.saveLog(taskId, documentId,
    DocumentTaskStageEnum.CONTENT_PARSE.getCode(),
    DocumentTaskEventTypeEnum.COMPLETE.getCode(),
    DocumentLogLevelEnum.INFO.getCode(),
    DocumentOperatorTypeEnum.SYSTEM.getCode(),
    null,
    "文档解析完成。",
    Map.of(
        "charCount", analysisResult.getCharCount(),
        "tokenCount", analysisResult.getTokenCount(),
        "structureLevel", analysisResult.getStructureLevel(),
        "contentQualityLevel", analysisResult.getContentQualityLevel(),
        "structureNodeCount", structureNodeCount
    ));

// 下一阶段进入策略推荐路由,意味着后面开始决定 Parent/Child 流水线该如何配置。
task.setCurrentStage(DocumentTaskStageEnum.STRATEGY_ROUTE.getCode());
taskMapper.updateById(task);

日志里把解析阶段的关键统计数据都记下来了:字符数、token 数、结构等级、内容质量等级、结构节点数。这些数据后续排查问题时非常有用。

然后把任务的当前阶段从 CONTENT_PARSE 推进到 STRATEGY_ROUTE,表示内容解析已完成,接下来要进入策略推荐阶段了。

小结

这篇接着结构节点提取之后,走完了 parse() 方法的后半段和 handleParseRoute 的解析收尾工作。整个过程可以概括为:

标题计数 → 段落切分 → token 估算 → 结构等级评估 → 内容质量评估 → 打包 DocumentAnalysisResult → 上传解析文本 → 结构节点落库 → 导航产物同步 → 文档画像生成 → 记录日志 → 推进到策略推荐阶段

到这里,一份文档就从"原始文件"走到了"解析完成"的状态。接下来 handleParseRoute 要做的最后一件大事就是策略推荐——决定后续索引构建时该用怎样的 Parent/Child 切块流水线,下一篇展开。


企业级项目导航:⬅️ 11-统计打分收口打包同步落库 | 12-解析结果统计与异步收尾 | ➡️ 01-索引构建入口与Kafka消息投递