3020 字
约 10 分钟
14
S22-X10 · RAG 切分策略与上下文预算

检索返回的单位是"块",模型消费的单位是"窗口",两者从来不是同一个东西。切分就是在这两个单位之间做分配:块小则召回准而上下文残缺,块大则语义稀而预算被单条吃光。预算算漏的表现通常不是报错,而是答案看起来对、却少了前提条件。这篇把切分与预算放进同一套账。


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. 自测

  1. 检索单位与生成单位的错配体现在哪两处,各用什么做法缓解?
  2. 重叠比例超过 30% 后收益为什么递减?
  3. "中段被忽略"这个效应,对分块大小给出了什么启示?
  4. 上下文预算里最容易被漏算的是哪一项,漏算会出现什么现象?
  5. 超预算三种处理的推荐顺序是什么,为什么减片段数放最后?
  6. 元数据里的 heading_path 缺失会影响哪两个环节?
S22-X10 · RAG 切分策略与上下文预算
https://www.clxhxhhr.top/posts/5872/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。