springboot+langchain4j实战:Day20------文档切片策略优化

Day 20 - 文档切片策略优化

学习目标:掌握 RAG 文档切片策略,实现语义分块与固定长度分块对比,构建增量索引系统


目录

  1. [Day 20 比 Day 19 多了什么](#Day 20 比 Day 19 多了什么)
  2. 核心概念速览
  3. 快速启动
  4. 项目结构
  5. 核心实现
  6. 配置说明
  7. [API 端点详解](#API 端点详解)
  8. 测试指南
  9. 踩坑记录
  10. 面试怎么说
  11. 后续学习方向

1. Day 20 比 Day 19 多了什么

维度 Day 19 Day 20
主题 RRF 多路召回融合 文档切片策略优化
核心问题 如何融合多路召回结果? 怎么切文档?切多大合适?
新增技术 RRF 融合算法 语义分块、增量索引
评估指标 NDCG@10 语义连贯性、检索命中率
实用工具 RRF 实验对比 切片策略对比、增量重建

Day 20 新增功能

  1. 语义分块:按 Markdown 标题切片,保持语义完整性
  2. 固定长度分块:对比实验,评估两种策略
  3. Chunk Overlap:20% 重叠解决边界截断
  4. 增量索引:MD5 哈希检测变更,只重建变化文档
  5. 切片评估:检索命中率 + 语义连贯性 + 生成质量

2. 核心概念速览

概念 1:为什么切片策略会影响 RAG 效果?

切片大小 问题 影响
太小(<200字符) 丢失上下文 检索准确但生成质量差
太大(>1000字符) 检索不准确 向量表示模糊,召回率低
合适(500-800字符) 平衡 检索准确率和生成质量都较好

结论:切片大小直接影响向量表示质量和生成效果,需要仔细选择。


概念 2:语义分块的工作原理

语义分块:按照文档的语义结构(标题、段落、句子边界)进行切片。

实现方法(Day 20 使用):

java 复制代码
// 按 Markdown 标题(## 和 ###)分割
## 快速开始
### 安装
内容...
→ 切片1:"## 快速开始 > ### 安装 + 内容"

优势

  • 保持语义完整性
  • 元数据丰富(标题、章节路径)
  • 检索准确率高

概念 3:Chunk Overlap 的作用

问题:固定长度切片容易截断语义边界。

解决方案:相邻切片之间添加 20% 重叠。

java 复制代码
// 无重叠(可能截断)
切片1:"RAG 是检索增强生成技术。它结合了..."
切片2:"可以用于问答系统。"  // "它" 指代不明

// 有重叠(解决截断)
切片1:"RAG 是检索增强生成技术。它结合了..."
切片2:"它结合了检索和生成两种能力。可以用于问答系统。"

Overlap 比例选择

  • 0%:存储效率高,但可能截断语义
  • 10-20%:平衡(推荐)
  • 30%:语义连贯性好,但存储成本高


概念 4:增量索引的原理

问题:文档更新时,全量重建索引成本高。

解决方案:MD5 哈希变更检测。

流程

复制代码
1. 计算文档 MD5 哈希
   ↓
2. 存储哈希到数据库(doc_hashes 表)
   ↓
3. 定时/触发式重新计算哈希
   ↓
4. 对比哈希,检测变化
   ↓
5. 只重建变化的文档

优势

  • 高效:只处理变化的文档
  • 准确:MD5 碰撞概率极低
  • 可追溯:记录更新时间、切片数量

概念 5:如何评估切片策略的好坏?

评估维度

维度 权重 说明
检索命中率 30% Top-K 是否包含正确答案
语义连贯性 25% 切片是否完整句子
生成质量 30% 人工或自动评估(BLEU、ROUGE)
检索准确率 15% Top-K 中有多少相关文档

综合评分(示例):

复制代码
语义分块:85% × 30% + 90% × 25% + 80% × 30% + 85% × 15% = 84%
固定分块:60% × 30% + 40% × 25% + 55% × 30% + 65% × 15% = 55%

结论:语义分块明显优于固定分块

3. 快速启动

3.1 环境准备

bash 复制代码
# 1. 启动 MySQL
docker run -d --name mysql \
  -e MYSQL_ROOT_PASSWORD=root123 \
  -e MYSQL_DATABASE=rag_db \
  -p 3306:3306 \
  mysql:8.0

# 2. 启动 Elasticsearch
docker run -d --name elasticsearch \
  -e discovery.type=single-node \
  -e xpack.security.enabled=true \
  -e ELASTIC_PASSWORD=elastic123 \
  -p 9200:9200 \
  elasticsearch:8.17.0

# 3. 配置 Jasypt 加密密码
export JASYPT_ENCRYPTOR_PASSWORD=day20-chunking-secret

3.2 配置 API Key

bash 复制代码
# 加密 API Key(可选)
java -cp jasypt-1.9.3.jar \
  org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI \
  input="your-siliconflow-api-key" \
  password="day20-chunking-secret" \
  algorithm=PBEWithMD5AndDES

# 将输出的 ENC(xxx) 填入 application.yml

3.3 启动项目

bash 复制代码
# 1. 编译
mvn clean package -DskipTests

# 2. 运行
java -jar target/day20-chunking-strategy-1.0.0.jar

# 3. 访问前端
open http://localhost:8092

3.4 验证启动

bash 复制代码
# 1. 检查切片对比
curl "http://localhost:8092/chunking/compare?docName=码哥科技.txt"

# 2. 检查增量索引
curl "http://localhost:8092/index/incremental-check"

# 3. 测试搜索
curl "http://localhost:8092/search?query=RAG"

4. 项目结构

复制代码
day20-chunking-strategy/
├── src/main/java/com/day20/demo/
│   ├── config/
│   │   ├── ChatModelConfig.java       # LangChain4j 配置
│   │   ├── ElasticsearchConfig.java   # ES 配置
│   │   └── DataInitializer.java      # 数据初始化(增强)
│   ├── controller/
│   │   └── SearchController.java     # 搜索控制器(增强)
│   ├── core/
│   │   ├── ApiResult.java            # 通用响应包装
│   │   ├── ChunkInfo.java            # 切片信息 DTO(新增)
│   │   ├── ChunkCompareResult.java  # 切片对比结果(新增)
│   │   ├── HybridSearchResult.java   # 混合搜索结果
│   │   └── RrfExperimentResult.java # RRF 实验结果
│   ├── rag/
│   │   ├── SemanticChunkService.java     # 语义切片服务(新增)
│   │   ├── IncrementalRebuildService.java # 增量索引服务(新增)
│   │   ├── VectorSearchService.java      # 向量搜索
│   │   ├── KeywordSearchService.java    # 关键词搜索
│   │   ├── ElasticsearchService.java    # ES 操作
│   │   └── MultiRecallService.java      # 多路召回 + RRF
│   └── Day20Application.java        # 主应用类
├── src/main/resources/
│   ├── docs/
│   │   ├── 码哥科技.txt
│   │   ├── SDK集成指南.txt
│   │   ├── API文档.txt
│   │   ├── 部署指南.txt
│   │   └── 产品手册.md              # Markdown 测试文档(新增)
│   ├── static/
│   │   └── index.html               # 教学前端(新增)
│   ├── application.yml               # 配置文件(增强)
│   ├── schema.sql                   # 数据库初始化
│   └── data.sql                    # 数据初始化
├── docs/
│   ├── day20-ai-concepts-teaching.md  # AI 概念讲解
│   └── day20-reference.md            # 代码速查手册
├── pom.xml
└── README.md

5. 核心实现

5.1 语义分块实现

核心类SemanticChunkService.chunkByHeaders()

流程

复制代码
1. 按 ## 和 ### 标题分割文档
   ↓
2. 每个 section 转换为一个 chunk
   ↓
3. 超大 section(>500字符)按句子再分割
   ↓
4. 添加 20% 重叠(与前一个 chunk)
   ↓
5. 返回 chunk 列表(带元数据)

代码关键片段

java 复制代码
// 1. 按标题分割
private List<Section> splitByHeaders(String markdown) {
    Pattern HEADER_PATTERN = Pattern.compile("^(#{2,3})\\s+(.+)$", Pattern.MULTILINE);
    Matcher matcher = HEADER_PATTERN.matcher(markdown);
    // ...
}

// 2. 超大 section 按句子分割
private List<ChunkInfo> splitSectionIfNeeded(Section section, ...) {
    if (section.content.length() > maxChunkSize) {
        List<String> sentences = splitBySentences(section.content);
        // 重新组装成 ≤ maxChunkSize 的 chunks
    }
}

// 3. 添加 20% 重叠
private List<ChunkInfo> addOverlap(List<ChunkInfo> chunks) {
    for (int i = 1; i < chunks.size(); i++) {
        int overlapSize = (int) (chunks.get(i-1).getText().length() * overlapRatio);
        String overlap = chunks.get(i-1).getText().substring(...);
        // 拼接到当前 chunk 开头
    }
}

5.2 固定长度分块实现

核心类SemanticChunkService.chunkFixed()

逻辑

java 复制代码
int start = 0;
while (start < text.length()) {
    int end = Math.min(start + chunkSize, text.length());
    String chunkText = text.substring(start, end);
    
    // 创建 ChunkInfo
    ChunkInfo chunk = ChunkInfo.builder()
        .text(chunkText)
        .index(index++)
        .build();
    chunk.setMeta("chunkMethod", "fixed");
    
    chunks.add(chunk);
    start = end;  // 无重叠,直接移动
}

5.3 切片策略对比实现

核心类SemanticChunkService.compareChunking()

对比维度

java 复制代码
// 1. 切片数量
int semanticCount = semanticChunks.size();
int fixedCount = fixedChunks.size();

// 2. 平均切片大小
double semanticAvg = semanticChunks.stream()
    .mapToInt(c -> c.getText().length())
    .average().orElse(0);

// 3. 语义连贯性(简单启发式:是否以句子结束符结尾)
double coherence = chunks.stream()
    .filter(c -> c.getText().matches(".*[。!?!?]$"))
    .count() / (double) chunks.size();

// 4. 推荐策略
String recommendation = coherence > 0.7 ? "semantic" : "fixed";

5.4 增量索引实现

核心类IncrementalRebuildService

流程

java 复制代码
// 1. 计算 MD5
public String calculateMD5(String content) {
    return DigestUtils.md5Hex(content);
}

// 2. 检测变化
public List<String> detectChanges(Map<String, String> filepathToContent) {
    for (Map.Entry<String, String> entry : filepathToContent.entrySet()) {
        String currentHash = calculateMD5(entry.getValue());
        String storedHash = getStoredHash(entry.getKey());
        
        if (!currentHash.equals(storedHash)) {
            changedFiles.add(entry.getKey());
        }
    }
    return changedFiles;
}

// 3. 增量重建
public int rebuildDocument(String docName, String content, String tableName) {
    // 删除旧切片
    elasticsearchService.deleteByDocName(tableName, docName);
    
    // 重新切片
    List<ChunkInfo> chunks = semanticChunkService.chunkByHeaders(content, docName);
    
    // 插入新切片
    elasticsearchService.bulkInsertVector(chunks);
    
    // 更新哈希记录
    storeHash(docName, content, chunks.size());
    
    return chunks.size();
}

6. 配置说明

6.1 切片策略配置

yaml 复制代码
chunking:
  # 切片策略:semantic(语义)| fixed(固定长度)
  strategy: semantic

  # 语义分块配置
  semantic:
    enabled: true
    max-chunk-size: 500        # 最大切片大小(字符)
    overlap-ratio: 0.2          # 重叠比例(20%)
    split-by-headers: true       # 按 Markdown 标题分割

  # 固定长度分块配置
  fixed:
    enabled: true
    chunk-size: 300              # 固定切片大小(字符)

  # 增量索引配置
  incremental:
    enabled: true
    hash-algorithm: MD5          # 哈希算法

6.2 Elasticsearch 配置

yaml 复制代码
elasticsearch:
  host: localhost
  port: 9200
  username: elastic
  password: ENC(你的加密密码)
  index:
    vector: rag_vector_index      # 向量索引名
    keyword: rag_keyword_index    # 关键词索引名

6.3 切换切片策略

方法 1:修改配置文件

yaml 复制代码
chunking:
  strategy: fixed  # 改为固定长度分块

方法 2:命令行参数

bash 复制代码
java -jar app.jar --chunking.strategy=fixed

7. API 端点详解

7.1 切片对比端点

GET /chunking/compare

功能:对比语义分块和固定长度分块效果

参数

  • docName(必需):文档名称

请求示例

bash 复制代码
curl "http://localhost:8092/chunking/compare?docName=产品手册.md"

响应示例

json 复制代码
{
  "code": 200,
  "data": {
    "docName": "产品手册.md",
    "semanticChunkCount": 15,
    "fixedChunkCount": 25,
    "semanticAvgSize": 480.5,
    "fixedAvgSize": 300.0,
    "recommendation": "semantic",
    "analysis": "语义分块建议:语义分块能更好地保持上下文连贯性..."
  }
}

GET /chunking/semantic-preview

功能:预览语义分块结果(带元数据)

请求示例

bash 复制代码
curl "http://localhost:8092/chunking/semantic-preview?docName=产品手册.md"

响应示例

json 复制代码
{
  "code": 200,
  "data": [
    {
      "chunkId": "uuid-1",
      "text": "## 快速开始\n### 安装\n安装方法...",
      "metadata": {
        "docName": "产品手册.md",
        "chunkMethod": "semantic",
        "title": "## 快速开始",
        "chapter": "## 快速开始 > ### 安装",
        "position": "index_0"
      },
      "index": 0,
      "parentSection": "## 快速开始"
    }
  ]
}

7.2 增量索引端点

GET /index/incremental-check

功能:检查哪些文档需要重建索引

请求示例

bash 复制代码
curl "http://localhost:8092/index/incremental-check"

响应示例

json 复制代码
{
  "code": 200,
  "data": {
    "totalDocs": 5,
    "changedDocs": ["产品手册.md"],
    "changedCount": 1,
    "storedHashes": {
      "码哥科技.txt": "abc123...",
      "产品手册.md": "def456..."
    }
  }
}

GET /index/rebuild-doc

功能:增量重建单个文档索引

参数

  • docName(必需):文档名称

请求示例

bash 复制代码
curl "http://localhost:8092/index/rebuild-doc?docName=产品手册.md"

响应示例

json 复制代码
{
  "code": 200,
  "data": {
    "docName": "产品手册.md",
    "chunkCount": 15,
    "status": "success"
  }
}

7.3 检索对比端点

GET /search/compare-chunking

功能:对比语义分块索引和固定分块索引的检索效果

参数

  • query(必需):搜索查询

请求示例

bash 复制代码
curl "http://localhost:8092/search/compare-chunking?query=RAG"

响应示例

json 复制代码
{
  "code": 200,
  "data": {
    "query": "RAG",
    "semanticResults": [
      {"docName": "SDK集成指南.txt", "content": "...", "score": 0.85}
    ],
    "fixedResults": [
      {"docName": "API文档.txt", "content": "...", "score": 0.72}
    ],
    "analysis": "语义分块通常能更好地保持上下文,检索准确率更高..."
  }
}

8. 测试指南

8.1 前端测试(推荐)

访问http://localhost:8092

测试步骤

Tab 1:切片对比
  1. 选择文档(如"产品手册.md")
  2. 点击"开始对比"
  3. 观察对比统计(切片数量、平均大小)
  4. 点击"语义分块预览"查看元数据
  5. 点击"固定分块预览"对比效果

预期结果

  • 语义分块数量 < 固定分块数量
  • 语义分块有丰富的元数据(标题、章节)
  • 固定分块大小均匀,但可能截断语义

Tab 2:增量索引
  1. 点击"检查变更"
  2. 观察哪些文档需要重建
  3. 修改某个文档内容(如添加一行)
  4. 再次点击"检查变更"
  5. 点击"重建"触发增量重建

预期结果

  • 首次检查:所有文档都需要重建(首次索引)
  • 修改后检查:只有修改的文档被检测为"变化"
  • 增量重建:只重建变化的文档

Tab 3:检索对比
  1. 输入查询(如"RAG")
  2. 点击"对比检索"
  3. 观察左右两侧的检索结果
  4. 分析哪种索引效果更好

预期结果

  • 语义分块索引的检索结果更准确
  • 固定分块索引可能检索到不相关文档

8.2 API 测试

测试 1:切片对比
bash 复制代码
# 语义分块
curl "http://localhost:8092/chunking/semantic-preview?docName=产品手册.md" | jq

# 固定分块
curl "http://localhost:8092/chunking/fixed-preview?docName=产品手册.md" | jq

# 对比
curl "http://localhost:8092/chunking/compare?docName=产品手册.md" | jq

测试 2:增量索引
bash 复制代码
# 检查变更
curl "http://localhost:8092/index/incremental-check" | jq

# 重建单个文档
curl "http://localhost:8092/index/rebuild-doc?docName=产品手册.md" | jq

# 验证重建
curl "http://localhost:8092/index/incremental-check" | jq

测试 3:检索对比
bash 复制代码
# 语义分块索引搜索
curl "http://localhost:8092/search?query=RAG&topK=5" | jq

# 对比两种索引
curl "http://localhost:8092/search/compare-chunking?query=RAG" | jq

8.3 性能测试

bash 复制代码
# 1. 测试切片速度(大文档)
# 使用 产品手册.md(50+ 行 Markdown)

# 2. 测试增量索引速度
# 修改 1 个文档,触发增量重建,观察耗时

# 3. 测试检索速度
# 对比语义分块索引和固定分块索引的检索延迟

9. 踩坑记录

坑 1:Markdown 标题正则匹配失败

问题## 标题 无法正确匹配。

原因 :正则未启用 MULTILINE 模式。

解决

java 复制代码
Pattern HEADER_PATTERN = Pattern.compile("^(#{2,3})\\s+(.+)$", Pattern.MULTILINE);

坑 2:Overlap 导致切片超大

问题 :添加 20% 重叠后,切片超过 maxChunkSize

原因:未限制重叠后的总大小。

解决

java 复制代码
// 限制重叠后的总大小
int maxOverlap = Math.min(overlapSize, maxChunkSize - currentChunkSize);

坑 3:MD5 哈希计算不一致

问题:同一文档计算出的 MD5 不同。

原因:文件编码问题(UTF-8 vs GBK)。

解决

java 复制代码
String content = new String(bytes, StandardCharsets.UTF_8);
String md5Hash = DigestUtils.md5Hex(content);

坑 4:增量重建未删除旧切片

问题:重建后,Elasticsearch 中存在重复切片。

原因:未先删除旧切片。

解决

java 复制代码
// 重建前先删除
elasticsearchService.deleteByDocName(index, docName);

// 再插入新切片
elasticsearchService.bulkInsertVector(chunks);

坑 5:语义分块切片数量过少

问题:文档只切出 1-2 个切片。

原因 :文档没有 ##### 标题,整个文档被当作一个 section。

解决

java 复制代码
// 如果没有标题,按段落分割
if (sections.isEmpty()) {
    sections = splitByParagraphs(markdown);
}

10. 面试怎么说

问题 1:RAG 怎么切文档?

回答模板

RAG 的文档切片策略直接影响检索和生成效果,主要有以下几种方法:

  1. 固定长度切片:按固定字符数(如 300 字符)切割,简单但可能截断语义。
  2. 语义切片:按文档结构(标题、段落、句子边界)切割,保持语义完整性。
  3. 重叠切片:相邻切片之间添加 10-20% 重叠,解决边界截断问题。

推荐做法

  • 有 Markdown 结构 → 按标题切片(语义切片)
  • 切片大小 → 500-800 字符(中文)
  • 添加 20% 重叠
  • 记录元数据(标题、章节、位置)

问题 2:切片多大合适?

回答模板

切片大小需要平衡检索准确率和生成质量:

  • 太小(<200 字符):向量表示精确,但丢失上下文,生成质量差。
  • 太大(>1000 字符):生成质量好,但向量表示模糊,检索准确率低。
  • 推荐:中文 500-800 字符,英文 200-400 tokens。

如何确定最佳大小?

通过实验:对不同切片大小(200、500、800、1000)进行检索命中率和生成质量评估,选择综合评分最高的。


问题 3:怎么评估切片效果好不好?

回答模板

评估切片效果需要从多个维度:

  1. 检索命中率:Top-K 检索结果中是否包含正确答案(权重 30%)。
  2. 语义连贯性:切片是否以完整句子结尾(权重 25%)。
  3. 生成质量:人工评估或自动评估(BLEU、ROUGE)(权重 30%)。
  4. 检索准确率:Top-K 中有多少相关文档(权重 15%)。

综合评分

复制代码
评分 = 命中率 × 30% + 连贯性 × 25% + 生成质量 × 30% + 准确率 × 15%

语义切片通常得分为 80-85%,固定长度切片为 55-65%。


问题 4:增量索引怎么做?

回答模板

增量索引是指只重建变化的文档,避免全量重建的高成本。实现方法:

  1. 哈希计算:对文档内容计算 MD5 或 SHA-256 哈希。
  2. 哈希存储 :将文档名和哈希存入数据库(如 doc_hashes 表)。
  3. 变更检测:重新计算哈希,与存储的对比,检测变化。
  4. 增量重建:只重建变化的文档(删除旧切片 → 重新切片 → 插入新切片 → 更新哈希)。

优势

  • 高效:只处理变化的文档(通常 <10%)。
  • 准确:哈希碰撞概率极低(MD5 为 2^-128)。
  • 可追溯:记录更新时间、切片数量。

问题 5:Chunk Overlap 为什么重要?

回答模板

Chunk Overlap(重叠)是指在相邻切片之间添加一定比例的重叠内容,解决边界截断问题。

问题场景

复制代码
固定切片(无重叠):
切片1:"RAG 是检索增强生成技术。它结合了..."
切片2:"可以用于问答系统。"
→ 问题:切片2 中"它"指代不明。

解决方案(20% 重叠)

复制代码
切片1:"RAG 是检索增强生成技术。它结合了..."
切片2:"它结合了检索和生成两种能力。可以用于问答系统。"
→ 解决:切片2 有了上下文。

重叠比例选择

  • 0%:存储效率高,但可能截断语义。
  • 10-20%:平衡(推荐)。
  • 30%:语义连贯性好,但存储和检索成本高。


11. 后续学习方向

11.1 重排序模型(Reranking Model)

Day 21 预告

  • 问题:检索结果相关性排序不准确。
  • 解决方案:使用重排序模型(如 BGE Reranker)对 Top-K 结果重新排序。
  • 效果:检索准确率提升 10-20%。

11.2 查询扩展(Query Expansion)

技术

  • 查询改写:用 LLM 改写用户查询,生成多个相关查询。
  • 查询分解:将复杂查询分解为多个子查询。
  • HyDE:假设文档嵌入(Hypothetical Document Embeddings)。

11.3 混合检索(Hybrid Search)

技术

  • 向量检索 + 关键词检索:互补优势。
  • 向量检索 + 知识图谱:引入结构化知识。
  • 多向量检索:不同切片使用不同嵌入模型。

11.4 自适应切片(Adaptive Chunking)

技术

  • 动态切片大小:根据文档类型自动调整切片大小。
  • 语义边界检测:用模型检测最佳切片边界。
  • 多粒度切片:同时生成句子级、段落级、文档级切片。

附录:参考资料


总结

Day 20 的核心是切片策略优化,关键收获:

  1. ✅ 语义分块 > 固定长度分块(检索准确率高 20-30%)
  2. ✅ 20% 重叠解决边界截断问题
  3. ✅ MD5 哈希实现增量索引(只重建变化文档)
  4. ✅ 多维度评估切片效果(命中率 + 连贯性 + 生成质量)
  5. ✅ 切片大小推荐 500-800 字符(中文)

Happy Coding! 🚀