1. 背景与目标
在构建知识库或 RAG 检索链路时,Markdown 文档的切分质量直接决定了后续向量召回的效果。本文围绕一个基于 LangGraph 的导入流程节点,讲解如何先按标题做语义切分,再对超长块做递归二次切割,最终产出结构清晰、长度可控的文本块。
整体目标可以概括为三点:
- 语义完整:优先以 Markdown 标题为边界,保证每个块的主题相对独立。
- 长度可控:单块内容不超过模型上下文限制,超长块需要二次切割。
- 可追溯:每个块保留所属标题与文件标题,便于后续检索与引用。
2. 文档切分节点概览
文档切分节点(node_document_split)位于 app/import_process/agent/nodes/node_document_split.py,核心职责是把长篇、无结构或半结构化的 Markdown 文档,转化为可直接向量化入库、可精准检索的微观语义单元(Chunk)。它是 RAG 知识库的核心基础:切分质量直接决定后续检索召回率与答案准确率,没有合理切分,再好的向量模型也无法发挥作用。
2.1 切割核心原则
- 语义完整:不切断句子、代码块、表格、条款,保证单个 Chunk 是完整的知识点或逻辑单元。
- 长度可控:Chunk 大小适配大模型上下文窗口和 Embedding 模型输入限制,不超上限、不碎片化。
- 边界清晰:相邻 Chunk 保留合理重叠,避免关键信息落在两块交界处。
- 可追溯:每个 Chunk 携带完整元数据(标题、父标题、文件名),便于后续溯源和引用。
3. RAG 文档切分核心思路
文档切分的核心逻辑是「先保语义,再控大小,最后补细节」,全程遵循三步核心流程,所有操作都围绕「不破坏语义、适配检索」展开。
3.1 第一步:先做语义分层
无论什么类型的文档,先不考虑长度,优先按文档本身的语义边界拆分,这是 RAG 切分的核心原则------语义完整永远比「凑长度」更重要。
- 核心逻辑:找到文档的天然语义边界(标题、章节、段落、条款、对话轮次),按边界拆分,确保每个拆分后的单元能独立表达一个完整的意思,比如一个章节、一个步骤、一个条款、一轮对话。
- 关键动作:先识别文档结构(是否有标题、条款、表格、代码块),再按结构拆分,不盲目按字符数切割。
3.2 第二步:再做长度优化
语义拆分后会出现两种情况:单个语义单元过长,超过模型输入限制;或者单个语义单元过短,碎片化导致检索无意义。此时进行针对性优化:
- 超长处理:对超过模型适配长度的语义单元进行递归切分,按更小的语义边界(段落 → 句子 → 标点)切割,不硬断句子、不破坏逻辑。
- 过短处理:对过短的语义单元(如单个句子、零散短语),在同语义层级内合并,比如同一章节下的短段落合并,避免碎片化。
具体切割大小会根据不同文档类型有差异,后文会单独说明。
3.3 第三步:最后补细节保障
长度优化后,补充两个关键细节,确保切分结果可落地、可调试:
- 重叠设置:相邻 Chunk 保留一定重叠(5%~10%),避免关键信息(如步骤、参数、条款)落在切口上,导致检索漏招。
- 元数据补齐:给每个 Chunk 添加元数据(标题、父标题、文件名、块序号),便于后续检索溯源、问题定位。
4. 不同类型文档的切分形式
不同文档的结构、用途不同,切分形式和侧重点也会不同。下面按类型介绍切分形式、核心要求与操作细节。
4.1 技术文档 / 手册 / API 文档
这类文档有清晰的标题层级(# 一级标题、## 二级标题......),包含步骤、代码块、参数说明,核心是「步骤完整、代码可读、参数不遗漏」。
- 语义拆分:按标题层级切分,一级标题拆分为大模块,二级标题拆分为子模块,以此类推,确保每个模块对应一个完整的知识点。
- 代码块保护 :识别代码块标记(``````````` /
~~~),代码块整体保留,不拆分、不切割,哪怕代码块过长也单独作为一个 Chunk,避免代码不可读。 - 长度优化:若单个标题下的内容过长(如超过 2000 字符,可自行设定),按段落拆分,优先在段落空行处切割,不切断步骤、不切断代码;若内容过短(如小于 300 字符)且与相邻子标题属于同一知识点,合并为一个 Chunk。
- 细节补充:重叠率 5%~8%,如 2000 字符的 Chunk 重叠 100~160 字符;元数据保留父标题、当前标题、文件名,便于追溯知识点所属模块。
核心要求:不切断步骤、不切断代码块、不切断参数说明,确保每个 Chunk 能独立体现一个操作或知识点。
4.2 论文 / 研究报告 / 长叙述文本
这类文档有摘要、引言、章节、结论,以文字叙述为主,核心是「论点与论据连贯、逻辑完整」,无代码块,多为段落式结构。
- 语义拆分:按章节标题加段落切分,先按章节标题拆分为大模块,再按段落拆分为子模块,确保每个子模块对应一个完整的论点或论据,如「2.1 实验方法」下的某一段实验描述。
- 长度优化:若单个段落过长(如超过 3000 字符),按句子拆分,优先在句号、感叹号处切割,不硬断句子;若单个段落过短(如小于 400 字符)且与相邻段落属于同一论点,合并为一个 Chunk。
- 细节补充:重叠率 8%~12%,如 2500 字符的 Chunk 重叠 200~300 字符,避免论点边界丢失;元数据保留章节标题、段落序号、文件名,标注论点所属章节。
核心要求:论点与论据不分离,不切断句子,确保每个 Chunk 能完整表达一个观点或一段论述。
4.3 对话记录 / 日志 / 工单
这类文档无标题、无章节,多为「角色 + 内容」的轮次结构,如客服对话、系统日志,核心是「上下文承接、轮次完整」。
- 语义拆分:按轮次或时间戳切分,优先按完整轮次拆分,如「用户提问 → 客服回复」为一个完整轮次;日志按时间戳分段拆分。
- 长度优化:若轮次过长(如多轮对话累计超过 1500 字符),按单轮对话拆分,确保每轮对话独立成块;若轮次过短(如单句对话小于 200 字符),合并相邻轮次,如连续 3 轮短对话合并为一个 Chunk,避免碎片化。
- 细节补充:重叠率 15%~20%,如 1000 字符的 Chunk 重叠 150~200 字符,确保上下文承接不丢失;元数据保留角色(如用户、客服)、时间戳、文件名,标注对话或日志的时间顺序。
核心要求:轮次完整、上下文承接,不切断单轮对话,确保每个 Chunk 能体现一段完整的交互或日志片段。
4.4 法律 / 合同 / 制度文档
这类文档有清晰的条款编号(如「第一条、第二条」),语言严谨,核心是「条款完整、不破坏引用关系」,不可拆分单条条款。
- 语义拆分:按条款编号切分,单条条款(如「第一条 定义」)作为一个基础语义单元;若条款过长(如包含多个子条款),按子条款拆分,如「第一条 1.1 定义」。
- 长度优化:若单条条款过长(如超过 2000 字符),按条款内的分句拆分,优先在分号、句号处切割,不破坏条款逻辑;不合并任何条款,哪怕条款过短,避免条款边界模糊、引用错误。
- 细节补充:重叠率 5%~10%,如 1500 字符的 Chunk 重叠 75~150 字符;元数据保留条款编号、父条款(若有)、文件名,标注条款所属章节。
核心要求:不拆分单条条款、不破坏引用关系,确保每个 Chunk 是完整的一条或一段条款,可直接用于法律检索、条款引用。
5. 切分的核心原则补充
- 语义优先原则:无论长度如何,先保证语义完整,不硬断句子、代码、条款,这是 RAG 切分的底线。
- 模型适配原则:Chunk 大小不超过 Embedding 模型输入限制,总和不超过大模型上下文窗口的 70%。
- 不碎片化原则:单个 Chunk 不小于 300 字符(纯中文,条款除外),避免零散碎片导致检索噪声。
- 边界保护原则:代码块、表格、条款整体保留,不拆分、不切割。
6. 代码实现步骤
本节点负责将 Markdown 文本切分为适合向量检索的语义块(Chunk),采用「先按标题语义切分 → 再按长度精细化切割」的稳定策略。
- 获取与清洗内容(Step 1) :从 state 中提取 Markdown 内容与文件标题,统一换行符格式(
\r\n/\r→\n),保证跨平台兼容。 - 按标题语义初切(Step 2) :基于 Markdown 标题语法(
#~######)进行语义级切分,自动跳过代码块内的标题匹配,避免误切注释,保证每个块语义完整。 - 无标题文档兜底处理(Step 2 内置):若文档无任何标题,自动生成默认标题「无主题」,确保内容不丢失、流程不中断。
- 超长块递归精细化切割(Step 3) :使用
RecursiveCharacterTextSplitter对超过指定长度的语义块进行二次切割,按「段落 → 换行 → 句子 → 空格」优先级切割,不产生碎片、不硬断句子、无需手动合并。 - 构建标准 Chunk 结构 :为每个切片补充完整元数据:
title、content、file_title、parent_title、part序号,保证可检索、可溯源。 - 本地备份与状态更新(Step 4) :将切分结果备份到本地
chunks.json文件,同时将最终 chunks 存入 state,供后续向量入库使用。
7. 全局配置
先定义切分相关的常量,方便后续统一调整:
python
# ====================== 全局配置(可根据模型调整)======================
CHUNK_MAX_SIZE = 500 # 500 字符触发二次切割
# 单个文本块最大长度(控制不超过模型上下文)
CHUNK_SIZE = 200 # 小值方便测试切割
# 块之间重叠长度(保证语义不丢失)
CHUNK_OVERLAP = 20
其中 CHUNK_MAX_SIZE 是触发二次切割的阈值,CHUNK_SIZE 与 CHUNK_OVERLAP 则交给 LangChain 的递归切割器使用。
8. 依赖导入
python
import json
import os
import re
from pathlib import Path
from typing import Tuple, List, Dict, Any
from langchain_text_splitters import RecursiveCharacterTextSplitter
from app.core.logger import logger, node_log, step_log
from app.import_process.agent.state import ImportGraphState
from app.utils.task_utils import add_running_task, add_done_task
9. 第一步:数据校验与清洗
该步骤负责从 state 中取出 md_content、file_title 和 md_path,完成非空校验、兜底读取以及换行符统一。
python
@step_log("step_1_validate_clean")
def step_1_validate_clean(state) -> Tuple[str, str]:
"""
1. 获取数据 md_content, file_title, md_path
2. md_content 进行非空校验,空 -> 异常
3. md_content 不为空 -> 数据清洗 -> 字符串统一替换
4. file_title 进行非空判断 -> 空 -> 通过 md_path 获取 file_title
5. 返回数据即可
"""
md_content = state['md_content']
file_title = state['file_title']
md_path = state['md_path']
# 进行必要非空校验
if not md_content:
logger.warning("没有从 state 读取到 md_content 内容,我们使用 md_path 尝试再次读取!")
if md_path:
md_content = Path(md_path).read_text(encoding="utf-8")
state['md_content'] = md_content
if not md_content:
logger.error("md_content 没数据,并且尝试读取 md_path 依然没有数据,终止执行!!")
raise ValueError("md_content 没数据,并且尝试读取 md_path 依然没有数据,终止执行!!")
if not file_title:
logger.warning("没有在 state 读取到 file_title,计划给与默认值!")
if md_path:
file_title = Path(md_path).stem
if not file_title:
file_title = "default"
state['file_title'] = file_title
md_content 内容进行清洗:\r\n、\r -> \n
md_content = md_content.replace("\r\n", "\n").replace("\r", "\n")
return md_content, file_title</code></pre>
这里有两个细节值得注意:一是 md_content 缺失时会尝试从 md_path 兜底读取,避免上游传参不完整导致流程中断;二是统一换行符,保证后续按 \n 切行时行为一致。
10. 第二步:按标题做语义切分
核心思路是逐行扫描 Markdown,识别 # 到 ###### 的标题行,并以标题为边界聚合内容。同时需要跳过代码块内部的 #,避免把注释误判为标题。
@step_log("step_2_data_chunk_title")
def step_2_data_chunk_title(md_content, file_title) -> List[Dict[str, str]]:
"""
1. 定义正则
2. 根据 \n 进行行的切割
3. 准备数据容器
4. 循环处理每行数据
5. 检查是否进入和退出代码块
6. 判断是不是标题且不在代码块 -> 标题处理
7. 不是代码块也不是标题 -> 普通行处理
8. 跳出循环后处理最后一个标题的内容
9. 检查没有标题的文档 -> title default, md_content
10. 返回结果
"""
# 1. 定义正则
reg = re.compile(r"^\s*#{1,6}\s.+")
# 2. 根据 \n 进行行的切割
lines = md_content.split("\n")
3. 准备数据容器
chunks = [] # 最终结果
current_title = None # 记录当前标题
current_title_lines = [] # 记录当前标题的行内容
is_code_block = False # 是否进入代码块,默认没有
chunk_size = 0 # 切的块数
4. 循环处理每行数据
for line in lines:
# 5. 检查是否进入和退出代码块
if line.startswith("```") or line.startswith("~~~"):
is_code_block = not is_code_block
current_title_lines.append(line)
continue
# 6. 判断是不是标题且不在代码块 -&gt; 标题处理
if reg.match(line) and not is_code_block:
# 结算上一个标题的内容
if current_title:
chunks.append({
"content": "\n".join(current_title_lines),
"title": current_title,
"file_title": file_title
})
# 清空旧数据
current_title = line
current_title_lines = [current_title]
chunk_size += 1
else:
# 7. 不是代码块也不是标题 -&gt; 普通行处理
current_title_lines.append(line)
8. 跳出循环后处理最后一个标题的内容
if current_title:
chunks.append({
"content": "\n".join(current_title_lines),
"title": current_title,
"file_title": file_title
})
9. 检查没有标题的文档
if chunk_size == 0:
chunks.append({
"content": md_content,
"title": "default",
"file_title": file_title
})
chunk_size = 1
10. 返回结果
logger.info(f"完成语义切割,切块数量:{chunk_size},内容:{chunks[:3]}")
return chunks</code></pre>
几个关键点:
代码块状态机:用 is_code_block 布尔值在 ``` 与 ~~~ 之间翻转,保证代码里的 # 注释不会被当成标题。
标题即内容首行:current_title_lines 初始化为 [current_title],让块内容自带标题,便于后续展示与检索。
无标题兜底:当 chunk_size 为 0 时,整篇文档作为一个 default 块返回。
11. 第三步:超长块二次切割
按标题切分后,单个块仍可能超过 CHUNK_MAX_SIZE。此时使用 LangChain 的 RecursiveCharacterTextSplitter 做二次切割,并为每个子块补充 parent_title 与 part 信息。
@step_log("step_3_data_refine_chunk")
def step_3_data_refine_chunk(chunks) -> List[Dict[str, str]]:
"""
作用:将超过指定 size 的标题内容(如 1000 字符)进行二次切分,
二次切分产生 parent_title 与 part。
入参:chunks
出参:chunks(含 part、parent_title)
步骤:
1. 先定义 LangChain 提供的递归切割器(块大小、重叠部分、切割符号)
2. 循环原来的 chunks
3. 获取每块的 content 长度
4. 没有超过 -> 不需要二次切割 -> 补充 part、parent_title
5. 超过 -> 递归切割器二次切割
6. 最终记录结果 final_chunks
"""
# 1. 定义递归切割器
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)
final_chunks = []
2. 循环原来的 chunks
for chunk in chunks:
content = chunk.get("content", "")
title = chunk.get("title", "default")
file_title = chunk.get("file_title", "default")
# 3. 获取每块的 content 长度
if len(content) &lt;= CHUNK_MAX_SIZE:
# 4. 没有超过 -&gt; 不需要二次切割
final_chunks.append({
"content": content,
"title": title,
"file_title": file_title,
"parent_title": title,
"part": 0
})
continue
5. 超过 -&gt; 递归切割器二次切割
sub_texts = splitter.split_text(content)
for idx, sub_text in enumerate(sub_texts):
final_chunks.append({
"content": sub_text,
"title": title,
"file_title": file_title,
"parent_title": title,
"part": idx
})
6. 最终记录结果
logger.info(f"完成二次切割,最终块数量:{len(final_chunks)}")
return final_chunks</code></pre>
切割符号的顺序体现了「先粗后细」的策略:优先按段落切,其次按行、句号、感叹号、问号、分号、逗号,最后退化到空格与空字符串,尽可能保证语义边界完整。
12. 流程总览
flowchart TD
A[读取 state: md_content / file_title / md_path] --> B{md_content 是否为空}
B -- 是 --> C[尝试从 md_path 读取]
C --> D{是否读取成功}
D -- 否 --> E[抛出异常,终止流程]
D -- 是 --> F[统一换行符]
B -- 否 --> F
F --> G[按标题语义切分]
G --> H{单块长度是否超过 CHUNK_MAX_SIZE}
H -- 否 --> I[直接保留,补充 parent_title 与 part]
H -- 是 --> J[递归切割器二次切割]
J --> K[补充 parent_title 与 part]
I --> L[输出 final_chunks]
K --> L
13. 小结
这套切分方案的核心是「两级切割」:第一级用标题做语义边界,保证主题聚合;第二级用递归切割器控制长度,保证不超上下文。配合 parent_title 与 part 字段,后续无论是做向量检索还是原文回溯,都能快速定位到内容所属章节。
实践中还可以继续优化:例如把 CHUNK_SIZE 与 CHUNK_OVERLAP 做成配置项,或针对代码块单独设置不切割策略,避免代码被截断影响可读性。