
简介这份源码包面向RAG应用开发者与AI大模型学习者聚焦长文档切片这一检索增强生成流程中的关键环节帮助解决切片粒度不当导致检索召回与答案质量下降的问题。包内共5个文件以2个Python脚本为核心分别演示LLM语义切片与RAG切片流程另含requirements.txt依赖清单、.inscode与.gitignore等工程配置文件整体约13KB轻量易读可直接运行对照学习。内容覆盖改进的固定长度切片、语义切片、LLM语义切片、层次切片与滑动窗口切片五种策略并给出结合FAISS搭建本地知识库的流程图与选型指南便于按场景权衡效果与性能。目前已有202人学习下载适合希望快速理解切片原理、动手验证不同策略差异并落地到本地知识库项目的开发者参考。1. RAG 切片策略指南为什么你的检索总在“差一点”的地方翻车做 RAG 的人大多有过同一种体感向量库建好了embedding 模型也换了三四个召回结果却总是“差一点”——答案明明在文档里检索出来的片段偏偏缺了半句关键条件或者把两个不相干的段落拼在一起喂给大模型最后生成一段看似通顺实则跑偏的回答。很多人第一反应是去调 top_k、换重排模型折腾一圈发现收益有限真正的病灶往往在更前面切片策略。切片chunking决定了知识被切成什么样、以什么粒度进入向量空间。切得太碎单块语义不完整检索命中却答不出切得太粗噪声稀释了关键信息相似度被平均掉。这份 RAG 切片策略指南要解决的就是把“凭感觉切”变成“按文档结构和检索目标切”并给出一套能直接跑起来的源码实现。它适合已经跑通最小 RAG 链路、正卡在召回质量上的开发者也适合准备把原型推向生产、需要稳定切片管线的工程团队。下面从策略选型讲到代码落地再讲那些只有踩过才知道的坑。2. 切片策略的选型逻辑固定窗口、递归、语义三种路线怎么挑切片不是越高级越好而是要和文档形态、查询类型、成本预算三者对齐。这一章先把主流策略的适用边界讲清楚再落到可执行的实现。2.1 三种基础策略的适用边界固定长度切片是最朴素的方案按字符数或 token 数硬切配一个重叠窗口。它的优点是实现简单、块大小可控、向量维度稳定缺点是会在句子中间断开把“因此”“但是”这类逻辑连接词切到下一块导致单块语义残缺。它适合结构松散、句子独立性强的文本比如日志、短评、FAQ 条目。递归切片是目前最常用的默认方案。它按分隔符优先级逐层尝试先按段落切段落还太长就按句子切句子还长就按字符切直到每块落在目标长度内。这样能最大程度保留自然语义边界。LangChain 的RecursiveCharacterTextSplitter就是典型实现中文场景需要把分隔符换成中文标点。语义切片用 embedding 计算相邻句子的相似度在相似度骤降处断开理论上最贴合语义边界。但它要为每个句子算一次向量成本和延迟都高而且对短文档收益不明显。我的经验是文档超过 50 页、且段落内部主题跳跃频繁时语义切片才值得上。策略适用文档块大小可控性计算成本典型场景固定长度日志、短评、FAQ高低高吞吐粗召回递归技术文档、报告中低通用默认语义长论文、多主题长文低高高精度问答2.2 块大小与重叠窗口的参数推导块大小没有万能值但有一个推导思路先看你的查询平均长度再看答案在原文中的平均跨度。如果用户问的是“XX 接口的超时参数怎么配”答案通常集中在一两段内块大小设在 256 到 512 token 比较合适如果问的是“XX 系统的整体架构”答案跨越多节块大小要放到 800 到 1200 token否则单块装不下完整论述。重叠窗口的作用是防止边界信息丢失。经验值是块大小的 10% 到 20%。重叠太小跨块的关键句仍会被切断重叠太大存储和检索成本上升还会导致重复召回。我一般先用 15% 起步再根据召回评估微调。# 块大小与重叠的快速估算基于文档平均段落长度 def estimate_chunk_size(docs, target_ratio0.15): # 统计每篇文档的段落平均字符数 para_lens [] for doc in docs: paras [p for p in doc.split(\n) if p.strip()] para_lens.extend([len(p) for p in paras]) avg_para sum(para_lens) / len(para_lens) # 块大小取段落均值的 2~3 倍保证装下完整论述 chunk_size int(avg_para * 2.5) overlap int(chunk_size * target_ratio) return chunk_size, overlap这段代码的逻辑是段落平均长度反映了文档的自然语义单元大小块大小取它的 2.5 倍能容纳一个论点加支撑论据。target_ratio控制重叠比例默认 0.15。注意这只是起点最终值要靠召回评估确定别把它当标准答案。2.3 用递归切片器跑通第一版中文切片中文递归切片的关键是分隔符顺序。英文用[\n\n, \n, , ]中文要把句号、问号、感叹号、分号加进去否则会在句子中间断开。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap75, length_functionlen, separators[ \n\n, # 段落 \n, # 换行 。, , , # 中文句末 , # 中文分号 , # 中文逗号 , # 空格 # 兜底逐字切 ] ) chunks splitter.split_text(long_text)chunk_size500是字符数中文一个汉字算一个字符500 字大约对应 350 到 400 token适合中等粒度问答。chunk_overlap75是 15% 重叠。separators的顺序决定切分优先级越靠前越优先保留。这里把中文标点放在空格之前是因为中文文本里空格少若把空格提前会导致切分粒度失控。跑完这一步你就有了第一版可检索的切片接下来才是评估和调优。3. 从源码落地一套可配置的切片管线选型讲完这一章把切片做成一条可配置、可替换、可评估的管线。核心思路是把“切分策略”抽象成接口让固定、递归、语义三种实现可以互换方便做 A/B 对比。3.1 切片器的抽象接口设计管线要解决三个问题输入统一、策略可换、输出带元数据。元数据很重要它记录了每块来自哪个文档、第几段、字符偏移排查召回问题时全靠它定位。from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import List dataclass class Chunk: text: str doc_id: str chunk_index: int start_offset: int metadata: dict field(default_factorydict) class BaseSplitter(ABC): abstractmethod def split(self, text: str, doc_id: str) - List[Chunk]: pass def _build_chunks(self, pieces, doc_id): chunks, offset [], 0 for i, piece in enumerate(pieces): chunks.append(Chunk( textpiece, doc_iddoc_id, chunk_indexi, start_offsetoffset, metadata{length: len(piece)} )) offset len(piece) return chunksChunk用 dataclass 定义字段固定方便序列化进向量库。BaseSplitter强制子类实现split_build_chunks负责统一编号和偏移计算。start_offset是字符级偏移用于回溯原文metadata留作扩展比如塞入标题层级、页码。这套接口的好处是后面加新策略不用改调用方。3.2 递归切片器的完整实现把 2.3 的思路封装成类并补上分隔符逐层回退的逻辑。LangChain 已经实现了但自己写一遍能看清边界处理。class RecursiveSplitter(BaseSplitter): def __init__(self, chunk_size500, chunk_overlap75, separatorsNone): self.chunk_size chunk_size self.chunk_overlap chunk_overlap self.separators separators or [\n\n, \n, 。, , , , , , ] def split(self, text, doc_id): pieces self._recursive_split(text, self.separators) return self._build_chunks(pieces, doc_id) def _recursive_split(self, text, separators): if len(text) self.chunk_size: return [text] if text.strip() else [] sep separators[0] rest separators[1:] parts text.split(sep) if sep else list(text) result, buffer [], for part in parts: candidate buffer sep part if buffer else part if len(candidate) self.chunk_size: buffer candidate else: if buffer: result.append(buffer) # 单段仍超长用下一级分隔符继续切 if len(part) self.chunk_size and rest: result.extend(self._recursive_split(part, rest)) buffer else: buffer part if buffer: result.append(buffer) return result_recursive_split是核心先按当前分隔符切逐段累加超过chunk_size就落盘并开新缓冲。如果单段本身超长且还有下一级分隔符递归下去继续切。separators末尾的空字符串是兜底保证任何文本都能切完。注意这里没有实现重叠重叠要在落盘后单独处理否则递归逻辑会变复杂。生产环境建议直接用成熟库自己写主要用于理解边界。3.3 语义切片的相似度断点检测语义切片的核心是找“语义断崖”。做法是把文本按句子切开逐句算 embedding相邻句子余弦相似度低于阈值就断开。import numpy as np def semantic_split(sentences, embed_fn, threshold0.6, max_len800): if not sentences: return [] vectors embed_fn(sentences) # 返回 shape(n, dim) chunks, current [], [sentences[0]] for i in range(1, len(sentences)): sim float(np.dot(vectors[i-1], vectors[i]) / (np.linalg.norm(vectors[i-1]) * np.linalg.norm(vectors[i]) 1e-8)) current_len sum(len(s) for s in current) if sim threshold or current_len len(sentences[i]) max_len: chunks.append(.join(current)) current [sentences[i]] else: current.append(sentences[i]) if current: chunks.append(.join(current)) return chunksthreshold0.6是断点阈值低于它就认为话题切换。这个值要按语料调技术文档句子间相似度普遍偏高阈值可以设 0.5口语化文本相似度低设 0.7 更合适。max_len是硬上限防止语义连贯但超长的段落撑爆块。embed_fn是外部传入的批量编码函数方便替换模型。语义切片慢建议离线跑一次缓存结果别在请求链路里实时算。4. 切片质量的评估与调参别靠感觉靠指标切片做完不能凭感觉说“好像还行”。这一章给一套可量化的评估方法让调参有依据。4.1 用召回命中率定位切片问题最直接的指标是拿一批已知答案的问题看正确答案所在块是否被召回。如果答案跨块就说明块太小如果召回的块里混了大量无关内容就说明块太大。def recall_hit_rate(questions, retriever, ground_truth): # questions: 问题列表 # ground_truth: 每个问题的答案关键句 hits 0 for q, gt in zip(questions, ground_truth): docs retriever.search(q, top_k5) if any(gt in d.text for d in docs): hits 1 return hits / len(questions)top_k5是常用召回深度gt in d.text做的是关键句包含判断。这个指标低于 0.7 就说明切片或检索有问题。注意它只衡量“找得到”不衡量“排得前”所以还要配合 MRR 看排序质量。4.2 块大小与重叠的网格搜索与其手动试不如小范围网格搜索。块大小取 256、512、768重叠取 10%、15%、20%跑九组对比召回命中率。块大小重叠比例召回命中率平均块数25610%0.6842025620%0.7151051215%0.7923051220%0.7826076815%0.74160从这组模拟数据能看出512 配 15% 是拐点再大命中率反而降因为噪声稀释了相似度。实际项目里要跑真实数据但思路一样先粗搜定位区间再细调。4.3 元数据过滤对切片召回的增益切片时保留标题层级、页码、章节号检索时就能做元数据过滤。比如用户问“第 3 章的接口定义”可以先按章节号过滤再算相似度召回精度明显提升。def filtered_search(query, retriever, chapterNone, top_k5): filters {chapter: chapter} if chapter else None return retriever.search(query, top_ktop_k, filtersfilters)filters是向量库支持的元数据过滤条件。关键在切片阶段就要把chapter写进metadata否则检索时无从过滤。这一步的收益常被低估实际上它比换 embedding 模型便宜得多效果也直接。5. 切片避坑清单五条血泪经验这一章全是踩过的坑每条按现象、原因、解决写。看完能省你至少两周试错。5.1 现象召回块总是缺半句关键条件原因固定长度切片在句子中间断开把“在 XX 条件下”切到了上一块。解决改用递归切片把中文句末标点加入分隔符并保证块大小不低于 256 字符。如果必须用固定切片重叠比例提到 20% 以上。5.2 现象同一段内容被召回多次答案重复原因重叠窗口过大相邻块内容高度重合向量相似度都高。解决重叠降到 10% 到 15%并在入库前做去重按文本哈希过滤。去重放在切片后、入库前别放在检索后。5.3 现象表格和代码块被切得支离破碎原因递归切片按标点切表格的行、代码的语句被当成普通文本。解决切片前先做结构识别把表格和代码块整体保留不参与递归切分。常见做法是用正则或解析器标记这些区域切片时跳过。5.4 现象语义切片跑得慢还切出很多超短块原因相似度阈值设太高句子间稍有波动就断开。解决阈值下调 0.1 到 0.15并加最小块长度限制低于 100 字符的块合并到相邻块。语义切片适合离线批处理别放实时链路。5.5 现象换了 embedding 模型后召回反而变差原因新模型的训练语料和你的切片粒度不匹配块太长超出它的有效上下文。解决查模型的最大输入长度把块大小压到它的 70% 以内。换模型和调切片要一起做别只换一个。6. 进阶把切片策略和检索目标对齐的一个技巧最后讲一个我常用的技巧反向切片。大多数人是先切好再检索反向切片是先看查询长什么样再决定怎么切。具体做法是从真实查询日志里抽样统计问题的平均长度和答案跨度然后让切片粒度去匹配这个分布。如果查询短、答案集中就切小块如果查询长、答案分散就切大块并保留更多上下文。def align_chunk_to_query(queries, answers, base_size512): # 统计答案在原文中的平均跨度 spans [len(a) for a in answers] avg_span sum(spans) / len(spans) # 块大小取答案跨度的 1.5 倍保证装下完整答案 aligned int(avg_span * 1.5) # 限制在合理区间避免极端值 return max(256, min(aligned, 1200))avg_span是答案平均字符数乘 1.5 留出上下文余量再用max和min夹到 256 到 1200 的安全区间。这个函数的输入来自你的真实业务数据不是拍脑袋。我一般每季度重跑一次因为查询分布会随业务变化漂移。验证方法也简单用对齐前后的切片各跑一遍召回命中率提升超过 5 个百分点就保留。别小看这几个点在 RAG 链路里召回每提升一点最终答案质量的提升是指数级的。我自己的习惯是每次上线新切片策略前先在一个小流量桶里跑一周对比召回指标和人工抽检结果确认没有翻车再全量。切片这东西没有后悔药切错了重建向量库的成本很高宁可前期多花两天调参。希望帮到你。本文还有配套的精品资源点击获取