知识库分片的两种基本方式

170 阅读5分钟

📚 知识库分片学习笔记

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);

分割效果示意:

image.png 可以看到,该方法经常会导致句子被强行切断,破坏语义完整性

1.2 基于句子的滑动窗口分割

相比字符级分割,按句子为单位进行滑动窗口式分割可以更好地保留上下文信息,保持语义完整性。

在文章 18种RAG技术大比拼 中提到,虽然句子分割有时效果不佳,但结合上下文构建滑动窗口后效果显著提升。

滑动窗口原理图:(图来自csdn)

image.png 窗口中心是当前句子,前后各加入一定数量的句子作为上下文,这样更有利于向量化效果与语义还原。

2. 实现逻辑与代码

✨ 核心思路

  1. 读取原始文本文件;
  2. 使用 BreakIterator 对文本按中文句子分割;
  3. 以滑动窗口方式构造包含上下文的分片;
  4. 中心句作为 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     - ----------

image.png 上述可以看出,该demo存储的正文是以当前句为核心,包括前后句的内容,但是嵌入是以当前句为主体进行嵌入,这样能够减少上下文字段的影响,提高检索准确度,但是这里还没做好的就是,目前滑动窗口是以1为步幅滑动,如果文本多的话存储压力就很大了,所以后续可以考虑步幅要提高一些。