项目标题是“paperclip”,在RAG和向量检索圈子里,这个名字一出,大家基本都知道说的是文本分块(Text Chunking)。
做自然语言处理的朋友应该都有体会,拿到一份几十页的文档,直接丢给大模型,上下文窗口第一个扛不住;不做处理直接向量化,检索出来的东西又经常答非所问。文本分块就是卡在“原始输入”和“模型可用输入”之间的那道关键工序。这篇就来聊聊我在这块积累的实操经验,包括分块策略怎么选、参数怎么定、踩过哪些坑,以及如何用paperclip思路搭出一套稳定的文本预处理流程。
1. 为什么文本分块直接决定RAG效果上限
1.1 先搞清楚分块到底解决了什么问题
文本分块,本质上是把长文本切成若干语义相对完整的小片段,每个片段作为独立单元进入后续的向量化、存储和检索环节。这个动作看似简单,却是RAG(检索增强生成)系统里最容易被低估的环节。
很多新手一上来就调Embedding模型参数、换向量数据库,结果效果就是上不去。折腾半天发现,问题出在源头——文档根本没切好。你可以把分块理解成做饭前的备菜:菜切得太大块,炒不熟;切得太碎,一炒就烂。分块粒度是否合适,直接决定了后面的检索和生成质量。
从实际需求来看,分块至少要同时满足三件事:
- 每个块要语义相对完整,不能把一个完整观点拦腰截断,否则向量化之后语义漂移,检索召回的准确性无从谈起。
- 块与块之间要能形成上下文衔接,尤其是长文档中前后关联紧密的内容,切块后不能被彻底割裂。
- 块的长度要适配Embedding模型和LLM的输入限制,既要喂得进去,也要保证效率,不能动不动就超出token上限。
1.2 分块粒度对检索召回率的影响
我团队之前做过一次对照实验,同一个PDF文档集,分别用256、512、1024三种chunk_size做向量检索,然后用同一批问题做召回测试。结果非常直观:chunk_size为256时,召回的相关片段过于碎片化,top5里经常出现“半个表格”或“打断的公式”;chunk_size为1024时,上下文完整了,但单个块包含太多噪声,向量相似度被稀释,精确匹配率明显下降。512在多数场景下是相对均衡的起点。
这不是说512就是万能值,而是想说明一个道理:分块粒度和你的业务问题类型强相关。如果问题是“某个具体数字是多少”,小分块往往更准;如果问题是“这个方案的实施步骤是什么”,大分块才能保留完整流程。
注意:分块策略没有银弹,任何参数都应该基于你的语料特点和查询模式去测试调优,而不是照抄别人的配置。
1.3 分块和Embedding模型的输入长度是联动关系
每个Embedding模型都有自己的最大输入长度,比如有些模型支持512 token,有些支持1024 token。如果你的分块长度远低于模型支持上限,等于浪费了模型能力;如果分块长度高于模型上限,部分token会被截断,嵌入向量可能只是“残段”的向量,检索质量大打折扣。
所以选定Embedding模型之后的第一件事,就是查清楚它的max_seq_length,然后反推你的chunk_size。还有一点很多人会忽略:同样长度下,中文和英文对应的token数量不一样,中文一个字可能就对0.6到1个token,英文一个单词约1.3个token。同样512 token的chunk,中文大概能容纳500到800字,英文只能容纳约400词,这个差异直接决定了你按“字数”配置分块参数时必须乘上语言系数。
2. 主流分块策略对比与选型逻辑
2.1 固定长度切分:简单直接但局限明显
固定长度切分是最朴素的做法,按字符数或token数硬切,比如每512个字符切一块,不够就补一个块。实现成本极低,几行代码就能跑起来。
但它的短板也极其明显:语义边界完全被忽略。一个完整段落可能被切成两半,第一块末尾和第二块开头各是半句话。别小看这个细节,向量化之后,这两块的语义都会偏移,检索时要么召不回,要么召回后LLM读起来前言不搭后语。所以这种策略我只建议用在不追求语义质量的场景,比如纯关键词检索、构建临时索引或做语料清洗。
2.2 递归字符切分:目前工业界最稳妥的默认选择
递归字符切分是目前最推荐的通用方案,核心思路是分层迭代:先用段落分隔符(如\n\n)切,切出的块还太大,再用句号、分号等次级分隔符继续切,直到每个块都满足尺寸要求。
这种做法的好处是,尽可能在语义自然边界处切断。它先保证块内是完整段落,再保证尽量是完整句子,最后才退而求其次在句子内部切断。递归切分本质上是一种“语义边界优先”的贪心算法,虽然没有真正理解语义,但在绝大多数文本上表现稳定。
我拿LangChain里的RecursiveCharacterTextSplitter做过实测,默认分隔符列表是["\n\n", "\n", " ", ""],切中文文档时问题很大,因为中文句子不是用空格分隔的,递归到“空格”这一层就停滞了,经常一个长段落被整块保留,chunk_size根本控制不住。后来我把分隔符列表改成["\n\n", "\n", "。", "!", "?", ";", ",", ""],情况立刻好转。
2.3 语义分块:理想丰满但落地成本高
语义分块是最近比较热的方向,核心思路是计算相邻句子之间的嵌入相似度,相似度低的地方就是语义边界。代表框架有LlamaIndex的SemanticSplitterNodeParser等。
听起来很美好,但落地时有两个现实问题:第一,需要额外调用Embedding模型做句级相似度计算,耗时和成本都会上涨;第二,相似度阈值很难标定,不同领域文档的最佳阈值差异很大,A域的0.7可能效果很好,B域0.7就切得稀碎。所以语义分块目前更适合小批量、高价值文档的处理场景,大规模流水线上用起来还不够划算。
2.4 专用结构分块:Markdown、代码、表格各有打法
针对特殊文档类型,需要设计专用的分块规则。Markdown按标题层级切分,天然契合文档结构;代码按函数、类定义切分,可以借助AST去识别完整作用域;表格最好整个块保留,不要把一个三列表格横切成三份。
这个方向很容易出效果,但需要针对具体语料写定制逻辑,通用性相对差一些。如果你的语料是Web页面或Markdown为主,建议优先考虑结构分块;如果是混合语料,可以用路由策略,先识别类型再选择分块模式。
3. 实操记录:用paperclip思想搭建文本分块流程
3.1 工具选型与基础参数配置
这里以一个典型的RAG文档处理流水线为例。我习惯用LangChain的RecursiveCharacterTextSplitter做基础切分,配合自定义分隔符列表。初始配置如下:
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 按token数估算,中文约500字 chunk_overlap=50, # 相邻块重叠50 token length_function=len, # 按字符数切分(中文场景) separators=["\n\n", "\n", "。", "!", "?", ";", ",", ""] ) chunks = splitter.split_text(long_document)chunk_overlap很多人不理解,其实是为了防止块之间信息断档。假设一句话横跨两个块,检索到后一个块时,如果完全没有前文信息,LLM无法理解“该方法”指代的是什么。重叠50 token,等于给相邻块之间加了一条“语义记忆衔接带”。
不过要注意,length_function用len是按字符数切,不是按token切。如果要按token切,应该用模型对应的tokenizer来计算长度,比如:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-embedding-model") def token_len(text): return len(tokenizer.encode(text)) splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=50, length_function=token_len, separators=["\n\n", "\n", "。", "!", "?", ";", ",", ""] )这样能确保每个块真正落在Embedding模型的安全输入范围之内,而不是名义上切了500“字”,实际token已经超限。
3.2 参数计算的全流程推导
很多人看到chunk_size、chunk_overlap就头大,其实核心逻辑就一个等式:chunk_size = 期望保存的语义单元长度 + 重叠长度。反过来推也一样:期望语义单元长度 = chunk_size - chunk_overlap。
拿中文技术文档举例。假设你的Embedding模型最大输入是512 token,中文平均每字约0.8 token,那么512 token约等于640字。考虑留出安全余量,chunk_size设为500字,重叠50字,实际每个块的有效新信息约为450字。这个长度对表达“一个完整方法步骤”或“一段原理阐述”来说基本够用。
如果文档是法律条款或合同文本,每条内容结构完整且独立性强,chunk_size可以适当调大到600字,减少无效切分;如果是Q&A对话记录,建议把chunk_size缩到300字以下,因为单轮问答通常只有几十到一百多字,大切块反而会混入其他话题。
3.3 完整分块流程的现场实录
我处理一个约50页的中文产品手册时,完整流程大致分四步:
先做文本清洗。PDF里经常有页眉页脚、重复的水印、多余的空行,这些不清理,分块时会被当成有效内容切进去,污染向量库。我一般用正则先把页码、页眉、页脚、多余空白字符过滤掉。
再做结构预扫描。用正则提取文档里的标题模式,比如“第1章”“1.1”“一、”这类,标记出来作为候选切分边界。递归切分器运行时,如果遇到这些标题,优先按标题切,保证每个块要么包含一个完整小节,要么至少以小标题开头。
然后执行递归切分。用统一配置跑一遍之后,我会写一个后处理函数,检查每个块的字符长度,按设定阈值筛查异常块。比如chunk_size设为500字,结果突然冒出一个1500字的块,大概率说明这个段落里分隔符不足,或者语料本身是超长无断句的文本。对这种特殊块,我用二次切分或句级滑窗处理。
最后是embedding前的块质量抽检。随机抽10个块,人工看一下语义是否连贯、是否有被硬切开的半句话。这一步很笨但极其有效。我踩过最深的坑就是当年偷懒跳过抽检,上线后用户问“你们的检索怎么老返回半截公式”,一查才发现公式块被横切了两半,向量全乱套。
3.4 父子分块策略让检索精度和上下文兼得
如果你追求更高阶的效果,可以试试“父子分块”(Parent-Child Chunking)。做法是维护两种粒度:小分块(Child)用于向量化检索,大分块(Parent)用于最终送给LLM生成上下文。检索时先召回小分块,再通过ID映射找到所属的大分块,将大分块作为完整上下文返回。
这套方案能同时兼顾检索精准度(小分块噪声低)和生成上下文完整性(大分块信息全),在问答型RAG里表现非常亮眼。
# 伪代码示意 child_chunks = splitter_small.split_text(doc) parent_chunks = splitter_large.split_text(doc) for child in child_chunks: parent = find_parent(child, parent_chunks) # 按位置匹配或ID关联 store_to_vector_db(child, metadata={"parent_id": parent.id})检索阶段先按child向量召回,再取出对应parent内容送给LLM。这个模式我目前在多个项目里都在用,尤其适合企业知识库问答和长文档综合分析类场景。
核心参数速查表 场景类型 推荐chunk_size 推荐overlap 备注 短问答/FAQ 200-300 token 15-30 token 块内语义紧凑,避免跨界噪声 技术文档/操作手册 400-600 token 40-60 token 保留完整步骤和示例 法律/合同 500-700 token 50-80 token 保条款独立性与逻辑连贯 学术论文 500-800 token 50-100 token 需要结合段落结构分块4. 分块常见问题与排查思路实录
4.1 检索结果总差一口气:先查块是不是切碎了
表现:top5结果看起来“相关”,但LLM生成答案时总说信息不完整,或者引用内容张冠李戴。
排查思路:打开其中一个召回块,看它是否以“根据上述”或“该方法”这类指代开篇,如果是,说明前置信息被切到了上一块。在分块逻辑里增加以“首先”“具体来说”“因此”等关联词开头的块与上一块合并,或者提升chunk_overlap比例。
4.2 向量化报错token超限:块大小和模型上限不匹配
表现:Embedding接口报错“input token exceeds max length”,集中在长段落或代码块上。
排查思路:把length_function换成模型对应tokenizer;对无法用长度函数覆盖的代码块、表格块,设置硬上限并强制按分隔符二次切分。
4.3 中文文档换行多导致分块错乱:分隔符列表要按语言调整
表现:中文语料分块后块大小忽大忽小,长段落没被切开,短句却单独成块。
排查思路:检查分隔符列表里是否有中文的句号、问号、感叹号。如果没有,补充后再跑。中文文本不能直接用英文句号.或空格当作语义边界,这是最典型的坑。
4.4 表格被切开导致检索语义崩坏:表格必须整块保留
表现:检索结果中表格只有表头没有数据,或者数据与列名对不上。
排查思路:识别文档中的表格区域,用整体块保留策略,不参与递归切分;如果表格太大超过模型限制,对表格做行级切分的同时把表头复制到每一个子块里,保证每个切块都包含列名信息。
常见问题速查表 症状 根因 对策 块内都是半句话 分隔符列表不含中文标点 补充中文句末标点作为分隔符 块大小严重超标 段落内无可用分隔符 对超长块启用二次句级滑窗切分 检索返回大量重复内容 overlap过大 降低overlap或启用相似度去重 表格数据无法对齐 表格被横向切开 表格整块保留,列头随行复制 Embedding接口超限 chunk按字符切未按token切 改为使用模型tokenizer计算长度4.5 再补一个细节:元数据一定要跟着块走
分块的每个块建议都带上来源信息,比如文档名、页码、章节标题、块序号。这些元数据在检索阶段不仅能帮你做权限控制和来源溯源,还能在召回后拼接上下文时快速定位所属大段落。很多RAG系统上线后不好排查问题,就是当初分块时没存元数据,出了问题根本定位不到是哪一块污染了检索结果。
我自己习惯在为每个chunk构建向量时,同时写入source_file、page_number、heading_path、chunk_index四个字段,看似多花一点存储,排查问题时的收益远超成本。
4.6 评估分块效果的两种低成本办法
想客观评估分块策略改得好不好,不必一上来就上复杂的评测框架。我长期用两种低成本办法:一种是在固定QA集上做召回准确性测试,每次调整分块参数后跑一遍,比较top5命中率;另一种是“块重写测试”——抽10个切好的块,让大模型根据块内容还原原文大意,看看还原出来是否连贯。如果大模型都觉得块内信息不完整,说明切得确实有问题。
分块这件事,看起来只是RAG流水线上的一颗“paperclip”,但它的质量高低,直接决定检索准确率是70%还是40%。我自己回看过往项目,凡是效果不达预期的,十有八九能在分块这层找到原因——参数没调、分隔符没换、中文语境没适配。做RAG,不一定要用最花哨的语义分块,但一定要把分块当成一等公民来对待。
根据我个人的实际体会,最稳的组合拳永远是:先做文本清洗,再用递归策略配合语言适配的分隔符,然后用元数据把每个块的来源钉死,最后定期抽检块质量。这套流程可能不那么“高级”,但胜在可靠、可控、易排查。
如果你正在搭知识库问答或者文档检索系统,别急着上来就调Embedding模型,先认真看看你的文本被切成了什么样子。切好了,后面的路会顺很多。