📚 知识库分片学习笔记
1. 分片方式概述
1.1 直接分割(字符级)
Spring AI 内置了字符级别的分割器,可通过简单配置直接使用:
@Bean
public TokenTextSplitter tokenTextSplitter() {
return new TokenTextSplitter();
}
使用方式如下:
TikaDocumentReader reader = new TikaDocumentReader("./data/test.md");
List<Document> documents = reader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
分割效果示意:
可以看到,该方法经常会导致句子被强行切断,破坏语义完整性。
1.2 基于句子的滑动窗口分割
相比字符级分割,按句子为单位进行滑动窗口式分割可以更好地保留上下文信息,保持语义完整性。
在文章 18种RAG技术大比拼 中提到,虽然句子分割有时效果不佳,但结合上下文构建滑动窗口后效果显著提升。
滑动窗口原理图:(图来自csdn)
窗口中心是当前句子,前后各加入一定数量的句子作为上下文,这样更有利于向量化效果与语义还原。
2. 实现逻辑与代码
✨ 核心思路
- 读取原始文本文件;
- 使用
BreakIterator
对文本按中文句子分割; - 以滑动窗口方式构造包含上下文的分片;
- 中心句作为
originText
进行向量嵌入,全文块用于存储。
📌 核心实现代码
@Slf4j
public final class DocumentProcessingUtils {
// 私有构造函数,防止实例化
private DocumentProcessingUtils() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
/**
* 读取指定文件,按句子分割,并构建滑动窗口文本块。
*
* @param inputFilePath 输入文件的路径 (相对于运行时)。
* @param sentencesBefore 每个窗口包含当前句子之前的句子数量。
* @param sentencesAfter 每个窗口包含当前句子之后的句子数量。
* @param knowledgeBaseName 知识库的名称,将添加到元数据中。
* @return 包含滑动窗口内容的 Document 列表,失败或无内容时返回空列表。
*/
public static List<Document> splitFileIntoSlidingWindowChunks(String inputFilePath,
int sentencesBefore,
int sentencesAfter,
String knowledgeBaseName) {
log.info("开始处理文件: {}, knowledgeBase={}, sentencesBefore={}, sentencesAfter={}",
inputFilePath, knowledgeBaseName, sentencesBefore, sentencesAfter);
// --- 读取文件内容 ---
String fileContent;
try {
// 考虑使用 ClassPathResource 处理 classpath 下的资源可能更健壮
TikaDocumentReader reader = new TikaDocumentReader(inputFilePath);
List<Document> documents = reader.get();
if (documents.isEmpty()) {
log.warn("未能从文件 {} 读取到任何文档内容。", inputFilePath);
return Collections.emptyList();
}
fileContent = documents.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n"));
if (fileContent.trim().isEmpty()) {
log.warn("文件 {} 内容为空或只包含空白字符。", inputFilePath);
return Collections.emptyList();
}
log.debug("从文件 {} 读取内容成功。", inputFilePath);
} catch (Exception e) {
log.error("读取文件 {} 时出错: {}", inputFilePath, e.getMessage(), e);
return Collections.emptyList();
}
// --- 按句子分割 ---
List<String> sentences = new ArrayList<>();
try {
BreakIterator iterator = BreakIterator.getSentenceInstance(Locale.CHINA); // 使用中文 Locale
iterator.setText(fileContent);
int start = iterator.first();
for (int end = iterator.next(); end != BreakIterator.DONE; start = end, end = iterator.next()) {
String sentence = fileContent.substring(start, end).trim();
if (!sentence.isEmpty()) {
sentences.add(sentence);
}
}
if (sentences.isEmpty()) {
log.warn("未能将文件 {} 内容分割成任何句子。", inputFilePath);
return Collections.emptyList();
}
log.info("文件 {} 内容被分割成 {} 个句子。", inputFilePath, sentences.size());
} catch (Exception e) {
log.error("分割文件 {} 内容为句子时出错: {}", inputFilePath, e.getMessage(), e);
return Collections.emptyList();
}
// --- 构建滑动窗口块 ---
List<Document> chunks = new ArrayList<>();
try {
for (int i = 0; i < sentences.size(); i++) {
String middleSentence = sentences.get(i);
int startIndex = Math.max(0, i - sentencesBefore);
int endIndex = Math.min(sentences.size() - 1, i + sentencesAfter);
List<String> windowSentences = sentences.subList(startIndex, endIndex + 1);
String chunkContent = String.join(" ", windowSentences);
Map<String, Object> metadata = new HashMap<>(); // 使用 HashMap 以便未来可能添加更多元数据
metadata.put("originText", middleSentence);
metadata.put("windowStartIndex", startIndex);
metadata.put("windowEndIndex", endIndex);
metadata.put("middleSentenceIndex", i);
metadata.put("sourceFile", inputFilePath);
metadata.put("knowledgeBase", knowledgeBaseName);
chunks.add(new Document(chunkContent, metadata));
}
log.info("成功为文件 {} 构建了 {} 个滑动窗口块。", inputFilePath, chunks.size());
} catch (Exception e) {
log.error("为文件 {} 构建滑动窗口块时出错: {}", inputFilePath, e.getMessage(), e);
return Collections.emptyList(); // 出现错误时返回空列表
}
return chunks;
}
}
注意点就是,添加元数据的时候,那个key的名字最好不要带下划线,不然后面使用时候可能会报错(实际踩坑)
25-04-22.11:31:24.076 [main ] INFO DynamicContextTest - --- 开始滑动窗口句子分割测试 (使用工具类),输入文件: ./data/introduction.md ---
25-04-22.11:31:24.076 [main ] INFO DocumentProcessingUtils - 开始处理文件: ./data/introduction.md, knowledgeBase=知识库2, sentencesBefore=1, sentencesAfter=1
25-04-22.11:31:24.459 [main ] INFO DocumentProcessingUtils - 文件 ./data/introduction.md 内容被分割成 15 个句子。
25-04-22.11:31:24.460 [main ] INFO DocumentProcessingUtils - 成功为文件 ./data/introduction.md 构建了 15 个滑动窗口块。
25-04-22.11:31:24.460 [main ] INFO DynamicContextTest - 工具类返回的滑动窗口块 (共 15 个):
25-04-22.11:31:24.460 [main ] INFO DynamicContextTest - --- 块 1 ---
25-04-22.11:31:24.460 [main ] INFO DynamicContextTest - Metadata: {originText=沃卞得智能科技有限公司:以AI重塑产业未来的创新先锋
沃卞得智能科技有限公司成立于2018年,是一家以人工智能技术为核心驱动力的创新型科技企业。, windowStartIndex=0, windowEndIndex=1, knowledgeBase=知识库2, middleSentenceIndex=0, sourceFile=./data/introduction.md}
25-04-22.11:31:24.460 [main ] INFO DynamicContextTest - Content: 沃卞得智能科技有限公司:以AI重塑产业未来的创新先锋
沃卞得智能科技有限公司成立于2018年,是一家以人工智能技术为核心驱动力的创新型科技企业。 公司总部位于深圳,在北京、上海、硅谷设立研发中心,业务覆盖全球20余个国家和地区,致力于通过自主研发的AI技术平台推动传统产业智能化升级,在计算机视觉、自然语言处理、智能决策系统等领域持续突破,已获得136项技术专利。
25-04-22.11:31:24.460 [main ] INFO DynamicContextTest - ----------
25-04-22.11:31:24.463 [main ] INFO DynamicContextTest - --- 开始对 origin_text 进行嵌入并存入 PgVector ---
25-04-22.11:31:24.463 [main ] INFO DynamicContextTest - 块 1/15: 准备嵌入并存储 'origin_text': [沃卞得智能科技有限公司:以AI重塑产业未来的创新先锋
沃卞得智能科技有限公司成立于2018年,是一家以人工智能技术为核心驱动力的创新型科技企业。]
25-04-22.11:31:25.319 [main ] INFO DynamicContextTest - -> 嵌入成功,维度: 768, 向量前5个值: [-0.25356990098953247, 1.0578829050064087, -3.3689651489257812, -0.050538744777441025, 0.3010038137435913]
25-04-22.11:31:25.746 [main ] INFO DynamicContextTest - -> 成功存入数据库,ID: a2d04ede-7490-4869-bd32-9bdb5c5ce438
25-04-22.11:31:25.746 [main ] INFO DynamicContextTest - ----------
上述可以看出,该demo存储的正文是以当前句为核心,包括前后句的内容,但是嵌入是以当前句为主体进行嵌入,这样能够减少上下文字段的影响,提高检索准确度,但是这里还没做好的就是,目前滑动窗口是以1为步幅滑动,如果文本多的话存储压力就很大了,所以后续可以考虑步幅要提高一些。