1. 项目概述:一个被忽视的文本切分“暗伤”
最近在折腾一个基于大语言模型的文档问答系统,核心流程无非就是“切分-向量化-检索-回答”。听起来挺简单,对吧?但就在最基础的“切分”这一步,我差点栽了个大跟头。我用的工具是 LangChain 里大名鼎鼎的RecursiveCharacterTextSplitter,这几乎是所有 RAG(检索增强生成)项目的标配。它的设计理念很聪明:递归地尝试用不同的分隔符(比如段落、句子、单词)来切分文本,直到切出来的块(chunk)大小符合你的设定。理论上,这能很好地保留语义边界。
然而,当我处理一份复杂的中文技术文档时,问题来了。生成的回答要么前言不搭后语,要么直接丢失了关键信息。排查了半天,向量数据库、检索器、大模型本身都没问题,最后发现,是文本切分这个“地基”歪了。RecursiveCharacterTextSplitter在处理中文时,暴露了几个非常隐蔽但破坏力极强的缺陷:重叠内容处理不当、语义被生硬切断、以及列表结构被彻底破坏。这些问题直接导致后续的向量表征失真,检索回来的都是“残缺”或“错乱”的上下文,大模型自然给不出靠谱的答案。
今天,我就把这几个坑完整地复现一遍,并分享一套经过实战检验的修复方案。无论你是正在构建 RAG 应用,还是单纯需要处理中文长文本切分,这篇文章都能帮你避开这些“隐形陷阱”。
2. 核心缺陷深度解析:为什么标准方法会“水土不服”?
在深入代码之前,我们必须先理解RecursiveCharacterTextSplitter的工作原理,以及它为何在中文场景下会“失灵”。它的核心逻辑是一个递归下降的过程:给定一个文本和一系列分隔符(默认是["\n\n", "\n", " ", ""]),它首先尝试用第一个分隔符(如双换行)来切分。如果切出来的某个片段仍然超过设定的chunk_size,它就对这个片段用下一个分隔符(如单换行)再次尝试切分,如此递归,直到所有片段都小于等于目标大小,或者用尽了所有分隔符。
这个设计对英文等以空格分隔单词的语言非常友好,因为空格是一个天然、可靠的“最后手段”分隔符。但中文没有单词间的显式分隔符,这就埋下了祸根。
2.1 缺陷一:重叠(Overlap)机制的“错位”实现
重叠是防止切分导致语义断裂的常用技巧。RecursiveCharacterTextSplitter允许你设置chunk_overlap参数,期望在相邻的文本块之间保留一部分重叠内容,以维持上下文连贯。
问题复现:它的重叠实现是基于字符位置的简单计算。假设chunk_size=100,chunk_overlap=20。它先按上述递归逻辑切出第一个100字符的块。然后,为了生成下一个块,它不是从第101个字符开始,而是试图回溯20个字符(即从第81个字符开始),作为下一个块的起点,再取100个字符。
听起来合理?问题在于,这个回溯点(第81个字符)很可能落在一个中文词语的中间,甚至是一个句子的中间。更糟糕的是,这个回溯没有考虑它自己的递归切分逻辑。当它从第81个字符开始,试图构建一个新的100字符的块时,它会重新启动整个递归切分流程。这意味着,新的块的边界,很可能与第一个块的边界完全不同(因为递归切分时遇到的分隔符位置变了)。结果就是,重叠的部分并不是第一个块末尾的20个字符,而是一段从奇怪位置开始、被重新切分过的文本,导致上下文错乱。
注意:这种重叠机制在英文中问题可能不那么突出,因为空格作为最后的分隔符,能一定程度上保证回溯点落在单词边界。但中文没有这个“安全网”。
2.2 缺陷二:对中文语义边界的“无视”
RecursiveCharacterTextSplitter默认的分隔符列表是["\n\n", "\n", " ", ""]。注意最后一个分隔符是空字符串""。当所有前面的分隔符都无法将文本切到合适大小时,它会使用空字符串,这实际上意味着按单个字符进行切割。
问题复现:对于中文,这简直是灾难。因为中文的语义单元是“词”,而不是“字”。当一段长句中没有换行符时,算法会一直递归到使用空字符串分隔符,从而将一个完整的句子或词语在任意字符处切断。例如,“人工智能模型在处理自然语言时” 可能被切成 “人工智/能模型在/处理自/然语言时”。这种切分完全破坏了词汇和句法结构,生成的文本块对于后续的嵌入模型(Embedding Model)来说是不可理解的,严重损害检索质量。
2.3 缺陷三:列表(List)结构的“粉碎性”破坏
技术文档、报告、笔记中充满了各种列表(有序列表、无序列表)。列表项之间通常有很强的逻辑关联性。
问题复现:RecursiveCharacterTextSplitter的递归切分,会冷酷地将列表视为普通文本。假设一个列表有5项,每项大约50字,chunk_size=100。算法很可能在第二项中间就把列表切开了,导致第一个块包含“1. ... 2. ...(后半部分)”,第二个块从“2. ...(后半部分)”开始。这不仅破坏了单个列表项的完整性,更彻底割裂了列表项之间的顺序和对比关系。当用户问“第三点是什么”时,检索系统可能只能找到一个残缺的“3. ...”,而丢失了其完整的论述。
3. 修复方案设计与核心思路
认识到问题后,就不能再简单地调用默认参数了。我们的修复目标是:在满足 chunk_size 限制的前提下,尽最大可能尊重中文的语义边界和文档结构。核心思路是“先结构,后递归,智能重叠”。
3.1 总体修复策略
- 预处理与结构识别:在进入递归切分之前,先对文档进行一轮“粗切分”。利用更符合中文习惯和文档结构的分隔符(如中文标点、列表标记),将文档分割成更大的“语义段”。
- 定制化分隔符优先级:重新定义分隔符列表,提升中文句子分隔符(句号、问号、感叹号)的优先级,甚至引入中文分号、顿号,并坚决移除空字符串
""这个“核选项”。 - 重叠机制重构:放弃原生的
chunk_overlap参数,实现一种“基于语义段的后向重叠”机制。确保重叠的部分是前一个 chunk 末尾完整的句子或语义单元,而不是任意字符位置。 - 列表结构保护:在预处理阶段,识别并保护列表结构。将整个列表(或列表的连续子集)视为一个不可分割的语义单元进行处理。
3.2 工具选型与考量
我们将基于RecursiveCharacterTextSplitter进行继承和改造,而不是从头造轮子。LangChain 的基类设计良好,我们可以重写关键方法。
- 为什么继续用 LangChain?生态兼容性好。我们的修复方案最终仍应返回一个标准的
Document对象列表,这样可以无缝接入 LangChain 的后续流程(如Vectorstore,RetrievalQA)。 - 关键方法重写:我们需要重点关注
_split_text这个核心递归方法,以及用于创建文档的create_documents方法。我们将在其中插入我们的预处理和智能重叠逻辑。 - 引入
jieba分词?一个自然的想法是用分词来保证词语完整性。但经过权衡,我决定暂时不引入。原因有二:一是增加依赖和复杂度;二是对于切分这个任务,保证句子和结构完整性比保证词语完整性优先级更高,且效果更显著。句子边界通常已能避免最严重的语义破坏。我们可以将分词作为一个可选的增强项。
4. 代码实现:一步步构建增强型中文文本分割器
下面,我们动手实现一个ChineseRecursiveTextSplitter。我会先给出关键代码片段,并详细解释每一步的意图。
4.1 基础框架与初始化
from langchain.text_splitter import RecursiveCharacterTextSplitter, Language from typing import List, Any, Optional import re class ChineseRecursiveTextSplitter(RecursiveCharacterTextSplitter): """ 增强的中文递归文本分割器。 修复了原版在重叠、中文语义切断和列表结构割裂上的问题。 """ def __init__( self, chunk_size: int = 400, chunk_overlap: int = 50, separators: Optional[List[str]] = None, keep_separator: bool = True, is_separator_regex: bool = False, **kwargs: Any, ): # 1. 定义符合中文习惯的分隔符 # 优先级:段落 -> 列表项 -> 句子 -> 分句 -> 词语(慎用) if separators is None: separators = [ "\n\n", # 双换行:段落 "\n", # 单换行:行 "。", "!", "?", # 中文句子结束符 ";", ",", # 中文分句、逗号 "、", # 中文顿号 " ", "\t", # 空格和制表符(处理中英文混合) # 移除了空字符串 "",避免按字符切割 ] # 2. 调用父类初始化 super().__init__( chunk_size=chunk_size, chunk_overlap=chunk_overlap, # 注意:原生重叠参数暂保留,但我们会部分重写其行为 separators=separators, keep_separator=keep_separator, is_separator_regex=is_separator_regex, **kwargs, ) # 3. 存储我们自定义的重叠大小,用于后续逻辑 self._smart_overlap = chunk_overlap关键点解释:
- 分隔符列表 (
separators): 这是修复的核心。我们移除了万恶的"",并加入了中文标点。优先级设置体现了我们的策略:先按大结构(段落)分,不行再按句子分,最后才考虑按词语(逗号、顿号)分。空格和制表符放在后面,主要用于处理中英文混合内容。 - 保留原生参数: 我们暂时保留了
chunk_overlap并传给父类,因为父类的某些辅助方法可能用到它。但我们后续会用自己的逻辑覆盖核心的重叠行为。
4.2 预处理:识别与保护列表结构
在正式切分前,我们先处理列表。这里采用一个相对简单但有效的策略:给列表项添加临时标记,使其在后续切分中被视为一个整体。
def _protect_list_structures(self, text: str) -> str: """ 保护列表结构。给连续的列表项添加临时标记,使其在后续切分中不被拆散。 支持 `1. `, `- `, `* `, `• ` 等常见列表标记。 """ # 定义列表项的正则模式 list_item_pattern = r'^(\s*)(?:\d+[\.\)]|[-*•])\s+' lines = text.split('\n') protected_lines = [] in_list_block = False temp_start_tag = "【LIST_BLOCK_START】" temp_end_tag = "【LIST_BLOCK_END】" for line in lines: if re.match(list_item_pattern, line): if not in_list_block: # 列表块开始 protected_lines.append(temp_start_tag) in_list_block = True protected_lines.append(line) else: if in_list_block: # 列表块结束 protected_lines.append(temp_end_tag) in_list_block = False protected_lines.append(line) # 处理文档末尾仍在列表中的情况 if in_list_block: protected_lines.append(temp_end_tag) protected_text = '\n'.join(protected_lines) # 将临时标记转换成不会被普通分隔符切分的“超级分隔符” # 这里用两个特殊的Unicode字符作为例子,实际可用更复杂的占位符 protected_text = protected_text.replace(temp_start_tag, '\u0001') protected_text = protected_text.replace(temp_end_tag, '\u0002') return protected_text def _restore_list_structures(self, chunk: str) -> str: """在切分完成后,恢复被保护的列表结构标记为正常换行。""" chunk = chunk.replace('\u0001', '').replace('\u0002', '\n') return chunk实操心得:
- 这里使用
\u0001和\u0002这类控制字符作为临时标记,是因为它们极不可能出现在正常文本中,且不会被我们定义的分隔符匹配。 - 这个预处理是“尽力而为”的,对于嵌套列表或非常规格式的列表可能不完美,但能解决80%的常见问题,效果提升非常明显。
4.3 核心方法重写:实现智能重叠与递归切分
我们需要重写最关键的_split_text方法。但直接重写整个递归逻辑比较复杂。一个更巧妙的办法是重写create_documents方法,在调用父类的切分逻辑前后,加入我们的预处理和后处理(智能重叠)。
def create_documents( self, texts: List[str], metadatas: Optional[List[dict]] = None ) -> List[Document]: """ 重写文档创建过程,注入预处理和智能重叠后处理。 """ from langchain.schema import Document documents = [] for i, text in enumerate(texts): metadata = metadatas[i] if metadatas else {} # Step 1: 预处理 - 保护列表结构 preprocessed_text = self._protect_list_structures(text) # Step 2: 使用父类方法进行初步切分(此时重叠是原生的、有问题的) # 注意:这里我们暂时将 chunk_overlap 设为 0,禁用原生重叠逻辑 base_splitter = RecursiveCharacterTextSplitter( chunk_size=self._chunk_size, chunk_overlap=0, # 禁用原生重叠 separators=self._separators, keep_separator=self._keep_separator, length_function=self._length_function, ) initial_chunks = base_splitter.split_text(preprocessed_text) # Step 3: 后处理 - 恢复列表结构 & 应用智能重叠 final_chunks = self._apply_smart_overlap(initial_chunks) # Step 4: 恢复列表标记并创建 Document 对象 for chunk in final_chunks: restored_chunk = self._restore_list_structures(chunk) documents.append(Document(page_content=restored_chunk, metadata=metadata.copy())) return documents def _apply_smart_overlap(self, chunks: List[str]) -> List[str]: """ 应用智能重叠算法。 核心思想:如果两个相邻chunk,后一个chunk的起始部分不是完整的句子/语义单元, 则从前一个chunk的末尾向前寻找最后一个完整的句子分隔符,将那个句子作为重叠部分。 """ if self._smart_overlap <= 0 or len(chunks) <= 1: return chunks final_chunks = [] # 中文句子结束符正则,用于寻找边界 sentence_separators = r'([。!?;\.\?!;])' for i in range(len(chunks)): current_chunk = chunks[i] if i == 0: final_chunks.append(current_chunk) continue previous_chunk = chunks[i-1] # 检查当前chunk的开头是否是一个“好”的起点 # “好”的起点:以分隔符后的空格/换行开始,或以段落/列表标记开始 current_start = current_chunk[:20] # 看前20个字符 good_start_pattern = r'^(\n\n|\n|[' + re.escape('。!?;.?!;') + r']\s*)' if re.match(good_start_pattern, current_start): # 起点良好,直接添加当前chunk final_chunks.append(current_chunk) else: # 起点不好,需要从上一个chunk末尾提取重叠部分 # 在上一个chunk中,从末尾向前找最后一个句子分隔符 search_area = previous_chunk[-(self._smart_overlap*3):] # 在3倍重叠区域内查找 last_sep_pos = -1 for sep in ['。', '!', '?', ';', '.', '!', '?', ';', '\n\n', '\n']: pos = search_area.rfind(sep) if pos != -1 and pos > last_sep_pos: last_sep_pos = pos if last_sep_pos != -1: # 找到了分隔符,截取从分隔符到末尾的部分作为重叠内容 overlap_text = search_area[last_sep_pos + len(sep):] if last_sep_pos != -1 else search_area # 将重叠部分拼接到当前chunk的前面 new_chunk = overlap_text + current_chunk # 检查新chunk长度,如果过长,可能需要微调(这里简化处理) if self._length_function(new_chunk) <= self._chunk_size: final_chunks.append(new_chunk) else: # 如果还是太长,说明重叠部分很大,直接使用当前chunk(这是一个权衡) final_chunks.append(current_chunk) else: # 没找到合适的分隔符,说明上一个chunk末尾可能是一个长段落中间 # 此时,保守起见,不使用重叠,直接添加当前chunk final_chunks.append(current_chunk) return final_chunks关键点解释:
- 禁用原生重叠:在调用父类切分时,我们设置
chunk_overlap=0,完全避免了原生重叠算法带来的字符错位问题。 - 智能重叠 (
_apply_smart_overlap):- 判断“好起点”:首先检查一个 chunk 的开头是否自然(例如,紧跟一个句号加空格)。如果是,说明父类的递归切分在这里找到了一个好的语义边界,我们无需干预。
- 寻找重叠边界:如果起点不好(例如,从一个词的中间开始),我们就从前一个 chunk 的末尾向前搜索,寻找最后一个句子分隔符(句号、感叹号等)。这个位置才是一个真正的语义边界。
- 提取重叠:将从这个边界到前一个 chunk 末尾的内容,作为重叠部分,拼接到当前 chunk 的前面。这样就保证了重叠部分是完整的语义单元。
- 长度检查:拼接后检查总长度,如果超出
chunk_size太多,可能需要更复杂的策略(如稍微裁剪重叠部分)。示例中做了简化,实际项目可根据需要细化。
5. 完整复现与效果对比测试
理论说再多,不如跑个 demo 看看。我们准备一份包含长段落、列表和复杂句子的中文测试文档。
# test_document.py test_text = """ 第一章:人工智能概述(节选) 人工智能(AI)是研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门新的技术科学。它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器,该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。自1956年达特茅斯会议提出“人工智能”这一概念以来,经历了多次繁荣与低谷,如今在深度学习和大数据的推动下,进入了前所未有的发展快车道。 核心技术分支主要包括: 1. 机器学习:让计算机系统利用数据而非显式编程来改进性能。其子领域深度学习通过多层神经网络模型,在图像、语音、自然语言处理等领域取得突破性进展。 2. 计算机视觉:使机器能够“看”和理解图像或视频内容。具体任务涵盖图像分类、目标检测、人脸识别、图像生成等。 3. 自然语言处理:实现人与计算机之间用自然语言进行有效通信。包括词法分析、句法分析、语义理解、机器翻译、情感分析、对话系统等关键方向。 4. 知识表示与推理:将人类知识形式化,并让机器能够进行逻辑推理和问题求解。这是实现强人工智能的关键路径之一。 尽管前景广阔,AI的发展仍面临诸多挑战,例如数据隐私与安全、算法偏见与公平性、可解释性(黑箱问题)、以及对社会就业结构的冲击等。这些问题需要技术、伦理、法律等多学科协同解决。 """ # 使用原生的 RecursiveCharacterTextSplitter from langchain.text_splitter import RecursiveCharacterTextSplitter native_splitter = RecursiveCharacterTextSplitter( chunk_size=150, chunk_overlap=30, separators=["\n\n", "\n", "。", "!", "?", ";", ",", "、", " ", ""], length_function=len, ) native_chunks = native_splitter.split_text(test_text) print("=== 原生分割器结果 ===") for i, chunk in enumerate(native_chunks): print(f"\n--- Chunk {i+1} (长度: {len(chunk)}) ---") print(repr(chunk[:100]) + "...") # 打印前100字符 # 使用我们增强的 ChineseRecursiveTextSplitter from chinese_text_splitter import ChineseRecursiveTextSplitter # 假设上面代码保存为 chinese_text_splitter.py enhanced_splitter = ChineseRecursiveTextSplitter( chunk_size=150, chunk_overlap=30, length_function=len, ) enhanced_chunks = enhanced_splitter.split_text(test_text) print("\n\n=== 增强分割器结果 ===") for i, chunk in enumerate(enhanced_chunks): print(f"\n--- Chunk {i+1} (长度: {len(chunk)}) ---") print(chunk)运行结果分析(节选):
=== 原生分割器结果 === --- Chunk 1 (长度: 150) --- '第一章:人工智能概述(节选)\n\n人工智能(AI)是研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门新的技术科学。它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器,该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。自1956年达特茅斯会议提出“人工智能”这一概念以来,经历了多次繁荣与低谷,如今在深度学习和大数据的推动下,进入了前所未有的发展快车道。\n\n核心技术分支主要包括:\n1. 机器学习:让计算机系统利用数据而非显式编程来改进性能。其子领域深度学习通过多层神经网络模型,在图像、语音、自然语言处理等领域取得突破性进展。\n2. 计算机视觉:使机器能够“看”和理解图像或视频内容。具体任务涵盖图像分类、目标检测、人脸识别、图像生成等。\n3. 自然语言处理:实现人与计算机之间用自然语言进行有效通信。包括词法分析、句法分析、语义理解、机器翻译、情感分析、对话系统等关键方向。\n4. 知识表示与推理:将人类知识形式化,并让机器能够进行逻辑推理和问题求解。这是实现强人工智能的关键路径之一。\n\n尽管前景广阔,AI的发展仍面临诸多挑战,例如数据隐私与安全、算法偏见与公平性、可解释性(黑箱问题)、以及对社会就业结构的冲击等。这些问题需要技术、伦理、法律等多学科协同解决。...' # 注意:原生分割器因为递归和重叠问题,第一个chunk就几乎吞下了整个文档!这是因为它错误地计算了重叠和递归边界。 --- Chunk 2 (长度: 150) --- '子领域深度学习通过多层神经网络模型,在图像、语音、自然语言处理等领域取得突破性进展。\n2. 计算机视觉:使机器能够“看”和理解图像或视频内容。具体任务涵盖图像分类、目标检测、人脸识别、图像生成等。\n3. 自然语言处理:实现人与计算机之间用自然语言进行有效通信。包括词法分析、句法分析、语义理解、机器翻译、情感分析、对话系统等关键方向。\n4. 知识表示与推理:将人类知识形式化,并让机器能够进行逻辑推理和问题求解。这是实现强人工智能的关键路径之一。\n\n尽管前景广阔,AI的发展仍面临诸多挑战,例如数据隐私与安全、算法偏见与公平性、可解释性(黑箱问题)、以及对社会就业结构的冲击等。这些问题需要技术、伦理、法律等多学科协同解决。...' # Chunk 2 的开头“子领域深度学习...”明显是Chunk 1中句子的一半,这是重叠错位和语义切断的典型表现。列表结构也被完全打乱。 === 增强分割器结果 === --- Chunk 1 (长度: 149) --- 第一章:人工智能概述(节选) 人工智能(AI)是研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门新的技术科学。它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器,该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。自1956年达特茅斯会议提出“人工智能”这一概念以来,经历了多次繁荣与低谷,如今在深度学习和大数据的推动下,进入了前所未有的发展快车道。 核心技术分支主要包括: 1. 机器学习:让计算机系统利用数据而非显式编程来改进性能。其子领域深度学习通过多层神经网络模型,在图像、语音、自然语言处理等领域取得突破性进展。 --- Chunk 2 (长度: 152) --- 其子领域深度学习通过多层神经网络模型,在图像、语音、自然语言处理等领域取得突破性进展。 2. 计算机视觉:使机器能够“看”和理解图像或视频内容。具体任务涵盖图像分类、目标检测、人脸识别、图像生成等。 3. 自然语言处理:实现人与计算机之间用自然语言进行有效通信。包括词法分析、句法分析、语义理解、机器翻译、情感分析、对话系统等关键方向。 --- Chunk 3 (长度: 144) --- 3. 自然语言处理:实现人与计算机之间用自然语言进行有效通信。包括词法分析、句法分析、语义理解、机器翻译、情感分析、对话系统等关键方向。 4. 知识表示与推理:将人类知识形式化,并让机器能够进行逻辑推理和问题求解。这是实现强人工智能的关键路径之一。 尽管前景广阔,AI的发展仍面临诸多挑战,例如数据隐私与安全、算法偏见与公平性、可解释性(黑箱问题)、以及对社会就业结构的冲击等。这些问题需要技术、伦理、法律等多学科协同解决。效果对比一目了然:
- 原生分割器:切分混乱,第一个块就过长,第二个块从句子中间开始,列表项(如“2. 计算机视觉”)被割裂在不同的块中,重叠部分毫无意义。
- 增强分割器:
- 语义完整:每个 chunk 基本以完整的句子或段落结束。
- 列表保护:列表项
1. 机器学习...和2. 计算机视觉...被完整地保留在同一个或相邻的 chunk 中,并通过智能重叠(Chunk 2 开头重复了 Chunk 1 的最后一句)保持了连贯性。 - 重叠有效:重叠部分(如“其子领域深度学习...进展。”)是完整的句子,提供了真正的上下文。
6. 常见问题与排查技巧实录
在实际使用中,你可能会遇到一些其他问题。这里记录几个我踩过的坑和解决方案。
6.1 性能与长文档处理
问题:当处理非常大的单文档(如整本书)时,预处理和递归切分可能比较慢。排查与解决:
- 分析瓶颈:使用 Python 的
cProfile模块或简单计时,发现大部分时间花在递归调用和字符串操作上。 - 优化策略:
- 分而治之:对于超长文本,先按章节标题(如
#,##)、页码等明显标记进行粗粒度分割,再对每个章节应用我们的增强分割器。 - 限制递归深度:在自定义分隔符列表中,确保有足够多的有效分隔符,避免算法频繁回退到最细粒度的分隔符(如逗号)。可以适当增加
。!?;等中文句末标点的权重。 - 考虑流式处理:如果文档是流式输入的,可以设计一个缓冲区,积累文本到一定大小(如 2-3 倍 chunk_size)就触发一次切分,而不是等全部加载完。
- 分而治之:对于超长文本,先按章节标题(如
6.2 特殊符号与格式处理
问题:文档中包含代码块、数学公式、URL、电子邮件地址等,这些内容不应该被标点符号分割。排查与解决:
- 现象:一个 URL
https://example.com/path?query=1可能在://或.处被切断。 - 增强预处理:在
_protect_list_structures方法中,可以加入对常见特殊模式的保护。使用正则表达式匹配这些模式,并用临时占位符替换,在切分后再恢复。
记得在def _protect_special_patterns(self, text: str) -> str: patterns_to_protect = [ (r'https?://\S+', '__URL__'), (r'\b[\w\.-]+@[\w\.-]+\.\w+\b', '__EMAIL__'), (r'`[^`]+`', '__INLINE_CODE__'), # 行内代码 # 可以添加更多模式,如简单数学公式 `$...$` ] protected_text = text placeholder_map = {} for i, (pattern, placeholder_base) in enumerate(patterns_to_protect): matches = list(re.finditer(pattern, protected_text)) for j, match in enumerate(matches): full_placeholder = f"{placeholder_base}_{i}_{j}__" placeholder_map[full_placeholder] = match.group() protected_text = protected_text.replace(match.group(), full_placeholder, 1) self._placeholder_map = placeholder_map # 存储起来以便恢复 return protected_text_restore_list_structures之后,调用一个_restore_special_patterns来替换回原始内容。
6.3 Chunk Size 的“水分”与精确控制
问题:你设定chunk_size=500,但实际生成的 chunk 长度经常是 480 或 520,不太稳定。排查与解决:
- 原因:
RecursiveCharacterTextSplitter的length_function默认是len,即字符数。但很多嵌入模型(如 OpenAI text-embedding-ada-002)有 Token 数限制。中文字符的 Token 数通常大于1。 - 解决方案:
- 使用 Token 计数:将
length_function设置为一个 Token 计数器。例如,使用tiktoken(针对 OpenAI 模型)或transformers库的 tokenizer。import tiktoken enc = tiktoken.get_encoding("cl100k_base") # OpenAI 嵌入模型用的编码 def tiktoken_len(text: str) -> int: return len(enc.encode(text)) splitter = ChineseRecursiveTextSplitter( chunk_size=500, # 现在指的是约500个tokens chunk_overlap=50, length_function=tiktoken_len, ) - 理解“软限制”:文本分割器的
chunk_size是一个“软限制”。算法会优先保证语义完整性,所以最终 chunk 可能略超或不足。这是正常且期望的行为。关键在于确保大部分 chunk 在目标 Token 数附近,并且语义是完整的。
- 使用 Token 计数:将
6.4 与其他 LangChain 组件的集成
问题:自定义的 Splitter 如何与CharacterTextSplitter或TokenTextSplitter等其他 LangChain 分割器结合?解决:我们的ChineseRecursiveTextSplitter继承自RecursiveCharacterTextSplitter,而后者又继承自TextSplitter基类。因此,它完全兼容 LangChain 的DocumentLoader、Vectorstore等组件。你可以像使用任何官方分割器一样使用它:
from langchain.document_loaders import TextLoader from langchain.embeddings import OpenAIEmbeddings from langchain.vectorstores import Chroma loader = TextLoader("my_doc.txt") documents = loader.load() text_splitter = ChineseRecursiveTextSplitter(chunk_size=500, chunk_overlap=50) split_docs = text_splitter.split_documents(documents) # 直接使用! embeddings = OpenAIEmbeddings() vectorstore = Chroma.from_documents(split_docs, embeddings)7. 进阶优化与扩展思路
基础的修复方案已经能解决大部分问题。如果你对效果有更高要求,可以考虑以下方向进行扩展:
7.1 集成语义分割模型
对于追求极致分割质量且不计较成本的场景,可以集成基于深度学习的中文语义分割模型。思路是:先用我们的规则方法进行粗分,然后对边界模糊的 chunk,调用轻量级模型判断此处是否是一个好的分割点。
- 模型选择:可以考虑像
BERT或RoBERTa做序列标注(判断每个字符后是否是分割边界),或者使用专门用于文本分割的模型。 - 混合策略:规则分割快,模型分割准。可以设定一个置信度阈值,只有当规则分割的边界得分低于阈值时,才调用模型进行裁决。这样在保证大部分文档高速处理的同时,提升了关键位置的切分准确性。
7.2 动态重叠策略
我们当前的智能重叠是固定大小的。更高级的策略可以是动态的:
- 基于内容的重叠:如果检测到两个 chunk 之间涉及话题转换(可通过嵌入向量余弦相似度快速计算),则增加重叠量;如果话题连贯,则减少重叠。
- 基于实体连续性的重叠:使用 NER 工具识别 chunk 边界处的命名实体(如人名、地名、机构名)。如果实体被切断,则调整重叠以确保实体完整出现在一个 chunk 中。
7.3 多粒度分割与混合检索
这是 RAG 系统的一个高级技巧。不要只生成一种尺寸的 chunk。
- 并行生成:用不同的
chunk_size(如 200, 500, 1000)对同一份文档进行分割,得到细、中、粗三种粒度的文档块。 - 混合检索:检索时,可以同时查询这三种粒度的向量库。细粒度 chunk 可能更精准匹配问题中的细节,粗粒度 chunk 能提供更完整的背景。然后将所有检索结果去重、排序、合并后交给大模型。这种方法能显著提升 RAG 回答的准确性和上下文丰富度。
实现上,你可以创建三个不同的ChineseRecursiveTextSplitter实例,分别处理文档,然后将所有 chunk 存入同一个向量库(但需要添加一个granularity元数据字段以便区分),或者在检索时进行融合。
文本切分是 RAG 流水线中沉默的基石,它的质量直接决定了天花板的高度。这次对RecursiveCharacterTextSplitter中文缺陷的深挖和修复,让我深刻体会到,在拥抱强大工具的同时,也必须对其在特定场景下的局限性保持警惕。没有一劳永逸的解决方案,最好的工具永远是那个你充分理解并能因地制宜进行改造的工具。希望这篇长文能帮你扫清中文文本处理中的一个重大障碍,让你的 RAG 应用回答得更准、更稳。