小美是文档仓库的管理员,每天都会收到一箱箱用 Markdown 写成的货物说明。她发现,如果不先看清货物的标签(标题)、包装(代码块)和分类(列表),就随手把货物切块上架,那么取货的人(RAG 里的检索模块)就永远找不到正确的零件。这篇文章要讲的,就是如何像整理仓库一样解析 Markdown:先看懂骨架、再决定怎么下刀、给每个块贴上「来路标签」、最后测评切得是否合格。读完你会对 RAG 的文档处理环节建立起一套完整的直觉,不再靠感觉调参数。

岗位关联

对 AI 从业者:Markdown 是知识库最常见的格式,掌握结构化解析与 chunk 切分,是从「能跑通 RAG」到「检索效果好用到能上线」的分水岭,也是 AI 应用工程面试的高频考点。

目录
  1. 可视化演示
  2. 看懂Markdown骨架
  3. 切分策略选择
  4. 给Chunk贴上「来路标签」
  5. 切片质量评估
  6. 工程落地优化
  7. 文本清洗
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

\u901a\u8fc7\u6807\u9898\u5c42\u7ea7\u5207\u5206\u3001\u5207\u5206\u7b56\u7565\u5bf9\u6bd4\u548c\u8d28\u91cf\u8bc4\u4f30\u6307\u6807\uff0c\u76f4\u89c2\u5c55\u793aMarkdown\u6587\u6863\u5982\u4f55\u6cbf\u7ed3\u6784\u7ebf\u62c6\u5206\u4e3a\u8bed\u4e49\u5b8c\u6574\u7684RAG\u68c0\u7d22chunk\u3002

本节配有交互动效(进入视口后自动加载,约 3KB)。

\u6807\u9898\u5c42\u7ea7\u5207\u5206 H1\uff1a\u4e00\u7ea7\u6807\u9898\u521b\u5efa\u4e3b\u5207\u5206\u8fb9\u754c H2\uff1a\u4e8c\u7ea7\u6807\u9898\u7ec6\u5206\u5b50\u4e3b\u9898 H3\uff1a\u4e09\u7ea7\u6807\u9898\u5212\u5206\u6700\u5c0f\u8bed\u4e49\u5757 Chunk=Heading+Content\u2193Hn \u6cbf\u6807\u9898\u7ed3\u6784\u7ebf\u5207\u5206\u4fdd\u8bc1\u8bed\u4e49\u5b8c\u6574

看懂Markdown骨架

小美打开一箱刚到货的项目文档,没有急着把东西往货架上塞,而是先摊开看看里面长什么样。箱子内部分了好几层:最上面是一层大隔板(一级标题 #),下面压着几块小隔板(二级标题 ##),最小的格子里放着零件(三级标题 ###);玻璃罩里有一台精密仪器(代码块),整罩摆放不能拆;贴着编号的小纸片(列表项)按顺序串在一起。看懂这些分隔和层次,才知道该从哪儿下刀。

在 RAG 系统里,这份「货物结构图」就是 Markdown 解析器做的事。解析器用正则表达式匹配行首的井号数量来判断标题层级,用成对的反引号围栏识别代码块,用行首符号识别列表和表格。解析完成后,文档会被组织成一棵树:每个标题是子树根节点,它下面的正文、列表、代码块都是子节点。这棵树就是后续所有切分决策的依据。

我们可以用一条路径公式记录每个标题节点的「来路」。假设某个标题是 ### 环境要求,它从根到自己的各级祖先标题依次是 项目文档、安装、环境要求,那么这条路径就是:

path(node)=[ t1, t2, …, tk ]\displaystyle path(node) = [\,t_1,\ t_2,\ \dots,\ t_k\,]

1. 节点定位:$node$ 表示 Markdown 中的某个标题节点,比如 ### 环境要求。

node=标题节点node = \text{标题节点}

2. 祖先序列:$t_1$ 到 $t_k$ 是从根标题到当前标题的各级标题文本,$k$ 就是当前标题的层深度。

t1=一级标题, tk=当前标题t_1 = \text{一级标题},\ t_k = \text{当前标题}

3. 路径拼接:$[\,t_1,\ t_2,\ \dots,\ t_k\,]$ 把这些标题按顺序排列成一条从根到叶的路径。

path=[ t1, t2, …, tk ]path = [\,t_1,\ t_2,\ \dots,\ t_k\,]

4. 后续用途:这条路径会被写进 chunk 的元信息,作为检索时定位的「来路标签」。

chunk 携带 path(node)\text{chunk 携带 } path(node)

小美把这张树状结构图画在仓库墙上之后,整个团队的取货速度都变快了——因为大家第一次知道,每件货物应该放在哪个隔板下面。

切分策略选择

货架整理有两种粗暴做法:一是完全按隔板自然分区,把每块隔板里的东西装进一个盒子;二是统一买一种固定尺寸的收纳盒,东西多了就硬压、少了就空着。第一种是「按标题切分」,第二种是「按长度切分」。如果文档本身有清晰的标题结构,按标题切能让每个盒子里的内容语义完整;但遇到纯文本日志、无标题的说明文件,就只能退而求其次按长度硬切。

聪明的仓库管理员会混合下刀:先看标题结构,如果某个标题下的内容不算太长,就整个作为一个 chunk;只有内容超长时才动用固定长度做二次切分,把超长部分再拆成几个小段。这样既保住了标题带来的语义边界,又不会出现一个块大到超过向量模型输入上限的情况。

把这条规则写成数学语言,就是一个分段函数:

strategy(c)={heading_split,len(c)≤Lmaxlength_split,otherwise\displaystyle strategy(c) = \begin{cases} \text{heading\_split}, & len(c) \le L_{max} \\ \text{length\_split}, & otherwise \end{cases}

1. 候选块:$c$ 表示某个标题节点下的正文内容块。

c=标题下的全部内容c = \text{标题下的全部内容}

2. 长度上限:$len(c)$ 是它的字符数,$L_{max}$ 是预先设定的单块最大长度。

len(c)≤Lmaxlen(c) \le L_{max}

3. 分支判断:当长度在上限内时选择按标题切($\text{heading\_split}$),否则选择按长度切($\text{length\_split}$)。

if len(c)≤Lmax then heading_split\text{if } len(c) \le L_{max} \text{ then heading\_split}

4. 策略含义:这个分段函数就是「结构优先、兜底保底」的数学表达。

strategy=heading_split or length_split\text{strategy} = \text{heading\_split or length\_split}

小美试了几周后发现,混合策略下,客户几乎没有再抱怨过「找到的零件缺了一角」——因为大多数盒子都完整地保留了原包装。

给Chunk贴上「来路标签」

每个上架的盒子上,小美都会贴一张进货单,写上它来自哪个货架、哪个隔板。顾客来取货时只要报出「文档 > 安装 > 环境要求」,取货员立刻就知道去哪个货架第几层找。在 RAG 系统里,这张进货单就是 chunk 的元信息:标题路径、文档 ID、块序号、锚点链接。

为什么必须这么干?因为向量检索是一个「模糊匹配」的过程,chunk 本身如果只有孤零零的正文,检索模型根本不知道这段话在整篇文档里的位置。把标题路径拼在正文之前,相当于给检索模型递了一张地图,让「环境要求」里的内容能和「安装」这个主题建立上下文关联。

拼装公式非常简单直接:

Tchunk=Theading+Tbody\displaystyle T_{chunk} = T_{heading} + T_{body}

1. 正文部分:$T_{body}$ 是该标题节点下的实际内容。

Tbody=该小节的正文T_{body} = \text{该小节的正文}

2. 标题路径:$T_{heading}$ 是形如「项目文档 > 安装 > 环境要求」的路径前缀。

Theading=t1>t2>⋯>tkT_{heading} = t_1 > t_2 > \dots > t_k

3. 拼接操作:$+$ 表示字符串拼接,把路径放在正文之前。

Tchunk=Theading+TbodyT_{chunk} = T_{heading} + T_{body}

4. 存储阶段:拼接后的完整文本才送入 embedding 向量化,字段顺序对检索效果有直接影呛。

Tchunk→embeddingT_{chunk} \rightarrow \text{embedding}

小美的经验是:别小看这张「进货单」,它让同一个盒子的命中概率至少翻了一倍——因为检索模型终于知道自己在哪一层楼了。

切片质量评估

每隔一段时间,小美会做一次盘点抽检:请几位老顾客随机报出货物的名字,看取货员能不能在货架上快速找到。找到了算命中,找不到算漏检。把命中次数除以总询问次数,就得到一个能横向比较的分数。在 RAG 工程里,这对应着一套用真实问答构造的测试集。

最常用的指标是 Recall@k:系统返回前 k 个候选块,看答案所在的块是否在其中。它的数学定义是:

Recall@k=∣ Rk∩G ∣∣G∣\displaystyle Recall@k = \frac{|\,R_k \cap G\,|}{|G|}

1. 正确答案集合:$G$ 表示测试集里人工标注的、包含答案的 chunk 集合。

G=标注的正确答案块G = \text{标注的正确答案块}

2. 返回集合:$R_k$ 表示检索系统对每个问题返回的前 $k$ 个 chunk。

Rk=检索返回的前 k 个块R_k = \text{检索返回的前 } k \text{ 个块}

3. 求交集:$R_k \cap G$ 表示返回结果里真正命中的正确答案块。

Rk∩G=命中的正确答案R_k \cap G = \text{命中的正确答案}

4. 取模求比例:$|\cdot|$ 是集合大小,$\frac{|R_k \cap G|}{|G|}$ 是命中块占全部正确答案的比例。

∣ Rk∩G ∣∣G∣∈[0,1]\frac{|\,R_k \cap G\,|}{|G|} \in [0, 1]

除了召回率,小美还会随机打开几十个盒子检查语义是否完整:一段话有没有被腰斩、一个列表有没有被拆散、代码块有没有缺行。数值指标加人工抽检,双管齐下才能判断切分策略值不值得上线。

工程落地优化

仓库越来越忙之后,小美想了三招提速。第一招:在相邻货架的边界都多放一份畅销品,这样客户无论从哪边走都能拿到货——对应「重叠窗口」,让相邻 chunk 之间交叠一部分文本。第二招:新货到货时先只拆开箱顶看标签,客户真下单才拆包装——对应「惰性解析」,先只扫描标题行,正文按需处理。第三招:只有某个货架的区域发生变化时才重新整理那一片——对应「增量更新」,按文件哈希判断是否需要重新解析。

重叠窗口的滑动规律可以用一个偏移公式描述:

offseti=i×(Swindow−Ooverlap)\displaystyle offset_i = i \times (S_{window} - O_{overlap})

1. 窗口大小:$S_{window}$ 是每个 chunk 的目标字符长度。

Swindow=单块目标长度S_{window} = \text{单块目标长度}

2. 重叠量:$O_{overlap}$ 是相邻两个块重复的字符数。

Ooverlap=重叠字符数O_{overlap} = \text{重叠字符数}

3. 滑动间距:$S_{window} - O_{overlap}$ 是每次实际向前推进的距离。

Swindow−Ooverlap=实际步长S_{window} - O_{overlap} = \text{实际步长}

4. 起点计算:$offset_i = i \times (S_{window} - O_{overlap})$ 是第 $i$ 块的起始偏移。

offseti=i×(Swindow−Ooverlap)offset_i = i \times (S_{window} - O_{overlap})

这三招让小美的仓库在大促期间也没有瘫痪:解析速度快了,检索命中率也没掉。边界修正则是锦上添花——如果块的首尾正好落在代码块中间,就把这个块整体向后挪几行,避免切断程序语法。

文本清洗

上架之前,小美还会把货物里的泡沫纸、空纸箱、缠歪的标签全部清掉。Markdown 文档里也有这类杂质:连续多个空行、图片链接、导航栏文字、站内跳转的锚点。这些内容不会被用户检索,却会稀释 chunk 里有效信息的密度,让向量更「模糊」。

清洗规则要区分文本类型:正文里的连续空行可以压缩成一个;代码块里的缩进和空行不能随便压,否则程序语义就变了;图片链接和导航文字可以直接删除,因为它们对问答没有贡献。清洗效果可以用信息密度来衡量:

D=CeffectiveCtotal\displaystyle D = \frac{C_{effective}}{C_{total}}

1. 有效字符数:$C_{effective}$ 表示清洗后保留的有意义文本字符数。

Ceffective=清洗后的有效字符C_{effective} = \text{清洗后的有效字符}

2. 总字符数:$C_{total}$ 表示清洗前的原始字符总数。

Ctotal=原始总字符C_{total} = \text{原始总字符}

3. 求密度:两者相除得到 $D$,范围在 0 到 1 之间。

D=CeffectiveCtotal∈[0,1]D = \frac{C_{effective}}{C_{total}} \in [0, 1]

4. 优化方向:$D$ 越接近 1,说明每单位文本的语义载荷越高,检索越容易命中。

D→1 表示越干净D \rightarrow 1 \text{ 表示越干净}

小美把这条公式贴在墙上当作「卫生标准」,从此每个上架的盒子都轻装上阵。仓库的空间利用率提高了,客户取货的精准度也上了一个台阶。

AI 应用场景

企业客服知识库问答

阶段:文档加载与处理

解决的问题:客服系统要上传数百篇产品手册(Markdown 格式),传统做法直接整篇喂给模型,不仅超出上下文窗口,检索时也定位不到具体段落。用标题解析 + 路径元信息切分后,每个 chunk 自带「文档 > 章节 > 小节」的定位,检索命中率大幅提升。

具体案例:某 SaaS 公司把 1000 篇使用手册按二级标题切成约 8000 个 chunk,每个 chunk 携带标题路径。上线后,用户问「如何重置密码」,系统能精确返回「账号管理 > 安全设置」下的段落,而不是整篇手册。

代码库问答助手

阶段:文档切分与清洗

解决的问题:开发者提问「这段代码怎么用」时,如果代码块在切分时被拦腰截断,嵌入向量会严重失真。通过代码块隔离与边界修正,让代码块整块保留,再配合周围文本组成 chunk。

具体案例:某 DevOps 团队的内部代码问答机器人,对 README 做切分时识别所有代码块并整块保护。工程师问「CI 流水线如何配置缓存」,机器人引用的代码块完整无缺,直接可以复制使用。

企业知识库增量更新

阶段:索引构建与更新

解决的问题:知识库文档每周更新,如果每次都全量重新解析,耗时且浪费算力。通过文件哈希判断文档是否变化,只对变更文件做增量切分和重新向量化。

具体案例:某银行合规部门每周更新制度文档,系统记录每个文件的哈希值,发现变更后只把对应文档重新解析成 chunk 并更新向量库。全量重跑需 2 小时,增量更新只需 3 分钟。

Python 演示

python47 行
# -*- coding: utf-8 -*-"""根据标题层级把 Markdown 拆成带路径的 chunk"""import re def parse_headings(md_text):    """返回 [(level, title, start_line)]"""    headings = []    for i, line in enumerate(md_text.splitlines()):        m = re.match(r'^(#{1,6})\s+(.*?)\s*$', line)        if m:            headings.append((len(m.group(1)), m.group(2).strip(), i))    return headings def split_by_headings(md_text):    headings = parse_headings(md_text)    lines = md_text.splitlines()    chunks = []    if not headings:        return chunks    for idx, (level, title, start) in enumerate(headings):        end = headings[idx + 1][2] if idx + 1 < len(headings) else len(lines)        body = "\n".join(lines[start:end]).strip()        if not body:            continue        chunks.append({"level": level, "path": title, "text": body})    return chunks def merge_small_chunks(chunks, min_chars=200):    merged = []    for c in chunks:        if merged and (c["level"] > merged[-1]["level"] or len(c["text"]) < min_chars):            prev = merged[-1]            prev["path"] = prev["path"] + " > " + c["path"]            prev["text"] = prev["text"] + "\n\n" + c["text"]            prev["level"] = min(prev["level"], c["level"])        else:            merged.append(dict(c))    return merged def main():    sample = """# 项目文档 ## 简介 这是项目的简介内容,说明项目用途。 ## 安装
目的:演示一个最简可用的 Markdown 标题切分器——先按 # 识别标题层级,再把每个标题及其下属内容组装成一个 chunk,最后把小标题就近合并到父块。
  1. re.match(r'^(#{1,6})\s+(.*?)\s*$', line)用正则识别 Markdown 标题行,井号数量就是标题层级。
  2. headings.append((len(m.group(1)), m.group(2).strip(), i))记录标题层级、标题文本和所在行号。
  3. end = headings[idx + 1][2] if idx + 1 < len(headings) else len(lines)下一个标题所在的行,就是当前标题块的结束位置。
  4. chunks.append({"level": level, "path": title, "text": body})每个标题子树作为一个 chunk 存入列表。
  5. merge_small_chunks把过短的小标题块合并到上一个父块中,避免出现碎片上下文。

常见误区

误区我以为只要把每个三级标题、四级标题都单独切成一块,检索时肯定更容易命中,于是把一篇文档切成了几百个碎块。

正解块太小会丢失上下文。检索模型看到的是一个只有两行字的「孤岛」,根本不知道它属于哪个章节。正确做法是设置块体积下限(如 200 字),小标题就近合并到父块中,保证每一块都有完整的语义单元。

为何常见:人们直觉上觉得「小块更容易精确匹配」,却忽略了语义完整对向量表征的影响,属于典型的局部最优思维。

误区RAG 反正都要向量化,直接按 500 字一个块切开就行,标题不标题的无所谓。

正解固定长度切分会把标题和正文、表格和代码拦腰截断,检索到的常常是残缺的碎片。只有对完全没有结构的纯文本才适合用固定长度;有结构的 Markdown 文档应当优先按标题切。

为何常见:很多入门教程为了省代码,都用固定长度切分演示,导致初学者误以为这是通用最优解。

误区代码块不就是普通文本吗?按长度切的时候从中间断开也没什么关系。

正解代码一旦被切断,语法结构就破坏了,嵌入向量的语义会严重失真。正确做法是把代码块整体隔离,绝不允许切分点落在代码块内部;必要时可把代码块压缩后与周围文本一起组成块。

为何常见:代码块在 Markdown 渲染中看起来只是「底色不同的文本」,初学者看不出它在结构上和普通段落的本质区别。

面试题

Q1. 在 Markdown 中,以 ## 开头的行表示什么?

单选题 · easy

  • A.一个一级标题
  • B.一个二级标题← 正确答案
  • C.正文被加粗
  • D.无序列表项

答案:B。井号数量代表标题层级,一个井号是一级标题,两个井号是二级标题。加粗用 `,列表用 - 或 *`,所以 B 正确。

Q2. 按标题切分 chunk 的主要优点是什么?

单选题 · easy

  • A.块大小完全一致
  • B.保持语义完整← 正确答案
  • C.无需解析文档
  • D.计算开销最低

答案:B。每个标题及其下属内容构成一棵子树,天然是完整的语义单元,按标题切分能让块与块之间边界清晰。块大小一致是固定长度切分的特点,按标题切不需要解析文档也是错的。

Q3. 混合切分策略的核心思想是什么?

单选题 · mid

  • A.完全靠标题定界
  • B.标题为主长度为辅← 正确答案
  • C.随机挑选一种方式
  • D.全部按段落切分

答案:B。混合策略的第一优先级是标题边界,只有标题块超长时才用固定长度兜底,即「标题优先、长度兜底」。A 忽略了兜底,C 和 D 都不是标准做法。

Q4. 在 chunk 中携带标题路径的主要作用是什么?

单选题 · mid

  • A.减小存储体积
  • B.补全上文语境← 正确答案
  • C.加快向量化速度
  • D.替代正文内容

答案:B。标题路径相当于给 chunk 加了一段「来路标签」,让检索模型知道这段内容在文档中的位置,从而补齐语义上下文。它不会减少存储、不会加快向量化,更不能替代正文。

Q5. 切分 Markdown 时,哪些内容应当整块保留不切断?

多选题 · mid

  • A.代码块内容← 正确答案
  • B.表格行列← 正确答案
  • C.文档空行
  • D.标题层级

答案:AB。代码块和表格都有严格的结构,一旦被拦腰截断语义就损坏了,所以切分点必须绕开。空行本身没有语义,标题层级是切分的依据而非需要保留的块。

Q6. 重叠窗口主要解决什么问题?

单选题 · hard

  • A.块间语义出现断层← 正确答案
  • B.存储空间严重不足
  • C.检索速度变得很慢
  • D.标题特征难以识别

答案:A。固定边界切分后,同一句话可能被拆到两个块里,导致检索哪边都搜不全。重叠窗口让相邻块共享一段文本,把跨越边界的内容在两边都保留一份,从而解决语义断层。

Q7. 评价切分质量时,最核心的量化指标是什么?

单选题 · mid

  • A.总块数尽可能少
  • B.检索召回率达标← 正确答案
  • C.单块体积尽量大
  • D.解析速度更快

答案:B。Recall@k 直接衡量「答案所在的块有没有被检索回来」,是切分质量最直接的量化指标。块数少、体积大、解析快都不等于切得好,切分的最终目的是让检索命中。

Q8. 下列哪些做法能有效提升 Markdown 切分质量?

多选题 · hard

  • A.压缩文档空行← 正确答案
  • B.隔离保护代码块← 正确答案
  • C.就近合并小标题← 正确答案
  • D.粗暴截断长段落

答案:ABC。空行压缩减少噪声,代码块隔离避免语法断裂,小标题合并避免碎片上下文,这三者都能提升质量。粗暴截断长段落是典型的反面操作,会破坏语义。

Q9. 固定长度切分最典型的副作用是什么?

单选题 · easy

  • A.文档加载速度更快
  • B.正文被拦腰截断← 正确答案
  • C.标题层级自动提升
  • D.代码错误自动修复

答案:B。固定长度完全不看文档结构,切分点可能落在句子中间、代码块内部或表格行之间,把完整的语义单元切断。其余选项都不是它的典型副作用。

Q10. 关于切分质量评估,下列哪些做法是正确的?

多选题 · hard

  • A.用真实问答验证召回← 正确答案
  • B.人工抽样检查语义完整← 正确答案
  • C.只统计块的字数大小
  • D.对比不同策略消融结果← 正确答案

答案:ABD。真实问答算 Recall@k、人工抽检语义完整性、对不同切分策略做消融对比,三者都是科学的评估手段。只统计字数是自欺欺人,无法反映切分质量。