Rag中的文档切片chunking

rag系列文章目录


前言

在rag整个流程中,文档的切片很重要。它的目的是为了使query更好地匹配chunk,也就是提高召回率。为了实现这个目的,需要考虑两个方面,一是chunk的长度,如果chunk太长,query就不容易匹配到chunk。二是chunk最好是语义相关的内容,也就是chunk里面噪声要小点。第一点,请参考我其他文档,不再赘述。今天主要是将给定chunk size的情况下,如何更好地切片,保证chunk语义的完整性,使其更好地匹配query。


一、几种切片策略

1 固定长度切割

这种切割方式是最简单的,效果也是最差的,就是根据固定长度(比如500)粗暴地切割整个文档内容,会导致语义被切碎。

2 滑动窗口切割

这种方法,使用滑动窗口技术,每次切片时向前移动固定步长(stride)。例如:窗口大小为 200 个字,步长为 100 个字,每个 chunk 可能有部分内容重叠。

它的优点是保证了上下文的连续性,减少信息丢失的可能性。但是增加了数据的冗余和存储成本。重叠部分的重复信息可能需要后续处理。

3 基于文档结构切割

利用文档的层次结构信息(标题、段落、表格等)来创建语义完整的切片。这种方法适用于结构化或半结构化的文档,比如技术文档、论文、报告、法律文件等。它将每个章节或子章节作为一个 chunk,最大程度保留文档的语义和逻辑结构。

4 语义切割

该方法使用自然语言处理(NLP)技术,例如句子分割(sentence splitting)结合语义分析。

有文章中提出,先对句子做embedding,然后通过聚类的方法划分语义chunk,这种方法使Chunk 内容的语义高度相关,便于检索和生成高质量答案。但是实现复杂,需要额外的 NLP 工具和计算资源,解析速度较慢。

此外,还可以先解析文档的段落,然后让大模型进行切割,这种切割方式成本也是比较高的。

5 结构和长度结合的切割

语义切割成本高,使用结构可以保存语义,但是chunk长度又会大小不一,所以需要结构和长度相结合的方式切割。

首先,切割文档,每个最小标题下面的内容作为一个chunk。
然后,遍历chunk,如果过长,进行切割,切割是先通过段落分割,如果仍然过大,再使用句号分割。
最后,再对细粒度的chunk进行合并,合并时从小标题合并到大标题。

以下是该切割策略的代码实践。

二、代码实践

代码如下(示例):

package org.example.util;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.ArrayList;
import java.util.List;

public class DocumentSplitter {

    private static final int MAX_SEGMENT_LENGTH = 300;

    public static void main(String[] args) throws Exception {
        String json = "{\"title\":\"一级标题\",\"content\":[{\"title\":\"二级标题1\",\"content\":[{\"title\":\"三级标题1\",\"content\":[\"这是一个段落,长度较短。\",\"这是另一个段落,长度也较短。\"]},{\"title\":\"三级标题2\",\"content\":[\"这是一个较长的段落,长度超过300字。这里将进行长度分割。善于听取他人的意见,才能更全面理性的看待自己,使自己更好的成长。唐代著名画家周肪为寺庙绘制壁画。为了听取更多人的意见,他先画出草图挂在路边,过往的行人为看,边评头论足。周肮仔细质听,把他人的议建牢记在,进行斟酌修改,一个月后,周肪的草图挂出,路过的人赞不绝,这时周防按照最后的图案将这幅作品完成,果然成为一幅名作。周肪从别的意见中找到了自己的不足,更加全面的进行修改结果成就了一幅传作。善于听取他人的意见,才能更全面理性的看待自己,使自己更好的成长。唐代著名画家周肪为寺庙绘制壁画。为了听取更多人的意见,他先画出草图挂在路边,过往的行人为看,边评头论足。周肮仔细质听,把他人的议建牢记在,进行斟酌修改,一个月后,周肪的草图挂出,路过的人赞不绝,这时周防按照最后的图案将这幅作品完成,果然成为一幅名作。周肪从别的意见中找到了自己的不足,更加全面的进行修改结果成就了一幅传作。善于听取他人的意见,才能更全面理性的看待自己,使自己更好的成长。唐代著名画家周肪为寺庙绘制壁画。为了听取更多人的意见,他先画出草图挂在路边,过往的行人为看,边评头论足。周肮仔细质听,把他人的议建牢记在,进行斟酌修改,一个月后,周肪的草图挂出,路过的人赞不绝,这时周防按照最后的图案将这幅作品完成,果然成为一幅名作。周肪从别的意见中找到了自己的不足,更加全面的进行修改结果成就了一幅传作。善于听取他人的意见,才能更全面理性的看待自己,使自己更好的成长。唐代著名画家周肪为寺庙绘制壁画。为了听取更多人的意见,他先画出草图挂在路边,过往的行人为看,边评头论足。周肮仔细质听,把他人的议建牢记在,进行斟酌修改,一个月后,周肪的草图挂出,路过的人赞不绝,这时周防按照最后的图案将这幅作品完成,果然成为一幅名作。周肪从别的意见中找到了自己的不足,更加全面的进行修改结果成就了一幅传作。\",\"精神赓续,薪火相传。一代代知识分子传承老一辈人爱国奋斗的优良传统,提升着精神高度,创造出崭新业绩。黄大年是大家公认的“科研疯子”,坚持“振兴中华,乃我辈责任”;李保国把自己变成农民,坚持“老百姓需要什么,我就研究什么”;南仁东仰望星空,脚踏实地,20年做一件事,只为打造世界最大单口径巨型射电望远镜;钟扬一生做着种子梦,只求守护祖国植物基因宝库。他们把对祖国的忠诚之心,对知识的炽热追求,转化为报国豪情、奋斗激情,在时代的洪流中书写出壮美人生。誓干惊天动地事,甘做隐姓埋名人。我国知识分子一直有爱国奋斗的优良传统。在革命、建设和改革开放的90多年里,广大知识分子胸怀赤子之心,坚守报国之志,为党和人民建立了彪炳史册的功勋,筑起了一座中华民族的精神丰碑。“两弹一星”精神第一条,就是热爱祖国、无私奉献。“回国不需要理由”“我们中国要出头”。当年,钱学森、邓稼先、郭永怀等知识分子冲破层层阻碍,与祖国走在一起,隐姓埋名、默默效力,铸就了共和国的“核盾牌”。他们是中华民族的脊梁,知识分子的楷模。\"]}]},{\"title\":\"二级标题2\",\"content\":[{\"title\":\"三级标题3\",\"content\":[\"善于听取他人的意见,才能更全面理性的看待自己,使自己更好的成长。唐代著名画家周肪为寺庙绘制壁画。为了听取更多人的意见,他先画出草图挂在路边,过往的行人为看,边评头论足。周肮仔细质听,把他人的议建牢记在,进行斟酌修改,一个月后,周肪的草图挂出,路过的人赞不绝,这时周防按照最后的图案将这幅作品完成,果然成为一幅名作。周肪从别的意见中找到了自己的不足,更加全面的进行修改结果成就了一幅传作。\",\"这是另一个段落,长度也适中。\"]}]}]}";
        ObjectMapper mapper = new ObjectMapper();
        JsonNode document = mapper.readTree(json);

        // Step 1: 递归分割文档,得到最小标题单元的Chunk
        List<Chunk> chunks = splitIntoChunks(document);
        System.out.println("Step 1 - Chunks: " + chunks);

        // Step 2: 对最小标题单元的chunk进行判断,若chunk长度较长,按长度进行二次分割
        List<Segment> segments = splitIntoSegments(chunks);
        System.out.println("Step 2 - Segments: " + segments);

        // Step 3: 对Segments进行判断,从最低级标题到高级标题开始依次判断并在长度范围内合并
        List<Block> blocks = mergeIntoBlocks(segments);
        System.out.println("Step 3 - Blocks: " + blocks);
    }

    // Step 1: 递归分割文档,得到最小标题单元的Chunk
    private static List<Chunk> splitIntoChunks(JsonNode node) {
        List<Chunk> chunks = new ArrayList<>();
        splitIntoChunksHelper(node, chunks, "");
        return chunks;
    }

    private static void splitIntoChunksHelper(JsonNode node, List<Chunk> chunks, String parentTitle) {
        if (node.has("title") && node.has("content")) {
            String currentTitle = node.get("title").asText();
            String fullTitle = parentTitle.isEmpty() ? currentTitle : parentTitle + " > " + currentTitle;

            if (node.get("content").isArray()) {
                for (JsonNode child : node.get("content")) {
                    if (child.isTextual()) {
                        // 如果内容是文本,直接作为段落
                        chunks.add(new Chunk(fullTitle, child.asText()));
                    } else {
                        // 如果内容是对象,递归处理
                        splitIntoChunksHelper(child, chunks, fullTitle);
                    }
                }
            }
        }
    }

    // Step 2: 对最小标题单元的chunk进行判断,若chunk长度较长,按长度进行二次分割
    private static List<Segment> splitIntoSegments(List<Chunk> chunks) {
        List<Segment> segments = new ArrayList<>();
        for (Chunk chunk : chunks) {
            if (chunk.content.length() > MAX_SEGMENT_LENGTH) {
                int start = 0;
                while (start < chunk.content.length()) {
                    int end = Math.min(start + MAX_SEGMENT_LENGTH, chunk.content.length());
                    String segmentContent = chunk.content.substring(start, end);
                    segments.add(new Segment(chunk.title, segmentContent));
                    start = end;
                }
            } else {
                segments.add(new Segment(chunk.title, chunk.content));
            }
        }
        return segments;
    }

    // Step 3: 对Segments进行判断,从最低级标题到高级标题开始依次判断并在长度范围内合并
    private static List<Block> mergeIntoBlocks(List<Segment> segments) {
        List<Block> blocks = new ArrayList<>();
        Block currentBlock = null;

        for (Segment segment : segments) {
            if (currentBlock == null || currentBlock.content.length() + segment.content.length() > MAX_SEGMENT_LENGTH) {
                if (currentBlock != null) {
                    blocks.add(currentBlock);
                }
                currentBlock = new Block(segment.title, segment.content);
            } else {
                currentBlock.content += " " + segment.content;
            }
        }

        if (currentBlock != null) {
            blocks.add(currentBlock);
        }

        return blocks;
    }

    // 定义Chunk、Segment、Block类
    private static class Chunk {
        String title;
        String content;

        Chunk(String title, String content) {
            this.title = title;
            this.content = content;
        }

        @Override
        public String toString() {
            return "Chunk{" +
                    "title='" + title + '\'' +
                    ", content='" + content + '\'' +
                    '}';
        }
    }

    private static class Segment {
        String title;
        String content;

        Segment(String title, String content) {
            this.title = title;
            this.content = content;
        }

        @Override
        public String toString() {
            return "Segment{" +
                    "title='" + title + '\'' +
                    ", content='" + content + '\'' +
                    '}';
        }
    }

    private static class Block {
        String title;
        String content;

        Block(String title, String content) {
            this.title = title;
            this.content = content;
        }

        @Override
        public String toString() {
            return "Block{" +
                    "title='" + title + '\'' +
                    ", content='" + content + '\'' +
                    '}';
        }
    }
}

以上代码还可以继续优化,比如增加overlap参数,太长的chunk使用句号切割等。

总结

结构和长度结合的切割方法适合结构化数据,比如markdown、doc文档可以直接解析,PDF文档需要使用ocr工具解析版面信息,转换为结构化数据。使用该方法可以较好地平衡chunk长度和语义的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值