“这是一个大模型RAG技术栈的系列教程,我将详细介绍RAG的所有核心组件,最后再手把手带你做两个具体场景的实战项目。想要从零开始学习RAG技术的同学赶紧点赞、关注、收藏~”
01 — 前言
经过前面两节的学习,我们已经可以精准的提取文档的内容了。接下来就要对提取出来的内容进行“加工处理”。
本篇文章篇幅较长,全是干货,推荐先收藏再观看。
很多开发者在搭建 RAG 系统时,往往把 90% 的精力花在了选择哪个大模型、调优哪种 Embedding 向量上。但等到系统上线一测试,却发现模型经常“胡言乱语”:要么找不到重点,要么回答得支离破碎。
其实,问题可能出在你最容易忽略的一步:文档分块(Chunking)。
在 RAG 的世界里,如果说大模型是精于烹饪的大厨,那么文档分块就是“备菜”的过程。食材切得太粗,核心滋味(语义)出不来;切得太细,又会丢失原本的纹理(上下文)。如何把长达万字的文档,优雅地切成模型最爱吃的“一口量”,不仅是一门技术,更是一门艺术。
02 — 为什么要分块?
- 从技术层面分析为什么分块这么重要
- **突破“硬限制”:嵌入模型的胃口有限,**所有的嵌入模型(Embedding Models)都有其最大上下文长度(Max Seq Length)。例如主流模型通常限制在 512 或 1024 个 Token。如果文档过长,模型会直接截断,导致后半部分的信息在向量空间中彻底消失。
- 提升“软实力”:检索精度的生死线,分块的粒度直接决定了检索的质量。分块太大会导致信息“稀释”,分块太小会导致上下文“碎片化”。分块,本质上是在做信息的“降噪”与“聚焦”。
分块太小导致一个文本块里缺少上下文,这个好理解,那为什么分块太大会导致信息稀释呢?
1. 向量的“稀释效应”
当你输入一段文本时,Embedding 过程如下:
- Token 化:文本被拆解为 N 个 Token。
- 向量化:每个 Token 被转化为一个高维向量。
- 池化(Pooling):这是关键。为了得到代表这段话的最终向量,模型通常会对所有 Token 向量求平均值(Mean Pooling)。
其数学表达式可以简化理解为:
2. 信息丢失的真相
当分块包含 1 个主题时:最终向量 V_final能精准指向该主题。
当分块包含 5 个不同主题时:最终向量是 5 个主题的均值。在多维空间中,这个点可能位于 5 个主题的中间地带,却不靠近任何一个主题。
结论:分块里的文本越多,信息的熵就越高,特征被平滑(Smooth)得越厉害,损失的语义细节就越多。
从业务场景分析为什么分块这么重要
最简单粗暴的分块方法就是按固定的字数进行分块,比如每500个字符分一个文本块。那么就很有可能出现一段话或者一个主题的内容被切到两个文本块里。我们还以上篇文章里用到的世界富翁排行榜文档为例:
如上面截图所示,这篇文档里每个首富排行榜的表格都对应着一个年份标题。如果只是简单粗暴地按500字一个分块进行切分,导致2025和对应的表格没有被切分到一个块里进行向量化并保存到向量数据库。最终去向量数据库里查找2025年的世界世界首富肯定查不出来。
03 — 要怎么分块?
我们总结一下分块太大和太小会导致的问题:
- 分块太大:信息会被稀释,导致最终检索召回的结果不够准确。即使检索出的结果包含了答案,也只占了答案的一小部分,大模型需要在冗余的上下文中自行查找重点。
- 分块太小:信息碎片化,导致单个分块中缺少上下文依赖,即使召唤命中,也无法单独支撑一个完整回答。由于切片数量急剧增多,相似度接近的向量变多,也会导致Top-k检索结果噪声占比升高,相关内容被挤出候选集合。
下面介绍主流的5种分块方案:
- 按固定尺寸分块(Fixed-Size Chunking)
这是最简单粗暴的方法。直接设定一个固定的字符数或 Token 数(如 500 个字符),强行将文本切开。为了保证上下文的连贯性,通常会设置一个“重叠区”(Overlap)或者叫“滑动窗口”。比如,设定块大小为 500 字,重叠 50 字,那么第二块会包含第一块末尾的 50 字。
这么做的好处是实现极其简单,且处理速度快,适合处理海量文档,能够快速完成索引构建。所以适合以下三种场景:
- 项目初期阶段进行快速原型开发时。
- 处理结构化程度极高的文档,比如日志文件、格式统一的说明书,由于其信息密度均匀,固定切割的效果尚可。
- 算力受限的情况: 当你需要低成本、高效率处理极大规模的文本库时。
这种方案的缺点也很显而易见:
- 容易语义断裂:这种“暴力切割”不考虑句子的完整性,可能导致一个关键词被切成两半,或者一段因果关系被硬生生拆开。
- 很可能上下文丢失:尽管有重叠区,但如果切割点选得不好,检索出来的片段可能读起来莫名其妙,影响大模型的最终回答质量。
在 LangChain 框架中,我们通常使用CharacterTextSplitter来实现固定尺寸分块。我们先来看代码:
from langchain_text_splitters import CharacterTextSplitter from langchain_community.document_loaders import TextLoader import os #获取当前文件所在目录 current_dir = os.path.dirname(os.path.abspath(__file__)) #构造文件夹路径 file_path = os.path.join(current_dir, '../documents/others/log.txt') # 1. 直接加载文档 loader = TextLoader(file_path, encoding="utf-8") data = loader.load() # 2. 初始化分块器 text_splitter = CharacterTextSplitter( separator = "\n", # 关键点:显式指定用单换行符切分 chunk_size = 200, # 目标块大小 chunk_overlap = 10 # 重叠大小 ) # 3. 切分文档 all_splits = text_splitter.split_documents(data) print(f"已处理文档,切分出 {len(all_splits)} 个段落。") print(f"第一个段落的字符数: {len(all_splits[0].page_content)}") print(f"第一个段落的内容: \n{all_splits[0].page_content}")上面代码读取并切分了一个日志文件,这个日志文件的特征是每条日志都在一行里不会出现中间换行,每条日志之间没有空白行。
下面是上面代码执行的结果:
定义CharacterTextSplitter时有几个关键参数:
separator:如果代码里没有指定这个参数,你会发现最终一共只切分出来一个分块,chunk_size=200完全没有生效!这是因为CharacterTextSplitter的默认分隔符是"\n\n"(两个换行符)。它会先寻找\n\n。如果你的日志文件中全是单换行符\n,它在整个文本里找不到任何一个\n\n。既然找不到分隔符,为了保证语义不被随便切断,它宁可违反chunk_size=200的限制,也会把整段文本看作是一个完整的块。
chunk_size:这个参数会指定目标块的大小。但是会把每个块都切分成200个字符吗?答案是否定的,实际上我跑下来的结果第一个段落的字符数为159。该分块器遵循一个核心原则:在不超过chunk_size的前提下,尽可能多地放入完整的“原子单元”。在你设置了separator="\n"(或者它默认寻找分隔符)的情况下,分块器会将每一行日志视为一个不可分割的原子单元**。第一行日志的字符数为159,第二行日志字符数为151,两行日志加起来一共310个字符。如果要满足不超过200个字符,就需要把第二行日志从中间切开,这就违背了每个原子单元不可切分的原则。所以最终第一个文本块就是第一行日志。**
chunk_overlap:这个参数用来指定分块器切分一个分块时,读取上一个分块里多少个字符作为“重叠区”。但是在上面这个代码示例里,这个参数实际并没有生效。这是因为只有当你的chunk_overlap大于一个完整的“原子零件”时,它才会显现出来。比如把参数chunk_size改成500,chunk_overlap改成160(大于一行的长度)。这样每个分块就会包含上一个分块的最后一行日志的内容(如果最后一行日志字符数少于160)。
看到这里,你应该就能明白为什么说这种方案非常适合日志文件了吧?这种日志文件每条日志都在一行里,按换行符进行切分,每行每条日志都是一个原子单元,不会出现前面说的语义断裂的情况。
- 递归字符分块(Recursive Character Chunking)
作为LangChain / LlamaIndex 等框架中默认推荐的分块方法,递归字符分块是最稳妥、最通用的默认方案之一。递归字符分块是一种基于层次结构的文本切分方法。它并不像前面介绍的固定尺寸切分那样“一刀切”,而是预设了一组分隔符(如段落、换行、空格),并按照优先级递归地尝试切分文本。具体的切分逻辑到下面代码里细讲。其核心目标是:尽可能地将语义相关的文本(如整个段落或句子)保留在同一个分块中。
这种方案的优点是:
- 语义完整性:优先按段落(
\n\n)和句子(\n)切分,能够最大限度保留上下文语义,避免由于物理切断导致的信息碎片化。 - 灵活性极高:它可以根据用户设定的
chunk_size自动调整切分层级。如果一段话太长,它会自动进入下一级(如按空格)寻找切分点。 - 检索效果好:由于分块内部逻辑连贯,Embedding 模型生成的向量能更准确地表达该段文本的真实含义,提高 Top-K 检索的准确率。
这么做的缺点是:
- 计算开销略大:相比于固定长度切分,它需要多次扫描字符串并进行递归判断,处理海量文档时速度稍慢。
- 依赖分隔符设置:如果文档格式极不规范(例如没有换行符的 PDF),递归切分的效果会退化成普通的字符切分。
- 分块大小不一:虽然设定了
chunk_size,但实际分块大小会有波动,需要精细调优overlap(重叠度)来弥补边缘信息的丢失。
所以它适合以下场景:
- 通用文档处理:如新闻报道、维基百科条目、公司规章制度。
- 结构化较强的文本:PDF 转写的文本、Markdown 文档。
- 长文本分析:需要保持上下文语境连贯的叙述性文本。
在LangChain框架里使用RecursiveCharacterTextSplitter来实现递归字符分块,下面这段代码用RecursiveCharacterTextSplitter切分了一个Markdown文档:
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.document_loaders import TextLoader import os #获取当前文件所在目录 current_dir = os.path.dirname(os.path.abspath(__file__)) #构造文件夹路径 file_path = os.path.join(current_dir, '../documents/china_history/唐朝历史介绍.md') # 1. 直接加载文档 loader = TextLoader(file_path, encoding="utf-8") data = loader.load() # 2.初始化切分器 splitter = RecursiveCharacterTextSplitter( chunk_size=200, # 适当放大,足以容纳一两个完整的长句 chunk_overlap=30, # 增加重叠,保证上下文衔接 # 关键:增加了中文句号、问号、以及Markdown的标题分隔符 separators=[ "\n\n", # 第一优先级:段落 "\n", # 第二优先级:换行 "。", # 第三优先级:中文句号 "!", # 第四优先级:感叹号 "?", # 第五优先级:问号 " ", # 第六优先级:空格 "" # 最后手段:字符切分 ] ) # 执行切分 chunks = splitter.split_documents(data) for i, chunk in enumerate(chunks): print(f"Chunk {i+1}:\n{chunk.page_content}\n{'-'*20}")递归字符分块的核心逻辑可以概括为:“按序尝试,超载递归,直到合规”。以上面代码为例:
- 首先寻找段落分隔符"\n\n"。如果一整个段落的长度小于 200 字符,它会暂时保留这个“整块”。
- 如果某个段落超过了 200 字符,切分器就会进入这个段落内部,寻找换行符“\n”进行二次拆分。
- 对于中文长段落,如果没换行,它会寻找句号、感叹号、问号。这保证了文本不会被切在“唐朝文化成”这种半句话上,而是切在语义完整的句尾。
- 只有当一个句子本身就超过 200 字符,且找不到任何标点和空格时,它才会祭出“手术刀”,硬生生按字符数切断。
- 切分器在切碎文本后,并不会直接返回碎片,而是尝试“拼乐高”:
目的:就像电影胶片的重叠,确保 Chunk 1 结尾提到的背景,在 Chunk 2 开头依然能看到,防止向量检索时丢失关键上下文。
最大化利用空间:它会把切碎的小块重新组合。比如有三个短句分别长 50、60、70 字符,它会把它们合并成一个 180 字符的 Chunk,因为这最接近你设定的
chunk_size=200。重叠留白 (
chunk_overlap=30):在开启下一个 Chunk 时,它会故意从上一个 Chunk 的结尾“往回数”30 个字符。
这是上面代码切分的文档:
最终切分结果:
可以看到切分出来的文本块不仅没有出现从中间截断的情况,而且还按照分隔符保留了上一个切块的最后一小部分内容。比如第一个切块的末尾和第二个切块的开头都是:“## 一、政治制度与治理体系 �️”,保证第二个切块的内容没有丢失标题,这对语义的完整性至关重要。
- 基于结构的分块 (Structural Chunking)
上面提到了,递归字符分块是一种基于层次结构的文本切分方法,但是它本质上仍是长度驱动的。对于一些层次结构非常规整的文件类型,我们可以进一步基于文档的结构进行分块。
基于结构的分块 (Structural Chunking)是指利用文档本身的层级结构或格式标记(如 HTML 标签、Markdown 标题、LaTeX 章节、JSON/XML 键值对等)来进行物理分割的方法。
它的优势在于:
- 语义完整性高:保证了标题与内容的强绑定,避免了段落被从中间切断导致的信息丢失。
- 检索更精准:当用户提问涉及某个特定章节时,结构化切块能提供上下文更完整的知识块。
- 元数据丰富:在切块的同时,可以自动提取标题层级作为元数据(Metadata),方便后续进行多向量检索或过滤。
它的劣势在于:
- 格式依赖性强:如果原始文档格式混乱(如 PDF 转 Markdown 后丢失了标题层级),分块效果会大打折扣。
- 块大小不均匀:有的章节可能只有一句话,有的则长达万字,可能导致 Embedding 模型输入超长或信息过稀疏。
基于结构的分块具体实现方式有两种:
第一种方式:直接使用专门为特定格式设计的“语法感知型”工具包,LangChain里就有很多这种工具包:
| 文档类型 | 对应的LangChain工具 | 识别的结构 |
| Markdown | MarkdownHeaderTextSplitter | #, ##, ### 等标题层级 |
| HTML | HTMLHeaderTextSplitter/HTMLSectionSplitter | <h1>,<div>,<span>等标签 |
| 代码 (Py/JS) | RecursiveCharacterTextSplitter.from_language | class,def,if,for等语法关键字 |
| LaTeX | LatexTextSplitter | \section,\subsection,\begin{enumerate} |
| JSON | RecursiveJsonSplitter | 键值对层级和嵌套深度 |
我们用MarkdownHeaderTextSplitter工具来试着切分前面切分过的唐朝历史介绍Markdown文档:
import os from langchain_text_splitters import MarkdownHeaderTextSplitter from langchain_community.document_loaders import TextLoader # 1. 读取文档内容 #获取当前文件所在目录 current_dir = os.path.dirname(os.path.abspath(__file__)) #构造文件夹路径 file_path = os.path.join(current_dir, '../documents/china_history/唐朝历史介绍.md') loader = TextLoader(file_path, encoding="utf-8") data = loader.load() # 2. 定义切分规则:将 Markdown 的标题层级映射为元数据(Metadata)中的键名 # 这样检索时,每个 Chunk 都会知道它属于哪个大标题和子标题 headers_to_split_on = [ ("#", "一级标题"), ("##", "二级标题"), ("###", "三级标题"), ] # 3. 初始化切分器并执行切分 markdown_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False # 设置为 False 可以保留正文中的标题行,提高 LLM 的阅读连贯性 ) # 4. 手动处理 Document 列表 all_header_splits = [] for doc in data: # 从 Document 对象中提取字符串内容进行切分 header_splits = markdown_splitter.split_text(doc.page_content) # 【进阶小技巧】:手动把 TextLoader 自带的元数据(如 source)加回去 for split in header_splits: split.metadata.update(doc.metadata) all_header_splits.extend(header_splits) # 5. 查看结果 print(f"切分完成,共生成 {len(all_header_splits)} 个块。\n") for i, chunk in enumerate(all_header_splits): print(f"--- Chunk {i+1} ---") # 现在元数据里既有“标题”,又有“source”了 print(f"【元数据】: {chunk.metadata}") print(f"【内容片段】: {chunk.page_content.strip()}...") print("\n")|
| |
上面代码可以自动识别markdown文档的各级标题,然后将每个标题和这个标题下的段落放到一个分块中。最棒的是,在每个分块的metadata里,会保存这个分块的所有层级的标题,这就非常完美地提取了文本的结构信息:
第二种方式:自己根据特定的文本类型实现分块逻辑。在一些企业内部文档的场景里,文档格式固定并且结构有公司自己的特色,非常适合这种方式。举个例子:
现在公司里有一个工单系统,可以导出的excel文档,表格里每条数据都是一个问题单的信息,字段可能有很多:单号、问题描述、严重程度、修改人、当前状态、滞留天数、提单人、同步单号等等。如果按前面介绍的固定尺寸分块和递归字符分块来切分,要么信息被切的七零八落,要么把很多不需要的冗余信息一起切进来。
这种场景我们就可以自己写一个python方法读取excel文档,将每一行记录里需要的字段提取出来都转化成一个key-value格式的字符串作为一个分块。例如:“单号:202601063366\n问题描述:登录失败,原因是处理大小写逻辑错误。\n严重程度:严重\n修改人:paul”。
这样切分出来的分块不仅保证了语义的完整度,大大提高了检索的精度。还有些额外的好处:对大模型和嵌入模型来说这种格式非常友好,而且相较于json格式也更加节省token。如果用户提问:“有没有严重的登录问题?”就会完美匹配到这一条切块。
上面已经介绍完了主流的三种分块方案,接下来介绍的两种理论大于实战的方案。由于成本太高,它们在实际项目中被应用的较少。但是在一些对问答准确性和精度要求很高的场景,这两种方案理论上是非常适合的。
- 语义分块 (Semantic Chunking)
在传统 RAG 系统中,文本切块(Chunking)通常是按字数或固定长度来完成的,比如“每 500 个字符切一块”。这种方式实现简单,但有一个明显问题:它并不关心文本真正表达的“意思”是否完整。
语义分块(Semantic Chunking)是一种基于语义相似度而不是字数的文本切块方法。它的核心思想是:
不再按“长度”切文本,而是按“意思”切文本。
具体做法是:
- 先将文档拆成更小的单位(如句子或短段);
- 计算相邻文本片段的Embedding 向量相似度;
- 当发现两段文本的语义相似度明显下降(“话题开始变化”);
- 就在这里切一刀,前面语义相似度近似的句子或短段合成一个新的 chunk。
你可以把它理解为:当文本在“讲另一件事”时,再切块,而不是在字数刚好用完时切块。
LangChain 已经内置了语义分块工具,可以直接使用。
- 智能体/模型驱动分块 (Agentic/AI-Driven Chunking)
如果说固定长度分块是“机械切割”,语义分块是“向量感知”,那么智能体 / 模型驱动分块则是:
直接让大模型来决定:
这段文本该不该断、断在哪里、哪些内容应该放在一起。
它的思想非常直观:让模型像人一样读文档,再告诉系统怎么切。首先将较长的文本直接交给 LLM。然后通过 Prompt 让模型:识别主题边界、判断语义是否完整、标注合理的分块位置。最后再由程序根据模型输出进行切块。
这种方案的优点很明显:
对极其复杂的非结构化文本效果最好:例如访谈记录、会议纪要、调研报告、自由文本。
不依赖规则,也不依赖向量阈值:模型可以利用上下文和推理能力,识别隐含结构。
天然适合跨主题、跨风格文本:当文档结构混乱、话题频繁跳跃时,传统方法往往失效。
听着是不是非常高级非常强大?但是这种方案在实际落地的时候有几个非常大的劣势:
1.成本极高
- 每一次分块,本质上都是一次或多次 LLM 推理
- 文档越长,token 消耗越大
- 在大规模文档入库时,成本几乎不可接受
2.速度最慢
- LLM 推理本身就比 embedding 慢
- 长文档常常需要多轮调用
- 不适合高频、批量处理
3.稳定性和可复现性差
- 同一文档,不同时间可能切出不同结果
- Prompt 稍有变化,分块方式就可能改变
- 不利于工程维护和调试
4.过度“聪明”,反而不利于 RAG
在某些场景下:
- Chunk 过于“抽象”
- 包含多层语义
- 反而不利于向量检索的精确匹配
所以总结一下,语义分块(Semantic Chunking)和智能体/模型驱动分块 (Agentic/AI-Driven Chunking)在实际项目中的真实定位更像是:
“兜底方案”,而不是主流方案。
当遇到一些常规手段切分效果较差、极其复杂的非结构化数据、且文档量小又十分重要的文档时,可以尝试使用这两种方案。
- PDF文档怎么分块?
前两章我们已经强调过了,PDF文档是非常重要又难搞的一种文档类型,具体的难点可以去看一下上一篇介绍导入PDF文档的文章。
我们看下前面提到的三种主流的分块方案:按固定尺寸分块、递归字符分块、基于结构分块。由于PDF是非结构化文档,所以基于结构分块肯定不适合。按固定尺寸分块、递归字符分块是常见的折中方案,设置适当的重叠长度(overlap)也能勉强够用,但是效果肯定不会太好。
所以在处理PDF文档时,我们需要采用以下进阶技巧:
- 解析先行(Layout Analysis):不要直接读取纯文本,先用
PyMuPDF、Unstructured、Docling、Marker等工具识别布局(标题、段落、表格)。 - 清洗逻辑线:在切块前,先去掉页眉、页脚和页码。
- Markdown 化:将 PDF 转为 Markdown 格式后再切。Markdown 的标题层级和表格标记能给切块提供天然的“手术导引”。
- 增加重叠度(Overlap):如果必须用固定尺寸,请务必设置10%-25% 的重叠,给断开的语义留一点缓冲带。
个人比较推荐的两种方案是:
- 先使用Docling或Unstructured将PDF转成Markdown文档,然后基于结构分块,直接使用MarkdownHeaderTextSplitter工具包切分。
- 如果不想先把PDF文档转成Markdown文档,也可以用Docling或Unstructured将文档拆分为
Title(标题)、NarrativeText(正文)、ListItem(列表项)等元素。然后遍历元素流,当遇到Title时,开启一个新的 Chunk;将后续的NarrativeText归入该标题下,直到遇到下一个Title。
这两种方案的具体实现方法和示例代码在上一篇讲解如何导入PDF文档的文章中都有详细介绍,不了解的同学可以去翻看上一篇文章的内容。
那么,如何系统的去学习大模型LLM?
作为一名深耕行业的资深大模型算法工程师,我经常会收到一些评论和私信,我是小白,学习大模型该从哪里入手呢?我自学没有方向怎么办?这个地方我不会啊。如果你也有类似的经历,一定要继续看下去!这些问题啊,也不是三言两语啊就能讲明白的。
所以我综合了大模型的所有知识点,给大家带来一套全网最全最细的大模型零基础教程。在做这套教程之前呢,我就曾放空大脑,以一个大模型小白的角度去重新解析它,采用基础知识和实战项目相结合的教学方式,历时3个月,终于完成了这样的课程,让你真正体会到什么是每一秒都在疯狂输出知识点。
由于篇幅有限,⚡️ 朋友们如果有需要全套 《2025全新制作的大模型全套资料》,扫码获取~
👉大模型学习指南+路线汇总👈
我们这套大模型资料呢,会从基础篇、进阶篇和项目实战篇等三大方面来讲解。
👉①.基础篇👈
基础篇里面包括了Python快速入门、AI开发环境搭建及提示词工程,带你学习大模型核心原理、prompt使用技巧、Transformer架构和预训练、SFT、RLHF等一些基础概念,用最易懂的方式带你入门大模型。
👉②.进阶篇👈
接下来是进阶篇,你将掌握RAG、Agent、Langchain、大模型微调和私有化部署,学习如何构建外挂知识库并和自己的企业相结合,学习如何使用langchain框架提高开发效率和代码质量、学习如何选择合适的基座模型并进行数据集的收集预处理以及具体的模型微调等等。
👉③.实战篇👈
实战篇会手把手带着大家练习企业级的落地项目(已脱敏),比如RAG医疗问答系统、Agent智能电商客服系统、数字人项目实战、教育行业智能助教等等,从而帮助大家更好的应对大模型时代的挑战。
👉④.福利篇👈
最后呢,会给大家一个小福利,课程视频中的所有素材,有搭建AI开发环境资料包,还有学习计划表,几十上百G素材、电子书和课件等等,只要你能想到的素材,我这里几乎都有。我已经全部上传到CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】
相信我,这套大模型系统教程将会是全网最齐全 最易懂的小白专用课!!