检索返回的单位是"块",模型消费的单位是"窗口",两者从来不是同一个东西。切分就是在这两个单位之间做分配:块小则召回准而上下文残缺,块大则语义稀而预算被单条吃光。预算算漏的表现通常不是报错,而是答案看起来对、却少了前提条件。这篇把切分与预算放进同一套账。
1. 错配从哪来:检索单位与生成单位
| 维度 | 检索单位(chunk) | 生成单位(context) |
|---|---|---|
| 粒度诉求 | 越小越准,向量表达越集中 | 越大越全,逻辑链条越完整 |
| 打分依据 | 与 query 的相似度 | 与任务相关的信息完备度 |
| 失败表现 | 召回对了但答不全 | 召回噪声大、关键句被淹没 |
| 优化手段 | 切分、嵌入模型、检索算法 | 预算分配、排序、压缩 |
矛盾在于:同一段文本对检索是"越纯越好",对生成是"越完整越好"。父子块这类做法的本质,就是把两个诉求拆到两个对象上分别满足。另一个错配是切分边界与语义边界不一致:按字符切的块首句承接上一块、末句未完待续,这两句在生成阶段都是干扰。
2. 四种切分策略与召回特征
# 固定长度 + 重叠:可控性最好,语义最差
def fixed_chunks(text, size=512, overlap=64):
step = size - overlap
return [text[i:i + size] for i in range(0, len(text), step)]
# 按结构切:Markdown 标题树,父标题路径写进元数据
def structural_chunks(md_text):
... # 维护 [h1, h2, h3] 栈,遇同级或更高级标题收口
| 策略 | 边界依据 | 召回特征 | 代价 |
|---|---|---|---|
| 固定长度+重叠 | 字符/token 计数 | 稳定、可预算、零实现成本 | 切断语义,跨块查询命中率低 |
| 结构与标题 | 标题层级、段落、列表 | 段落型文档准,块自带章节路径 | 依赖排版质量,超长段落仍需二次切 |
| 语义边界 | 句向量相邻相似度谷值 | 块内主题单一,多跳问答友好 | 需额外嵌入计算,块长不可控 |
| 父子块 | 父为生成单位、子为检索单位 | 子块精定位 + 父块补全上下文 | 存储索引翻倍,需维护映射 |
经验规则:文档型内容(手册、API 说明)优先结构与标题,问答型内容(FAQ、工单)优先语义边界,超长叙述用父子块。固定长度适合兜底。语义切分把边界判断从规则换成了模型,判断本身有方差,同一份文档两次切分的块数可能不同,会影响评测的可复现性。
3. 重叠比例:召回与存储的交换
重叠的唯一目的是防止关键句正好落在切缝上,代价是索引体积膨胀,且同一段内容在 top-k 里反复出现并挤掉其他片段。
| 重叠 | 效果 | 副作用 |
|---|---|---|
| 0 | 体积最小 | 切缝处的句子两边都残缺 |
| 10%–15% | 覆盖多数切缝问题 | 体积膨胀可控 |
| 20%–30% | 边界稳健 | 重复命中明显,top-k 去重成必需 |
| >40% | 收益递减 | 重排成本上升,预算被重复内容占用 |
spring:
ai:
vectorstore:
token-splitter:
chunk-size: 800 # 单位取决于 tokenizer,按所用模型版本核对
chunk-overlap: 120 # 约 15%
首块不做前置重叠、末块不做后置重叠,否则会生成超短碎片。
4. 块大小与"中段被忽略"
| 上下文位置 | 模型利用倾向 | 对切分的含义 |
|---|---|---|
| 开头(系统提示 + 首个片段) | 强 | 放最强约束与最相关片段 |
| 中部 | 弱 | 少量同质片段尚可,异质片段易被忽略 |
| 结尾(末个片段 + 待答问题) | 强 | 放次相关片段与任务指令 |
结论不是"无脑压缩上下文",而是:预算一定时,宁可少放几条,也不要往中段硬塞。块大小与位置效应会叠加——大块占中段时,被忽略的是"整块信息",损失大于小块。该效应随模型与版本变化,按自己用的模型版本做一次位置敏感性验证,不要照搬某个比例当结论。
5. 上下文预算的构成
预算 = 上下文上限 − 输出预留 − 系统提示 − 对话历史 − 检索片段。
| 预算项 | 占比(量级) | 决定因素 | 常见错误 |
|---|---|---|---|
| 系统提示 | 小 | 角色、约束、格式说明 | 塞入大量 few-shot,挤占片段 |
| 检索片段 | 大 | top-k × 块大小 | 只按块数配,不换算 token |
| 对话历史 | 中 | 多轮累积 | 无限追加,越聊越挤 |
| 输出预留 | 中 | 最长答案长度 | 最容易漏算的一项 |
def build_context(budget_tokens, system_text, history, chunks, max_output):
room = budget_tokens - count(system_text) - count(history) - max_output
picked, total = [], 0 # 输出预留必须先扣
for c in chunks: # chunks 已按相关性排序
t = count(c.text) + PER_CHUNK_OVERHEAD # 分隔符与来源标注也要计
if total + t > room:
break
picked.append(c); total += t
return picked, room - total # 余量留给多轮追问
片段计数不等于 token 计数:同一句话在不同分词器下差异明显,中英混排、代码、表格时更明显。预算必须用与调用模型一致的分词器算,按具体版本核对。
6. 超预算的三种处理
| 处理 | 做法 | 适用 | 风险 |
|---|---|---|---|
| 裁剪历史 | 保留最近 N 轮 + 更早内容摘要化 | 多轮对话为主 | 丢失较早的约束条件 |
| 减少片段数 | 按相关性截断 top-k | 单轮问答为主 | 召回不足导致答不全 |
| 压缩片段 | 抽取式压缩,只留命中句 | 长文档、噪声大 | 失真,引用定位变难 |
优先级:先裁剪历史(对当前问题贡献最低)→ 再压缩片段(保留命中句结构)→ 最后才减片段数。减片段数直接降低信息上限:被去掉的那条可能正是唯一答案来源。压缩后的片段要保留来源标记,否则引用无法定位。
def shrink(picked, room, history):
if over(picked, room):
history = summarize_old(history, keep_recent=3) # 第一步
if over(picked, room):
picked = [extract_sentences(c, query) for c in picked] # 第二步
while over(picked, room):
picked.pop() # 第三步
return picked, history
7. 排序:把最相关的放首尾
[系统提示] → [片段 A:最相关] → [片段 B、C …] → [片段 N:次相关] → [任务指令 + 用户问题]
| 排序方式 | 逻辑 | 风险 |
|---|---|---|
| 分数降序 | 最相关在最前 | 次相关片段落在中段,可能被忽略 |
| 首尾夹逼 | 最强放头、次强放尾 | 需先知道每条相关性(通常已有) |
| 按文档结构 | 维持原文顺序 | 相关片段可能分散在中段 |
| 按时间 | 新内容优先 | 与相关性无关,仅适合时效型问题 |
实现很便宜,对排序结果做一次头尾交错重排:
def head_tail_rank(items): # items 已按相关性降序
return items[0::2] + items[1::2][::-1]
排序改变的是"被读到的概率",不改变召回集合。答案根本没进候选时,重排救不了——那是检索环节的问题。
8. 元数据:过滤与引用的基础
| 字段 | 用途 | 缺失后果 |
|---|---|---|
| source_id | 去重、限定检索范围 | 多文档串味,无法溯源 |
| heading_path | 按章节过滤、引用定位 | 引用只能给到文件级 |
| updated_at | 时效过滤、新旧覆盖 | 检索到已废弃内容 |
| tenant / 权限标签 | 多租户隔离 | 越权检索 |
| chunk_index / total | 父子块映射、相邻块拼接 | 上下文无法还原 |
meta = {"source_id": doc_id, "heading_path": ["部署", "配置", "限流"],
"updated_at": "2026-10-01", "chunk_index": i, "chunk_total": n}
过滤要在检索侧做(向量库的 filter / metadata 表达式),不要拿回本地再筛:先过滤再检索能提高有效 top-k,拿回来再筛会把 k 用光在无关文档上。带过滤条件的检索通常需要更大的返回条数,按所用向量库版本核对其过滤是前置还是后置。
9. 切分后的质量检查
| 检查项 | 判据 | 异常信号 |
|---|---|---|
| 碎片率 | 低于阈值长度的块占比 | 大量块只有一两句,多为标题残片 |
| 尺寸分布 | 块长度分位数 | 长尾过长,结构切分失效 |
| 信息完整性 | 抽检块是否自成一句/一节 | 多数块以"其中""因此"开头 |
| 重复率 | 相同前缀的块占比 | 重叠过大或文本本身重复采集 |
def audit(chunks):
lens = sorted(len(c.text) for c in chunks)
frag = sum(1 for L in lens if L < MIN_LEN) / len(lens)
dup = len(chunks) - len({c.text[:64] for c in chunks})
return {"n": len(chunks), "p95": lens[int(len(lens) * .95)],
"fragment_ratio": frag, "dup_prefix_count": dup}
这套检查跑在入库前,而不是等线上答错再回头查。切分参数与检查结果要一起写进索引版本元数据,换参数时才有对比基线。
10. 排错
| 现象 | 成因 | 处理 |
|---|---|---|
| 答案总是缺一半 | 块切在句子中间 | 增重叠,或改按句边界切 |
| 表格/代码被切碎 | 按字符切忽略结构 | 表格整块保留,按行切并重复表头 |
| 同一段落反复出现 | 重叠过大或去重缺失 | 重叠降到 15% 以内,按 source+index 去重 |
| 跨文档内容串味 | 无元数据过滤 | 检索侧加 source/tenant 过滤 |
| 多轮后答非所问 | 历史占用过多预算 | 裁剪并摘要历史,保留最近若干轮 |
| 引用无法定位 | 缺章节路径元数据 | 切分时写入 heading_path 与块序号 |
11. 边界
| 场景 | 该在切分/预算层解决吗 | 说明 |
|---|---|---|
| 术语不匹配(问法与文档用词不同) | 否 | 属于检索层(同义词、混合检索) |
| 需跨多篇文档综合 | 部分 | 父块与聚合有帮助,但依赖重排去重 |
| 答案依赖整篇文档 | 否 | 改用摘要索引或分层检索 |
| 结构化数值聚合 | 否 | 走 SQL 或工具调用,不塞进向量库 |
12. 自测
- 检索单位与生成单位的错配体现在哪两处,各用什么做法缓解?
- 重叠比例超过 30% 后收益为什么递减?
- "中段被忽略"这个效应,对分块大小给出了什么启示?
- 上下文预算里最容易被漏算的是哪一项,漏算会出现什么现象?
- 超预算三种处理的推荐顺序是什么,为什么减片段数放最后?
- 元数据里的 heading_path 缺失会影响哪两个环节?