RAG 文档切片实战:国标知识库篇(一)——基础切片
面向场景:将 GB/T 50001-2017、GB/T 18229-2000、GB/T 50104-2010 等国标文档接入 RAG 系统时,如何选择切片策略?
本文通过真实国标条文示例,对比固定长度切片与递归切片的效果,并给出可直接落地的 Python 实现。
一、固定长度切片(Fixed-Length Chunking)
1.1 核心思想
按字符数或 token 数硬切,比如每 1000 字符一块。不管内容边界在哪,到了长度就切。
1.2 国标示例
假设从 GB/T 18229-2000 提取出这样一段原始文本:
4.2.1 CAD工程制图应根据专业、用途、内容等划分图层,并采用统一的图层命名规则。图层名宜采用英文字母、数字和连字符组成。4.2.2 图层命名宜采用以下格式:专业代码-功能代码-序号。专业代码应符合表 4.2.2 的规定。表 4.2.2 专业代码对照表 | 专业代码 | 含义 | A | 建筑 | S | 结构 | M | 暖通 | P | 给排水 | E | 电气 | 4.2.3 图层颜色应与专业代码对应,便于识图和管理...设chunk_size=60,overlap=0,切出来:
Chunk 1: 4.2.1 CAD工程制图应根据专业、用途、内容等划分图层,并采用统一的图层命名规则。图层名宜采用英文字母、数字和连字符组成。 Chunk 2: 4.2.2 图层命名宜采用以下格式:专业代码-功能代码-序号。专业代码应符合表 4.2.2 的规定。表 4.2.2 专 Chunk 3: 业代码对照表 | 专业代码 | 含义 | A | 建筑 | S | 结构 | M | 暖通 | P | 给排水 | Chunk 4: E | 电气 | 4.2.3 图层颜色应与专业代码对应,便于识图和管理...1.3 用户提问暴露的缺陷
用户问:“表 4.2.2 里 P 代表什么专业?”
| 问题 | 表现 |
|---|---|
| 条文编号被切断 | 4.2.2 和它的表格被拆到不同 chunk |
| 表格被切碎 | 表头在 Chunk 2,内容在 Chunk 3,尾在 Chunk 4 |
| 上下文丢失 | Chunk 3 里的 “A | 建筑”,不知道这是表 4.2.2 的专业代码 |
| 引用关系断裂 | Chunk 1 提到"应符合表 4.2.2 的规定",但表格内容在别的 chunk |
检索结果:系统可能命中 Chunk 3(“P | 给排水”),但 LLM 看到的是一个没有表头、没有条文引用的孤立片段,无法确认 P 的含义,甚至不知道这是哪张表。
1.4 Python 实现
""" fixed_length_chunker.py 固定长度切片器 —— 最基础的实现 """fromtypingimportListclassFixedLengthChunker:def__init__(self,chunk_size:int=1000,overlap:int=0):""" Args: chunk_size: 每块最大字符数 overlap: 相邻块之间的重叠字符数 """self.chunk_size=chunk_size self.overlap=overlapdefsplit(self,text:str)->List[dict]:""" 将文本切成固定长度的块 Returns: 包含 chunk 文本和元数据的字典列表 """chunks=[]start=0text_len=len(text)chunk_idx=0whilestart<text_len:end=min(start+self.chunk_size,text_len)chunk_text=text[start:end]chunks.append({"chunk_id":f"chunk_{chunk_idx}","chunk_type":"fixed_length","content":chunk_text,"start":start,"end":end,"metadata":{}})# 步进:chunk_size - overlap,保证重叠窗口start+=self.chunk_size-self.overlap chunk_idx+=1returnchunks# ===== 使用示例 =====if__name__=="__main__":text=("4.2.1 CAD工程制图应根据专业、用途、内容等划分图层,""并采用统一的图层命名规则。图层名宜采用英文字母、数字和连字符组成。""4.2.2 图层命名宜采用以下格式:专业代码-功能代码-序号。""专业代码应符合表 4.2.2 的规定。""表 4.2.2 专业代码对照表 | 专业代码 | 含义 | A | 建筑 | S | 结构 | ""M | 暖通 | P | 给排水 | E | 电气 | ""4.2.3 图层颜色应与专业代码对应,便于识图和管理。")chunker=FixedLengthChunker(chunk_size=60,overlap=10)chunks=chunker.split(text)forcinchunks:print(f"[{c['chunk_id']}] ({len(c['content'])}chars)")print(c['content'])print("-"*50)输出:
[chunk_0] (60 chars) 4.2.1 CAD工程制图应根据专业、用途、内容等划分图层,并采用统 -------------------------------------------------- [chunk_1] (60 chars) 图层,并采用统一的图层命名规则。图层名宜采用英文字母、数字和 -------------------------------------------------- [chunk_2] (60 chars) 、数字和连字符组成。4.2.2 图层命名宜采用以下格式:专业代码- -------------------------------------------------- ...1.5 加入 Overlap 后的改善与局限
chunker=FixedLengthChunker(chunk_size=60,overlap=20)Overlap 能缓解"句子被拦腰切断"的问题,但对国标结构性问题无能为力:
| 问题 | Overlap 能改善? | 原因 |
|---|---|---|
| 短句被切到边界 | ✅ 可以 | 简单重复,检索不受影响 |
| 表格被切碎 | ❌ 不行 | 只能挪断裂点,表格还是两半 |
| 条文和引用分离 | ❌ 不行 | 引用是语义依赖,不是字数问题 |
| 层级结构丢失 | ❌ 不行 | overlap 不恢复标题层级 |
| 邻接条文逻辑链断裂 | ⚠️ 看运气 | 刚好落在 overlap 区能救,不保证 |
二、递归切片(Recursive Character Text Splitting)
2.1 核心思想
按分隔符的优先级层级逐层切分,而不是硬按字数切。
优先级队列:
第一优先:段落分隔符(\n\n) 第二优先:换行符(\n) 第三优先:句子分隔符(。、.、!、?) 第四优先:其他标点(,、;、:) 最后:单字符逻辑:先用大的分隔符切,如果块还太大,再用下一级分隔符继续切,直到每块小于chunk_size。
2.2 国标示例
原始文本(已按换行格式化):
4 图线 4.1 一般规定 4.1.1 图线宽度应根据图样的复杂程度和比例确定,并应从下列线宽系列中选取:0.18mm、0.25mm、0.35mm、0.5mm、0.7mm、1.0mm、1.4mm、2.0mm。 4.1.2 图线分为粗线、中粗线、细线。 4.2 图线宽度 4.2.1 粗线宽度宜为0.7mm或1.0mm。 4.2.2 中粗线宽度宜为0.5mm或0.35mm。 4.2.3 细线宽度宜为0.25mm或0.18mm。 表 4.2.3 图线宽度系列 | 线型 | 宽度(mm) | 用途 | | 粗线 | 0.7, 1.0 | 主要可见轮廓线 | | 中粗线 | 0.5, 0.35 | 可见轮廓线、尺寸线 | | 细线 | 0.25, 0.18 | 填充线、索引符号 | 4.3 图线画法 4.3.1 虚线、点画线应保持线段长度一致,间距均匀。设chunk_size=120,递归切片后的结果:
Chunk 1: "4 图线" (5 chars) Chunk 2: "4.1 一般规定" (8 chars) Chunk 3: "4.1.1 图线宽度应根据图样的复杂程度和比例确定..." (78 chars) Chunk 4: "4.1.2 图线分为粗线、中粗线、细线。" (22 chars) Chunk 5: "4.2 图线宽度" (7 chars) Chunk 6: "4.2.1 粗线宽度宜为0.7mm或1.0mm。" (25 chars) Chunk 7: "4.2.2 中粗线宽度宜为0.5mm或0.35mm。" (27 chars) Chunk 8: "4.2.3 细线宽度宜为0.25mm或0.18mm。" (27 chars) Chunk 9: "表 4.2.3 图线宽度系列\n| 线型 | 宽度(mm) |..." (115 chars) Chunk 10: "4.3 图线画法" (7 chars) Chunk 11: "4.3.1 虚线、点画线应保持线段长度一致,间距均匀。" (29 chars)2.3 改善了什么
- ✅ 4.2.1 / 4.2.2 / 4.2.3 各自独立成块
- ✅ 表 4.2.3 完整地在一个 chunk 里
- ✅ 每个 chunk 边界都是自然语义边界
- ✅ 章节标题单独成块
2.4 用户提问暴露的缺陷
用户问:“第 4 章关于图线有哪些具体规定?”
| 问题 | 表现 |
|---|---|
| 章节标题成了"孤儿 chunk" | Chunk 1 只有"4 图线"5 个字,没有内容,检索时语义空虚 |
| 层级关系完全丢失 | 4.2.1 属于"4.2 图线宽度"、属于"4 图线"——这些信息没有记录 |
| 表格和引用它的条文分离 | 4.2.3 说"细线宽度宜为 0.25 或 0.18",表 4.2.3 列出了用途,但两者只是"相邻 chunk",没有显式关系 |
| 编号体系未被利用 | 无法按chapter_no做 metadata filter,无法回答"列出第 4 章所有条文" |
检索结果:命中 4.2.1 时,系统无法自动带上"它所在的章节是图线宽度"这个上下文。LLM 看到一条孤零零的条文,不知道它在整个标准里的位置。
2.5 Python 实现
""" recursive_chunker.py 递归切片器 —— 按分隔符优先级层级切分 """fromtypingimportListclassRecursiveChunker:""" 递归字符切片器 分隔符优先级:段落 > 换行 > 句子 > 标点 > 字符 """# 默认分隔符优先级(从大到小)DEFAULT_SEPARATORS=["\n\n",# 段落"\n",# 换行"。",# 中文句号";",# 中文分号":",# 中文冒号",",# 中文逗号" ",# 空格"",# 单字符(兜底)]def__init__(self,chunk_size:int=1000,separators:List[str]=None):self.chunk_size=chunk_size self.separators=separatorsorself.DEFAULT_SEPARATORSdefsplit(self,text:str)->List[dict]:"""入口:递归切分文本"""returnself._split_recursive(text,0)def_split_recursive(self,text:str,sep_idx:int)->List[dict]:""" 递归切分核心逻辑 Args: text: 待切分文本 sep_idx: 当前使用的分隔符在优先级列表中的索引 """# 如果文本已经够短,直接返回iflen(text)<=self.chunk_size:return[{"content":text}]iftext.strip()else[]# 如果已经用尽所有分隔符,按字符硬切(兜底)ifsep_idx>=len(self.separators):returnself._hard_split(text)separator=self.separators[sep_idx]chunks=[]ifseparator=="":# 单字符兜底returnself._hard_split(text)# 按当前分隔符切分parts=text.split(separator)current_chunk=""fori,partinenumerate(parts):# 还原分隔符(最后一个后面不加)candidate=part+separatorifi<len(parts)-1elsepartiflen(current_chunk)+len(candidate)<=self.chunk_size:current_chunk+=candidateelse:# 当前 chunk 已经满了,先存起来ifcurrent_chunk.strip():iflen(current_chunk)>self.chunk_size:# 即使按当前分隔符切,还是太长,递归到下一级分隔符chunks.extend(self._split_recursive(current_chunk,sep_idx+1))else:chunks.append({"content":current_chunk.strip()})current_chunk=candidate# 处理最后剩余的文本ifcurrent_chunk.strip():iflen(current_chunk)>self.chunk_size:chunks.extend(self._split_recursive(current_chunk,sep_idx+1))else:chunks.append({"content":current_chunk.strip()})returnchunksdef_hard_split(self,text:str)->List[dict]:"""按字符硬切(最后兜底)"""chunks=[]foriinrange(0,len(text),self.chunk_size):chunk=text[i:i+self.chunk_size].strip()ifchunk:chunks.append({"content":chunk})returnchunks# ===== 使用示例 =====if__name__=="__main__":text="""4 图线 4.1 一般规定 4.1.1 图线宽度应根据图样的复杂程度和比例确定,并应从下列线宽系列中选取:0.18mm、0.25mm、0.35mm、0.5mm、0.7mm、1.0mm、1.4mm、2.0mm。 4.1.2 图线分为粗线、中粗线、细线。 4.2 图线宽度 4.2.1 粗线宽度宜为0.7mm或1.0mm。 4.2.2 中粗线宽度宜为0.5mm或0.35mm。 4.2.3 细线宽度宜为0.25mm或0.18mm。 表 4.2.3 图线宽度系列 | 线型 | 宽度(mm) | 用途 | | 粗线 | 0.7, 1.0 | 主要可见轮廓线 | | 中粗线 | 0.5, 0.35 | 可见轮廓线、尺寸线 | | 细线 | 0.25, 0.18 | 填充线、索引符号 | 4.3 图线画法 4.3.1 虚线、点画线应保持线段长度一致,间距均匀。"""chunker=RecursiveChunker(chunk_size=120)chunks=chunker.split(text)fori,cinenumerate(chunks):content=c['content']print(f"[chunk_{i}] ({len(content)}chars)")print(content[:80]+"..."iflen(content)>80elsecontent)print("-"*50)输出:
[chunk_0] (5 chars) 4 图线 -------------------------------------------------- [chunk_1] (8 chars) 4.1 一般规定 -------------------------------------------------- [chunk_2] (78 chars) 4.1.1 图线宽度应根据图样的复杂程度和比例确定,并应从下列线宽系列中选取:... -------------------------------------------------- ...2.6 与 LangChain 的对应
上述实现就是 LangChainRecursiveCharacterTextSplitter的核心逻辑简化版:
fromlangchain.text_splitterimportRecursiveCharacterTextSplitter text_splitter=RecursiveCharacterTextSplitter(chunk_size=120,chunk_overlap=0,separators=["\n\n","\n","。",";",":",","," ",""])chunks=text_splitter.split_text(text)三、两种基础切片方式对比
| 维度 | 固定长度 | 递归切片 |
|---|---|---|
| 核心逻辑 | 按字数硬切 | 按分隔符优先级切 |
| 条文完整性 | ❌ 容易切断 | ✅ 自然边界保护 |
| 表格完整性 | ❌ 切碎 | ✅ 通常完整(若表格 < chunk_size) |
| 标题层级关系 | ❌ 完全丢失 | ❌ 仍然丢失 |
| 章节-内容关联 | ❌ 无 | ❌ 无 |
| 条文-表格引用 | ❌ 分离 | ⚠️ 相邻但无显式关系 |
| 实现难度 | 极简(10 行代码) | 简单(LangChain 内置) |
| 国标适用性 | ❌ 不推荐 | ⚠️ 可做预处理,不建议做主方案 |
四、下篇预告
基础切片虽然实现简单,但对国标这种强结构文档来说,最大的痛点是层级关系完全丢失。下一篇将介绍两种结构化切片方案:
- 标题感知切片:按
章 > 节 > 条 > 表层级切,保留完整路径 - 父子切片:Parent 保上下文 + Child 做精确检索,解决"精确性 vs 完整性"的矛盾
系列文章索引:
- (一)基础切片:固定长度与递归切片 ← 本文
- (二)结构化切片:标题感知与父子切片
- (三)特殊切片与最终方案:表格感知、语义切片与国标场景推荐
