一、面试题目
请详细介绍 RAG 中文本切块(Chunking)四大核心策略:固定长度切块、语义切块、递归字符切块、切块重叠设计,分别原理、优缺点、适用场景、工程选型建议。
二、知识储备
1. 基础概念
Chunking 文本切块把长文档切分成小块,用于向量化入库、检索召回,是 RAG影响召回精度最重要的前置环节。切块核心矛盾:块太小丢上下文、块太大语义混杂。
2. 四种切块策略详解
(一)固定长度切块(Fixed-size Chunking)
原理
按固定字符 / Token 长度一刀切,比如每 512 Token 切一块,顺序截取,不考虑句子、段落、语义边界。
优点
- 实现最简单、速度最快、成本最低
- 适合海量文档批量处理
- 可控性强、容易做容量预估
缺点
- 容易截断句子、割裂语义
- 一块内可能包含多个无关主题
- 边界破碎严重,检索精度低
适用场景
- 结构化不强、口语化、网文、杂乱文档
- 低成本快速搭建 RAG、原型验证
- 对召回精度要求不高的场景
工程要点
常用:512 / 1024 Token 固定长度。
(二)递归字符切块(Recursive Character Text Splitter)
原理
分层递归切割,按优先级从大到小分隔符依次切:段落换行 \n\n→单行换行 \n→句号。→逗号,→空格→ 单个字符 `先按大分隔符切,若仍超长度,再用下一级更细分隔符递归切。
优点
- 尽量保留段落、句子完整性
- 不强行截断语义边界
- 效果远好于固定长度,实现简单
- 工业级默认首选切块方案
缺点
- 仍不理解深层语义,只靠符号规则
- 特殊无标点文档效果一般
适用场景
- 技术文档、PDF、知识库、手册、书籍
- 绝大多数企业 RAG 落地首选
- 有标准标点、段落结构的正式文本
(三)语义切块(Semantic Chunking)
原理
- 先按短句 / 小片段粗切
- 对每个片段做 Embedding 向量化
- 计算相邻片段语义相似度
- 相似度低于阈值 → 判定语义边界,从此处切分
优点
- 按语义主题自然切分,不割裂上下文
- 每块内部主题单一、纯度高
- RAG 召回准确率最高
缺点
- 要多次调用 Embedding,成本高、速度慢
- 阈值调优依赖经验
- 长文档处理耗时明显增加
适用场景
- 高精度知识库、法律合同、医疗文档、专业论文
- 对问答精准度要求极高、允许一定成本开销
- 主题边界清晰的正式文稿
(四)切块重叠设计(Chunk Overlap / 滑动重叠)
原理
相邻两个 Chunk 之间保留一段重复文本,例如块长 1024,重叠 128。后一块开头复用前一块末尾一段内容,形成上下文平滑过渡。
核心作用
- 解决关键信息落在两块边界被拆分、检索漏召
- 保持跨块上下文连续性
- 提升问答连贯性,避免断章取义
优点
- 极低成本大幅提升召回效果
- 兼容所有切块策略(固定 / 递归 / 语义都可加重叠)
缺点
- 增加少量 Token、向量数量变多、存储略增
工程经验值
- 块长 512 → 重叠 64~128
- 块长 1024 → 重叠 128~256
- 重叠不宜过大:超过块长 20% 收益边际递减
适用场景
所有生产级 RAG 都必须开启重叠,是标配设计。
3. 四种策略横向对比总结
切块方式 | 核心原理 | 精度 | 速度成本 | 工程推荐 |
固定长度 | 按固定字符硬切 | 低 | 最快、最低 | 原型、杂乱文本 |
递归切块 | 按换行 / 句号分层递归切 | 中高 | 快、低成本 | 企业 RAG 默认首选 |
语义切块 | 向量相似度识别语义边界 | 最高 | 慢、高成本 | 高精度专业场景 |
重叠设计 | 块间保留重复上下文 | 提升 5%~15% | 微增成本 | 全部场景必开 |
4. 工程落地最佳选型(面试直接背)
- 通用企业 RAG:递归字符切块 + 固定重叠(10%~20%)
- 高精度专业场景:语义切块 + 适度重叠
- 快速原型 / 杂乱网文:固定长度切块 + 小重叠
- 任何生产环境:不允许无重叠切块,必须加 Overlap
5. 常见踩坑
- 只固定长度不做递归:语义割裂严重、问答乱答
- 不开重叠:边界关键信息丢失、召回漏缺
- 语义切块阈值乱设:块过碎或块过大
- 切块统一尺寸不做自适应:长句被硬截断
三、破局之道
面试高阶总结:文本切块不是简单切字符串,而是在语义完整性、块大小、速度成本、检索召回率之间做平衡。固定长度最简单但精度最差;递归切块靠分隔符分层切割,是工业级性价比首选;语义切块通过向量相似度识别主题边界,精度最高但成本高;重叠设计是所有生产级 RAG 的标配,用极小成本解决边界信息丢失问题。
落地最优组合:递归切块 + 合理重叠做通用基线,高精度业务再叠加语义切块。
四、极简代码实现
Python
# 1. 固定长度切块 + 重叠 def fixedChunk(text, chunkSize=512, overlap=128): chunks = [] start = 0 textLen = len(text) while start < textLen: end = start + chunkSize chunks.append(text[start:end]) # 滑动步进 = 块长 - 重叠 start += chunkSize - overlap return chunks # 2. 递归字符切块 + 重叠 def recursiveChunk(text, chunkSize=1024, overlap=128): # 切割分隔符优先级 separators = ["\n\n", "\n", "。", ",", " "] def splitRecurse(content, sepIdx): # 不能再分 或 已经小于块长,直接返回 if sepIdx >= len(separators) or len(content) <= chunkSize: return [content] # 按当前分隔符切割 parts = content.split(separators[sepIdx]) res = [] for p in parts: res.extend(splitRecurse(p, sepIdx + 1)) return res # 先递归切分 rawChunks = splitRecurse(text, 0) # 加相邻重叠 final = [] for i in range(len(rawChunks)): if i > 0: # 前一块末尾 + 当前块 merge = rawChunks[i-1][-overlap:] + rawChunks[i] final.append(merge) else: final.append(rawChunks[i]) return finalJavaScript
// 1. 固定长度切块 + 重叠 function fixedChunk(text, chunkSize = 512, overlap = 128) { const chunks = []; let start = 0; const textLen = text.length; while (start < textLen) { const end = start + chunkSize; chunks.push(text.slice(start, end)); start += chunkSize - overlap; } return chunks; } // 2. 递归字符切块 + 重叠 function recursiveChunk(text, chunkSize = 1024, overlap = 128) { const separators = ["\n\n", "\n", "。", ",", " "]; function splitRecurse(content, sepIdx) { if (sepIdx >= separators.length || content.length <= chunkSize) { return [content]; } const parts = content.split(separators[sepIdx]); let res = []; for (let p of parts) { res = res.concat(splitRecurse(p, sepIdx + 1)); } return res; } // 递归原始切块 const rawChunks = splitRecurse(text, 0); // 叠加重叠 const final = []; for (let i = 0; i < rawChunks.length; i++) { if (i > 0) { const merge = rawChunks[i-1].slice(-overlap) + rawChunks[i]; final.push(merge); } else { final.push(rawChunks[i]); } } return final; }