ARTICLE · 1130277
05-RAG 文档chunk实战(上)
你有没有遇到过这种情况:大模型能力很强、Prompt 反复打磨、Embedding 和检索器都调过了,可问答效果就是不理想——答案要么缺胳膊少腿、上下文不全,要么干脆事实错误。排查了向量库、调优了检索器之后,很多人偏偏忽略了最基础的一环:数据在进向量库之前,到底是怎么被切的?
分块切不好,就像给再厉害的模型递上一堆被打乱、切碎的信息,它也拼不出正确答案。这一篇(上)先讲清楚为什么要分块、两个关键参数、以及基础分块和结构感知分块,全部配 LangChain 代码。
一、为什么一定要分块
两个硬原因:
| 上下文长度限制 | |
| 向量检索依赖语义完整度 |
所以理想的分块要在两个目标间找平衡:
一方面信息密度要够——别切得太碎,至少保住一句完整的话的语义; 另一方面块不能太大——用 chunk size 控制大小,保证上下文完整、又不至于断章取义。
🤔 想一想:块切太大和切太小,分别会带来什么问题?(记住这个矛盾,它贯穿整个分块话题。) —— 太大:噪声多、烧钱、干扰判断;太小:语义断裂、上下文不足。
二、两个关键参数:chunk_size 与 chunk_overlap
| chunk_size | ||
| chunk_overlap |
overlap(重叠)是干嘛的? 防止关键信息刚好卡在两块的边界被切断。比如一个 512 token 的块,留 50 token 重叠,就能保证边界附近的语义不会被硬生生劈开。
块1: [........... 内容A ...........][重叠区]块2: [重叠区][........... 内容B ...........] ↑ 重叠让边界语义不断裂三、分块策略全景:四大类,由浅入深
| ① 基础分块 | ||
| ② 结构感知分块 | ||
| ③ 语义 / 主题分块 | ||
| ④ 高级策略 |
这一篇讲①和②,下一篇讲③和④。
四、基础分块
1)固定长度分块——最简单,但最容易切坏
每 N 个字符切一刀。简单,但缺点明显:很可能把一句话从中间劈成两半,语义完整性一断,模型就看不懂了。
from langchain.text_splitter import CharacterTextSplittertext = "……一段需要分块的长文本……"splitter = CharacterTextSplitter( separator=" ", # 按空格分割 chunk_size=100, # 每块最大 100 chunk_overlap=20# 重叠 20%)chunks = splitter.split_text(text)for c in chunks:print(len(c), c)运行后你会看到每一块的长度都不超过 chunk_size(因为按分隔符回退切分,实际长度会略小于上限)。但它只认空格/分隔符,语义断裂的风险仍在——所以生产上一般不推荐纯固定长度。
2)句子分块——保住句子完整性
先把文本切成一个个完整句子,再把句子逐个拼接,直到快超过 chunk_size 就另起一块。每块结尾一定是完整句子,适合法律条文、新闻稿这类对句子完整性要求高的文本。
import nltknltk.download("punkt")from nltk.tokenize import sent_tokenizedefsentence_chunk(text, max_size=200): sentences, chunks, cur = sent_tokenize(text), [], ""for s in sentences:iflen(cur) + len(s) <= max_size: cur += selse: chunks.append(cur); cur = sif cur: chunks.append(cur)return chunks提示:NLTK 的句子切分对英文友好;中文建议改用正则,按句号、问号、感叹号切:
import resentences = re.split(r'(?<=[。!?])', text)
3)递归字符分块——通用首选,强烈推荐
它聪明在有一个分割优先级:优先按段落(两个换行)切;没有就按单个换行;再没有就按空格;实在不行才按字符硬切。这样能最大程度保住段落和句子的完整性。
from langchain.text_splitter import RecursiveCharacterTextSplittersplitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", " ", ""] # 从粗到细依次尝试)chunks = splitter.split_text(text)它是 LangChain 的默认分块器,也适用绝大多数通用场景。 一句话记住:不知道用什么,就用递归字符分块。
五、结构感知分块
很多文档本身就带结构——技术博客、产品手册、网页文档、FAQ……这时候还硬按字符切,就白瞎了这些结构。
1)Markdown / HTML 按标题切(还能做溯源)
按 #、## 这样的标题层级切分,并且把标题作为元数据保留下来。好处是:检索时不仅拿到内容,还知道它来自第几章第几节,方便溯源和过滤。
from langchain.text_splitter import MarkdownHeaderTextSplittermd = """# 第一章 公司简介本公司成立于 2010 年……## 1.1 发展历程2015 年完成 A 轮……# 第二章 核心技术我们的核心技术是……"""splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[ ("#", "H1"), ("##", "H2"),])docs = splitter.split_text(md)for d in docs:print(d.metadata, "→", d.page_content[:20])# {'H1': '第一章 公司简介'} → 本公司成立于 2010 年…# {'H1': '第一章 公司简介', 'H2': '1.1 发展历程'} → 2015 年完成 A 轮…# {'H1': '第二章 核心技术'} → 我们的核心技术是…🤔 想一想:把标题存进元数据,除了"溯源",还能帮上什么忙? —— 能做过滤检索(比如只在"第二章"里搜)、能在答案里标注出处,可信度和体验都上一个台阶。
2)对话 / 客服记录——按发言轮次切
客服对话、访谈稿这类"一问一答"的数据,别按字符切,按发言轮次切——比如每 3 轮对话作为一块,既保住对话逻辑连贯,又不让单块过长。
defdialogue_chunk(turns, turns_per_chunk=3):"""turns: [(role, text), ...] 按轮次切块""" chunks = []for i inrange(0, len(turns), turns_per_chunk): block = turns[i:i + turns_per_chunk] chunks.append("\n".join(f"{r}:{t}"for r, t in block))return chunks结构感知分块的核心思想就一句:利用文档本身的结构来切,而不是暴力按字符切。
小结
这一篇(上)讲了分块的地基:
- 为什么分块
:上下文限制 + 检索依赖语义完整; - 两个参数
:chunk_size(256/512/1024)、chunk_overlap(10~20%); - 基础分块
:固定长度(不推荐)、句子分块、递归字符(通用首选); - 结构感知分块
:Markdown 按标题切(带元数据溯源)、对话按轮次切。
但还有一类文档很头疼:既没有清晰结构、话题又跳来跳去(上一段还在讲产品功能,下一段突然跳到售后政策)。这种按字符、按结构都不好使,怎么办?
下一篇分块实战(下),我们上更聪明的招——语义分块、主题分块、小大分块、代理式分块、以及混合分块,并给出一套"到底怎么选"的决策方法。
(本文为「RAG 落地实战系列」深入拆解第 5 篇。)