Top-K 和 MMR 再精细,也补不回被切碎的表格和从中间断开的函数。本文从块边界、重叠和元数据讲起,给出一个只依赖 Node.js 内置能力的结构感知切片器,并用召回率、引用完整度来衡量分块质量。读完可以替换「按 token 硬切」的默认实现,也清楚何时该小块检索、何时该整段保留。
硬切把上下文切断之后,检索救不回来
许多 RAG 教程把文档按固定 token 窗口切开,再加一段重叠。这个默认实现在叙事性文章上勉强能用,一旦遇到 API 文档、运维手册或带示例的 README,问题会立刻暴露。检索器返回的是块,不是原书。表格的表头和数据行被分到两个块里,模型只能看到没有列名的数字,既判断不了单位,也无法完整引用。函数从中间切断,签名在一块、函数体在另一块;即便 Top-K 把两块都捞回来,MMR 还可能因为「太相似」丢掉其中一块。
所以分块质量先于检索策略。边界切错了,后面换嵌入模型、调 MMR 的 λ、再加 rerank,都是在错误的候选集上做精细化。结构感知切片的目标很具体:标题用来维护路径,而不是当成普通句子;表格和围栏代码作为原子单元,允许超过长度上限也不拆;只有散文按长度合并,并在同一标题路径下做有限重叠。
按长度硬切还有一个隐蔽代价:切点落在列表中间或「如下表所示」之后,块在向量空间里看起来仍像一段通顺中文,检索分数不低,但拿到生成阶段就无法独立成立。这类失败用人工抽查几条还不容易发现,要用金标区间去对。
块边界、重叠和元数据
先把三个概念定清楚,后面的代码只是在落实它们。
块边界应当对齐人类阅读时的「不可再分单元」。Markdown 里最稳妥的三类原子块是:标题(只提供路径,尽量不单独成块)、GFM 表格、围栏代码。段落和列表可以合并。不要在表格行之间、代码围栏内部、以及标题标记处切开。若来源是 PDF 转写,用注释把页码写进原文,切片时读出来写入元数据,比事后用字符偏移反推可靠。
重叠只对可合并的散文有意义。它的作用是让跨段指代(「如上所述」「该函数」)在相邻块里仍能被读懂。把整张表或整段代码再复制一份到下一块,只会污染向量空间、浪费上下文窗口。重叠长度应明显小于块上限,并且仅在标题路径相同的相邻块之间启用。换了小节就不要重叠,避免把上一节的结论粘到下一节开头。
元数据不是可选项。至少写入三样:标题路径(例如 计费说明 > 价格表),便于过滤和生成引用;页码,便于回到原件;语言,来自代码围栏的 info string,便于按语言选择后续处理。这些字段进向量库的 payload。路径可以少量拼进待嵌入文本的开头当前缀,页码这类检索用不到的字段只放 metadata,避免同一页上所有块被无意义地拉近。
长度上限怎么选
上限用字符比用 token 更直观,也避免在切片阶段引入分词器依赖。对中文技术文档,单块大约 600 到 1000 字,通常放得下一张中等表格或一个完整函数。超长表格或超长代码选择整段保留,而不是退回硬切;检索时用小块定位,生成时再按路径取出完整段。这正是后文「小块检索、整段保留」的分工。具体数字没有普适最优值,应当在自己的金标集上比较,而不是照搬教程里的 512 token。
用 Node.js 做结构感知切片器
下面这份实现只使用 Node.js 内置语法,保存为 chunker.mjs 后执行 node chunker.mjs 即可看到切分结果。词法分析按行扫描,识别标题、围栏代码、GFM 表格和散文;打包时标题刷新路径并开启新块,表格与代码不拆,散文可合并,并在同路径下写入重叠。演示里把上限刻意调小,是为了让重叠和「超长也不拆」同时可见;接到真实文档时再把 MAX_CHARS 调回 800 左右。
// chunker.mjs
// 运行:node chunker.mjs
const MAX_CHARS = 320;
const OVERLAP_CHARS = 60;
function isTableRow(line) {
return /^\s*\|.*\|\s*$/.test(line);
}
function isTableSep(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
}
function lexMarkdown(text) {
const lines = text.split(/\r?\n/);
const blocks = [];
let i = 0;
let page = 1;
while (i < lines.length) {
const line = lines[i];
const pageMatch = line.match(/<!--\s*page:\s*(\d+)\s*-->/i);
if (pageMatch) {
page = Number(pageMatch[1]);
i += 1;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*$/);
if (headingMatch) {
blocks.push({
type: "heading",
level: headingMatch[1].length,
text: headingMatch[2].trim(),
raw: line,
page,
});
i += 1;
continue;
}
const fenceMatch = line.match(/^```([\w+-]*)\s*$/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const buf = [line];
i += 1;
while (i < lines.length && !lines[i].startsWith("```")) {
buf.push(lines[i]);
i += 1;
}
if (i < lines.length) {
buf.push(lines[i]);
i += 1;
}
blocks.push({ type: "code", language: lang, raw: buf.join("\n"), page });
continue;
}
if (isTableRow(line) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
const buf = [line, lines[i + 1]];
i += 2;
while (i < lines.length && isTableRow(lines[i])) {
buf.push(lines[i]);
i += 1;
}
blocks.push({ type: "table", raw: buf.join("\n"), page });
continue;
}
if (line.trim() === "") {
i += 1;
continue;
}
const buf = [line];
i += 1;
while (i < lines.length) {
const nxt = lines[i];
if (nxt.trim() === "") break;
if (/^(#{1,6})\s+/.test(nxt)) break;
if (/^```/.test(nxt)) break;
if (isTableRow(nxt) && i + 1 < lines.length && isTableSep(lines[i + 1])) break;
if (/<!--\s*page:/i.test(nxt)) break;
buf.push(nxt);
i += 1;
}
blocks.push({ type: "prose", raw: buf.join("\n"), page });
}
return blocks;
}
function attachHeadingPath(blocks) {
const stack = [];
return blocks.map((b) => {
if (b.type === "heading") {
while (stack.length && stack[stack.length - 1].level >= b.level) stack.pop();
stack.push({ level: b.level, text: b.text });
return { ...b, headingPath: stack.map((s) => s.text) };
}
return { ...b, headingPath: stack.map((s) => s.text) };
});
}
function newChunk(b) {
return {
headingPath: b.headingPath || [],
page: b.page || 1,
language: b.language || null,
text: "",
types: new Set(),
overlapFromPrev: false,
};
}
function finalize(c) {
const text = c.text.trim();
return {
text,
headingPath: c.headingPath,
page: c.page,
language: c.language,
types: [...c.types],
overlapFromPrev: c.overlapFromPrev,
chars: text.length,
};
}
function packChunks(blocks, maxChars, overlapChars) {
const chunks = [];
let current = null;
const pathKey = (p) => (p || []).join(" > ");
const flush = () => {
if (current && current.text.trim()) chunks.push(current);
current = null;
};
for (const b of blocks) {
if (b.type === "heading") {
flush();
current = newChunk(b);
current.text = b.raw + "\n";
continue;
}
const piece = b.raw + "\n";
const atomic = b.type === "code" || b.type === "table";
if (!current) current = newChunk(b);
if (atomic) {
if (current.text.length > 0 && current.text.length + piece.length > maxChars) {
flush();
current = newChunk(b);
}
current.text += piece;
if (b.language) current.language = b.language;
current.types.add(b.type);
continue;
}
if (current.text.length + piece.length > maxChars && current.text.length > 0) {
flush();
current = newChunk(b);
const prev = chunks[chunks.length - 1];
if (prev && pathKey(prev.headingPath) === pathKey(b.headingPath) && overlapChars > 0) {
const tail = prev.text.trimEnd();
current.text = tail.slice(Math.max(0, tail.length - overlapChars)) + "\n";
current.overlapFromPrev = true;
}
}
current.text += piece;
current.types.add(b.type);
}
flush();
return chunks.map(finalize);
}
export function chunkMarkdown(text, options = {}) {
const maxChars = options.maxChars ?? MAX_CHARS;
const overlapChars = options.overlapChars ?? OVERLAP_CHARS;
return packChunks(attachHeadingPath(lexMarkdown(text)), maxChars, overlapChars);
}
const fence = "`".repeat(3);
const demo = [
"# 计费说明",
"<!-- page: 1 -->",
"按量计费适用于偶发流量。",
"## 价格表",
"| 档位 | QPS | 单价 |",
"| --- | --- | --- |",
"| 基础 | 10 | 0.02 |",
"| 专业 | 100 | 0.015 |",
"## 示例代码",
"校验签名时不要把密钥写进查询参数。",
fence + "js",
"function sign(body, secret) {",
' return crypto.createHmac("sha256", secret).update(body).digest("hex");',
"}",
fence,
"连续两段说明用于演示重叠。第一段解释签名必须覆盖完整原文。",
"第二段说明时间戳窗口,避免重放。这两段属于同一小节,允许少量字符重叠。",
].join("\n");
const chunks = chunkMarkdown(demo);
for (const [i, c] of chunks.entries()) {
console.log("--- chunk", i, "---");
console.log(JSON.stringify({
headingPath: c.headingPath,
page: c.page,
language: c.language,
types: c.types,
overlapFromPrev: c.overlapFromPrev,
chars: c.chars,
}));
console.log(c.text);
console.log();
}
运行后,价格表会作为一块完整输出,sign 所在围栏不会被拦腰切断,散文块带有 headingPath、page、language。把 MAX_CHARS 再调小,可以观察重叠是否只出现在同一小节的散文之间。接入流水线时用 chunkMarkdown 的返回值写入向量库即可。若在 CommonJS 环境运行,去掉 export,直接调用函数。
实现上有意不引入 Markdown AST,也不调用嵌入 API。切片器应当稳定、可单测、不把网络 IO 混进来。页码注释是入库前约定,不是 Markdown 标准语法;HTML 表格、MDX 表达式需要另加规则。对 .js / .py 源码文件应当按函数或类边界切,不要复用这一套围栏逻辑。
用召回率和引用完整度衡量分块
不要用「看起来更整齐」当验收标准。准备一小份带金标的问答:每道题标注答案所在的原文区间,区间可以是一张表、一个围栏或连续几个段落。分块之后做两件事,并且对比时只改切片器、固定嵌入和检索参数。
召回率:用现有检索取 Top-K,看金标区间是否落在返回块的并集里。若金标被切到两块且只命中一块,记为部分命中,不要当成成功。这项指标直接反映切错边界会不会让答案消失。
引用完整度:在已召回的前提下,金标单元是否被单个块完整覆盖。生成阶段要引用表格或代码时,缺表头、缺返回值分支都会让答案看起来像幻觉。完整度低,就应把该类单元标成原子块,而不是继续加大重叠——重叠解决的是指代,解决不了二维结构被拆开。
样本不必多,几十条覆盖表格、代码、纯叙述三类即可看出趋势。不要把某一组实验数字当成普遍结论;文档类型一变,最优上限和是否启用重叠都会变。把金标和切片结果一起做回归,比盯着块大小方差有用。
何时小块检索,何时整段保留
小块适合事实点:名词解释、配置项默认值、错误码含义。这类内容短、区分度高,块一大反而容易和同节其他事实混在一个向量里。检索用小块,命中后再决定要不要根据 headingPath 把同一小节的相邻块拼回去给模型。
整段保留适合表格、完整示例、操作步骤、函数或类定义。它们的语义是二维或有序的,拆开就不再自洽。若原子块超过模型上下文,优先做双层存储:检索用说明段或函数签名加一行注释,生成用原块,两者用同一路径或块 ID 关联。不要把超长代码再按固定字数切一刀,那会回到本文开头的问题。
一个能落地的默认策略:结构感知切片作为主路径;散文上限约 800 字,重叠约 100 字且同路径才启用;表格和代码永不拆;检索 Top-K 用这些块;生成前若命中原子块则直接使用,若命中散文则按标题路径扩展到整节,并给节长度设上限,防止整章灌入。这比一上来上父子索引更少概念,也更容易对照金标做回归。等默认策略在你自己的手册上稳定之后,再考虑要不要为超长节单独做摘要块。
总结
按 token 硬切的代价是把表格和函数变成无法引用的碎片,后续检索再精细也救不回来。先定块边界、限制重叠的适用范围、把标题路径和页码写成元数据,再用结构感知切片器落地。验收看召回率和引用完整度,而不是块大小是否均匀。小块用来定位事实,整段用来保留表格与代码。把这篇的 chunker.mjs 接到现有入库脚本里,先在自己的文档上跑一遍金标,再决定要不要加更重的解析器。
评论