ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG系统切片策略全解析:从固定长度到语义切分

RAG系统切片策略全解析:从固定长度到语义切分

1. 被忽视的切片:为什么它才是RAG系统的隐形天花板

做RAG(检索增强生成)的人,十有八九把精力砸在了模型选型、向量库调参、Prompt工程上,却对切片(Chunking)这一步草草了事。我见过太多项目,embedding模型用的是顶配,向量数据库也是企业级方案,但最终问答效果就是上不去——答案要么缺胳膊少腿,要么检索出一堆无关内容。排查到最后,问题往往出在最开始那一步:文档切片没做好。

切片之所以不起眼,是因为它太“前处理”了。大家潜意识里觉得,把文档按固定字数切开,扔进向量库就完事了。但实际情况是,切片策略直接决定了检索系统能“看到”什么粒度的信息。切得太碎,语义被割裂,检索出来的片段缺乏上下文,生成模型拿到手里也拼不出完整答案;切得太粗,一个片段里混了好几个主题,向量表示被稀释,检索精度断崖式下跌。更麻烦的是,这两种错误在系统上线初期往往不会暴露——简单问题还能应付,一旦用户问得稍微具体一点,短板立刻现形。

这篇文章面向的是正在搭建或优化RAG系统的开发者,无论你用的是LangChain、LlamaIndex还是自研管线,切片这一步都绕不开。我会从切片的本质目标讲起,拆解几种主流策略的适用场景和坑点,给出可落地的参数建议,最后分享一些我在实际项目中踩过的坑和验证过的调优手法。读完你至少能判断:自己手头的切片方案到底合不合格,以及该往哪个方向改。

2. 切片的本质:在语义完整与检索精度之间找平衡

2.1 切片到底在解决什么问题

RAG系统的核心链路是:用户提问 → 检索相关文档片段 → 把片段塞进上下文 → 生成答案。切片就是这条链路的起点,它决定了“相关文档片段”这个中间产物的质量。理想情况下,每个切片应该是一个语义自洽的单元——它独立存在时,人能看懂它在说什么;它被检索出来时,能直接回答或支撑某个具体问题。

但现实很骨感。一份文档的语义结构是树状的:章包含节,节包含段,段包含句。切片本质上是在这棵树上找一个切割面,把连续文本切成离散块。切在哪里,直接决定了每个块的语义边界是否清晰。切在段落之间,语义完整度高,但块可能很长;切在句子之间,块短了,但上下文可能断裂。这就是切片的核心矛盾:语义完整性和检索精度之间的权衡。

2.2 固定长度切片的致命缺陷

最常见的做法是按固定字符数切,比如每500个字符一刀。这种做法实现简单,但问题很致命。假设一段文本正好在“该方案的优势在于”后面被切断,下一个块以“成本低、部署快”开头。检索时,如果用户问“这个方案有什么优势”,第一个块可能被召回,但答案的后半截在第二个块里,生成模型拿到的上下文是残缺的。更糟糕的是,第二个块单独看完全不知道在说什么,向量表示会非常模糊,检索时容易被漏掉。

固定长度切片还有一个隐蔽问题:它完全无视文档的天然结构。技术文档有标题、段落、列表、代码块,法律合同有条款,论文有摘要和章节。这些结构本身就是语义边界的强信号,按固定长度切等于把这些信号全部丢弃。我做过一个对比实验,同一份技术文档,固定长度切片和按结构切片,在相同检索问题下的召回率差了将近30个百分点。

2.3 语义完整性与检索精度的量化关系

要理解这个权衡,可以看一个简化模型。假设文档总长度为L,切片长度为c,切片数量n≈L/c。检索时,系统从n个块中召回top-k个。每个块的语义信息量可以粗略认为与c正相关,但块内主题纯度与c负相关——块越长,混入多个主题的概率越大。

当c很小时,每个块主题纯度高,向量表示尖锐,检索精度高,但块内信息量不足,生成模型可能拿不到完整答案。当c很大时,块内信息量足,但向量表示被多个主题平均,检索时容易召回“什么都沾一点”的块,精度下降。存在一个最优c,使得检索精度和答案完整度的乘积最大。这个最优值取决于文档类型、问题类型和embedding模型的能力,没有万能数字。

注意:不要迷信网上流传的“最佳切片长度是512 tokens”这类说法。那是在特定数据集、特定模型下的经验值,换一个场景可能完全不适用。

3. 主流切片策略的实战拆解与选型逻辑

3.1 按字符数硬切:什么时候可以用,什么时候绝对不能用

按字符数硬切是最简单的策略,代码几行就能实现。它的适用场景非常有限:文档结构极其规整,比如每段都是独立的知识点,段与段之间没有依赖关系。比如FAQ文档,每个问答对就是天然切片,硬切反而不会出大问题。

但在绝大多数场景下,硬切都是灾难。技术文档、研究报告、法律文书、产品手册,这些文档的语义是跨段落的。一个概念的定义可能在第一段,展开在第二段,举例在第三段。硬切会把它们打散,检索时只能召回其中一块,生成模型拿到的信息是碎片化的。

如果非要用硬切,至少加一个重叠窗口(overlap)。比如每500字符切一刀,但相邻块之间重叠100字符。这样即使切在了句子中间,下一个块也能包含前一个块的尾部内容,缓解语义断裂。重叠比例一般设在10%到20%之间,太小起不到作用,太大则冗余严重,检索时容易召回大量重复内容。

def fixed_size_chunk(text, chunk_size=500, overlap=100): chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) start = end - overlap return chunks

这段代码看起来没问题,但实际用的时候要注意:如果文档里有代码块或表格,硬切可能把代码从中间截断,导致语法错误。表格被切散后,行列对应关系丢失,检索出来完全没法用。所以硬切之前,最好先把代码块和表格单独提取出来,不要参与常规切片。

3.2 按段落和标题切:利用文档的天然结构

大多数文档都有天然的结构信号:标题、子标题、段落分隔符。按这些信号切,能最大程度保持语义完整性。具体做法是:先按标题层级把文档拆成树状结构,每个叶子节点(最小标题单元)下的内容作为一个候选切片。如果某个叶子节点内容太长,再按段落进一步切分。

这种策略的好处是,每个切片都有明确的主题归属。检索时,如果用户的问题匹配到某个标题,可以直接定位到对应切片,精度很高。而且切片自带层级信息,可以在检索时利用标题做过滤或加权。

但要注意,标题层级不一定规整。有些文档标题层级混乱,或者根本没有标题。这时候可以退而求其次,按段落切。段落是作者有意划分的语义单元,按段落切比按字符切合理得多。如果段落本身太长(比如超过1000字),再考虑按句子切分。

import re def paragraph_chunk(text, max_length=800): paragraphs = re.split(r'\n\s*\n', text) chunks = [] current = "" for para in paragraphs: if len(current) + len(para) <= max_length: current += para + "\n\n" else: if current: chunks.append(current.strip()) current = para + "\n\n" if current: chunks.append(current.strip()) return chunks

这个实现有个细节:它不是简单地把每个段落单独成块,而是尽量把短段落合并到接近max_length。这样避免产生大量过短的切片,影响检索效率。但合并时要注意,不要把不同主题的段落硬凑在一起。一个简单的判断方法是看相邻段落的相似度,如果相似度低于阈值,就不合并。

3.3 递归切分:LangChain的默认策略到底靠不靠谱

LangChain的RecursiveCharacterTextSplitter是很多人用的默认方案。它的逻辑是:先尝试用第一优先级分隔符(比如双换行)切,如果切出来的块还是太大,就用第二优先级分隔符(比如单换行)继续切,以此类推。分隔符列表通常是["\n\n", "\n", "。", ",", ""],从粗到细。

这个策略比硬切聪明,因为它优先在语义边界处切。但它也有问题:分隔符列表是固定的,不感知文档的实际结构。比如一份Markdown文档,标题是用#标记的,但分隔符列表里没有#,递归切分可能把标题和正文切开,导致切片丢失标题信息。

另外,递归切分的终止条件是块大小小于chunk_size,但它不保证块内语义完整。如果文档里有一段很长的连续文本,没有任何换行或标点,递归切分最后会退化成按字符硬切。所以用这个策略时,要针对文档类型定制分隔符列表。比如处理Markdown时,把"## "、"### "加进去;处理代码时,把"\nclass "、"\ndef "加进去。

3.4 语义切片:用embedding找真正的语义边界

语义切片是更高级的做法:先对文本按句子切分,然后计算相邻句子的embedding相似度。如果相似度低于某个阈值,说明这里可能是主题转换点,就在这里切一刀。这样切出来的块,内部语义一致性高,块与块之间的边界也更有意义。

具体实现上,可以用sentence-transformers计算句子向量,然后计算相邻句子的余弦相似度。阈值一般设在0.5到0.7之间,具体取决于文档类型。技术文档的阈值可以低一点,因为技术概念之间的关联性本身就弱;叙事类文档的阈值可以高一点,因为故事线是连贯的。

from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer('all-MiniLM-L6-v2') def semantic_chunk(sentences, threshold=0.6): embeddings = model.encode(sentences) chunks = [] current_chunk = [sentences[0]] for i in range(1, len(sentences)): sim = np.dot(embeddings[i-1], embeddings[i]) / ( np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i]) ) if sim < threshold: chunks.append(" ".join(current_chunk)) current_chunk = [sentences[i]] else: current_chunk.append(sentences[i]) if current_chunk: chunks.append(" ".join(current_chunk)) return chunks

语义切片的缺点是计算成本高。一份长文档可能有几千个句子,每个句子都要算embedding,耗时可能几十秒甚至几分钟。而且阈值不好定,需要针对具体文档调。我的经验是,语义切片适合对精度要求极高的场景,比如法律合同检索、医疗文献问答。对于一般的技术文档或产品手册,按结构切分已经够用了。

3.5 策略选型对照表

策略适用场景优点缺点推荐参数
固定长度硬切FAQ、短文本集合实现简单,速度快语义断裂严重chunk_size=300-500,overlap=10%-20%
按段落/标题切结构清晰的文档语义完整,自带层级依赖文档结构质量max_length=500-1000
递归切分通用场景平衡了结构和灵活性分隔符需定制chunk_size=500-800,分隔符按文档类型调整
语义切片高精度要求场景语义边界准确计算成本高,阈值难调相似度阈值0.5-0.7

4. 切片参数调优:从500到1500,我踩过的那些坑

4.1 chunk_size不是拍脑袋定的

很多人设chunk_size就是拍脑袋,看别人用500就跟着用500。但chunk_size应该由文档类型和问题类型共同决定。如果用户的问题通常很具体,比如“XX功能的配置参数是什么”,那切片应该小一点,让检索能精确定位到参数所在的段落。如果用户的问题比较宏观,比如“这个系统的架构设计思路”,那切片可以大一点,包含更多上下文。

我一般会先分析用户问题的平均长度和具体程度。如果问题平均在10个字以内,且大多是事实型查询,chunk_size设在300到500之间比较合适。如果问题平均在20字以上,且涉及推理或总结,chunk_size可以放到800到1200。另外,embedding模型的最大输入长度也是一个硬约束。比如all-MiniLM-L6-v2最大支持256个token,如果chunk_size超过这个数,后面的内容会被截断,等于白切。

4.2 overlap设多少才不浪费

overlap的作用是防止语义在边界处丢失,但它不是越大越好。overlap太大,相邻切片重复内容多,检索时会召回大量相似结果,浪费上下文窗口。overlap太小,起不到缓冲作用。我的经验是,overlap设在chunk_size的10%到15%之间比较合理。比如chunk_size=500,overlap设50到75。

但有一种情况需要加大overlap:文档里有很多跨段落的指代关系。比如第一段说“该方案有三个优势”,第二段分别展开。如果切片正好切在第二段中间,第一个切片可能只包含第一个优势,第二个切片包含后两个优势,但第二个切片开头没有“该方案”的上下文,检索时可能匹配不上。这时候把overlap加大到20%到30%,让第二个切片也包含“该方案有三个优势”这句话,就能解决。

4.3 切片长度分布比平均值更重要

调参时不要只看平均切片长度,要看长度分布。如果大部分切片在500字左右,但有几个切片超过2000字,这几个超长切片就是隐患。它们可能包含多个主题,向量表示模糊,检索时要么召不回,要么召回了但生成模型被无关信息干扰。

我一般会统计切片长度的百分位数。如果90分位数超过平均值的2倍,说明切片长度分布不均匀,需要调整切分策略。比如对超长段落做二次切分,或者对过短切片做合并。理想情况下,切片长度应该集中在目标值附近,标准差不要太大。

4.4 用检索指标反推切片质量

切片好不好,最终要看检索效果。我习惯在切片完成后,构造一批测试问题,然后看检索的召回率和精确率。具体做法是:对每个测试问题,人工标注哪些切片是相关的,然后看系统召回的top-k里有多少是相关的。如果召回率低,说明切片粒度太粗或语义边界不清;如果精确率低,说明切片粒度太细或重叠太多。

这个评估过程不需要很复杂,几十个测试问题就能看出趋势。关键是测试问题要覆盖不同类型的查询:事实型、推理型、总结型。如果某类问题的指标明显偏低,就针对性地调整切片策略。比如事实型问题召回率低,就把chunk_size调小;总结型问题召回率低,就把chunk_size调大或增加overlap。

5. 特殊文档的切片处理:代码、表格、多语言混排

5.1 代码块切片:别把函数从中间切断

技术文档里经常夹杂代码块。代码的语义单元是函数或类,从中间切断会导致语法错误,检索出来也没法用。处理方法是:先用正则表达式把代码块提取出来,单独作为一个切片,或者按函数/类边界切分。如果代码块太长,可以按逻辑块切,比如一个函数一个切片,但要在切片开头保留函数签名和注释。

import re def extract_code_blocks(text): pattern = r'```(\w*)\n(.*?)```' blocks = [] for match in re.finditer(pattern, text, re.DOTALL): lang = match.group(1) code = match.group(2) blocks.append({'lang': lang, 'code': code, 'start': match.start(), 'end': match.end()}) return blocks

提取出来后,代码块不参与常规文本切片,而是单独建立索引。检索时,如果问题涉及代码,可以优先从代码块索引中召回。这样既保证了代码完整性,又不会干扰文本切片的语义纯度。

5.2 表格切片:保留行列对应关系

表格被切散是另一个常见问题。一个表格如果被切成两半,前半部分有表头没有数据,后半部分有数据没有表头,检索出来完全没法解读。处理方法是:把整个表格作为一个切片,如果表格太大,按行切分,但每个切片都要带上表头。

具体做法是:解析表格结构,提取表头,然后按行分组。每组行数根据表格宽度和chunk_size决定,但每组都必须包含表头。这样每个切片都是一个自包含的小表格,检索出来可以直接用。

5.3 多语言混排文档的切片陷阱

中英文混排的文档,按字符数切分会出现严重偏差。中文一个字符的信息量远大于英文一个字符,同样500字符,中文可能只有一段话,英文可能有好几段。如果按字符数统一切,中文切片会偏长,英文切片会偏短。

解决办法是按token数切,而不是字符数。用tokenizer把文本转成token序列,然后按token数切分。这样中英文的信息量就对齐了。大多数embedding模型都有对应的tokenizer,直接用就行。如果不想引入tokenizer,也可以按语言分别处理:中文按字符数切,英文按单词数切,然后合并。

6. 切片之后:存储、索引与检索的联动优化

6.1 切片元数据:别只存文本

切片存进向量库时,不要只存文本和向量。元数据至少应该包含:来源文档ID、切片在文档中的位置(起止字符或页码)、所属标题层级、切片类型(正文/代码/表格)。这些元数据在检索时非常有用。比如可以按标题层级过滤,只召回特定章节的内容;或者按切片类型加权,代码类问题优先召回代码切片。

元数据还有一个用途是溯源。生成模型给出答案后,可以展示答案来自哪个文档的哪个部分,方便用户验证。这在企业场景下尤其重要,用户不会盲目相信模型生成的内容,需要能追溯到原始文档。

6.2 父子切片:用小块检索,用大块生成

这是一个很实用的技巧:建索引时用小块,生成时用大块。具体做法是,把文档切成两层:父块和子块。父块较大,比如1000字,保证语义完整;子块较小,比如200字,保证检索精度。检索时用子块匹配问题,命中后返回对应的父块给生成模型。

这样既解决了小块检索精度高但信息不足的问题,又解决了大块信息足但检索不准的问题。实现上,每个子块存一个父块ID,检索到子块后根据ID取出父块。LangChain的ParentDocumentRetriever就是干这个的,但自己实现也不复杂。

6.3 切片与embedding模型的匹配

不同embedding模型对切片长度的敏感度不同。有些模型在短文本上表现好,有些在长文本上更稳。选切片策略时,要结合embedding模型的特点。比如OpenAI的text-embedding-ada-002对512 token以内的文本效果最好,超过后性能下降。而有些开源模型支持更长的输入,但长输入下的向量质量未必好。

我的做法是,先用目标embedding模型跑一个切片长度扫描:从200到1500,每隔100取一个值,看检索指标的变化。通常能找到一两个峰值,选峰值对应的长度作为chunk_size。这个扫描过程可能要多跑几轮,但比拍脑袋定参数靠谱得多。

7. 我踩过的三个真实坑与修复过程

7.1 坑一:切片太碎导致答案拼不完整

早期做一个产品手册问答系统,为了追求检索精度,把chunk_size设成了200。测试时发现,简单问题回答得挺好,但稍微复杂一点的问题,答案总是缺一部分。排查后发现,用户问“如何配置XX功能”,相关说明分散在三个切片里:第一个讲配置前提,第二个讲具体步骤,第三个讲注意事项。检索时只召回了第二个切片,生成模型只看到了步骤,没看到前提和注意事项,答案自然不完整。

修复方法是把chunk_size调到600,同时增加overlap到100。这样三个切片被合并成两个,且相邻切片有重叠,检索时更容易召回完整上下文。调整后,复杂问题的答案完整度明显提升。

7.2 坑二:表格被切散导致检索失效

另一个项目处理的是财务报表,文档里有大量表格。最初用递归切分,表格被切得七零八落。用户问“第三季度的营收是多少”,检索召回的切片里只有数字没有表头,生成模型根本不知道这个数字对应哪个季度。

修复方法是单独处理表格:解析出表格结构后,把每个表格作为一个独立切片,并在切片开头加上表头。如果表格行数太多,按行分组,每组都带表头。这样每个切片都是自解释的,检索出来直接能用。

7.3 坑三:多语言文档切片长度失衡

一个中英文混排的技术文档,按字符数切分后,中文切片平均800字,英文切片平均300字。检索时,英文问题召回的中文切片太长,噪音多;中文问题召回的英文切片太短,信息不足。后来改用tokenizer按token数切分,中英文切片长度对齐到相同的token数,问题才解决。

这个坑的教训是:切片策略要跟文档的语言特性匹配。纯中文文档可以按字符数切,纯英文文档可以按单词数切,混排文档最好按token数切。虽然tokenizer会引入额外依赖,但为了检索质量,这点成本值得花。

8. 切片策略的迭代节奏与评估闭环

切片不是一次调好就完事的。文档在更新,用户问题在变化,embedding模型也可能升级。我一般会建立一个评估闭环:定期用一批标注好的测试问题跑检索指标,如果指标下降超过阈值,就触发切片策略的重新评估。

评估时不要只看整体指标,要分场景看。事实型问题、推理型问题、总结型问题的指标可能差异很大。如果某类问题指标持续偏低,就针对性地调整切片参数。比如事实型问题召回率低,就减小chunk_size;总结型问题召回率低,就增大chunk_size或增加overlap。

另外,用户反馈也是重要信号。如果用户经常抱怨“答案不完整”或“答非所问”,大概率是切片出了问题。这时候不要急着调模型或Prompt,先回头看看切片策略是不是跟当前文档和问题类型匹配。很多时候,把切片调好,比换一个更大的模型效果更明显。

提示:切片调优的投入产出比很高。花半天时间调切片,可能比花一周时间调Prompt带来的提升还大。但这一步太靠前,容易被忽略。建议在RAG项目启动时就把切片策略作为独立模块来设计和评估,不要等到效果不好了才回头补课。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进