2241 字
约 7 分钟
13
S20-X07 · 跨 Chunk 信息完整性:四种补全策略

分块是 RAG 里最反直觉的一步:你为了让检索"精准"而切碎文档,代价是答案被切碎。 单个 chunk 是检索单元,但用户问的很多问题是"完整的定义""完整的一张表",它们天然跨越边界。

这个问题不是调 chunk_size 能解决的——调大提高完整性、降低检索精度,调小反之,在同一个坐标系里两头都拿不到。真正的解法是让检索单元和生成单元不再必须是同一个东西。


1. 固定窗口切分的三个断裂点

断裂点 典型文档 断法 后果
表格跨页 参数表、报价单 第 3 页表头在第 2 页 召回只有数据行,没有列名
代码块被切 配置示例、SDK 示例 函数体被切成两段 拿到半个示例,语法不通
定义与例子分离 名词解释 + 用例 定义在 chunk 4,例子在 chunk 5 只召回定义,答不出"举个例子"
def fixed_window(text, size=800):
    return [text[i:i + size] for i in range(0, len(text), size)]

# 破坏性演示:这行程式恰好被切断
DEMO = "def retry(fn, n=3):\n    for _ in range(n):\n        try:\n            return fn()"
for c in fixed_window(DEMO, 48):
    print(repr(c))

典型输出:

'def retry(fn, n=3):\n    for _ in range(n):\n        '
'try:\n            return fn()'

第二块单独看毫无意义,但它在向量库里有自己的位置,还可能被单独召回。


2. 四种补全策略

策略 机制 优点 代价 适用
重叠滑窗 相邻块共享一段尾部/头部 实现最简单 存储 +10~30%,冗余召回 通用兜底
按结构切 依标题/段落/代码块边界 不断语义 块长不均 结构化文档
父子块 检索小块,返回大块 兼顾精度与完整 需存两套索引 定义类、手册类
句子窗口扩展 命中句后按窗口取前后文 精准且便宜 依赖句子对齐 长叙述文档

这四种不是互斥的。常见组合是:按结构切 → 块内再按句子建子索引(父子的变体)→ 边缘加 10% 重叠。


3. RecursiveCharacterTextSplitter 的 separators 优先级

这个切分器的核心是一串按优先级排列的分隔符,从上往下试,能切就切。默认值是为英文调的,中文与代码必须改。

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    separators=[
        "\n## ",            # 二级标题
        "\n### ",           # 三级标题
        "\n\n",             # 段落
        "```",              # 代码围栏(尽量在块边界切)
        "\n",               # 行
        "。", ";", "!", "?",   # 中文句读
        ",",               # 逗号(最后手段)
        "",
    ],
    keep_separator=True,
    length_function=len,
)
separators 顺序 效果 错误示范
标题 → 段落 → 句 → 字 语义块优先 正确
先放 "" 退化为定长切分 等于不用这个类
缺中文句读 中文按字符切 句子被劈开
代码块边界放最后 代码被切碎 示例不完整

两个实践提醒:

  • chunk_size 按字符算是中文字数约等于 token 数的 1.5 倍左右,具体依 tokenizer 而定,别直接把 token 预算当字符数。
  • keep_separator=True 让切出来的块保留分隔符,回填时更容易还原结构,检索也更容易被标题词命中。

4. 父子块:small-to-big

思路:用小块做检索(精度高),用大块做生成(完整)。中间靠 parent_id 关联。

def build_parent_child(doc, parent_size=2400, child_size=400, overlap=60):
    parents, children = [], []
    for pi, ptext in enumerate(fixed_window(doc["text"], parent_size)):
        pid = f"{doc['doc_id']}#p{pi}"
        parents.append({"id": pid, "text": ptext,
                        "title": doc["title"], "section_path": doc["section_path"]})
        for ci, ctext in enumerate(fixed_window(ptext, child_size)):
            children.append({
                "id": f"{pid}#c{ci}", "parent_id": pid, "text": ctext,
                "embed_input": f"{doc['title']} > {doc['section_path']}\n{ctext}",
            })
    return parents, children

注意 embed_input:编码时带上下文头,返回时给原文。这两个必须是不同的字符串,混用会污染召回。

参数 建议比例 说明
parent : child 4:1 到 6:1 再大则父块里噪声多
child 重叠 10%–15% 保证子块边界句完整
父块数量 与子块数一起估存储 内存占用约为子块版本的 parent/child 倍
检索单元 子块 精度来源
返回单元 父块去重合并 完整性来源

去重很重要:一次查询命中同一个父块的三个子块,只应返回一次父块,否则上下文里同一段内容出现三遍。

def expand(children_hits, parents_map, max_parents=4):
    seen, out = set(), []
    for h in children_hits:
        pid = h["parent_id"]
        if pid in seen:
            continue
        seen.add(pid)
        out.append(parents_map[pid])
        if len(out) >= max_parents:
            break
    return out

5. 上下文头的作用

给每个 chunk 前置一行"它属于哪里",解决的是孤块无主问题:

头信息 解决什么 缺失的后果
文档标题 同一段落出现在多份文档 召回来源不可辨
章节路径 "默认值"这类代词无指代 答成另一个模块的默认值
版本号 新旧版本混用 找到旧参数
更新时间 时效判断 引用了已废止内容
def with_header(chunk, doc):
    head = f"[{doc['title']}] > {doc['section_path']}(v{doc['version']},更新于 {doc['updated_at']})"
    return f"{head}\n{chunk['text']}"

成本几乎为零,收益在专有名词稀疏的短块上最明显——那些块本身语义信息不够,头部基本决定了它能否被召回。


6. 评估:用一个必然跨块的问题验完整性

不要用"怎么配置超时"这种问题评估切分,它的答案大概率落在一个块里。要专门造跨块问题。

问题类型 构造方法 判定标准
表格列名 + 数据 问"第 3 行那列的取值" 返回内容是否同时含表头与数据行
完整代码示例 问"给出完整示例" 代码块是否闭合
定义 + 例子 问"举个例子说明 X" 是否同时命中定义与例子
流程全步骤 问"完整流程" 步骤序号是否连续无缺
def completeness_score(answer, expected_steps):
    hit = [s for s in expected_steps if s in answer]
    return {"covered": len(hit), "total": len(expected_steps),
            "missing": [s for s in expected_steps if s not in answer],
            "ratio": round(len(hit) / len(expected_steps), 2)}

典型输出(示意):

{'covered': 4, 'total': 7, 'missing': ['步骤5:回滚验证', '步骤6:清理', '步骤7:通知'],
 'ratio': 0.57}

ratio 长期低于 0.8 说明切分策略有系统性问题,靠调 prompt 补不回来。


7. 排错:五种"信息不完整"

现象 原因 处理
表格答不出列名 表头与数据被切开 按表格结构切,或整表作为一个块
代码示例语法不通 代码块内部被切 把 ``` 放进 separators 靠前位置
步骤总少最后两步 定长切分,尾部在小块里 加重叠,或用父子块
同一内容出现多次 重叠过大 + 父块未去重 重叠降档,expand 去重
召回的是旧版本参数 上下文中缺版本号 加上下文头 + 版本过滤

8. 边界

场景 做法 理由
语料全是短问答对 不需要父子块 一条就是完整语义单元
语料是长表格(财报) 整表入一个块,必要时转 Markdown 切开即失去意义
代码库文档 按函数/类切 语法结构即语义边界
上下文窗口紧张 只用句子窗口扩展 比父块省 token
文档更新频繁 父块与子块要一起失效 否则父子关系悬空

最后一条是父子块最大的运维陷阱:更新父块时必须删掉它的全部子块并重建,只改父块会出现"检索命中旧子块、返回新父块"的错配。


9. 自测

  1. 为什么把 chunk_size 调大解决不了跨块完整性问题?
  2. RecursiveCharacterTextSplitter 的 separators 从上往下试,先放 "" 会发生什么?
  3. 父子块里 embed_input 与返回给模型的文本为什么可以不同?
  4. 一次查询命中同一父块的三个子块,应该返回几次父块内容?
  5. 上下文头在哪类 chunk 上收益最明显?
  6. 更新父块时只改父块不重建子块,会产生什么错配?
S20-X07 · 跨 Chunk 信息完整性:四种补全策略
https://www.clxhxhhr.top/posts/5682/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。