RAG 分块策略优化的工程实践:从语义分段、重叠窗口与父子块索引到离线对照实验和线上效果回溯
RAG 分块策略优化的工程实践从语义分段到线上效果回溯做 RAG 时很多团队先调 embedding再调召回 TopK最后才回头看分块。我的经验刚好相反分块没处理好后面一串参数都像在补锅。说实话我一开始也低估了这件事。同一份知识库分块方式一变召回命中率、答案完整度、上下文噪声都会跟着波动。问题不抽象很工程块太大召回进来一堆无关段块太小证据被切碎生成阶段拼不回来。本文把我在业务里落过的一套方案写清楚重点放在可复现、实测结果、对比方法和工程细节。一、问题是怎么暴露出来的先看一个典型现象。用户问退款后优惠券是否返还知识库原文在帮助中心里其实写得很清楚但它分散在两个自然段段落 A退款成功后的资金处理规则段落 B优惠券返还条件区分平台券和商家券如果按固定长度 500 字符切块恰好把 B 的前半段切到上一个块、后半段切到下一个块检索层经常只能召回“退款资金原路返回”却漏掉“优惠券返还条件”。生成出来的答案就会半对半错。很常见。我在一个客服知识库项目里统计过 1200 条问答样本错误里有 38% 能追溯到分块问题不是模型理解差也不是 embedding 选型失误而是证据本身被切坏了。这类问题通常有几种信号TopK 里能看到相关关键词但证据不完整相同 query 在不同 chunk_size 下答案波动很大rerank 分数不低最终答案还是漏条件长文档命中率明显低于 FAQ 类短文档如果你线上也有这些现象先别急着换模型先看分块。二、我怎么定义“好的分块”我一般不用“看起来合理”这种标准而是落到几个可量化指标上1召回命中率对每条 query标注标准证据片段统计 TopK 是否命中。常用指标Hit1Hit3Hit5MRR2证据完整度命中了不等于够用。块里是否包含回答该问题所需的完整条件、限制项、例外规则这个要单独看。我会给证据块打一个简单标签full单块内可直接回答partial只包含部分条件noise有点相关但不支持回答3上下文利用率把召回进 prompt 的 chunk 中真正被答案引用或可验证支持的比例算出来。这个值低说明噪声高token 花了但没起作用。4成本指标块变小索引条数会涨块变大rerank 和生成上下文成本会上去。工程里要一起看平均文档切块数向量库容量检索延迟单请求输入 token只有效果没有成本约束最后落不到线上。三、基线方案先把实验台搭起来别一上来改 chunk_size。先搭一个能复跑的实验框架不然后面所有结论都不稳。我一般准备四类输入1原始文档集保留文档层级信息比如doc_idtitlesection_pathparagraph_idcontentupdated_at2评测 query 集每条 query 至少带这些字段{query_id:q_1024,query:退款后优惠券是否返还,doc_id:help_refund_12,gold_spans:[退款成功后平台券在满足有效期条件下返还,商家券是否返还以活动规则为准],answer_type:rule}3统一检索流程固定住这些条件相同 embedding 模型相同向量库相同召回 TopK相同 rerank 模型相同生成模型只让“分块策略”变成唯一变量。4回放脚本所有 query 能离线批量跑输出统一结果表。这个很关键后面做线上回溯时也能复用。一个最小可用的数据结构如下fromdataclassesimportdataclassfromtypingimportList,DictdataclassclassChunk:chunk_id:strdoc_id:strparent_id:str|Nonetext:strmeta:DictdataclassclassRetrievalResult:query_id:strstrategy:strtopk_chunks:List[str]hit_at_3:inthit_at_5:intevidence_completeness:strinput_tokens:int四、四种分块策略的实现思路本文重点对比四类常见方案都是我实测过的。方案 A固定长度分块最省事通常按字符数或 token 数切分。deffixed_chunk(text:str,size:int500,overlap:int50):chunks[]start0whilestartlen(text):endmin(len(text),startsize)chunks.append(text[start:end])ifendlen(text):breakstartend-overlapreturnchunks优点是简单、稳定、吞吐高。缺点也直接句子和段落边界经常被切断。适合什么场景结构松散、文本短、FAQ 为主的知识库可以先拿它做 baseline。复杂制度文档、技术手册、法务条款用这个常常不够。方案 B语义分段核心思路是按自然段、标题层级、列表项、表格边界切尽量不破坏语义单元。实现上我一般分两步先做文档结构解析再把连续短段合并到目标长度附近importredefsplit_by_structure(text:str):sectionsre.split(r\n(?#{0,3}\s*[^\n])|\n{2,},text)return[s.strip()forsinsectionsifs.strip()]defsemantic_chunk(text:str,target_size:int500):unitssplit_by_structure(text)chunks[]buf[]cur_len0forunitinunits:unit_lenlen(unit)ifcur_lenunit_lentarget_sizeornotbuf:buf.append(unit)cur_lenunit_lenelse:chunks.append(\n\n.join(buf))buf[unit]cur_lenunit_lenifbuf:chunks.append(\n\n.join(buf))returnchunks这个方案的关键不是“语义”两个字而是你能否从原始文档里拿到结构信息。PDF 转文本如果丢了标题层级效果会打折。这里有个小缺点脏文档需要先清洗不然列表、换行、页眉页脚会把分段搞乱。方案 C重叠窗口语义分段解决了“切坏句子”重叠窗口解决的是“信息跨块分布”。比如规则说明跨两个段落一个块只拿到前提另一个块才有例外条件。这时加 overlap 往往有用。实现并不复杂难的是重叠多少合适。defadd_overlap(chunks,overlap_size100):results[]fori,chunkinenumerate(chunks):prefixsuffixifi0:prefixchunks[i-1][-overlap_size:]ifilen(chunks)-1:suffixchunks[i1][:overlap_size]mergedprefix\nchunk\nsuffix results.append(merged.strip())returnresults经验值我给一个起点FAQ/短规则overlap 20~50 tokens说明文档overlap 50~120 tokens条款/手册overlap 80~150 tokens别太大。太大以后相邻块相似度会很高召回结果容易被近重复块占满反而挤掉别的相关证据。方案 D父子块索引这是我在线上更常用的一种做法。思路很直接子块用于检索短一些提升匹配精度父块用于生成长一些保证证据完整也就是“先用细粒度找位置再回填大块上下文”。一个简单结构如下fromuuidimportuuid4defbuild_parent_child_chunks(document,parent_size1200,child_size300):parentsfixed_chunk(document,sizeparent_size,overlap100)all_parents[]all_children[]forparent_textinparents:parent_idfp_{uuid4().hex[:8]}all_parents.append({chunk_id:parent_id,text:parent_text,level:parent})childrenfixed_chunk(parent_text,sizechild_size,overlap50)forchild_textinchildren:all_children.append({chunk_id:fc_{uuid4().hex[:8]},parent_id:parent_id,text:child_text,level:child})returnall_parents,all_children检索时流程一般是在子块索引中召回 TopN按parent_id聚合去重回表取父块内容rerank 父块或父块内证据片段拼到生成上下文这一招对长文档很好用。短句命中更稳给模型看的上下文也不至于碎。五、离线对照实验怎么做我这里给一套可复跑的实验设计避免“感觉好像更准”。1实验变量固定模型和检索流程只改这些实验组分块方式chunk sizeoverlap检索粒度生成粒度A1固定长度5000chunkchunkA2固定长度50080chunkchunkB1语义分段目标 5000chunkchunkB2语义分段目标 50080chunkchunkC1父子块parent 1200 / child 30050childparent2样本分桶别只看总均值要按 query 类型拆开。我常见的桶有单跳事实问答多条件规则问答跨段落汇总问答长文档定位问答分桶以后问题会清楚很多。比如固定长度对单跳 FAQ 可能已经够用但跨段落规则问答通常会掉得明显。3评测脚本下面这个示例演示如何判断 gold spans 是否命中defcompute_hit(retrieved_texts,gold_spans,topk3):merged\n.join(retrieved_texts[:topk])forspaningold_spans:ifspaninmerged:return1return0defcompleteness_label(retrieved_texts,gold_spans,topk3):merged\n.join(retrieved_texts[:topk])matchedsum(1forspaningold_spansifspaninmerged)ifmatchedlen(gold_spans):returnfullifmatched0:returnpartialreturnnoise真实项目里我会把字符串精确匹配换成 span 对齐或人工复核但这个版本已经能把大趋势看出来。4结果落表建议每次实验都输出 CSV字段尽量固定importcsvdefsave_results(rows,path):withopen(path,w,newline,encodingutf-8)asf:writercsv.DictWriter(f,fieldnamesrows[0].keys())writer.writeheader()writer.writerows(rows)后面做回归对比、线上回溯、异常样本抽查都会轻松很多。六、一组实测结果下面这组数据来自一个中文帮助中心知识库文档 860 篇评测 query 1200 条。embedding、rerank、生成模型都固定只改分块策略。总体结果策略Hit3Hit5full 比例平均输入 tokens平均检索耗时固定长度 500 / 无重叠71.8%79.6%54.2%148092ms固定长度 500 / overlap 8075.1%82.7%58.9%166597ms语义分段 / 无重叠78.6%85.2%63.5%143895ms语义分段 / overlap 8080.4%87.1%68.1%1609101ms父子块索引83.9%89.4%74.6%1718109ms这个结果很有代表性。单看 Hit5差距没大到夸张可一旦看full 比例父子块索引就拉开了。原因不难理解子块召回更准父块补足上下文模型拿到的是完整证据不是碎片。分桶结果query 类型固定长度 500语义分段父子块索引单跳事实问答 Hit386.2%87.5%88.1%多条件规则问答 Hit364.7%72.8%79.6%跨段落汇总问答 Hit359.3%70.4%78.8%长文档定位问答 Hit361.8%69.9%81.2%差异主要集中在后面几类。FAQ 类简单问题提升有限制度规则和长文档提升更明显。没想到的是语义分段在“跨段落汇总问答”这一桶里已经能明显拉开固定长度。说明很多问题在检索层就输了不一定非得靠更强生成模型补回来。七、线上方案怎么落离线实验跑通后我通常不会直接全量切换而是按下面这套方式上线。1索引版本化每种分块策略都独立产出索引版本kb_v202604_fixed500kb_v202604_semantic500kb_v202604_parent_child请求日志里带上index_version后面才能按版本回溯。2灰度开关按 query、租户、业务线或流量比例做灰度。比如先放 10%只替换检索索引不改生成 prompt。简单点说变量少一点定位快很多。3日志埋点我建议至少记录这些字段{request_id:r_9981,query:退款后优惠券是否返还,index_version:kb_v202604_parent_child,retrieved_chunk_ids:[c_1,c_9,c_2],retrieved_parent_ids:[p_3,p_1],rerank_scores:[0.91,0.84],prompt_tokens:1732,answer:...,feedback:thumb_up}缺了这些线上出问题时几乎没法查。4线上指标常见指标我会分成两组效果侧用户点赞率人工复核正确率追问率未解决会话占比成本侧检索 P95 延迟Prompt token 均值单请求成本向量库容量增长率如果只看点赞率容易被表述风格干扰加上追问率和人工复核判断会稳一点。八、一个可复现的线上回溯方法很多团队离线效果不错线上体感却一般问题通常出在样本分布变了或者线上 query 比离线更短、更脏。我会定期做一轮“线上失败样本回放”1筛失败样本从日志里捞这些请求用户点踩命中后仍追问人工判定错误低 rerank 分但最终输出自信答案2回放到不同索引版本同一条 query分别在旧索引和新索引上重跑比较是否命中 gold 文档是否命中完整证据输入 token 是否异常上涨最终答案是否改善3做归因标签我常用这几个标签split_breaks_evidence证据被切断overlap_insufficient跨块上下文不够chunk_too_large噪声太多chunk_too_small信息太碎parent_fill_works父块回填起效归因一旦结构化后面优化就不再是拍脑袋。九、我现在的默认策略如果没有太强先验我会按文档类型选默认分块方案而不是整库一个参数打穿。FAQ、短问答库固定长度或轻量语义分段chunk 300~500overlap 20~50帮助中心、操作手册语义分段优先目标 chunk 400~700overlap 50~100条款规则、制度说明、长文档父子块索引优先child 200~400parent 800~1500child overlap 30~80表格、枚举项很多的文档先做结构解析再分块。别直接拿 OCR 文本硬切效果通常很差。我自己踩过坑同一份商品运营规则OCR 出来的表格被打平成连续文本召回看似命中答案却总漏限制条件。后来把“表头 行内容”重组后再切块Hit3 直接提了 9 个点。十、几个容易忽略的工程细节1chunk 不要只存正文把标题路径一并写进去检索质量会稳很多。比如一级标题退款说明 二级标题优惠券处理规则 正文退款成功后平台券在有效期内可返还...短 query 很依赖这类结构提示。2去重别只看 chunk_id重叠窗口和父子块都会引入高相似文本。召回后最好做文本相似去重不然 TopK 会被近重复块占掉。3评测集里要有人为构造的“边界样本”比如关键结论在段尾限制条件在下一段开头表格说明和脚注分开标题里有概念正文里有条件这类样本对分块策略特别敏感拿来做回归测试很有效。4别把 chunk_size 当唯一旋钮有时候不是大小问题而是“是否保留文档结构”和“检索粒度是否与生成粒度分离”。父子块索引之所以常常更稳核心就在这里。十一、结论如果你的 RAG 已经接入了还不错的 embedding 和 rerank但答案依旧经常“像是知道一点又差半口气”优先排查分块。结合这次实测我的结论很明确固定长度分块适合做 baseline上手快但对跨段落规则类问题不稳语义分段在大多数帮助中心场景里已经能拿到更好的命中率和完整度重叠窗口能补一部分跨块信息但数值不能随便拉大父子块索引在线上更实用尤其适合长文档、制度规则、技术手册真要落地我建议顺序是先搭统一评测框架再跑离线对照最后做灰度和线上回溯。这样每一步都能复现每个提升都能解释。文章里的代码都做了最小化处理方便你快速改成自己的版本。你如果正在做 RAG 分块优化可以直接把评测表、日志字段和父子块方案先接上再看效果。