前一篇我们已经讲过,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 的正文在页面里本来是连续段落,但转换后常常会变成这样:
本系统支持多种文档格式的解析与转换, 包括 PDF、Word、Excel 等常见办公文档。 在实际使用过程中,建议先进行结构清洗, 再进入向量化和检索阶段。人看问题不大,但对 RAG 来说,这种“视觉换行”会误导 chunking。
尤其是你如果按行处理、按固定长度切块,或者后面还要做标题识别、段落合并,这种碎裂文本会非常影响质量。
更麻烦的是,有些被切碎的句子恰好落在 chunk 边界上,前半句和后半句分到两个块里,最后召回时上下文不完整,大模型就开始“半懂不懂”。
3. 标题层级混乱
这是知识库入库里最伤结构的一类问题。
你原本希望文档是这样的:
# 1 总则 ## 1.1 适用范围 ## 1.2 审批流程 # 2 报销标准结果转出来可能变成:
1 总则 适用范围 审批流程 2 报销标准或者更糟一点:
### 1 总则 # 适用范围 #### 审批流程 ## 2 报销标准这时候,标题层级一乱,chunk 的语义边界就乱了。
你本来是想按章节、按小节切,最后却变成正文和标题混在一起,父子关系全没了。这样一来,检索到的块就没有稳定上下文,大模型很难知道“这句话属于哪个主题之下”。
对于 RAG 而言,标题不是排版装饰,而是结构锚点。
锚点不稳,整个知识库的组织方式都会变差。
4. 表格结构不稳定
表格是 PDF 转 Markdown 里最容易“看起来有,实际不好用”的部分。
常见问题包括:
- 表头和数据行错位
- 单元格内容被拆散
- 多行单元格被挤压成一行
- 表格被输出成 HTML,但入库流程没处理
- 表格前后的说明文字和表本体断开
对人来说,表格还能靠视觉补全;
对模型来说,一个结构坏掉的表,基本等于半失效。
尤其在知识库场景里,很多关键内容都藏在表里,比如:
- 费用标准
- 参数对照
- 权限矩阵
- 操作步骤
- 型号配置
你如果不做表格修复,最后最重要的信息反而最难被搜到。
5. 图片、图注和正文关系断裂
PP-StructureV3 往往会把图片抽出来,把 Markdown 里保留图片引用。但问题是,图片本体、图注、上下文说明,未必天然黏在一起。
例如原文是:
图 3 系统总体架构图 系统采用三层架构设计,包括接入层、处理层和存储层。结果转出来可能变成:
 系统采用三层架构设计,包括接入层、处理层和存储层。 图 3 系统总体架构图这样后面做 chunking 时,图片、图注、正文很可能被切开。
一旦切开,图就只是图,注就只是注,说明就只是说明,三者失去关联。检索时你搜“系统总体架构”,召回出来的可能只有一张图片路径,或者只有一句说明,没有完整语义。
6. 列表、编号、多栏顺序错乱
很多制度文件、说明书、论文、报告都存在:
- 有序列表
- 多级编号
- 双栏排版
- 左右并列模块
如果阅读顺序没恢复好,就会出现这种情况:
1. 提交申请 3. 财务复核 2. 部门审批 4. 完成报销或者本来左栏是一段,右栏是一段,结果被交叉拼接在一起。
这类问题非常致命,因为它不是“脏”,而是“错”。
脏数据还能靠检索概率碰运气,
错顺序会直接让模型理解反过来。
你让它回答“报销流程是什么”,它可能会把先后步骤讲颠倒。
三、为什么这些脏点会直接毁掉 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 清洗后,到底差在哪?
来看一个非常常见的例子。
清洗前
企业财务管理制度 第 8 页 3 报销流程 员工提交报销申请 并附相关票据材料 部门负责人审批 财务复核后完成支付 企业财务管理制度这段内容看起来不算“乱码”,但对知识库很不友好:
- 页眉重复
- 页码混入
- 标题没层级
- 正文被切成一行一行
如果直接拿去做 chunking,很容易切成:
- 块 1:企业财务管理制度 / 第 8 页 / 3 报销流程
- 块 2:员工提交报销申请 / 并附相关票据材料
- 块 3:部门负责人审批 / 财务复核后完成支付
这三个块都不完整,语义也不稳。
清洗后
# 3 报销流程 员工提交报销申请,并附相关票据材料。部门负责人审批,财务复核后完成支付。现在再做 chunking,结果就会非常清晰:
标题是标题,正文是正文,流程信息完整连续,检索“报销流程”“财务复核”“票据材料”时,召回概率都会更高。
再看一个表格类例子。
清洗前
报销类型 | 上限金额 | 审批人 差旅费 | 2000元 | 部门负责人 招待费 | 5000元 | 总经理如果缺少 Markdown 表头分隔线,很多后续工具不会把它当标准表格。
清洗后
| 报销类型 | 上限金额 | 审批人 | | --- | --- | --- | | 差旅费 | 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,那恭喜,你已经完成了最难的上半场。
接下来别急着把文件直接丢进向量库,先把页眉页脚、标题层级、表格修复、段落合并这些基础清洗做好。因为对知识库来说,干净的结构,永远比多跑一次模型更值钱。