《PP-StructureV3 转出来的 Markdown,为什么还不能直接丢进 RAG?》
前一篇我们已经讲过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(encodingutf-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, encodingutf-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那恭喜你已经完成了最难的上半场。接下来别急着把文件直接丢进向量库先把页眉页脚、标题层级、表格修复、段落合并这些基础清洗做好。因为对知识库来说干净的结构永远比多跑一次模型更值钱。