Chunking-centric-RAG

固定窗口常把同一条方法约束拆开(「20 nM siRNA」和「处理 48 h 再抽 RNA」进不同块),多约束问句只能命中一半,后面换嵌入也补不回。根因是切分边界破坏了原子事实的完整性。本方法把 chunk 策略当成可评测对象,用冻结检索的 Recall@k 选窗口与 overlap。它不解决双栏乱序和表格错切(Document Parsing)。

本文属于 RAG 工程框架中的「1 数据接入与文档切分」环节,聚焦「Chunking centric RAG」方法。

定位

维度 内容
角色 数据切分节点的策略选择器,不是解析器、不是检索器
输入 → 输出 已规整文本 → 带 chunk_id / doc_id / text / metadata 的块列表
默认组合 结构边界(标题/段落)+ 固定窗口网格;用同一批评测问句选参
何时不用 版式未还原(先 Parsing);需要章节粗召回(改 Hierarchical)

核心机制

目标:在冻结嵌入与 $k$ 的前提下,选一组 $(s,o)$(窗口、重叠),使召回可达性最大,并控制膨胀。

$$
(s^\star,o^\star)=\arg\max_{s,o};\mathrm{Recall}@k\bigl(\mathrm{split}(D;s,o)\bigr)
\quad\text{s.t.}\quad
\mathrm{Inflation}(s,o)\le \tau
$$

段末注释:召回率@k(Recall at k,Recall@k) = 金标准相关块落入检索前 $k$ 条的比例;膨胀比(Inflation) = 切分后 token 总数 / 原文 token 数,overlap 会把它抬高。

语义完整率(Semantic Completeness,SC)解释 Recall 为何随 $s$ 变化:原子事实 $f$ 必须落在同一块内,否则多约束问句的金标准块集合被拆散。

图 1 短窗口把剂量与时程切开,检索只能命中一半;完整块加 overlap 保住多约束

实现路径与心智:不要把 splitter 当成「设一次就忘」的文本工具。离线路径是「枚举 $(s,o)$ → 物化 chunks → 用同一 embedder 建临时索引 → 算冻结 Recall@k / SC / Inflation → 只 persist 胜者」;线上路径只有胜出的那一套切分,不再搜索。底层心智:切分是套在冻结检索器外面的参数搜索,评测器才是决策者,肉眼看块不是。

优缺点

  • 优点:离线可网格搜索,在线几乎零额外时延;对后续任何检索器都抬上限。
  • 缺点:最优 $(s,o)$ 随领域与问句类型变;一套参数打全部文档会在表格/方法节失效。

契约与走通样例

本阶段输出停在 chunk 列表 + 选定策略,不生成答案。

输入

1
2
3
4
5
{
"doc_id": "P-GAPDH-01",
"section": "Methods",
"text": "Treat HEK293T cells with 20 nM siRNA targeting GAPDH for 48 h before RNA extraction."
}

中间量(两种策略)

1
2
3
4
[
{"strategy": "fixed_40_overlap_0", "chunks": ["Treat HEK293T cells with 20 nM siRNA targeting GAPDH", "for 48 h before RNA extraction."]},
{"strategy": "fixed_120_overlap_24", "chunks": ["Treat HEK293T cells with 20 nM siRNA targeting GAPDH for 48 h before RNA extraction."]}
]

评测问句 $q$:「GAPDH siRNA 处理多久再抽 RNA?」金标准需含 48 h。冻结 bge-m3、$k=5$:

  • fixed_40_overlap_0:含「48 h」的块排名第 7 → $\mathrm{Recall}@5=0$,SC 对该事实为 0。
  • fixed_120_overlap_24:剂量与时程同块,排名第 2 → $\mathrm{Recall}@5=1$,SC $=1$。

故选后者。$\mathrm{Inflation}$ 因 overlap 约为 $1.2$,低于预设 $\tau=1.5$。

输出(给下游索引)

1
2
3
4
5
6
7
8
9
10
11
12
{
"selected_strategy": "fixed_120_overlap_24",
"reason": "冻结检索下 recall@5 更高且原子事实未拆散",
"chunks": [
{
"chunk_id": "P-GAPDH-01-C1",
"doc_id": "P-GAPDH-01",
"text": "Treat HEK293T cells with 20 nM siRNA targeting GAPDH for 48 h before RNA extraction.",
"metadata": {"section": "Methods"}
}
]
}

社区实现

默认 langchain-text-splittersRecursiveCharacterTextSplitter)做窗口网格;LlamaIndex SentenceSplitter / SemanticSplitter 作对照臂。

  • 场景:版式已规整的方法/SOP 正文。
  • 接法:多套 (chunk_size, chunk_overlap) 建临时索引,同一 JSONL 评测集算 Recall@k,再 persist 胜者。
  • 风险:按字符数切英文论文会在句子中途断开;优先按段落/句号边界,窗口只作上限。SemanticSplitter 依赖额外嵌入,成本接近「再跑一遍 Naive」。

工程落地

最小可运行示例

复制为 .py 后直接运行(仅标准库)。冻结同一套评测问句,只换切分窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
"""多策略切块 → 冻结词重叠检索算 Recall@k → 只 persist 胜者。"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass
class Doc:
"""一篇已规整正文。"""

text: str


@dataclass
class Chunk:
chunk_id: str
text: str


class WindowSplitter:
"""按词窗口切。生产优先按段落/句号边界,窗口只作上限。"""

def __init__(self, size: int, overlap: int) -> None:
self.size, self.overlap = size, overlap

def split(self, doc: Doc, prefix: str) -> list[Chunk]:
words = doc.text.split()
step = max(self.size - self.overlap, 1)
chunks = []
i, n = 0, 0
while i < len(words):
piece = " ".join(words[i : i + self.size])
chunks.append(Chunk(f"{prefix}-{n}", piece))
n += 1
i += step
return chunks


def recall_at_k(chunks: list[Chunk], query: str, gold_substr: str, k: int) -> float:
q = set(query.lower().split())
ranked = sorted(chunks, key=lambda c: -len(q & set(c.text.lower().split())))
return float(any(gold_substr in c.text for c in ranked[:k]))


def chunking_centric_build(doc: Doc, query: str, gold_substr: str, k: int = 1) -> dict:
"""输出胜出策略名与 Recall@k。"""
strategies = {
"w8": WindowSplitter(8, 2),
"w4": WindowSplitter(4, 1),
}
best = None
for name, splitter in strategies.items():
chunks = splitter.split(doc, name)
inflation = sum(len(c.text) for c in chunks) / max(len(doc.text), 1)
if inflation > 1.6:
continue
rec = recall_at_k(chunks, query, gold_substr, k)
if best is None or rec > best["recall_at_k"]:
best = {"strategy": name, "recall_at_k": rec, "n_chunks": len(chunks)}
return best or {}


if __name__ == "__main__":
doc = Doc("Treat with 20 nM siRNA targeting GAPDH 48 h before RNA extraction")
print(chunking_centric_build(doc, "20 nM 48 h", "20 nM"))

参数

参数 起点 影响
chunk_size 英文方法节 256~512 token;过短先看 SC 太小拆散原子事实,太大约噪声、Precision 下降
chunk_overlap 约为 chunk_size 的 10%~20% 抬 Recall,抬 Inflation;过大近重复块拖慢检索
k(冻结评测) 与线上 top-k 相同,常用 5 评测 $k$ 与线上不一致,选出的策略会上线失效
embedder 与线上同一模型(如 bge-m3 换模型等于换评测器,网格结果不可迁移
inflation_max 1.3~1.6 硬约束 overlap;超了直接淘汰该策略

失效—信号—螺丝

  • 只肉眼看一块、未跑冻结 Recall:上线后多约束问句仍漏。信号:SC 低而单句「看起来完整」。螺丝:锁评测 JSONL,禁止手选。
  • overlap 过大:索引体积涨、近重复占满 Top-k。信号:Inflation $>1.6$ 且 Precision@k 掉。螺丝:降 overlap 或先按段落边界切。
  • 一套参数打全部文档:表格/试剂清单被切碎。信号:Methods 子集 Recall 远低于 Abstract 子集。螺丝:按 section 分策略,或把表格交给 Parsing 的 merge_table

规模(100 篇生物学 PDF)

假设约 100 篇生物学 PDF、10–15 页/篇;A10 24GB 非必需。版式已规整时,多策略网格 + 同一批评测问句约 0.5–2 CPU·h;内存 8–16 GB;磁盘增量约 0.5–1.5 GB。在线切分成本为零。

-------------本文结束感谢您的阅读-------------