RAG

(选学)自定义支持父子分片的基于Word标题的分片器

我们在项目中用的比较多的是这个基于work文档中标题做分片的分片器。主要的功能和markdown标题分片差不多,但是还有一些差异。 首先,markdown的标题是有明确的语法的,比如 这种,而word不一定的。虽然有些文档中的标题会…

TL;DR

我们在项目中用的比较多的是这个基于work文档中标题做分片的分片器。主要的功能和markdown标题分片差不多,但是还有一些差异。 首先,markdown的标题是有明确的语法的,比如 这种,而word不一定的。虽然有些文档中的标题会…

我们在项目中用的比较多的是这个基于work文档中标题做分片的分片器。主要的功能和markdown标题分片差不多,但是还有一些差异。 首先,markdown的标题是有明确的语法的,比如# ## ### 这种,而word不一定的。虽然有些文档中的标题会采用特殊格式,如 这种的话,比较简单,我们可以通过poi解析出来,但是实际上,大多数文档其实是没有这么标准的,就需要有另外一套兜底的处理逻辑。 还有就是,有的时候,word文档内容可能会比较长,而embedding模型都是有token数限制的,如: 所以,如果只按照标题分的话,可能会比较长,所以还需要额外支持chunkSize和overlap的分片,即一个标题下,如果过长,则需要二次切分。(当然,这个可能在markdown的分片器可能也是需要的,大家也可以自行实现) 具体的实现的代码贴在下面了:

/**
 * Word文档分割器,基于标题样式层级进行文档分段
 * 支持保留元数据、父子分段关系等高级特性
 *
 * @author Hollis
 */
public class WordHeaderTextSplitter extends OverlapParagraphTextSplitter {

    /**
     * 需要分割的标题级别列表(1-9),对应Word的标题样式
     */
    private List<Integer> headingLevelsToSplitOn;

    /**
     * 是否按段落返回结果
     */
    private boolean returnEachParagraph;

    /**
     * 是否剥离标题段落本身
     */
    private boolean stripHeadings;

    /**
     * 是否启用父子分段模式
     */
    private boolean parentChildModel;

    /**
     * 构造函数(支持chunkSize和overlap)
     *
     * @param headingLevelsToSplitOn 标题级别列表,如Arrays.asList(1, 2, 3)表示分割标题1、2、3
     * @param returnEachParagraph    是否按段落返回结果,false时会聚合相同元数据的段落
     * @param stripHeadings          是否在结果中移除标题段落
     * @param parentChildModel       是否启用父子分段模式,启用后会在元数据中添加parentChunkId
     * @param chunkSize              每块最大字符数,0表示不限制
     * @param overlap                相邻块之间重叠字符数,0表示不重叠
     */
    public WordHeaderTextSplitter(List<Integer> headingLevelsToSplitOn, boolean returnEachParagraph,
                                  boolean stripHeadings, boolean parentChildModel,
                                  int chunkSize, int overlap) {
        super(chunkSize, overlap);
        this.headingLevelsToSplitOn = headingLevelsToSplitOn != null ?
                new ArrayList<>(headingLevelsToSplitOn) : Arrays.asList(1, 2, 3, 4, 5, 6);
        // 按级别排序,确保从小到大
        Collections.sort(this.headingLevelsToSplitOn);
        this.returnEachParagraph = returnEachParagraph;
        this.stripHeadings = stripHeadings;
        this.parentChildModel = parentChildModel;
    }

    /**
     * 重写apply方法以支持元数据的传递
     */
    @Override
    public List<Document> apply(List<Document> documents) {
        if (documents == null || documents.isEmpty()) {
            return Collections.emptyList();
        }

        List<Document> result = new ArrayList<>();
        for (Document doc : documents) {
            try {
                //doc/docx文件没办法直接把内容读取成string,必须要通过FileInputStream转成HWPFDocument才行。
                Object wordInputStream = doc.getMetadata().get("wordInputStream");
                if (wordInputStream instanceof InputStream) {
                    try {
                        List<DocumentWithMetadata> segments = splitWordDocument((InputStream) wordInputStream, doc.getMetadata());
                        for (DocumentWithMetadata segment : segments) {
                            result.add(new Document(segment.getContent(), segment.getMetadata()));
                        }
                    } finally {
                        ((InputStream) wordInputStream).close();
                    }
                } else if (wordInputStream instanceof byte[]) {
                    try (InputStream is = new ByteArrayInputStream((byte[]) wordInputStream)) {
                        List<DocumentWithMetadata> segments = splitWordDocument(is, doc.getMetadata());
                        for (DocumentWithMetadata segment : segments) {
                            result.add(new Document(segment.getContent(), segment.getMetadata()));
                        }
                    }
                } else {
                    // 如果没有提供输入流,尝试从文本内容解析(兜底方案)
                    List<DocumentWithMetadata> segments = splitPlainText(doc.getText(), doc.getMetadata());
                    for (DocumentWithMetadata segment : segments) {
                        result.add(new Document(segment.getContent(), segment.getMetadata()));
                    }
                }
            } catch (Exception e) {
                throw new RuntimeException("Word文档分割失败: " + e.getMessage(), e);
            }
        }
        return result;
    }

    /**
     * 简化版分割方法,不保留元数据
     *
     * @param text 待分割的文本
     * @return 分割后的文本片段列表
     */
    @Override
    protected List<String> splitText(String text) {
        return splitPlainText(text, new HashMap<>()).stream()
                .map(DocumentWithMetadata::getContent)
                .collect(Collectors.toList());
    }

    /**
     * 核心分割逻辑 - 处理Word文档
     *
     * @param inputStream  Word文档输入流
     * @param baseMetadata 基础元数据,会被传递到每个分段中
     * @return 带有元数据的文档片段列表
     */
    private List<DocumentWithMetadata> splitWordDocument(InputStream inputStream, Map<String, Object> baseMetadata) throws Exception {
        // 检测文件格式(.doc 或 .docx)
        baseMetadata.remove("wordInputStream");
        BufferedInputStream bis = new BufferedInputStream(inputStream);
        bis.mark(8192); // 标记位置,以便重新读取

        FileMagic fileMagic = FileMagic.valueOf(bis);
        bis.reset(); // 重置流到标记位置

        // 根据文件格式选择不同的处理方法
        if (fileMagic == FileMagic.OLE2) {
            // 旧格式 .doc 文件
            return splitDocDocument(bis, baseMetadata);
        } else if (fileMagic == FileMagic.OOXML) {
            // 新格式 .docx 文件
            return splitDocxDocument(bis, baseMetadata);
        } else {
            throw new IllegalArgumentException("不支持的文件格式,仅支持 .doc 和 .docx 文件");
        }
    }

    /**
     * 处理新格式Word文档 (.docx)
     */
    private List<DocumentWithMetadata> splitDocxDocument(InputStream inputStream, Map<String, Object> baseMetadata) throws Exception {
        XWPFDocument document = new XWPFDocument(inputStream);
        List<ParagraphWithMetadata> paragraphsWithMetadata = new ArrayList<>();
        List<String> currentContent = new ArrayList<>();
        Map<String, Object> currentMetadata = new HashMap<>(baseMetadata);
        List<HeadingInfo> headingStack = new ArrayList<>();  // 标题栈,用于追踪当前的标题层级结构
        Map<String, Object> initialMetadata = new HashMap<>(baseMetadata);

        // 遍历所有段落
        for (XWPFParagraph paragraph : document.getParagraphs()) {
            String text = paragraph.getText().trim();
            if (text.isEmpty()) {
                continue;
            }

            // 获取段落样式
            String style = paragraph.getStyle();
            Integer headingLevel = extractHeadingLevelFromDocx(style, paragraph);

            // 检测并处理标题段落
            if (headingLevel != null && headingLevelsToSplitOn.contains(headingLevel)) {
                // 维护标题栈:移除所有级别大于等于当前级别的标题
                while (!headingStack.isEmpty() && headingStack.get(headingStack.size() - 1).getLevel() >= headingLevel) {
                    HeadingInfo poppedHeading = headingStack.remove(headingStack.size() - 1);
                    initialMetadata.remove(poppedHeading.getMetadataKey());
                }

                // 将当前标题加入栈,并更新元数据
                String metadataKey = "heading" + headingLevel;
                HeadingInfo headingInfo = new HeadingInfo(headingLevel, metadataKey, text);
                headingStack.add(headingInfo);
                initialMetadata.put(metadataKey, text);
                initialMetadata.put("headingLevel", headingLevel);
                // 为每个分段生成唯一ID,用于后续建立父子关系
                String currentChunkId = UUID.randomUUID().toString();
                initialMetadata.put("chunkId", currentChunkId);

                // 遇到新标题时,保存之前累积的内容
                if (!currentContent.isEmpty()) {
                    paragraphsWithMetadata.add(new ParagraphWithMetadata(String.join("\n", currentContent), currentMetadata));
                    currentContent.clear();
                }

                // 根据stripHeadings配置决定是否保留标题段落
                if (!stripHeadings) {
                    currentContent.add(text);
                }
            } else {
                // 处理非标题段落
                currentContent.add(text);
            }

            // 更新当前元数据为最新的标题信息
            currentMetadata = new HashMap<>(initialMetadata);
        }

        // 处理最后累积的内容
        if (!currentContent.isEmpty()) {
            paragraphsWithMetadata.add(new ParagraphWithMetadata(String.join("\n", currentContent), currentMetadata));
        }

        document.close();

        // 根据配置决定返回方式
        return processSegments(paragraphsWithMetadata);
    }

    /**
     * 处理旧格式Word文档 (.doc)
     */
    private List<DocumentWithMetadata> splitDocDocument(InputStream inputStream, Map<String, Object> baseMetadata) throws Exception {
        HWPFDocument document = new HWPFDocument(inputStream);
        Range range = document.getRange();

        List<ParagraphWithMetadata> paragraphsWithMetadata = new ArrayList<>();
        List<String> currentContent = new ArrayList<>();
        Map<String, Object> currentMetadata = new HashMap<>(baseMetadata);
        List<HeadingInfo> headingStack = new ArrayList<>();
        Map<String, Object> initialMetadata = new HashMap<>(baseMetadata);

        // 遍历所有段落
        for (int i = 0; i < range.numParagraphs(); i++) {
            Paragraph paragraph = range.getParagraph(i);
            String text = paragraph.text().trim();

            if (text.isEmpty()) {
                continue;
            }

            // 对于.doc文件,主要通过文本模式检测标题
            Integer headingLevel = detectHeadingByTextPattern(text);

            // 检测并处理标题段落
            if (headingLevel != null && headingLevelsToSplitOn.contains(headingLevel)) {
                // 维护标题栈
                while (!headingStack.isEmpty() && headingStack.get(headingStack.size() - 1).getLevel() >= headingLevel) {
                    HeadingInfo poppedHeading = headingStack.remove(headingStack.size() - 1);
                    initialMetadata.remove(poppedHeading.getMetadataKey());
                }

                // 将当前标题加入栈,并更新元数据
                String metadataKey = "heading" + headingLevel;
                HeadingInfo headingInfo = new HeadingInfo(headingLevel, metadataKey, text);
                headingStack.add(headingInfo);
                initialMetadata.put(metadataKey, text);
                initialMetadata.put("headingLevel", headingLevel);
                String currentChunkId = UUID.randomUUID().toString();
                initialMetadata.put("chunkId", currentChunkId);

                // 遇到新标题时,保存之前累积的内容
                if (!currentContent.isEmpty()) {
                    paragraphsWithMetadata.add(new ParagraphWithMetadata(String.join("\n", currentContent), currentMetadata));
                    currentContent.clear();
                }

                // 根据stripHeadings配置决定是否保留标题段落
                if (!stripHeadings) {
                    currentContent.add(text);
                }
            } else {
                // 处理非标题段落
                currentContent.add(text);
            }

            // 更新当前元数据
            currentMetadata = new HashMap<>(initialMetadata);
        }

        // 处理最后累积的内容
        if (!currentContent.isEmpty()) {
            paragraphsWithMetadata.add(new ParagraphWithMetadata(String.join("\n", currentContent), currentMetadata));
        }

        document.close();

        // 根据配置决定返回方式
        return processSegments(paragraphsWithMetadata);
    }

    /**
     * 处理段落列表,返回最终的文档片段
     */
    private List<DocumentWithMetadata> processSegments(List<ParagraphWithMetadata> paragraphsWithMetadata) {
        List<DocumentWithMetadata> segments;
        if (!returnEachParagraph) {
            // 聚合模式:将相同元数据的段落合并
            segments = aggregateParagraphsToChunks(paragraphsWithMetadata);
        } else {
            // 逐段模式:保持每段独立
            segments = paragraphsWithMetadata.stream()
                    .map(para -> new DocumentWithMetadata(para.getContent(), para.getMetadata()))
                    .collect(Collectors.toList());
        }
        return segments;
    }

    /**
     * 从段落中提取标题级别 (.docx格式)
     *
     * @param style     段落样式名称
     * @param paragraph 段落对象
     * @return 标题级别(1-9),如果不是标题则返回null
     */
    private Integer extractHeadingLevelFromDocx(String style, XWPFParagraph paragraph) {
        // 方法1: 标准Word标题样式:Heading1, Heading2, ..., Heading9
        // 或中文版:标题 1, 标题 2, ...「
        Integer headingLevel = null;
        if (style != null && (style.matches("(?i)heading\\s*\\d") || style.matches("标题\\s*\\d"))) {
            try {
                String levelStr = style.replaceAll("(?i)heading|标题|\\s", "");
                headingLevel = Integer.parseInt(levelStr);
            } catch (NumberFormatException e) {
                // 继续尝试其他方法
            }
        }

        // 方法2: 基于文本内容的模式匹配(用于没有应用标准样式的文档)
        if (headingLevel == null) {
            String text = paragraph.getText();
            if (text != null && !text.isEmpty()) {
                Integer level = detectHeadingByTextPattern(text.trim());
                if (level != null && level > 0) {
                    headingLevel = level;
                }
            }
        }

        return headingLevel;
    }

    /**
     * 通过文本模式检测标题级别
     * 适用于没有应用Word标准样式的文档,这部分可以根据自己的文档的情况自己调整。
     *
     * @param text 段落文本内容
     * @return 标题级别,如果不是标题则返回null
     */
    private Integer detectHeadingByTextPattern(String text) {
        if (text == null || text.isEmpty()) {
            return null;
        }

        // 匹配 "第X章" 或 "第X部分"
        if (text.matches("^第[一二三四五六七八九十百]+章.*")) {
            return 1;
        }
        if (text.matches("^第[一二三四五六七八九十百]+部分.*")) {
            return 1;
        }

        if (text.matches("^第[一二三四五六七八九十百]+条.*")) {
            return 2;
        }

        // 匹配 "(一)" "(二)" 等
        if (text.matches("^[((][一二三四五六七八九十百]+[))].*")) {
            return 3;
        }

        // 匹配 "一、" "二、" 等
        if (text.matches("^[一二三四五六七八九十百]+、.*")) {
            return 2;
        }

        // 匹配 "1." "2." 等数字标题(后面不能紧跟数字,避免误判如 "1.1")
        if (text.matches("^\\d+\\.\\s*[^0-9].*")) {
            return 3;
        }

        // 匹配 "(1)" "(2)" 等
        if (text.matches("^[((]\\d+[))].*")) {
            return 3;
        }

        // 匹配 "1.1" "1.2" 等多级编号
        if (text.matches("^\\d+\\.\\d+.*")) {
            return 4;
        }

        // 检查是否全部是中文且较短(可能是章节标题)
        if (text.length() <= 20 && text.matches("^[一-龥]+$")) {
            // 常见的章节关键词
            if (text.contains("总则") || text.contains("附则") || text.contains("说明") ||
                    text.contains("须知") || text.contains("规定") || text.contains("制度") ||
                    text.contains("办法") || text.contains("条例")) {
                return 1;
            }
        }

        // 检查是否包含标题关键词
        if (text.length() <= 30) {
            if (text.contains("管理") || text.contains("制度") || text.contains("规范") ||
                    text.contains("流程") || text.contains("职责") || text.contains("权限") ||
                    text.contains("考核") || text.contains("培训") || text.contains("招聘") ||
                    text.contains("薪酬") || text.contains("福利") || text.contains("假期")) {
                if (text.endsWith("制度") || text.endsWith("管理") ||
                        text.endsWith("规定") || text.endsWith("办法")) {
                    return 2;
                }
            }
        }

        return null;
    }

    /**
     * 兜底方案:处理纯文本(当无法解析Word文档时)
     */
    private List<DocumentWithMetadata> splitPlainText(String text, Map<String, Object> baseMetadata) {
        baseMetadata.remove("wordInputStream");
        List<DocumentWithMetadata> result = new ArrayList<>();
        Map<String, Object> metadata = new HashMap<>(baseMetadata);
        metadata.put("chunkId", UUID.randomUUID().toString());
        result.add(new DocumentWithMetadata(text, metadata));
        return result;
    }

    /**
     * 聚合段落为分块
     * 将具有相同元数据的段落合并为一个分块,并处理父子关系
     *
     * @param paragraphs 待聚合的段落列表
     * @return 聚合后的文档片段列表
     */
    private List<DocumentWithMetadata> aggregateParagraphsToChunks(List<ParagraphWithMetadata> paragraphs) {
        List<ParagraphWithMetadata> aggregatedChunks = new ArrayList<>();

        for (ParagraphWithMetadata paragraph : paragraphs) {
            // 元数据相同,直接合并到上一个分块
            if (!aggregatedChunks.isEmpty() &&
                    aggregatedChunks.get(aggregatedChunks.size() - 1).getMetadata().equals(paragraph.getMetadata())) {
                ParagraphWithMetadata last = aggregatedChunks.get(aggregatedChunks.size() - 1);
                last.setContent(last.getContent() + "\n" + paragraph.getContent());
            } else {
                // 创建新分块
                aggregatedChunks.add(paragraph);
            }
        }

        // 如果启用了chunkSize限制,需要对聚合后的分块再次按大小分割
        if (chunkSize > 0) {
            aggregatedChunks = applySizeBasedSplitting(aggregatedChunks);
        }

        // 处理父子分段关系
        if (parentChildModel) {
            try {
                // 遍历所有分块,为非顶级标题建立父子关系
                for (int i = 0; i < aggregatedChunks.size(); i++) {
                    Map<String, Object> currentMetaData = aggregatedChunks.get(i).getMetadata();
                    Integer headingLevel = (Integer) currentMetaData.get("headingLevel");

                    // 顶级标题(level=1)或无标题的分块跳过
                    if (headingLevel == null || headingLevel == 1) {
                        continue;
                    }

                    // 向前查找第一个级别更低的标题作为父节点
                    if (headingLevel > 1) {
                        for (int j = i - 1; j >= 0; j--) {
                            Map<String, Object> lastMetaData = aggregatedChunks.get(j).getMetadata();
                            Integer lastHeadingLevel = (Integer) lastMetaData.get("headingLevel");
                            if (lastHeadingLevel != null && lastHeadingLevel < headingLevel) {
                                // 将父节点的chunkId设置为当前节点的parentChunkId
                                currentMetaData.put("parentChunkId", lastMetaData.get("chunkId"));
                                break;
                            }
                        }
                    }
                }
            } catch (Exception e) {
                System.out.println("父子模式转换失败," + e.getMessage());
            }
        }

        return aggregatedChunks.stream()
                .map(chunk -> new DocumentWithMetadata(chunk.getContent(), chunk.getMetadata()))
                .collect(Collectors.toList());
    }

    /**
     * 根据chunkSize对已聚合的分块进行二次分割
     *
     * @param chunks 已聚合的分块列表
     * @return 按大小分割后的分块列表
     */
    private List<ParagraphWithMetadata> applySizeBasedSplitting(List<ParagraphWithMetadata> chunks) {
        List<ParagraphWithMetadata> result = new ArrayList<>();

        for (ParagraphWithMetadata chunk : chunks) {
            String content = chunk.getContent();
            Map<String, Object> metadata = chunk.getMetadata();

            // 如果内容长度小于等于chunkSize,直接保留,不添加overlap
            if (content.length() <= chunkSize) {
                result.add(chunk);
                continue;
            }

            // 内容超过chunkSize,需要分段并添加overlap
            List<String> subContents = super.splitText(content);
            for (int i = 0; i < subContents.size(); i++) {
                // 为每个子分段创建新的元数据,保留原有信息并添加分段索引
                Map<String, Object> segmentMetadata = new HashMap<>(metadata);
                // 为子分段生成新的chunkId,但保留原始的headingLevel等信息
                String originalChunkId = (String) metadata.get("chunkId");
                segmentMetadata.put("chunkId", originalChunkId + "_" + i);
                segmentMetadata.put("segmentIndex", i);
                segmentMetadata.put("isSplit", true);

                result.add(new ParagraphWithMetadata(subContents.get(i), segmentMetadata));
            }
        }

        return result;
    }

    /**
     * 内部类:表示带有元数据的段落
     */
    private static class ParagraphWithMetadata {
        private String content;
        private Map<String, Object> metadata;

        public ParagraphWithMetadata(String content, Map<String, Object> metadata) {
            this.content = content;
            this.metadata = metadata;
        }

        public String getContent() {
            return content;
        }

        public void setContent(String content) {
            this.content = content;
        }

        public Map<String, Object> getMetadata() {
            return metadata;
        }

        public void setMetadata(Map<String, Object> metadata) {
            this.metadata = metadata;
        }
    }

    /**
     * 内部类:表示Word标题信息
     */
    private static class HeadingInfo {
        /**
         * 标题级别(1-9)
         */
        private int level;
        /**
         * 元数据中的键名
         */
        private String metadataKey;
        /**
         * 标题文本内容
         */
        private String text;

        public HeadingInfo(int level, String metadataKey, String text) {
            this.level = level;
            this.metadataKey = metadataKey;
            this.text = text;
        }

        public int getLevel() {
            return level;
        }

        public String getMetadataKey() {
            return metadataKey;
        }

        public String getText() {
            return text;
        }
    }

    /**
     * 内部类:携带元数据的文档片段
     */
    private static class DocumentWithMetadata {
        private final String content;
        private final Map<String, Object> metadata;

        public DocumentWithMetadata(String content, Map<String, Object> metadata) {
            this.content = content;
            this.metadata = new HashMap<>(metadata);
        }

        public String getContent() {
            return content;
        }

        public Map<String, Object> getMetadata() {
            return metadata;
        }
    }
}
版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。