夜雨聆风学习资料网

ARTICLE · 1130277

05-RAG 文档chunk实战(上)

05-RAG 文档chunk实战(上)
上一篇末尾我留了个结论:分块的质量,决定了 RAG 系统性能的下限。

你有没有遇到过这种情况:大模型能力很强、Prompt 反复打磨、Embedding 和检索器都调过了,可问答效果就是不理想——答案要么缺胳膊少腿、上下文不全,要么干脆事实错误。排查了向量库、调优了检索器之后,很多人偏偏忽略了最基础的一环:数据在进向量库之前,到底是怎么被切的?

分块切不好,就像给再厉害的模型递上一堆被打乱、切碎的信息,它也拼不出正确答案。这一篇(上)先讲清楚为什么要分块、两个关键参数、以及基础分块和结构感知分块,全部配 LangChain 代码。


一、为什么一定要分块

两个硬原因:

原因
说明
上下文长度限制
大模型一次能处理的内容有上限,不可能把整本书塞进一次推理
向量检索依赖语义完整度
一句话从中间被切断,模型就理解不了它真正的意思,检索也会失准

所以理想的分块要在两个目标间找平衡:

  • 一方面信息密度要够——别切得太碎,至少保住一句完整的话的语义;
  • 另一方面块不能太大——用 chunk size 控制大小,保证上下文完整、又不至于断章取义。

🤔 想一想:块切太大和切太小,分别会带来什么问题?(记住这个矛盾,它贯穿整个分块话题。) —— 太大:噪声多、烧钱、干扰判断;太小:语义断裂、上下文不足。


二、两个关键参数:chunk_size 与 chunk_overlap

参数
含义
常见取值
chunk_size
每一块的最大长度
256 / 512 / 1024(单位 token 或字符)
chunk_overlap
相邻块之间的重叠部分
块大小的 10%~20%

overlap(重叠)是干嘛的? 防止关键信息刚好卡在两块的边界被切断。比如一个 512 token 的块,留 50 token 重叠,就能保证边界附近的语义不会被硬生生劈开。

块1: [........... 内容A ...........][重叠区]块2:                       [重叠区][........... 内容B ...........]                                ↑ 重叠让边界语义不断裂

三、分块策略全景:四大类,由浅入深

类别
代表方法
特点
① 基础分块
固定长度 / 句子 / 递归字符
简单直接,纯机械
② 结构感知分块
Markdown/HTML 标题、对话轮次
利用文档本身结构
③ 语义 / 主题分块
按语义变化点切
更懂内容,成本升
④ 高级策略
小大分块、代理式分块
最智能,成本最高

这一篇讲①和②,下一篇讲③和④。


四、基础分块

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 篇。)

相关学习资料