我的 RAG 系统检索效果一直很差、召回的片段总是答非所问或残缺不全,我换了更好的向量模型也没用,排查半天才发现问题出在最不起眼的一步——我把文档按固定字数机械地切成了块、把完整的语义从中间劈断了的深度复盘
这是一次让我对"切分一个整体,顺着它的自然纹理切、还是按外部的机械标准硬切,天差地别"有了刻骨认知的事故。我做了个 RAG(检索增强生成)系统:把文档切成小块、转成向量存进向量库,用户提问时检索最相关的块、连同问题喂给大模型作答。整套流程我搭得有模有样,可检索效果一直很差。
具体表现是:检索召回的片段,要么答非所问(和问题沾点边、却不含真正的答案),要么残缺不全(一段话只有上半句、一个问答只召回了问题没召回答案、一张表只切到了一半)。用户问的明明文档里写得清清楚楚,系统就是给不出准确回答。我一开始以为是向量模型不行、是检索算法的问题,换了更强的 embedding 模型、调了检索参数,效果依旧。直到我把存进向量库的那些"块"一个个打印出来看,才恍然大悟、也哭笑不得:问题出在我做 RAG 的最前面、最不起眼的一步——文档分块(chunking)。我图省事,把文档按固定字数(比如每 500 字)机械地切块:数到 500 字就一刀切下去,完全不管这一刀切在了哪里。结果这一刀经常切在一段话的中间、切在一个问答的问和答之间、切在一张表的某两行之间——把一个完整的语义单元从中间硬生生劈成了两半。于是向量库里存的,全是这种"残缺的半截块":一个块可能前半句属于上一个话题、后半句属于下一个话题,语义混杂、谁也不像;一个块可能只有问题没有答案,检索命中了它、喂给模型也没用。分块这一步切坏了,后面向量模型再强、检索再准,都是在一堆"被劈碎的、语义残缺的块"里打转,自然答不好。
故障现场:固定字数硬切,把完整语义从中间劈断
我把这个"固定字数切坏语义"的现象还原出来,问题一目了然:
原文档(一个完整的问答 + 一段说明):
"问: 退货政策是怎样的?
答: 生鲜类商品签收起 24 小时内、不影响二次销售的可申请退货,超过则不予退货。
另外, 关于运费, 退货运费由..."
我的分块: 按固定 500 字, 数到 500 字一刀切 ↓
块1: "...(前面别的内容)... 问: 退货政策是怎样的?
答: 生鲜类商品签收起 24 小时内、不影响二次销" ← 答案被从中间切断!
块2: "售的可申请退货,超过则不予退货。另外, 关于运费,
退货运费由..." ← 上半句是上个答案的残尾
问题:
- 块1: 有"问"、答案却只切到一半("不影响二次销") → 召回它, 答案不全
- 块2: 开头是上个答案的残尾、又混了运费的话题 → 语义混杂, 谁也不像
- 检索"退货时限": 真正含"24小时"的内容被劈在块1末尾, 上下文残缺
→ 向量模型再强, 也只能在这些"半截、语义混杂的块"里检索, 答不准
# 根因: 按【固定字数】这个外部机械标准硬切, 完全不顾文档【内在的语义边界】
# (段落、句子、问答对、标题章节、表格行)——把完整语义单元从中间劈断
# 分块是 RAG 的第一步, 也是地基; 地基切坏了, 后面全白搭
# 正解方向: 顺着文档"自然的语义/结构边界"切, 别按固定字数一刀切
看着那些"被从中间劈断的半截块",我才彻底明白:分块,是要把一份大文档,切成一个个"语义完整、能独立表达一个意思"的小单元,好让检索能精准命中、让喂给模型的上下文是完整的。可我用"固定字数"这个完全外部的、机械的标准去切——数到多少字就切,根本不管这一刀切在了哪里。文档内在有它自然的语义/结构边界(段落、句子、问答对、标题章节、表格的行),而固定字数的"一刀",经常正好落在这些边界中间,把一个完整的意思劈成两半。结果向量库里全是语义残缺、混杂的块——检索基于这些块,自然又不准、又不全。而分块是 RAG 流程的第一步、地基:地基(切出来的块)的质量,决定了后面检索和生成的上限;地基切坏了,后面再强的向量模型、再精的检索,都只是在一堆碎块里打转。我以为分块只是"把长文切短"的琐碎预处理,其实它是决定 RAG 成败的关键一步,而我用最粗暴的方式把它切坏了。
第一件事:搞懂分块的关键——顺着语义/结构边界切,而非按固定字数硬切
冷静下来,我去把"RAG 的分块策略(chunking)"这一课认真补了,才明白这个"检索差"的根源:
【为什么固定字数分块会毁掉 RAG, 以及怎么切才对】
分块的目的:
- 把大文档切成"语义完整、能独立表达一个意思"的小单元(chunk)
- 好让: 检索能精准命中相关单元 + 喂给模型的上下文是完整连贯的
- 它是 RAG 的【第一步/地基】, 块的质量决定检索和生成的上限
固定字数硬切的问题:
- 按"数到 N 字就切"这个【外部机械标准】, 不顾文档【内在语义/结构边界】
- 一刀常切在段落/句子/问答对/表格行的【中间】→ 把完整语义劈成两半
- 结果: 块语义残缺(只有半句/有问无答)、语义混杂(跨了两个话题)
- → 检索召回半截/不相关、喂给模型上下文残缺 → 答不准不全
怎么切才对——顺着文档"自然的边界"切:
1. 按结构/语义边界切: 优先在 段落、标题/章节、句子、问答对、
列表项、表格行 这些"自然断点"处切, 别在中间劈
2. 递归字符分块(常用): 优先按大边界(段落)切, 太大再按小边界(句子)切,
尽量保住语义完整(如 LangChain 的 RecursiveCharacterTextSplitter)
3. 块大小适中: 太大→稀释、检索不聚焦; 太小→缺上下文; 按内容和模型调
4. 块间重叠(overlap): 相邻块留一点重叠, 避免边界处的信息被切丢
5. 带上下文/元数据: 给块附标题、章节、来源, 检索和理解都更准
6. 结构化内容特殊处理: 表格、代码、列表别当普通文本按字数切
核心: 分块要尊重内容"内在的结构与语义", 顺着它的自然纹理切;
按固定字数这种外部机械标准硬切, 会破坏语义完整性、毁掉检索质量
这一下点醒了我:我把"分块"当成了"把长文按长度切短"这种无脑的机械操作,可分块的真正目的,是切出一个个"语义完整、自成一意"的单元;要做到这一点,就得顺着文档内在的、自然的语义和结构边界(段落、句子、问答、表格行)去切,而不是拿"固定字数"这个和内容毫无关系的外部标准一刀切下去。固定字数的刀,因为不顾内容的纹理,经常正好劈在完整语义的中间,把它切成两半残块;而 RAG 的检索和生成全建立在这些块之上,块碎了,一切都跟着碎。不是向量模型或检索算法不行,是我在最前面的分块这一步,用错了切法——拿外部的尺子,硬切了有内在纹理的内容。
第二件事:正解——按语义/结构边界切、块间重叠、带元数据
找到根因,正解就清晰了:分块要顺着文档自然的语义/结构边界切——优先在段落、标题/章节、句子、问答对、表格行这些"自然断点"处切(用递归字符分块等);块大小适中(太大稀释、太小缺上下文);相邻块留重叠避免边界信息丢失;给块带上标题/章节/来源等元数据;表格、代码这类结构化内容特殊处理,别当普通文本按字数切。
# 错误: 按固定字数硬切, 劈断语义
def chunk_bad(text, size=500):
return [text[i:i+size] for i in range(0, len(text), size)] # ✗ 不顾边界
# 正解1: 递归字符分块 —— 优先按大边界(段落)切, 太大再按句子切(常用)
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=80, # 块间重叠, 防边界信息被切丢
separators=["\n\n", "\n", "。", "!", "?", " ", ""], # 优先在自然断点切
)
chunks = splitter.split_text(text) # ✓ 尽量保住段落/句子完整
# 正解2: 给块带上下文/元数据(标题、章节、来源), 检索和理解更准
docs = [{"text": c,
"meta": {"title": title, "section": section, "source": url}}
for c in chunks]
# 正解3: 结构化内容特殊处理, 别按字数切
# - 表格: 整张表(或按行+表头)作为一个块, 别切到一半
# - 问答/FAQ: 一个"问+答"作为一个完整块
# - 代码: 按函数/类等逻辑单元切, 别从中间断
# - Markdown: 按标题层级切, 保留章节结构
# 正解4: 块大小按内容和模型调, 并评估
# - 太大: 一块混多个主题, 检索不聚焦、稀释
# - 太小: 缺上下文, 召回了也理解不了
# - 用一组"问题→应命中块"的评估集, 量化分块+检索的命中率, 据此调参
# 关键: 分块要尊重内容内在结构, 顺着自然边界切, 别拿固定字数一刀切
这套做法的精髓,是把分块从"按外部尺子机械切"变成"顺着内容内在的纹理切":在段落、句子、问答、表格行这些自然断点处下刀,尽量让每个块都是一个语义完整的单元;块间留重叠兜住边界信息;带上标题/章节等元数据补充语境;结构化内容(表格/代码)按其逻辑单元切而非按字数。再用一组"问题→应命中块"的评估集去量化分块+检索的效果,据此调块大小和重叠。不是不切(长文必须切),而是切的时候尊重内容本身的结构,别用一把不认识内容的尺子硬切。
【RAG 分块, 几条原则】
1. 分块是 RAG 的地基: 块的语义完整性决定检索和生成的上限
2. 顺着自然边界切(段落/标题/句子/问答/表格行), 别按固定字数硬切
3. 递归字符分块: 优先按大边界切, 太大再退到小边界, 尽量保语义完整
4. 块间留重叠(overlap), 避免边界处信息被切丢
5. 给块带元数据(标题/章节/来源), 提升检索和理解
6. 表格/代码/列表等结构化内容特殊处理, 按逻辑单元切别按字数
7. 块大小适中, 用评估集量化命中率来调参, 别凭感觉
第三件事:其他"按外部机械标准切割、破坏内在完整性"的同类坑
顺着"切分要顺内在纹理、别按外部机械标准硬切"这条线,我把同类的坑都梳理了一遍:
第一个,按固定字节截断多字节字符。按字节数截断 UTF-8 文本,正好切在一个多字节字符中间,产生乱码。要按字符边界截断。
第二个,按行数机械切割日志/CSV。一条跨多行的日志(含堆栈)、或带换行的 CSV 字段,被按行数硬切成两半,语义断裂。要按记录边界切。
第三个,按固定大小分片传输结构化数据。把一个完整的消息/记录按固定字节分片,接收端组装边界没对齐就解析失败。要按消息边界分帧。
第四个,代码格式化/重构按行数而非语法结构。按行机械拆分代码、不顾语法块边界,拆出语法不完整的片段。要按语法单元处理。
第四件事:固定字数切 vs 按语义边界切,一张表对照
我把"按固定字数硬切"和"按语义/结构边界切"的差别整理成一张表,这是我现在做 RAG 分块的依据:
| 维度 | 固定字数硬切 | 按语义/结构边界切 |
|---|---|---|
| 切在哪 | 数到 N 字一刀, 不顾边界 | 段落/句子/问答/表格行的自然断点 |
| 块的语义 | 常残缺、混杂(半句/有问无答) | 完整、自成一意 |
| 检索 | 召回半截/不相关 | 精准命中相关单元 |
| 给模型的上下文 | 残缺、断裂 | 完整、连贯 |
| 边界信息 | 切丢 | 重叠兜住 |
| 实现 | 简单但毁效果 | 递归分块+重叠+元数据 |
这张表让我看清:固定字数切实现最简单,却因为不顾内容纹理而把语义切碎,毁掉整个 RAG 的检索质量;按语义/结构边界切,才能切出语义完整的块,让检索精准、上下文连贯。分块是地基,这一步的切法直接决定 RAG 的上限。
第五件事:我对"文档分块"的几个想当然
这次事故,本质是我把"分块"当成了"按长度切短"的琐碎预处理。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "分块就是把长文按字数切短" | 是切出语义完整单元;固定字数会劈断语义 |
| "检索差是向量模型/算法的问题" | 常是分块切坏了, 块本身残缺混杂 |
| "分块是琐碎预处理, 随便切就行" | 它是 RAG 地基, 决定检索和生成的上限 |
| "固定字数切简单又均匀, 挺好" | 不顾内容纹理, 常把完整语义从中间劈开 |
| "换更强的 embedding 就能救检索" | 块切碎了, 再强的模型也在碎块里打转 |
| "表格代码也按字数切没关系" | 结构化内容按字数切会断裂, 要按逻辑单元 |
第六件事:做 RAG 分块、排查检索差时,我现在的自检习惯
现在每当我做 RAG 分块、或排查"检索效果差、召回残缺",我都会先按这张图问自己:
这张图的精髓,是"RAG 检索差先看存进库的块本身完不完整;固定字数硬切会劈断语义,要顺自然边界切"。设计就用递归字符分块顺段落句子边界切、块间重叠、带元数据、结构化内容按逻辑单元切、排查就先打印块看是不是被固定字数切残了再怀疑向量模型。这套习惯,让我从"分块就是按字数切短"变成了"分块要切出语义完整单元、顺内容纹理切"——核心始终是:分块是 RAG 的第一步和地基,目的是把大文档切成语义完整、能独立表达一个意思的小单元,好让检索精准命中、给模型的上下文完整;按固定字数机械硬切用的是和内容无关的外部标准,一刀常落在段落/句子/问答对/表格行的中间、把完整语义劈成残缺混杂的半块,导致检索召回半截不相关、上下文断裂,向量模型再强也在碎块里打转;正解是顺着文档内在的语义/结构边界切——优先在段落标题句子问答表格行等自然断点处下刀(递归字符分块)、块大小适中、相邻块留重叠防边界信息丢失、给块带标题章节来源等元数据、表格代码等结构化内容按逻辑单元切而非按字数,并用问题到应命中块的评估集量化调参。
我立下的几条规矩
这场"固定字数分块切碎语义"的事故,换来了我做 RAG 时,刻进骨子里的几条铁律:
- 分块是 RAG 的地基:切出来的块的语义完整性,决定检索和生成的上限。
- 别按固定字数硬切:那是用和内容无关的外部标准,常把完整语义从中间劈断。
- 顺着文档自然的语义/结构边界切:段落、标题/章节、句子、问答对、表格行。
- 用递归字符分块:优先按大边界切,太大再退到小边界,尽量保住语义完整。
- 相邻块留重叠(overlap),避免边界处的信息被切丢;给块带标题/章节/来源等元数据。
- 表格、代码、列表等结构化内容按其逻辑单元切,别当普通文本按字数切。
- 检索效果差先打印块看是不是切坏了,再怀疑向量模型;用评估集量化命中率来调块大小和重叠。
附:我现在做 RAG 分块的"按结构切 + 重叠 + 元数据"骨架
这是我现在做 RAG 分块固定套的骨架——把这次踩坑的教训(顺自然边界切、块间重叠、带元数据、结构化内容特殊处理)固化成一套流程,让"固定字数切碎语义"再不会毁掉检索:
from langchain.text_splitter import RecursiveCharacterTextSplitter
def chunk_document(doc):
# 1) 结构化内容(表格/代码/FAQ)先单独抽出, 按逻辑单元整体成块, 别按字数切
blocks = []
for table in extract_tables(doc):
blocks.append({"text": table.to_markdown(), "type": "table",
"meta": base_meta(doc)}) # 整张表(带表头)一个块
for qa in extract_faq(doc):
blocks.append({"text": f"问: {qa.q}\n答: {qa.a}", "type": "qa",
"meta": base_meta(doc)}) # 一个问答一个完整块
# 2) 普通正文: 递归字符分块, 顺段落/句子等自然边界切 + 块间重叠
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=80,
separators=["\n\n", "\n", "。", "!", "?", "; ", " ", ""], # 优先大边界
)
for section in split_by_heading(doc.body): # 先按标题/章节切, 保结构
for c in splitter.split_text(section.text):
blocks.append({
"text": c, "type": "text",
# 3) 带元数据: 标题/章节/来源 → 检索和理解都更准
"meta": {**base_meta(doc), "section": section.title},
})
return blocks
# 4) 评估: 用"问题→应命中块"标注集, 量化分块+检索命中率, 据此调 size/overlap
def eval_chunking(blocks, qa_dataset):
hit = sum(gold in [b.id for b in retrieve(q)] for q, gold in qa_dataset)
return hit / len(qa_dataset)
这套骨架把我这次的教训钉死在了分块这步:结构化内容(表格、问答)先抽出来按逻辑单元整体成块(绝不从中间切),普通正文先按标题章节切保住结构、再用递归分块顺段落句子边界切并留重叠,每个块都带上标题/章节/来源元数据,最后用评估集量化命中率来调块大小和重叠。有了它,存进向量库的每个块都是一个语义完整、自成一意的单元——检索能精准命中、喂给模型的上下文也完整连贯,而不再是当初那一堆被固定字数刀劈碎的残块。把"顺着内容内在结构、在自然接缝处分解"这个道理,沉淀成 RAG 分块的固定流程,这是我对这次事故最实在的交代——毕竟,地基的每一块都切完整了,上面的检索和生成才立得稳。
写在最后
回头看,这场由"固定字数分块"引发的"RAG 检索差"事故,真正教给我的,远不止"用递归分块器"这一个技巧。它让我对"要把一个本身有着内在结构、内在脉络的整体, '分解'成若干部分时, 用'顺着它内在的纹理、在自然的接缝处分', 还是'拿一把外部的、和它内在结构毫无关系的尺子机械地切', 结果天差地别; 后者图的是省事和均匀, 代价却是把原本浑然一体的意义, 在一个个本不该断开的地方生生切碎",有了一次刻骨的体会。我栽跟头,是因为我用一个'外部的、机械的、不认识内容的标准(固定字数)', 去分解一个'有着内在语义结构的整体(文档)'——我图的是简单、均匀: 数到 500 字切一刀, 多干脆;我没意识到, 文档不是一串均匀的字符, 而是由段落、句子、问答、表格这些"有内在边界、各自表达一个完整意思"的单元组成的; 我那把"固定字数"的尺子, 对这些内在边界一无所知, 于是一刀刀都切在了不该切的地方, 把完整的意思劈成了残缺的碎片;而 RAG 后面的一切, 都建立在这些碎片之上——地基碎了, 楼自然盖不好。这让我领悟到一个关于"分解、内在结构与外部标准"的深刻认知:任何有内在结构的整体(文档、数据、代码、知识、任务), 在被分解时, 都有它"自然的接缝"——那些分开后各部分仍能保持自身完整与意义的地方;用"顺着自然接缝分", 各部分依然完整、可用、有意义; 而用"外部机械标准硬分"(图省事、图均匀、图整齐), 则极可能切在自然接缝之外、把完整的单元拦腰斩断, 得到一堆"看着切好了、实则各自残缺"的碎片;更关键的是, 这种分解往往是后续一切工作的基础, 分解的质量, 默默决定了整个系统的上限——而它又恰恰是最容易被当成"琐碎预处理"而被粗暴对待的一步。这给了我一种看待"一切'把有结构的整体分解成部分'之事"时的清醒:每当我要把一个有内在结构的东西分解成若干部分时, 要追问"我是在顺着它内在的、自然的接缝分, 还是拿一把和它结构无关的外部尺子在硬切?这样切, 每个部分还保持着自身的完整与意义吗, 还是被拦腰斩断了?这个分解是后续工作的地基吗——如果是, 我更不该图省事粗暴地切"——识别并尊重内容的内在结构、在自然接缝处分解, 而不是用外部机械标准图省事地硬切;"顺着内在结构在自然接缝处分解、别用外部机械标准切碎完整单元", 是做对 RAG 分块、也是做对一切'分解'之事的关键。认清分块是 RAG 地基、固定字数硬切会劈断语义、要顺自然边界切——这,是我用一次 RAG 检索差的事故,换来的、关于 AI、也关于如何尊重内在结构去分解的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次做 RAG、随手写下"按每 N 字切块"时,先想想"这一刀会不会正好切在一段话、一个问答、一张表的中间?我是不是该顺着段落和句子的边界切?",并换上递归分块、带上重叠,那我对着那一堆"被劈成半截的残缺块"折腾的大半天,就值了。
—— 别看了 · 2026