RAG 分块策略实测:固定滑窗、段落感知怎么选,80 行 Python 附自检
之前写过最小 ReAct Agent和Transformer/解码系列,里面的检索都是假占位——这篇把真东西补上:RAG 的第一环,分块。收藏榜上 RAG 进阶帖和向量数据库帖都在收收藏,但进阶帖讲的是父子分块/层级索引那些大设计,基础分块写对的人反而不多。本篇只做两件事:三种策略的代码,和一个能抓住丢字事故的自检。
结论先放这儿
| 策略 | 怎么切 | 优点 | 缺点 |
|---|---|---|---|
| 固定滑窗 | 按长度 + 重叠 | 保证不超长,实现最简 | 语义经常被腰斩 |
| 段落感知 | 段落聚合,段内按句拆 | 边界贴语义 | 实现多几行 |
| 硬切兜底 | 超长单句直接切 | 保证不超长 | 语义最差,只做最后手段 |
两句话铁律:分隔符优先级从大到小(段落 > 句子 > 硬切),分块代码必须有覆盖率自检。
一、为什么分块是 RAG 第一坑
块太大:向量语义被稀释,检索不精准;块太小:上下文残缺,检到了也答不对。多数人死在第三种事故上:分块代码把字符弄丢了——丢的往往是一句话的开头或结尾,检索照常出结果,答案悄悄变差,不报错,很难查。
所以本篇的自检核心就一条:分块结果拼回去,必须还原原文。
二、完整代码(单文件,直接跑)
# chunking.py — 固定滑窗 vs 段落感知分块 # 依赖:无(纯标准库) import re def fixed_chunks(text, size=200, overlap=20): """固定长度 + 重叠滑窗""" if overlap >= size: raise ValueError("overlap 必须小于 size,否则 step<=0 死循环") step = size - overlap return [text[i:i + size] for i in range(0, len(text), step)] or [""] def split_long(text, max_size): """单段超长:按句拆(保留标点),塞不下的单句硬切兜底""" chunks, buf = [], "" for s in re.split(r"(?<=[。;,])", text): if not s: continue if len(buf) + len(s) <= max_size: buf += s else: if buf: chunks.append(buf) while len(s) > max_size: # 单句超长:硬切 chunks.append(s[:max_size]) s = s[max_size:] buf = s if buf: chunks.append(buf) return chunks def paragraph_chunks(text, max_size=200): """段落感知:\n\n 聚合,段内按句拆,相邻小块合并""" chunks = [] for para in text.split("\n\n"): para = para.strip() if not para: continue for c in split_long(para, max_size): if chunks and len(chunks[-1]) + len(c) + 2 <= max_size: chunks[-1] += "\n\n" + c # 相邻小块合并,减少碎块 else: chunks.append(c) return chunks if __name__ == "__main__": text = "\n\n".join( f"第{i}段。" + "这是测试内容," * (30 if i % 3 == 0 else 5) for i in range(20) ) + "超长无标点句子" * 100 # 触发硬切兜底 # 1) 固定滑窗:去重叠后拼回应原文(丢字/重字当场报) fc = fixed_chunks(text, size=200, overlap=20) rebuilt = fc[0] + "".join(c[20:] for c in fc[1:]) assert rebuilt == text, "滑窗分块丢字或重字" assert all(len(c) <= 200 for c in fc) # 2) 段落感知:无丢字 + 不超长 sc = paragraph_chunks(text, max_size=200) assert "".join(sc).replace("\n\n", "") == text.replace("\n\n", ""), "段落分块丢字" assert all(len(c) <= 200 for c in sc) print(f"固定滑窗: {len(fc)} 块 段落感知: {len(sc)} 块 self-check ok")两条 assert 各管一类事故:滑窗的覆盖率检查抓重叠偏移算错(最容易写错的地方),段落的无丢字检查抓分隔符切丢内容。块数差异也能看出来:段落感知的块更少更整齐。
三、三个踩坑(都见过真实事故)
- token ≠ 字符:中文 1 字对应 1~3 token,
chunk_size按字符数设 200,实际 token 可能是 400+,向量库的 token 上限直接报错。预算按 token 算,字符数只是代理 - overlap ≥ size 死循环:step = size - overlap ≤ 0,range 一步不走直接卡死。代码里 raise 掉,别信调用方
- 表格/代码块被腰斩:Markdown 表格按行切后,每块都是没头的几列,检索命中了也没法用。结构化内容先按块整体提取再分块,别让通用切分器碰它
四、下一步:检索块和上下文块拆开
本篇三策略有个共同假设:检索用什么块,就返回什么块。进阶玩法是把两个职责拆开——子块做检索锚点(短、准),命中后沿映射返回父块(完整上下文),即父子分块/放大检索。这个需要稳定的 parent_id 映射,映射不稳会出现“命中 A 返回 B”的事故,值得单开一篇。
总结
铁律压成三句:
- 分隔符优先级从大到小,硬切只做兜底
- 块大小按 token 预算算,字符数只是代理
- 分块代码必须有覆盖率自检,丢字是最隐蔽的 RAG 事故