Day 20 - 文档切片策略优化
学习目标:掌握 RAG 文档切片策略,实现语义分块与固定长度分块对比,构建增量索引系统
目录
- [Day 20 比 Day 19 多了什么](#Day 20 比 Day 19 多了什么)
- 核心概念速览
- 快速启动
- 项目结构
- 核心实现
- 配置说明
- [API 端点详解](#API 端点详解)
- 测试指南
- 踩坑记录
- 面试怎么说
- 后续学习方向
1. Day 20 比 Day 19 多了什么
| 维度 | Day 19 | Day 20 |
|---|---|---|
| 主题 | RRF 多路召回融合 | 文档切片策略优化 |
| 核心问题 | 如何融合多路召回结果? | 怎么切文档?切多大合适? |
| 新增技术 | RRF 融合算法 | 语义分块、增量索引 |
| 评估指标 | NDCG@10 | 语义连贯性、检索命中率 |
| 实用工具 | RRF 实验对比 | 切片策略对比、增量重建 |
Day 20 新增功能
- 语义分块:按 Markdown 标题切片,保持语义完整性
- 固定长度分块:对比实验,评估两种策略
- Chunk Overlap:20% 重叠解决边界截断
- 增量索引:MD5 哈希检测变更,只重建变化文档
- 切片评估:检索命中率 + 语义连贯性 + 生成质量
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 前端测试(推荐)
测试步骤:
Tab 1:切片对比
- 选择文档(如"产品手册.md")
- 点击"开始对比"
- 观察对比统计(切片数量、平均大小)
- 点击"语义分块预览"查看元数据
- 点击"固定分块预览"对比效果
预期结果:
- 语义分块数量 < 固定分块数量
- 语义分块有丰富的元数据(标题、章节)
- 固定分块大小均匀,但可能截断语义
Tab 2:增量索引
- 点击"检查变更"
- 观察哪些文档需要重建
- 修改某个文档内容(如添加一行)
- 再次点击"检查变更"
- 点击"重建"触发增量重建
预期结果:
- 首次检查:所有文档都需要重建(首次索引)
- 修改后检查:只有修改的文档被检测为"变化"
- 增量重建:只重建变化的文档
Tab 3:检索对比
- 输入查询(如"RAG")
- 点击"对比检索"
- 观察左右两侧的检索结果
- 分析哪种索引效果更好
预期结果:
- 语义分块索引的检索结果更准确
- 固定分块索引可能检索到不相关文档
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 的文档切片策略直接影响检索和生成效果,主要有以下几种方法:
- 固定长度切片:按固定字符数(如 300 字符)切割,简单但可能截断语义。
- 语义切片:按文档结构(标题、段落、句子边界)切割,保持语义完整性。
- 重叠切片:相邻切片之间添加 10-20% 重叠,解决边界截断问题。
推荐做法:
- 有 Markdown 结构 → 按标题切片(语义切片)
- 切片大小 → 500-800 字符(中文)
- 添加 20% 重叠
- 记录元数据(标题、章节、位置)
问题 2:切片多大合适?
回答模板:
切片大小需要平衡检索准确率和生成质量:
- 太小(<200 字符):向量表示精确,但丢失上下文,生成质量差。
- 太大(>1000 字符):生成质量好,但向量表示模糊,检索准确率低。
- 推荐:中文 500-800 字符,英文 200-400 tokens。
如何确定最佳大小?
通过实验:对不同切片大小(200、500、800、1000)进行检索命中率和生成质量评估,选择综合评分最高的。
问题 3:怎么评估切片效果好不好?
回答模板:
评估切片效果需要从多个维度:
- 检索命中率:Top-K 检索结果中是否包含正确答案(权重 30%)。
- 语义连贯性:切片是否以完整句子结尾(权重 25%)。
- 生成质量:人工评估或自动评估(BLEU、ROUGE)(权重 30%)。
- 检索准确率:Top-K 中有多少相关文档(权重 15%)。
综合评分:
评分 = 命中率 × 30% + 连贯性 × 25% + 生成质量 × 30% + 准确率 × 15%语义切片通常得分为 80-85%,固定长度切片为 55-65%。
问题 4:增量索引怎么做?
回答模板:
增量索引是指只重建变化的文档,避免全量重建的高成本。实现方法:
- 哈希计算:对文档内容计算 MD5 或 SHA-256 哈希。
- 哈希存储 :将文档名和哈希存入数据库(如
doc_hashes表)。- 变更检测:重新计算哈希,与存储的对比,检测变化。
- 增量重建:只重建变化的文档(删除旧切片 → 重新切片 → 插入新切片 → 更新哈希)。
优势:
- 高效:只处理变化的文档(通常 <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)
技术:
- 动态切片大小:根据文档类型自动调整切片大小。
- 语义边界检测:用模型检测最佳切片边界。
- 多粒度切片:同时生成句子级、段落级、文档级切片。
附录:参考资料
- LangChain4j 文档:https://docs.langchain4j.dev/
- Elasticsearch 向量检索:https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html
- RAG 最佳实践:https://www.pinecone.io/learn/retrieval-augmented-generation/
- 语义切片论文:https://arxiv.org/abs/2305.06566
总结
Day 20 的核心是切片策略优化,关键收获:
- ✅ 语义分块 > 固定长度分块(检索准确率高 20-30%)
- ✅ 20% 重叠解决边界截断问题
- ✅ MD5 哈希实现增量索引(只重建变化文档)
- ✅ 多维度评估切片效果(命中率 + 连贯性 + 生成质量)
- ✅ 切片大小推荐 500-800 字符(中文)
Happy Coding! 🚀