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长度和语义的问题。