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. 自测
- 为什么把
chunk_size调大解决不了跨块完整性问题? RecursiveCharacterTextSplitter的 separators 从上往下试,先放""会发生什么?- 父子块里
embed_input与返回给模型的文本为什么可以不同? - 一次查询命中同一父块的三个子块,应该返回几次父块内容?
- 上下文头在哪类 chunk 上收益最明显?
- 更新父块时只改父块不重建子块,会产生什么错配?
S20-X07 · 跨 Chunk 信息完整性:四种补全策略
https://www.clxhxhhr.top/posts/5682/ 评论
0 条
还没有评论,先写一条吧。