ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

RAG二 文档切分实战:从语义分层到长度优化

RAG二 文档切分实战:从语义分层到长度优化

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. 全局配置

先定义切分相关的常量,方便后续统一调整:

# ====================== 全局配置(可根据模型调整)====================== CHUNK_MAX_SIZE = 500 # 500 字符触发二次切割 # 单个文本块最大长度(控制不超过模型上下文) CHUNK_SIZE = 200 # 小值方便测试切割 # 块之间重叠长度(保证语义不丢失) CHUNK_OVERLAP = 20

其中CHUNK_MAX_SIZE是触发二次切割的阈值,CHUNK_SIZE与CHUNK_OVERLAP则交给 LangChain 的递归切割器使用。

8. 依赖导入

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,完成非空校验、兜底读取以及换行符统一。

@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 -&gt; \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. 判断是不是标题且不在代码块 -&amp;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. 不是代码块也不是标题 -&amp;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) &amp;lt;= CHUNK_MAX_SIZE: # 4. 没有超过 -&amp;gt; 不需要二次切割 final_chunks.append({ "content": content, "title": title, "file_title": file_title, "parent_title": title, "part": 0 }) continue 5. 超过 -&amp;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 做成配置项,或针对代码块单独设置不切割策略,避免代码被截断影响可读性。
返回列表