博客文章的解析:Markdown 也是有讲究的
约 2581 字大约 9 分钟
2026-09-12
从这篇开始进入正餐第一道菜:解析。就是把一篇篇 Markdown 文件,变成程序能用的、干净的结构化数据。本篇对应 relax-admin 的「知识库 → 知识入库」和「语料管理 → 文档上传 / 解析列表」——讲完这篇,这两页背后的后端就该从演示数据变成真接口了。
我知道你想说什么——「Markdown 本来就是纯文本,读文件不就完了吗」。我一开始也是这么想的,但仔细一琢磨,「读文件全文入库」这个最偷懒的版本,坑是真的多:frontmatter 混进正文、目录块变成检索噪声、老文章里的 HTML 残渣……这一篇就是把这些「想当然」一个个揪出来。
先把判断立在这:解析质量决定 RAG 效果的上限。后面切分、检索、模型都是在解析产出的东西上做文章,你解析时丢了标题层级,切分就只能瞎切;你把 frontmatter 混进正文,检索就会被 createTime: 2024/03/01 这种字符串干扰。垃圾进,垃圾出,后半条管道没得救。
解析到底要干几件事
拆开看其实是四件事:
- 读文件,抽出正文和结构——正文在哪、标题层级是什么、哪些是代码块
- 摘出 frontmatter 元信息——title、分类、时间这些
- 清洗——去掉不该进语料的东西
- 组装成统一模型——正文加元数据,打包成 Java AI 的
Document
一件件说。
frontmatter:最容易被「顺手搞砸」的部分
博客文章开头那段这个:
---
title: 别让重复请求搞垮你的下单接口
createTime: 2024/03/01
category: 后端
---它叫 frontmatter,是博客系统存元信息的地方。解析时的正确姿势是把它摘出来放进 Document 的元数据,而不是留在正文里让它一起被向量化。
直接全文入库会怎样?我推演一下就很明显:用户问「三月写过什么」,检索命中的理由全是 createTime: 2024/03/01 这个字符串——这种日期字符串在语义空间里跟一堆「时间类问题」的距离都不远,纯纯的噪声。日期进元数据可以参与过滤(「只搜 2024 年以后的文章」),混进向量就是祸害。
通用原则
结构化信息走元数据,非结构化语义走向量,别让它们互相客串。
正文结构:标题层级是黄金资产
Markdown 看着是纯文本,其实是有结构的:# 到 ###### 是六级标题,三个反引号包起来的是代码块,| 拼的是表格。解析阶段的头号任务,就是把这些结构原样保住。
为什么这么重要?两个下游都指着它:
- 切分靠它定位:下一篇会讲「按标题切分」,前提是解析结果里标题还是标题。你要是把整篇拍成无结构的纯文本,按标题切就没戏了,只能退回按字数硬切,效果掉一档
- 引用靠它说话:用户看到的答案出处如果能显示「《下单接口》> 方案二:分布式锁」这种标题路径,比一个光秃秃的文章名友好十倍——这路径就是解析时攒下来的
Java AI 这边有个现成的 MarkdownDocumentReader,能按标题层级把一篇 Markdown 拆成带结构的段,段里带上层级信息。可以直接用,也可以看着它的思路自己写——我是打算自己写个轻量的,因为要在遍历标题树的时候顺手干几件私货事(下面说)。
清洗:语料里常见的几类垃圾
按遇到的可能性从高到低说:
第一类,HTML 残渣。 有些文章是从别的平台搬过来的,正文里嵌着 <div class="highlight">、 这类东西。处理办法是「有标签就剥,剥完保正文」,用一个 HTML 清洗库过一遍再拼回 Markdown。有个细节得留神:这类文章里 <pre><code> 包着的代码块,剥标签的时候要转成 Markdown 代码块格式,别把代码内容搞坏了。
第二类,文章里的目录块。 有些文章开头有自己手写的目录(一堆锚点链接)。这东西对读者有用,对语料是纯毒药——它是一堆标题的无序罗列,切分后会变成「一堆全是指向自己的链接的碎片」。
检测思路
[xxx](#xxx) 这种锚点链接密集出现的区块,大概率就是目录,整块删掉。
第三类,碎碎念。 两百字以下的短文、以及标记了 draft: true 的草稿,直接在入库前过滤掉。上一篇说过原因:垃圾语料占的不是存储,是检索结果里的坑位。
清洗的总原则就四个字:去噪不去义。删的是确定无用的,但凡有一丝信息量的(代码块、表格、链接文字)都留着。宁可漏删,不可错杀。
元数据设计:第一天就要定稿的东西
上次定了最低集合,这次正式定稿,并且说清楚每个字段是干嘛的——因为它们每一个都对应着后面的某个功能:
Document doc = new Document(cleanText, Map.of(
"slug", "prevent-duplicate-orders", // 文章唯一标识:文章改版后全删全灌的定位键
"title", "别让重复请求搞垮你的下单接口", // 引用展示和标题路径的根
"category", "后端", // 检索过滤:只搜某分类
"publish_time", "2024-03-01", // 时间过滤 + 新旧文档冲突处理
"embedding_model", "text-embedding-v3", // 06 篇的主角,先占位
"chunk_index", 3 // 切分时填,先占位
));特别说下 slug。博客文章是会改的:改错字、补充内容、甚至重写。我的策略是以文章为单位全删全灌——用 slug 把这篇旧文章的所有切片删干净,再灌新版本。这个「按文章重建」的能力,是后面一切切分策略调整的基础设施。没有它,你调一切分参数都要面对「新旧切片混在一起打架」的幽灵问题。
一个「解析也算架构」的冷知识
顺便说个很多人没意识到的点:Markdown 解析在 Java AI 的 ETL 管道里和 PDF、Word 走的是同一套抽象(DocumentReader → DocumentTransformer → DocumentWriter)。这意味着你今天为博客写的这套解析逻辑,明天想支持「让读者上传 PDF 提问」,只需要换一个 Reader,后面的切分、入库全复用。
这也是 Java AI 这套 ETL 抽象的价值所在:解析的产出被统一成了 Document 这个「通用货币」,后面所有环节不关心你原文是什么格式。所以别把解析写成「读文件 + 字符串处理」的一个大方法,按 Reader/Transformer 的粒度拆好,是你给未来的自己留的礼物。
顺带泼个冷水
PDF 可没有 Markdown 这么友好。它是排版指令集不是结构化文档,双栏论文会读出交错乱序的段落,扫描件里压根没有文本层。真要支持 PDF,TikaDocumentReader 起步,坑一个不少。这是后面「上传文档」功能要正面处理的事。
解析质量怎么自查
写完不能拍脑袋说「应该没问题」,给你两个自查手段:
一是抽样肉眼比对。 随机抽十篇,把解析产出打出来和原文件对着看:标题层级对不对、代码块完整不完整、frontmatter 干不干净。土,但有效,这一步能抓住 90% 的问题。
二是给个量化指标。 记录每篇文章的「解析后字符数 ÷ 原文件字符数」,这个比值应该稳定在一个区间(比如 0.7 到 0.95,清洗掉一些很正常)。哪天某篇文章这个值掉到 0.3,大概率是清洗规则误杀或者编码出问题了。异常检测比逐篇人工靠谱,几百篇文章你肉眼看不过来。
这两个手段落地之后,载体就是 relax-admin 的「解析列表」页:每一行一篇文档,状态(解析中/就绪/失败)、字符数、失败原因一目了然,「抽样比对」点开详情就能看解析产出的正文和结构。解析是异步任务,状态机就在这一页可视化——这也回答了「为什么要异步」:同步解析大文件会拖垮 HTTP 线程,博客走 Gitee 同步批量进来时更得异步排队。
📷 图片位(待补充)
应放什么图:「语料管理 → 解析列表」页截图,重点圈出:状态列(一篇「解析中」、一篇「失败」带错误原因、若干「就绪」)、字符数列。如果详情抽屉能展示某篇文档的解析结果(标题层级 + 正文),一并截上更佳。
顺带一提,「文档上传」页是给上传型知识库用的(拖拽上传、格式白名单校验),博客知识库走 Gitee 同步不经过它——但两个来源最后汇进同一条解析管道,这正是下一节要讲的架构价值。
今天的代码清单
到这里,离线管道的第一站就通了:一堆杂七杂八的 Markdown 文件,变成了整齐的「正文 + 结构 + 元数据」的 Document 列表。
下一篇进入整个系列技术含量和手感要求最高的一环:切分。为什么说「效果的一半在切分」?512 还是 1024 的窗口怎么定?代码块被切成两半怎么办?——坑我提前替你数好了,下一篇见。