Spring AI文本分块:5种分块策略对比与生产环境选型
Spring AI文本分块:5种分块策略对比与生产环境选型
适读人群:正在构建RAG知识库、想提升检索质量的Java工程师 阅读时长:约15分钟 文章价值:搞懂分块策略,RAG准确率直接提升20%+
先说一件真实的事
小李做了个企业文档问答系统,知识库里放了几百篇技术文档。刚上线的时候用户反馈不错,但过了一段时间开始收到抱怨:问"部署步骤是什么",给出的答案里只有第二步和第三步,第一步不见了;问一个跨章节的概念,总是答不完整。
他找我排查,问题出在文本分块上。他用的是默认的 TokenTextSplitter,块大小500token,重叠50token,一刀切地切所有文档。
但现实是,每种文档的结构都不一样:有些文档按章节组织,有些是流水文本,有些是问答对,有些是表格。一把刀切所有文档,要么切断了语义,要么块太大导致检索噪音。
调整分块策略之后,他的系统准确率从72%涨到了89%。今天就来系统聊聊这件事。
文本分块为什么重要
RAG的本质是:把文档切成小块 → 向量化 → 检索最相关的块 → 拼给LLM。
分块策略直接影响两件事:
- 召回率:好的分块能让相关内容完整地被检索到
- 精确率:好的分块让每个块语义完整,LLM能理解
5种分块策略详解
策略1:固定大小分块(TokenTextSplitter)
最简单,按Token数量切分,支持重叠。
适合:结构不规律的流水文本、日志文本 不适合:有明显章节结构的文档
@Bean
public TokenTextSplitter fixedSizeSplitter() {
return TokenTextSplitter.builder()
.withDefaultChunkSize(512) // 每块token数
.withMinChunkSizeChars(100) // 最小字符数(太短的块丢弃)
.withMinChunkLengthToEmbed(5) // 最小词数
.withMaxNumChunks(10000) // 最大块数
.withKeepSeparator(true) // 保留分隔符
.build();
}
// 使用示例
@Service
public class FixedSizeChunkService {
private final TokenTextSplitter splitter;
private final VectorStore vectorStore;
public void ingest(String content, String docId) {
Document doc = new Document(content, Map.of("doc_id", docId));
List<Document> chunks = splitter.apply(List.of(doc));
// 每个chunk追加位置信息
for (int i = 0; i < chunks.size(); i++) {
Document chunk = chunks.get(i);
chunk.getMetadata().put("chunk_index", i);
chunk.getMetadata().put("chunk_total", chunks.size());
}
vectorStore.add(chunks);
}
}参数调优参考:
| 文档类型 | 推荐块大小 | 推荐重叠 |
|---|---|---|
| 长篇技术文档 | 512-1024 | 50-100 |
| 新闻/博客 | 256-512 | 30-50 |
| 问答对 | 128-256 | 20-30 |
| 合同/法规 | 1024-2048 | 100-200 |
策略2:按语义段落分块(SentenceTransformersTokenTextSplitter)
先按句子分,再合并到目标大小,保证不在句子中间截断。
@Service
@Slf4j
public class SentenceAwareChunkService {
/**
* 基于句子边界的分块实现
* Spring AI 原生SentenceSplitter的替代实现
*/
public List<Document> chunkBySentence(String text, int targetChunkSize, int overlap) {
// 1. 按句子分割(支持中英文)
List<String> sentences = splitToSentences(text);
List<Document> chunks = new ArrayList<>();
List<String> currentChunk = new ArrayList<>();
int currentSize = 0;
for (String sentence : sentences) {
int sentenceSize = sentence.length();
if (currentSize + sentenceSize > targetChunkSize && !currentChunk.isEmpty()) {
// 当前chunk已满,保存并开始新chunk(保留最后N个句子作为重叠)
chunks.add(new Document(String.join("", currentChunk)));
// 计算重叠:保留最后几个句子
int overlapSize = 0;
int overlapStart = currentChunk.size();
while (overlapStart > 0 && overlapSize < overlap) {
overlapStart--;
overlapSize += currentChunk.get(overlapStart).length();
}
currentChunk = new ArrayList<>(currentChunk.subList(overlapStart, currentChunk.size()));
currentSize = overlapSize;
}
currentChunk.add(sentence);
currentSize += sentenceSize;
}
if (!currentChunk.isEmpty()) {
chunks.add(new Document(String.join("", currentChunk)));
}
log.info("句子分块完成: {} 个chunks", chunks.size());
return chunks;
}
private List<String> splitToSentences(String text) {
// 中英文句子分割
return Arrays.stream(text.split("(?<=[。!?.!?])\\s*"))
.filter(s -> !s.isBlank())
.collect(Collectors.toList());
}
}策略3:按Markdown/HTML结构分块
文档有明确的标题层级时,按标题分块是最好的选择,能保证每块语义完整。
@Service
@Slf4j
public class StructureAwareChunkService {
/**
* 按Markdown标题层级分块
* 每个 ## 或 ### 章节作为独立chunk
*/
public List<Document> chunkByMarkdownHeader(String markdownText, Map<String, Object> baseMeta) {
List<Document> chunks = new ArrayList<>();
// 按标题分割
String[] sections = markdownText.split("(?=#{1,3} )");
for (String section : sections) {
if (section.isBlank()) continue;
// 提取标题作为元数据
String title = extractTitle(section);
int level = extractHeaderLevel(section);
Map<String, Object> meta = new HashMap<>(baseMeta);
meta.put("section_title", title);
meta.put("header_level", level);
// 如果章节太长,二次分块
if (section.length() > 2000) {
TokenTextSplitter splitter = new TokenTextSplitter(512, 50, 5, 10000, true);
List<Document> subChunks = splitter.apply(List.of(new Document(section, meta)));
// 保留标题信息
subChunks.forEach(c -> c.getMetadata().putAll(meta));
chunks.addAll(subChunks);
} else {
chunks.add(new Document(section.trim(), meta));
}
}
return chunks;
}
private String extractTitle(String section) {
String firstLine = section.split("\n")[0];
return firstLine.replaceAll("^#+\\s*", "").trim();
}
private int extractHeaderLevel(String section) {
String firstLine = section.split("\n")[0];
int count = 0;
for (char c : firstLine.toCharArray()) {
if (c == '#') count++;
else break;
}
return count;
}
}策略4:语义相似度分块(Semantic Chunking)
最智能的方式:用Embedding判断相邻句子的语义连贯性,在语义跳变的地方切断。
@Service
@Slf4j
public class SemanticChunkService {
private final EmbeddingModel embeddingModel;
private static final double SIMILARITY_THRESHOLD = 0.8; // 语义跳变阈值
/**
* 基于语义相似度的自适应分块
* 在语义发生明显跳变的位置切割
*/
public List<Document> chunkBySemantic(String text, Map<String, Object> meta) {
// 1. 先按句子分割
List<String> sentences = splitToSentences(text);
if (sentences.size() <= 1) {
return List.of(new Document(text, meta));
}
// 2. 计算相邻句子的语义相似度
List<float[]> embeddings = embeddingModel
.embedForResponse(sentences)
.getResults()
.stream()
.map(e -> e.getOutput())
.collect(Collectors.toList());
// 3. 找到语义跳变点
List<Integer> breakPoints = new ArrayList<>();
for (int i = 0; i < embeddings.size() - 1; i++) {
double similarity = cosineSimilarity(embeddings.get(i), embeddings.get(i + 1));
if (similarity < SIMILARITY_THRESHOLD) {
breakPoints.add(i + 1);
log.debug("语义跳变点: 句子{}->{},相似度: {}", i, i+1, similarity);
}
}
// 4. 按跳变点切割
List<Document> chunks = new ArrayList<>();
int start = 0;
for (int breakPoint : breakPoints) {
String chunkText = String.join("", sentences.subList(start, breakPoint));
if (!chunkText.isBlank()) {
chunks.add(new Document(chunkText, new HashMap<>(meta)));
}
start = breakPoint;
}
// 最后一段
String lastChunk = String.join("", sentences.subList(start, sentences.size()));
if (!lastChunk.isBlank()) {
chunks.add(new Document(lastChunk, new HashMap<>(meta)));
}
return chunks;
}
private double cosineSimilarity(float[] v1, float[] v2) {
double dot = 0, norm1 = 0, norm2 = 0;
for (int i = 0; i < v1.length; i++) {
dot += v1[i] * v2[i];
norm1 += v1[i] * v1[i];
norm2 += v2[i] * v2[i];
}
return dot / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
private List<String> splitToSentences(String text) {
return Arrays.stream(text.split("(?<=[。!?.!?])\\s*"))
.filter(s -> !s.isBlank())
.collect(Collectors.toList());
}
}语义分块的缺点:需要调用 Embedding 模型,入库速度比固定分块慢3-5倍,成本也更高。
策略5:父子分块(Parent-Child Chunking)
最近很流行的一种策略:存储时用小块(提高检索精度),返回时用大块(提供更多上下文)。
@Service
@Slf4j
public class ParentChildChunkService {
private final VectorStore vectorStore;
private final DocumentRepository parentDocRepo; // 存父文档
// 父块1000 token,子块200 token
private final TokenTextSplitter parentSplitter = new TokenTextSplitter(1000, 100, 5, 10000, true);
private final TokenTextSplitter childSplitter = new TokenTextSplitter(200, 20, 5, 10000, true);
public void ingestWithParentChild(String content, String docId) {
Document fullDoc = new Document(content, Map.of("doc_id", docId));
// 1. 生成父块
List<Document> parentChunks = parentSplitter.apply(List.of(fullDoc));
for (int i = 0; i < parentChunks.size(); i++) {
Document parent = parentChunks.get(i);
String parentId = docId + "_p_" + i;
parent.getMetadata().put("parent_id", parentId);
// 2. 存储父块(不向量化,只存原文)
parentDocRepo.save(ParentDocument.of(parentId, parent.getText()));
// 3. 生成子块并向量化
List<Document> childChunks = childSplitter.apply(List.of(parent));
for (int j = 0; j < childChunks.size(); j++) {
Document child = childChunks.get(j);
child.getMetadata().put("parent_id", parentId);
child.getMetadata().put("doc_id", docId);
}
// 4. 子块向量化存入向量库
vectorStore.add(childChunks);
}
log.info("父子分块完成: {} 个父块,docId={}", parentChunks.size(), docId);
}
/**
* 检索时:找子块,返回父块
*/
public String retrieveWithParentContext(String query) {
// 检索子块
List<Document> childResults = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(5));
// 通过父ID获取完整上下文
return childResults.stream()
.map(child -> {
String parentId = (String) child.getMetadata().get("parent_id");
return parentDocRepo.findById(parentId)
.map(ParentDocument::getContent)
.orElse(child.getText()); // 找不到父块就用子块
})
.distinct()
.collect(Collectors.joining("\n\n---\n\n"));
}
}5种策略横向对比
| 策略 | 实现复杂度 | 检索精度 | 语义完整性 | 入库速度 | 适用场景 |
|---|---|---|---|---|---|
| 固定大小 | 极低 | 中 | 差 | 极快 | 快速验证、流水文本 |
| 句子边界 | 低 | 良 | 良 | 快 | 通用场景首选 |
| 结构感知 | 中 | 高 | 高 | 快 | 有章节结构的文档 |
| 语义分块 | 高 | 高 | 极高 | 慢(需Embedding) | 高质量要求场景 |
| 父子分块 | 高 | 极高 | 极高 | 中 | 长文档、高准确率要求 |
如何在生产中选型
实际项目建议:先用句子边界分块上线,通过评估数据找出哪类问题效果差,再针对性引入其他策略。不要一开始就上最复杂的方案。
小结
分块不是小事。RAG效果的天花板,很大程度上由分块质量决定。
- 默认用句子边界分块,比固定大小分块的准确率提升明显
- 文档有章节结构,用结构感知分块,保持章节语义完整
- 对准确率要求极高,上父子分块,用小块检索+大块上下文
- 语义分块效果最好,但成本也最高,用在值得的场景
小李把核心业务文档换成了结构感知分块,其他文档用句子边界分块,整体准确率从72%涨到了89%。分块策略这一关过了,RAG才真正能用起来。
