《PP-StructureV3 转出来的 Markdown,为什么还不能直接丢进 RAG?》

12 阅读13分钟

前一篇我们已经讲过,PDF 转 Markdown 的本质,不是把文字抠出来,而是把结构尽量还原出来。 这也是为什么很多人在第一次用 PP-StructureV3 的时候,会产生一种“终于搞定了”的错觉:模型跑通了,Markdown 也生成了,标题、正文、表格、图片看起来也都有了,于是下一步就很自然地想把它直接丢进向量库,接入 RAG,开干。

但真正做过知识库的人都知道,最容易翻车的,恰恰就是这一步。

因为“转出来”不等于“能入库”,更不等于“适合检索”。 你看到的是一个能打开的 .md 文件,向量库看到的却是一堆会影响 chunking、检索召回和答案质量的噪声。很多时候,大模型答得不准,不是因为 embedding 模型不行,也不是 rerank 不够强,而是因为你喂进去的原始文本,从一开始就不干净。

所以这篇文章我们就把这件事讲透:PP-StructureV3 转出来的 Markdown,为什么还不能直接丢进 RAG?到底要做哪些 Markdown 后处理,才能让它真正变成适合知识库入库的结构化内容?

一、为什么“转出来”不等于“适合检索”?

先说一个最核心的判断标准:

面向人看的 Markdown,和面向 RAG 的 Markdown,不是同一个东西。

面向人看,只要大致能看懂,标题像标题,正文像正文,表格没完全坏掉,很多问题都还能忍。 但面向 RAG 不一样。RAG 关心的是:

文本是否连续 标题层级是否稳定 chunk 边界是否合理 表格信息是否可被正确切分 图文关系是否没有丢 噪声是否足够少

也就是说,RAG 要的不是“能看”,而是“能切、能搜、能答”。

PP-StructureV3 的优势在于,它已经帮你把复杂 PDF 从“纯视觉页面”变成了“带有结构意识的文本结果”。这一步非常关键,没有这一步,后面连谈 Markdown 后处理、知识库入库、chunking 优化都无从谈起。 但同样要看到,PP-StructureV3 的职责主要是“解析”和“转换”,不是“替你把知识库清洗到可直接上线”。它更像是把原材料从石头打成毛坯,而不是直接给你成品家具。

所以,PP-StructureV3 是上游,Markdown 后处理是中游,RAG/知识库 才是下游。 中间这一步不做,后面的效果大概率会打折。

二、原始 Markdown 最常见的 6 个脏点

1. 页眉页脚残留

这是最常见、也最容易被忽略的问题。

很多 PDF 每一页都会带:

文档标题、公司名、章节名、页码、保密标识、水印文字

转成 Markdown 之后,这些内容会一页一页重复出现。人看一眼就知道那是页眉页脚,脑子会自动忽略;但向量库不会。它只会老老实实把这些重复内容切进 chunk 里,最后造成两个问题:

第一,噪声占据 chunk 空间。 第二,重复信息被 embedding 强化,让检索结果越来越偏向这些无意义内容。

最后你搜“报销流程”,召回出来的可能是一堆带着公司标题、页码和保密声明的块,而不是具体步骤。

2. 段落被硬换行切碎

很多 PDF 的正文在页面里本来是连续段落,但转换后常常会变成这样:

image.png

人看问题不大,但对 RAG 来说,这种“视觉换行”会误导 chunking。 尤其是你如果按行处理、按固定长度切块,或者后面还要做标题识别、段落合并,这种碎裂文本会非常影响质量。

更麻烦的是,有些被切碎的句子恰好落在 chunk 边界上,前半句和后半句分到两个块里,最后召回时上下文不完整,大模型就开始“半懂不懂”。

3. 标题层级混乱

这是知识库入库里最伤结构的一类问题。

你原本希望文档是这样的:

image.png

结果转出来可能变成:

image.png

或者更糟一点:

image.png

这时候,标题层级一乱,chunk 的语义边界就乱了。 你本来是想按章节、按小节切,最后却变成正文和标题混在一起,父子关系全没了。这样一来,检索到的块就没有稳定上下文,大模型很难知道“这句话属于哪个主题之下”。

对于 RAG 而言,标题不是排版装饰,而是结构锚点。 锚点不稳,整个知识库的组织方式都会变差。

4. 表格结构不稳定

表格是 PDF 转 Markdown 里最容易“看起来有,实际不好用”的部分。

常见问题包括:

表头和数据行错位 单元格内容被拆散 多行单元格被挤压成一行 表格被输出成 HTML,但入库流程没处理 表格前后的说明文字和表本体断开

对人来说,表格还能靠视觉补全; 对模型来说,一个结构坏掉的表,基本等于半失效。

尤其在知识库场景里,很多关键内容都藏在表里,比如:

费用标准、参数对照、权限矩阵、操作步骤、型号配置

你如果不做表格修复,最后最重要的信息反而最难被搜到。

5. 图片、图注和正文关系断裂

PP-StructureV3 往往会把图片抽出来,把 Markdown 里保留图片引用。但问题是,图片本体、图注、上下文说明,未必天然黏在一起。

例如原文是:

image.png

结果转出来可能变成:

image.png

这样后面做 chunking 时,图片、图注、正文很可能被切开。 一旦切开,图就只是图,注就只是注,说明就只是说明,三者失去关联。检索时你搜“系统总体架构”,召回出来的可能只有一张图片路径,或者只有一句说明,没有完整语义。

6. 列表、编号、多栏顺序错乱

很多制度文件、说明书、论文、报告都存在:

有序列表 多级编号 双栏排版 左右并列模块

如果阅读顺序没恢复好,就会出现这种情况:

image.png 或者本来左栏是一段,右栏是一段,结果被交叉拼接在一起。 这类问题非常致命,因为它不是“脏”,而是“错”。

脏数据还能靠检索概率碰运气, 错顺序会直接让模型理解反过来。 你让它回答“报销流程是什么”,它可能会把先后步骤讲颠倒。

三、为什么这些脏点会直接毁掉 chunk 质量?

很多人把 RAG 做不好,第一反应是去换 embedding,换 rerank,换大模型。 但很多时候,真正的问题在更上游:chunk 质量太差。

chunking 不是简单地“每 500 字切一刀”,而是在做一件更底层的事:把文档切成既完整、又可检索的语义单元。

而前面那 6 类脏点,会分别从 4 个方向毁掉 chunk 质量。

第一,语义被切断

段落碎裂、图文分离、表格断裂,都会让一个本来完整的信息单元被拆成几截。 这样检索到的块往往只包含半句话、半张表、半段说明。模型拿到的是不完整上下文,答案自然不稳定。

第二,边界被误导

标题层级混乱、多栏顺序错乱,会让系统误以为某些内容应该放在一起,或者误以为某段内容已经结束。 于是 chunk 边界不是按语义切,而是按噪声切。

第三,噪声被放大

页眉页脚、水印、页码、重复标题这类内容一旦大量入库,会在 embedding 空间里形成高频干扰。 结果就是:本来应该搜到“审批流程”,最后召回的却是“管理制度 第 8 页”。

第四,检索意图和文本结构对不上

RAG 检索不是全文回放,而是“拿用户问题去找最像的知识片段”。 如果你的 Markdown 结构本来就散,问题再精确也很难匹配到真正有用的块。

所以说到底,Markdown 后处理不是格式洁癖,而是检索质量工程。

四、给一份可直接跑的 Markdown 后处理脚本

下面这份脚本不是万能的,但很适合作为 PP-StructureV3 输出后的第一道清洗工序。它主要做 5 件事:

删除明显的页码和重复短行 合并被硬换行切碎的正文段落 规范标题层级 压缩多余空行 让简单表格和图注更稳定一些

from pathlib import Path
import re
from collections import Counter
 
INPUT_MD = "output/raw.md"
OUTPUT_MD = "output/cleaned.md"
 
 
def is_structural_line(line: str) -> bool:
    s = line.strip()
    if not s:
        return True
    return any([
        s.startswith("#"),
        s.startswith("|"),
        s.startswith("![]("),
        s.startswith("- "),
        s.startswith("* "),
        bool(re.match(r"^\d+\.\s+", s)),
        s.startswith("```"),
        s.startswith(">"),
    ])
 
 
def remove_page_noise(text: str) -> str:
    lines = text.splitlines()
 
    # 去除常见页码行
    cleaned = []
    for line in lines:
        s = line.strip()
        if re.fullmatch(r"第?\s*\d+\s*页", s):
            continue
        if re.fullmatch(r"-?\s*\d+\s*-?", s):
            continue
        cleaned.append(line)
 
    # 统计重复短行,疑似页眉页脚
    stripped = [x.strip() for x in cleaned if x.strip()]
    counter = Counter(stripped)
 
    result = []
    for line in cleaned:
        s = line.strip()
        # 短、重复、且不像正常正文/结构行,则删除
        if (
            s
            and len(s) <= 25
            and counter[s] >= 3
            and not is_structural_line(s)
            and not re.search(r"[。;:,、]", s)
        ):
            continue
        result.append(line)
 
    return "\n".join(result)
 
 
def normalize_headings(text: str) -> str:
    lines = text.splitlines()
    out = []
 
    for line in lines:
        s = line.strip()
 
        # 一级标题:一、xxx / 1 xxx / 1. xxx
        if re.match(r"^[一二三四五六七八九十]+、", s) or re.match(r"^\d+[\.\s]+", s):
            if len(s) <= 30 and not s.startswith("#"):
                s = "# " + s.lstrip("#").strip()
                out.append(s)
                continue
 
        # 二级标题:(一)xxx / (一)xxx / 1.1 xxx
        if re.match(r"^([一二三四五六七八九十]+)", s) or re.match(r"^\([一二三四五六七八九十]+\)", s) or re.match(r"^\d+\.\d+[\.\s]*", s):
            if len(s) <= 35 and not s.startswith("#"):
                s = "## " + s.lstrip("#").strip()
                out.append(s)
                continue
 
        # 三级标题:1.1.1 xxx
        if re.match(r"^\d+\.\d+\.\d+[\.\s]*", s):
            if len(s) <= 40 and not s.startswith("#"):
                s = "### " + s.lstrip("#").strip()
                out.append(s)
                continue
 
        out.append(line)
 
    return "\n".join(out)
 
 
def merge_broken_paragraphs(text: str) -> str:
    lines = text.splitlines()
    merged = []
    buffer = []
 
    def flush_buffer():
        nonlocal buffer
        if buffer:
            merged.append(" ".join(x.strip() for x in buffer))
            buffer = []
 
    for line in lines:
        s = line.strip()
 
        if not s:
            flush_buffer()
            merged.append("")
            continue
 
        if is_structural_line(s):
            flush_buffer()
            merged.append(s)
            continue
 
        # 普通正文先进入缓冲,后续合并成连续段落
        buffer.append(s)
 
        # 若当前行以句末标点结尾,则认为段落结束
        if re.search(r"[。!?;:]$", s):
            flush_buffer()
 
    flush_buffer()
    return "\n".join(merged)
 
 
def normalize_blank_lines(text: str) -> str:
    # 连续3个以上空行压成2个
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip() + "\n"
 
 
def normalize_captions(text: str) -> str:
    lines = text.splitlines()
    out = []
    for line in lines:
        s = line.strip()
 
        # 图注统一单独成行
        if re.match(r"^图\s*\d+", s):
            out.append("")
            out.append(s)
            out.append("")
            continue
 
        # 表注统一单独成行
        if re.match(r"^表\s*\d+", s):
            out.append("")
            out.append(s)
            out.append("")
            continue
 
        out.append(line)
    return "\n".join(out)
 
 
def fix_simple_pipe_tables(text: str) -> str:
    lines = text.splitlines()
    out = []
    i = 0
 
    while i < len(lines):
        line = lines[i]
        s = line.strip()
 
        # 简单处理:若一行像表头,但下一行不是分隔线,则补一个分隔线
        if s.startswith("|") and s.endswith("|"):
            next_line = lines[i + 1].strip() if i + 1 < len(lines) else ""
            if not re.match(r"^\|[\-\s:\|]+\|$", next_line):
                cols = s.count("|") - 1
                if cols >= 2:
                    sep = "|" + "|".join([" --- "] * cols) + "|"
                    out.append(line)
                    out.append(sep)
                    i += 1
                    continue
 
        out.append(line)
        i += 1
 
    return "\n".join(out)
 
 
def main():
    raw_text = Path(INPUT_MD).read_text(encoding="utf-8")
 
    text = raw_text
    text = remove_page_noise(text)
    text = normalize_headings(text)
    text = merge_broken_paragraphs(text)
    text = normalize_captions(text)
    text = fix_simple_pipe_tables(text)
    text = normalize_blank_lines(text)
 
    Path(OUTPUT_MD).write_text(text, encoding="utf-8")
    print(f"清洗完成:{OUTPUT_MD}")
 
 
if __name__ == "__main__":
    main()

这份脚本的定位很明确: 不是把所有问题一次性解决,而是先把最影响 RAG 的基础噪声打掉。

复杂表格、跨页表格、多栏重排、图表关联增强这些问题,后面还可以继续做更专门的处理;但只要先把页眉页脚、标题层级、断裂段落、空行和简单表格处理掉,入库质量就已经会明显提升一个档次。

五、清洗前 vs 清洗后,到底差在哪?

来看一个非常常见的例子。

清洗前

image.png

这段内容看起来不算“乱码”,但对知识库很不友好:

页眉重复 页码混入 标题没层级 正文被切成一行一行

如果直接拿去做 chunking,很容易切成:

块 1:企业财务管理制度 / 第 8 页 / 3 报销流程 块 2:员工提交报销申请 / 并附相关票据材料 块 3:部门负责人审批 / 财务复核后完成支付

这三个块都不完整,语义也不稳。

清洗后

image.png

现在再做 chunking,结果就会非常清晰: 标题是标题,正文是正文,流程信息完整连续,检索“报销流程”“财务复核”“票据材料”时,召回概率都会更高。

再看一个表格类例子。

清洗前

image.png

如果缺少 Markdown 表头分隔线,很多后续工具不会把它当标准表格。

清洗后

image.png

效果如下:

报销类型上限金额审批人
差旅费2000元部门负责人
招待费5000元总经理

这时候无论你是后续转 HTML、做人审,还是做表格增强切分,都会稳定很多。

六、真正适合入库的,不是“原始 Markdown”,而是“清洗后的结构化 Markdown”

很多人做知识库,流程是这样的:

PDF → OCR → Markdown → 向量库 → 问答

看起来路径没错,但中间少了一步最关键的:

PDF → PP-StructureV3 → Markdown → Markdown 后处理 → chunking → 向量库 → RAG

这中间那层 Markdown 后处理,决定了三件事:

你入库的是“知识”,还是“噪声” 你切出来的是“语义块”,还是“碎片块” 你后面检索到的是“答案候选”,还是“页面残骸”

这也是为什么同样都在用 PP-StructureV3,有的人做出来的知识库检索效果很好,有的人却觉得“大模型怎么总答不准”。 问题未必出在模型,很多时候出在你把什么东西送进了模型。

七、最后给你一套“入库前检查清单”

在把 PP-StructureV3 生成的 Markdown 丢进 RAG/知识库之前,至少过一遍这套检查清单:

1. 页眉页脚清掉了吗?

看看有没有重复出现的公司名、文档名、章节名、页码、水印。

2. 标题层级稳定吗?

一级、二级、三级标题有没有明确区分?正文有没有误判成标题?

3. 段落是连续的吗?

是不是还保留了大量视觉换行?一句完整的话有没有被切成很多行?

4. 表格能读吗?

表头、数据、列关系是不是清楚?有没有出现表格散架、断页、错列?

5. 图、图注、正文关系还在吗?

图片路径是否可用?图注有没有和对应说明尽量放在一起?

6. 列表和流程顺序对吗?

编号顺序是否正常?多栏文档有没有串行错乱?

7. 空行和格式噪声处理了吗?

是否还有过多空白、无意义分隔、重复符号?

8. chunking 规则和文档结构匹配吗?

别拿标题混乱、表格断裂的 Markdown 直接按字数硬切。结构不稳,切得再漂亮也没用。

结语

很多人第一次接触 PP-StructureV3,会把它理解成一个“PDF 转 Markdown 工具”; 但真正把它用进生产流程之后,你会发现,它更像是文档结构化流水线的起点。

它负责把原始 PDF 从不可用状态,推进到可处理状态; 而真正决定 RAG 和知识库质量的,是后面那层常常被忽略的 Markdown 后处理。

所以这篇文章想讲清楚的,其实只有一句话:

PP-StructureV3 很重要,但它不是终点。 PDF 转出来只是第一步,清洗成适合 chunking 和检索的结构化 Markdown,才是真正能让 RAG 跑起来的那一步。

如果你现在已经能用 PP-StructureV3 把 PDF 转成 Markdown,那恭喜,你已经完成了最难的上半场。 接下来别急着把文件直接丢进向量库,先把页眉页脚、标题层级、表格修复、段落合并这些基础清洗做好。因为对知识库来说,干净的结构,永远比多跑一次模型更值钱。