RAG 项目做到第二个版本十有八九会撞上同一堵墙模型换了两轮提示词改了十几版检索出来的东西还是“擦边”——问的是某条规定的例外情形召回的是这条规定的适用范围问的是某个参数的默认值召回的是这个参数的取值范围。很多人第一反应是嵌入模型不行、向量库不行、大模型不够聪明于是开始换模型、加 rerank、上混合检索钱和时间都砸下去效果提升却像挤牙膏。我处理过几轮这类问题结论比较扫兴大部分“检索不准”其实在文档切分那一步就注定了。切分是整条 RAG 链路里唯一一个“一错到底”的环节——上游切坏了下游的嵌入、索引、召回、重排、生成全都在为这个错误买单而且每一层都看起来“工作正常”你很难从日志里直接看出病灶。这也是为什么这个环节最容易被跳过它不像模型选型那样有明确的对比表也不像提示词那样改一行就能看到变化它更像打地基塌的时候已经是三楼了。这篇内容想解决的就是这件事Chunk Size 到底怎么定、Overlap 到底要不要给、语义切分是刚需还是噱头、以及怎么用一套低成本、可复现的方法把这些参数实测出来而不是拍脑袋。不管你是刚接手一个知识库问答项目还是已经在调优阶段卡住下面这些内容应该都能直接拿去用。前面会讲清机制和取舍逻辑中间给可落地的切分实现路径后面是一整套评测流程和工程细节最后分享几个我自己踩过、且网上资料里很少提的坑。1. 切分为什么是 RAG 里唯一“一错到底”的环节1.1 解析错误还能人工兜底切分错误连兜底的机会都没有先说清楚一件事文档进入 RAG 之前其实有两道关口第一道是解析PDF 转文本、表格还原、标题层级识别第二道才是切分。解析出问题通常比较显眼——乱码、段落粘连、表格变成一堆数字跑几篇文档肉眼就能发现实在不行还能针对某类文档写定制解析规则或者干脆人工校对一批。切分不一样。它不改变文本内容一个 500 页的手册切完之后字符总数几乎不变你抽样看几个块每一块都是通顺的、有意义的、语法完整的。问题藏在“边界”里某个块讲完了“适用范围”下一个块才开始讲“例外情形”而用户的问题恰好落在两者之间。这种缺陷在你的原文里根本不存在是切分“制造”出来的。它不会被解析质检发现也不会被嵌入模型报错它只会安静地降低检索质量然后你在评估报表上看到一句“准确率 61%”完全不知道从哪下手。我见过一个团队花了三周时间在这上面绕圈先怀疑嵌入模型换了一个更大的再怀疑向量库从本地部署换到托管服务最后加了一层重排模型。三轮下来准确率从 58% 涨到 67%大家都觉得“方向对了”但实际上他们只是用重排把“已经召回到但排得靠后”的那部分样本救回来了。真正没被召回的那 33%无论怎么重排都救不了因为目标块压根没进候选集。1.2 三种典型症状对应三种不同的切分病灶在实际排查里我习惯先看“症状”反推切分问题这比直接读切分代码快得多。大致能归成三类每类的病灶位置完全不同。症状表现典型回答形态大概率病灶优先验证动作答非所问但召回内容确实相关回答笼统、只讲通用规则块太小缺少完整语义单元把命中块前后各扩 1 块看答案是否补齐答案“差一点”关键条件缺失回答正确一半漏掉前提块边界切断了条件与结论定位原文看条件句与结论句是否跨块召回噪声大多个块互相矛盾回答自相矛盾或含糊Overlap 过大或结构切分重复统计同一文档块在 top-k 中的占比明明文档里有就是召不回回答“未找到相关信息”嵌入被静默截断或标题信息丢失单独把原文整段送去嵌入看能否召回第三行那个“top-k 里同一文档块占比过高”是个很实用的信号。正常情况下 top-5 应该来自不同章节如果经常出现同一小节的三四个相邻块霸榜基本可以确定 Overlap 给多了或者切分粒度太碎导致信息密度过低向量之间高度相似挤掉了其他候选。第四行是我认为最隐蔽、也最容易被忽略的一类嵌入模型的输入长度限制导致静默截断。很多常用的句向量模型最大输入只有 512 token你把 chunk size 设成 800 token 甚至 1000 token嵌入接口不会报错它只会默默把超出的部分丢掉。于是你的索引里存着 1000 token 的原文向量却只表达了前 512 token 的语义。结果就是一个块的后半段内容相当于“没被索引”你能检索到的永远只有前半段的意思。这个坑我踩过一次排查了整整两天因为从任何日志里都看不出异常。1.3 把切分问题当提示词问题修是成本最高的一条路这里有个很现实的经济账。一次切分策略调整成本是“重新嵌入 重建索引”如果文档量在几千页量级用本地模型嵌入可能几十分钟就跑完而一次提示词优化成本是你自己、可能还有同事的时间以及反复评测的模型调用费用。看起来提示词更便宜所以大家的默认反应是先去改提示词。但提示词优化的天花板由检索质量决定。检索给的上下文里如果没有答案再强的模型也只能编或者拒答上下文里有答案但被切碎了模型最多能“拼”出一半。所以正确的顺序应该是先确认“答案确实被召回到了候选集里”再谈提示词和重排。验证方法很土但很有效——把标准答案对应的原文段落人工贴进上下文看模型能不能答对。如果能答对说明生成环节没问题问题一定在检索如果答不对那才是提示词或模型的事。这一步能帮你省掉大量无效的提示词迭代。2. Chunk Size 的真实权衡不是“越小越准”2.1 嵌入模型的输入上限是一道硬墙必须先算再定定 chunk size 的第一步不是想“多长比较合理”而是先确认技术约束。你需要三个数字嵌入模型的最大输入 token 数、向量库单条记录的长度限制、以及生成模型能接受的上下文预算。这三个里最小的那个是 chunk size 的绝对上限。这里有个容易被忽略的细节token 和字符不是一回事。中文场景下一个汉字大约对应 0.6~1 个 token取决于分词器一个英文单词大约 1.3 个 token而代码因为符号多token 密度更高。所以“500 字”不等于“500 token”。我的做法是先写个小脚本对文档抽样 20 段用实际的分词器统计 token/字符比。# 抽样统计 token 与字符的比例用于后续按字符估算 chunk size from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(your-embedding-model-name) samples load_sample_paragraphs(manual.pdf, n20) ratios [] for s in samples: n_token len(tokenizer.encode(s, add_special_tokensFalse)) ratios.append(n_token / max(len(s), 1)) print(f平均 token/字符 {sum(ratios)/len(ratios):.3f}) print(f最大值 {max(ratios):.3f}) # 用最大值做保守估算如果统计出来是 0.75而你希望块落在 400 token 以内那按字符切分时目标就应该是 400 / 0.75 ≈ 530 字符而且要用最大值 0.9 再算一次作为下限保护。别小看这个换算我见过直接把“512 字符”当成“512 token”用的实际跑出来块普遍在 350 token 左右白白浪费了嵌入模型的容量信息密度上不去。还有一个更隐蔽的约束很多模型不是“超长就截断”而是“超长就报错”但报错被你的批量处理脚本吞掉了。如果你的嵌入流程里有 try/except 并且 except 里只是 continue那么超长块会被静默跳过索引里根本没有它。这个一定要在日志里打出明确的超长告警我一般会在切分后直接加一道断言任何超过模型上限 90% 的块都打印出来。2.2 块小提升定位精度但会摧毁“可回答性”为什么大家本能地觉得“块越小越准”因为在小块上向量表达的主题更聚焦相似度计算的区分度更高检索的“定位精度”确实会提升。一百字的块只讲一件事向量自然干净一千字的块可能讲了五件事向量是五件事的平均跟任何一个具体问题的相似度都不算高。但检索的最终目的不是“定位到相关段落”而是“让模型能答出问题”。这两件事在小块上会打架。举个具体的某份技术文档里有一句“当环境温度低于 5℃ 时需将预热时间延长至 30 分钟”。如果按 200 字切这句话可能落在块 A 的末尾而前面“适用于户外设备”这个前提落在块 B。用户问“户外设备冬天启动要多久”你召回了块 A“30 分钟”在里面模型答得出来但如果用户问“低温启动需要额外做什么”块 A 只有结论没有条件模型很可能答成“延长预热时间”却漏掉“仅限户外设备”然后你得到一条“看起来对但有风险”的答案。这就是我说的可回答性一个块单独拿出来能不能支撑一个完整、无歧义的回答。这个性质跟块大小不是线性关系它取决于文档本身的“语义单元”结构。所以真正合理的做法不是盯着 token 数而是先花半天时间读一遍目标文档找出它的“最小完整论述单元”大概多长——有的 FAQ 一条就是 80 字有的规章制度一个条款带例外说明能到 600 字。2.3 top-k 与 chunk size 的乘积才是你真正喂给模型的上下文预算这个换算很多人不做但它直接决定你的成本和质量。假设你的生成模型上下文窗口足够大但你把 top-k 设成 8、chunk size 设成 1024 token那你每次请求要塞进去 8000 多 token 的检索内容其中大部分是噪声而且模型的注意力会被稀释——这是实打实的“更多不等于更好”。反过来chunk 128 token、top-20总预算 2560 token看起来省但 20 个碎块里有 15 个是不相关的同样稀释注意力还多花了 20 次向量检索。我一般会用这张表来对齐参数先把“有效上下文预算”定下来比如 2000~3000 token 是很多场景的甜点区再倒推组合chunk size (token)top-k检索上下文预算适用情形主要风险12820~2560FAQ、术语表、字段说明上下文碎片化模型难拼完整逻辑2568~2048大多数结构化文档的默认起点长条款被切断5125~2560手册、制度、说明书单块内多主题向量被平均10243~3072长篇论述、会议纪要、报告定位精度下降需配合 rerank我的经验起点是 256~512 token、top-5 到 top-8然后在这个区间内做小步调整。超过 1024 token 的块除非你的文档本身就是长篇连续论述否则基本都会遇到“向量被平均”的问题——这也是为什么很多团队用“小块检索 大块生成”后面会讲来绕开这个矛盾。2.4 按文档类型给经验区间比追求一个万能参数更实际我特别不建议在整个知识库里用同一个 chunk size。真实项目里往往混着产品手册、FAQ、合同条款、会议纪要、代码文档它们的语义结构差异极大。下面这张表是我实际用下来比较稳的初始值可以直接当起点然后再做小范围调优。文档类型建议 chunk size建议 overlap切分依据说明FAQ / 问答对100~200 token0一问一答为单位一条问答天然是一个语义单元别硬切产品手册 / 操作指引300~500 token10%按步骤、按小节步骤之间强依赖overlap 要保住上下文制度 / 合同条款400~600 token5%~10%按条、按款条款号必须随块保留否则无法引用技术报告 / 论文500~800 token15%按段落、按小节论述链长块太小会失去论证完整性会议纪要300~400 token10%按议题、按发言人段落议题边界比字数边界更重要代码 / API 文档按函数或类切0语法结构绝对不能按字符切会切碎函数体表格密集型文档整表或按行组0表格结构单元格被切开等于信息全丢表里最需要注意的是“表格密集型”那一行。表格是切分的重灾区一个 20 行的参数对照表如果按 500 字硬切会变成上半张表和下半张表两个块每个块都缺列头向量里全是孤立的数值。这种块召回去之后模型根本不知道数字对应哪个参数。处理方式要么整表保留哪怕超过目标大小要么把每一行改写成“参数名值取值范围……默认值……”这样的自然语言句子彻底放弃表格形态。3. Overlap 的边际收益与代价什么时候它真的有用3.1 Overlap 真正解决的是“边界截断”不是“语义补全”关于 overlap网上流传最广的说法是“让相邻块有重叠避免语义被切断”。这个说法对但太模糊容易让人以为 overlap 越大越安全。事实上 overlap 的作用范围非常有限它只能保证“一个跨越边界的句子在两个块里都完整出现”它没法保证“一个跨越多个块的论证链”被完整保留。举个能说明边界的例子。原文是“设备支持三种工作模式。模式 A 适用于室内功耗低。模式 B 适用于室外需防水。模式 C 为待机模式功耗最低。”如果按每块两句话切块 1 是“设备支持三种模式 模式 A”块 2 是“模式 B 模式 C”。用户问“室外用哪个模式”块 2 能答用户问“三种模式分别适用什么场景”任何一个块都答不全。这时候加 overlap 只是让块 2 多带上“模式 A”那半句依然答不全因为完整信息本来就需要三个块。所以 overlap 的定位应该明确成防止单句被腰斩。它对付的是那些“条件在前一句、结论在后一句”的紧邻结构。如果你的文档里这类结构很多制度、合同、技术规范都是给 10%~15% 的 overlap 是划算的。但如果你的文档是 FAQ 或者每条独立的条目式内容overlap 基本是纯浪费。3.2 10%~20% 是常见拐点超过之后开始反噬我给 overlap 的默认值是 chunk size 的 10%~15%这个区间覆盖了绝大多数“句子被切断”的情况。之所以不往高了给是因为 overlap 有三个不太直观的代价。第一个代价是索引膨胀。overlap 20% 意味着文本总量增加 20%嵌入成本、存储成本、检索耗时都按比例上升。文档量小的时候无所谓几万页文档下这个成本很实在。第二个代价更隐蔽是召回冗余。重叠的块之间向量相似度天然很高它们会成簇地出现在 top-k 里。假设 top-5 里有 3 个是同一段内容的连续切片你等于只召回了 3 个有效信息源另外 2 个名额浪费了。这个现象在 overlap 超过 30% 时会非常明显表现出来就是“召回的东西翻来覆去都是那一段”。第三个代价是生成端的重复信息。模型看到三个内容高度重叠的块有时会误以为是不同来源在重复强调某件事从而放大某个细节的权重有时则会在回答里出现重复表述。这个我没法给出量化结论但在实际输出里确实观察到过。下面这组数据是我在一个中等规模手册文档集大约 800 页上跑出来的对比用的是同一套 40 条金标问答指标定义后面第 5 节会详细讲这里只看趋势Overlap 比例索引文本量Hit5MRR答案正确率平均检索耗时01.00x0.710.630.581.00x10%1.11x0.820.720.711.07x20%1.25x0.840.730.721.16x30%1.43x0.830.700.691.28x50%2.00x0.800.650.641.45x从 0 到 10% 是明显的跃升Hit5 从 0.71 到 0.8210% 到 20% 收益已经很小20% 之后开始回落。这个形状我在几个不同项目里都见过拐点位置略有差异但“10%~20% 是甜点区、30% 以上开始亏”这个判断基本稳定。当然这是针对手册类文档如果你的文档句子特别长、一段就是一个完整论述那 overlap 的合理值会更高一些。3.3 结构切分叠加 Overlap会制造三种重复污染这是我觉得最值得单独说的一点如果切分本身是按结构做的按标题、按条款、按段落再叠加 overlap很可能产生语义上的重复块。原因是结构切分的边界本来就是语义边界你在一个语义边界上再做字节级重叠等于把上一节的结尾硬塞进下一节的开头。具体会出现三种情况。第一种是标题污染按 H2 切分时块头通常是“## 3.2 参数配置”如果加 overlap下一个块的开头可能变成“……如上所述参数配置分为三类。## 3.3 默认值”这个块的主题就变得模糊了。第二种是前后矛盾文档里前一条规定“默认开启”后一条修订为“默认关闭”。overlap 让这两句话出现在同一个块里模型看到互相矛盾的表述输出就会含糊其辞。这种情况在版本更新频繁的文档里很常见。第三种是列表项粘连一个 10 项的操作清单如果每 300 字切一次并加 overlap会出现“第 7、8 项在块 A 尾部第 8、9 项在块 B 开头”的情况检索时同时召回 A 和 B模型看到第 8 项出现两次会误以为它是重点。我的处理原则是结构切分不加 overlap固定窗口切分加 10%~15% overlap。这两条策略不要混着用。如果你确实担心结构切分后某段太长导致信息被稀释那应该做的是“二次切分”——先按结构切出 800 token 的大块作为父块再在父块内按句子切成 200 token 的子块检索用子块、生成用父块。这是第 4 节要讲的方案它比加 overlap 有效得多。4. 语义切分的三条实现路径与各自的翻车点4.1 相邻句向量相似度断点法原理简单判据难定语义切分最流行的实现是把文档拆成句子逐句算向量相邻两句的相似度低于阈值就在中间断开。逻辑很直白——相似度高说明还在讲同一件事相似度骤降说明话题变了。实际跑起来第一道坎是阈值怎么定。余弦相似度没有一个跨文档通用的绝对阈值同样的 0.6在技术文档里可能表示“相关”在小说里可能表示“完全无关”。所以我更推荐用相对判据先算出全部相邻相似度取分布的某个百分位作为断点阈值。import numpy as np def semantic_split(sentences, embed_fn, percentile15, max_chunk_tokens400): 基于相邻句相似度的百分位断点做语义切分 vecs embed_fn(sentences) # 一次批量嵌入避免逐句调用 sims np.array([ np.dot(vecs[i], vecs[i1]) / (np.linalg.norm(vecs[i]) * np.linalg.norm(vecs[i1]) 1e-8) for i in range(len(vecs) - 1) ]) threshold np.percentile(sims, percentile) # 最低的 15% 视为话题转折 chunks, buf [], [sentences[0]] for i, sim in enumerate(sims): is_break sim threshold is_overflow count_tokens(buf) max_chunk_tokens if is_break or is_overflow: chunks.append(.join(buf)) buf [sentences[i1]] else: buf.append(sentences[i1]) chunks.append(.join(buf)) return chunks这里有个必须加的保护max_chunk_tokens上限。纯靠相似度断点遇到通篇平铺直叙的文档比如一个很长的操作步骤列表相邻句子相似度都很高你可能切出一个 3000 token 的巨块。加一道硬上限能兜住这种极端情况。第二道坎是句向量模型和检索模型不是同一个。断点用的是句子级嵌入模型检索用的是文档级嵌入模型两者的语义空间不一致这意味着“切得好”和“检索得好”是两个独立目标。我实测下来用同一个模型做断点和检索会稍好一些但也不能保证对齐。所以语义切分的效果必须用端到端指标验证不能只看切出来的块“读起来顺不顺”。第三道坎是短句噪音。目录页、图表标题、页眉页脚这些短句向量本身就不稳定跟任何句子的相似度都偏低会制造大量假断点。我的做法是先把长度小于 8 个字符的句子合并到相邻句并且在做断点检测前剔除掉页眉页脚模式的文本。4.2 结构标记切分往往比向量方法更稳更便宜说个可能不太受欢迎的观点如果你的文档有良好的结构标记标题层级、编号、条款、列表那么基于规则的切分在绝大多数情况下比语义切分更可靠而且成本几乎为零。语义切分适合的是那些“一大段平铺文字、没有明显结构”的文档比如访谈记录、转录稿、小说。结构切分的实现很简单先解析出标题树然后按标题层级递归切分超长的节点再降级到下一层。import re HEADING_PATTERN re.compile(r^(#{1,6})\s(.*)$) def split_by_heading(md_text, max_tokens500): 按 Markdown 标题层级切分超长节点继续下钻 lines md_text.splitlines() sections, cur_title, cur_body [], , [] def flush(): if not cur_body: return body \n.join(cur_body).strip() if not body: return if count_tokens(body) max_tokens: # 超长小节按空行分段再按句子兜底 for para in re.split(r\n\s*\n, body): if count_tokens(para) max_tokens: sections.append((cur_title, para)) else: sections.extend(split_by_sentence(para, max_tokens, cur_title)) else: sections.append((cur_title, body)) for line in lines: m HEADING_PATTERN.match(line) if m: flush() cur_title, cur_body m.group(2).strip(), [] else: cur_body.append(line) flush() return sections这段代码的关键不是切分本身而是cur_title被保留了下来。标题路径是切分时最容易被丢掉、又极其值钱的信息。一个块的内容是“预热时间延长至 30 分钟”如果不知道它属于“户外设备 低温启动”这一节这个块几乎是不可检索的因为“30 分钟”这个词在任何文档里都可能出现。把标题路径拼到块的开头比如“户外设备 低温启动 预热时间延长至 30 分钟”检索命中率会有肉眼可见的提升。这一点后面第 6 节还会展开。结构切分的翻车点主要在解析质量上PDF 转出来的标题可能带页码、带乱码编号可能是“3.2.1”也可能是“三、二1.”需要针对文档来源写规则。我的建议是宁可多写几条正则也不要在解析不干净的文本上跑语义切分因为脏标题会让向量断点判据彻底失效。4.3 递归字符切分的中文分隔符优先级是个细节活固定长度切分里递归字符切分按分隔符列表依次尝试是性价比最高的方案它不需要模型、速度快、结果稳定。但默认的分隔符列表通常是给英文设计的[\n\n, \n, , ]直接用在中文上会把句子从中间切开——中文句子之间没有空格最后会退化到按字符硬切。中文场景的分隔符优先级我一般这样排SEPARATORS [ \n\n, # 段落 \n, # 换行列表项、条款 。, , , , # 句末标点 , 、, # 句内停顿 , # 最后兜底 ]这里有个细节把句号、问号、感叹号放在同一优先级切的时候要保留标点在块尾而不是把标点丢到下一个块开头。否则下一个块会以“。”开头向量质量会受影响。实现上就是切分后做一次“标点回粘”。另一个细节是别在数字和英文单词中间切。像“版本 3.2.1”“API 调用超时 30s”这类内容如果在“3.”或者“30”后面断开块里就剩个“2.1”和“s”完全是垃圾。我一般会在切分后做一个后处理检测块尾是否是“数字点”或块首是否是纯数字片段如果是就把它和下一个块合并。4.4 小块检索配父块生成绕开“定位精度”和“回答完整性”的矛盾前面第 2 节讲的矛盾——块小定位准但答不全块大答得全但定位差——有一个很实用的解法用小块做检索用大块做生成。具体做法是维护两级块子块 150~250 token 用于嵌入和召回父块 600~1000 token 用于喂给模型。子块上存一个parent_id命中子块后回溯取父块内容。# 索引结构示意 { child_id: doc7_c012, parent_id: doc7_p004, text: 当环境温度低于 5℃ 时需将预热时间延长至 30 分钟。, embedding: [...], metadata: {title_path: 户外设备 低温启动, page: 42} }这个方案的收益很直接检索阶段子块语义聚焦MRR 通常比大块高生成阶段父块信息完整答案正确率上来了。代价是索引体积变大子块数量是父块的 3~5 倍以及需要多一次父块查询。在几千到几万块的规模下这个代价完全可以接受。有个坑要提醒父块要按“一个完整的语义单元”来定而不是简单地把 4 个子块拼起来。如果父块就是机械地把相邻子块连起来那你可能把两节内容拼成一个父块反而引入噪声。正确做法是父子两级都基于同一套结构切分父块对应一个完整小节子块是小节内的句子组。5. 搭一套可复现的切分评测流程5.1 先造 30~50 条金标问答答案必须能锚定到原文没有评测的调参就是玄学。但我也理解很多项目没条件做完整的人工标注。折中方案是从文档里反向造题。挑 30~50 个段落每个段落人工写 1 个问题问题的答案必须能在这一段里找到原文句子。这个过程一个熟悉文档的人大概两三个小时能完成投入产出比极高。关键在“锚定”二字。每条样本要记录三样东西问题、标准答案可以是原文句子、答案所在的原文片段用字符位置或文本片段标识。有了“原文片段”你就能在切分后自动判断“哪个块包含了答案”从而计算检索命中完全不需要人工判断。举个样本的样子{ q: 户外设备在低温环境下启动需要额外注意什么, a: 需将预热时间延长至 30 分钟, anchor: 当环境温度低于 5℃ 时需将预热时间延长至 30 分钟, doc: manual_v3, type: 条件-结论型 }样本要覆盖不同类型的问法事实型某参数默认值是多少、条件型什么情况下会触发某行为、对比型A 和 B 有什么区别、多跳型需要两处内容才能回答。其中条件型和多跳型最能暴露切分问题所以我一般会让它们占到一半以上。5.2 三层指标先看召没召到再看排得对不对最后看答得好不好评测指标我分三层每层回答一个不同的问题混在一起看会误判。层级指标回答什么问题达标参考检索层Hitk答案块有没有进候选集k5 时 ≥ 0.85排序层MRR / nDCGk答案块排得够不够靠前MRR ≥ 0.70生成层答案正确率最终输出对不对≥ 0.75人工或模型评判这三层要按顺序看。如果 Hit5 就很低那排序和生成都不用看了直接回去改切分或嵌入。如果 Hit5 高但 MRR 低说明答案块召回到了但排在后面这时候加 rerank 或者调整 top-k 是有意义的。如果前两层都高但答案正确率低那问题在生成侧跟切分关系不大了。我特别想强调 Hitk 这个指标的价值它把“切分问题”和“排序问题”彻底分开了。很多团队一上来就优化排序但如果答案块压根没进候选集排序优化是零收益。先看 Hitk能帮你省下大量无效工作。5.3 跑批的组织方式先粗筛再精调别一上来就跑全网格参数空间看着不大但组合起来很吓人4 种 chunk size × 3 种 overlap × 4 种切分策略 48 组每组都要重新嵌入全量文档如果文档量大成本会很可观。我的做法是分两阶段。第一阶段粗筛只取 50~100 篇有代表性的文档覆盖所有文档类型chunk size 只跑 {192, 384, 768}overlap 只跑 {0, 15%}策略只跑 {固定递归, 结构切分}一共 12 组。这一阶段的目标不是找到最优解而是快速排除明显差的组合。实测下来12 组跑完通常能砍掉一半。第二阶段精调在粗筛胜出的 2~3 个组合附近做细粒度扫描比如 chunk size 在 256~512 之间步长 64overlap 在 5%~20% 之间步长 5%。这一阶段文档用全量指标加测 MRR 和答案正确率。跑批时有个工程细节要注意固定随机种子、固定 top-k、固定嵌入模型版本。我见过一次因为嵌入模型悄悄升级了小版本导致前后两批结果不可比白跑了两天。还有一点评测要跑两遍看波动尤其是答案正确率这种依赖模型生成的指标单次结果的偶然性不小两遍差异超过 5 个百分点就说明样本量不够或者评测方式不稳定。5.4 结果解读什么时候该停手换策略而不是继续调参拿到一堆数字之后最容易犯的错是盯着最高分那组死磕继续微调参数。我的经验是设三条止损线。第一条如果最优组合的 Hit5 仍然低于 0.8别再调参数了问题大概率在文档解析或者嵌入模型不匹配上。把出错的样本捞出来看原文很多时候你会发现原文里那段话在解析阶段就已经坏了。第二条如果不同 chunk size 之间的指标差异小于 3 个百分点说明这是一个平台区参数已经不敏感了继续调是浪费时间应该转向别的优化方向比如加 rerank、加元数据过滤、改混合检索。第三条如果答案正确率明显低于 Hit5 对应的预期比如 Hit5 是 0.9 但正确率只有 0.6先检查上下文是否被切碎。具体做法是把参考块人工拼成完整段落再跑一遍如果正确率一下子上到 0.85那答案很明确——上“小块检索 父块生成”。这套流程我自己跑过几轮最大的价值不是找到某个“最佳参数”而是把讨论从“我觉得应该切成 500 字”变成“数据说 384 字配 15% overlap 在这个文档集上 Hit5 高 6 个点”。团队里再有人提“要不要换个模型”你可以直接甩指标表。6. 工程落地里几个容易被忽略的细节6.1 表格、代码、公式这三类内容必须走特殊通道前面提过表格不能按字符切这里展开说一下具体处理。表格的难点在于列头是隐式上下文一个单元格脱离列头就没有意义。我的做法分两种小表格行数 ≤ 15整表保留并且把表头拼成一段描述放在前面大表格按行分组每组强制带上表头。def table_row_to_text(headers, row): 把表格行转成自然语言便于嵌入和生成 pairs [f{h}{v} for h, v in zip(headers, row) if v.strip()] return .join(pairs)转成“参数名值取值范围……默认值……”这样的句子之后检索命中率会明显好于原始的竖线表格。代价是 token 数变多所以只对参数对照类表格这么做纯展示类表格比如图片说明表可以整表保留。代码的处理原则是按语法结构切函数、类、方法作为最小单位并且每个块都要带上文件顶部的 import 和模块说明。这一点在 API 文档场景特别重要——一个函数块如果没有 import模型生成调用示例时经常瞎编包名。公式的处理类似公式本身要保留同时把上下文里的符号说明带上否则召回一个孤立的公式块毫无用处。6.2 标题路径注入一个改动小、收益大的动作这个前面提过单独再说一下实现细节。标题路径注入有几种做法效果和成本不一样。第一种是只用于嵌入把“标题路径 正文”送去算向量但返回给模型的是纯正文。这样不增加生成端的 token 消耗又能让向量带上层级语义。这是我目前最推荐的尤其在文档层级深、专业术语重复率高的场景。第二种是嵌入和返回都带路径好处是模型能知道这段话的出处便于引用坏处是每个块多几十个 token。第三种是只存进 metadata 不做嵌入用于过滤和展示对召回没有直接帮助。实测下来第一种做法的性价比最高。在一个层级很深的规范文档集上加上标题路径之后 Hit5 提升了大概 8 个百分点原因是很多术语在不同章节里含义不同纯正文的向量区分不开加上章节路径之后就区分开了。6.3 重新切分等于重建索引迁移方案要提前想好这一点是运维层面的但踩过的人不少。切分参数一改所有块的 id、边界、数量都变了旧的向量索引基本作废必须全量重建。如果你是在线服务重建期间怎么保证可用要提前设计。我的做法是双索引 别名切换新索引写到index_v2全量构建并验证通过之后把查询别名从index_v1切到index_v2保留index_v1一段时间以便回滚。这样整个过程对调用方是透明的。还有个细节是块 id 的生成规则。如果 id 里带上了块的序号比如chunk_0001那重新切分之后同一个内容的 id 就变了所有引用这些 id 的下游数据比如用户收藏、反馈记录都会失效。更稳的做法是用“文档 id 内容哈希”作为块 id内容不变则 id 不变重新切分时只有内容真的变了的块才会换 id。另外提醒一句重新切分之后一定要重跑一遍评测。切分策略的改动对指标的影响有时候是反直觉的我遇到过一次“看起来更合理的切分”让 Hit5 掉了 5 个点原因是新切分把小节标题单独切成了很短的块这些块在向量空间里到处乱撞挤占了 top-k 名额。后来加了“短块合并到下一块”的规则才修好。最后分享一个我自己反复验证过的体会切分这件事优化顺序比优化幅度重要得多。先确认嵌入没有静默截断再确认标题信息没丢再定 chunk size 的合理区间最后才轮到语义切分这种“高级玩法”。我见过太多项目直接从语义切分开始做绕了一大圈又回到“按段落切 标题注入”效果反而更好、成本还更低。真正决定 RAG 上限的往往不是那个最花哨的切分算法而是你有没有把边界、层级和上下文这三件事老老实实地处理好。
阅读完成 · 觉得有帮助?