案例 #1725:HTML 表格归一化与分块保护
结论先行
PaddleOCR-VL 将文档中的表格渲染为带内联样式的 HTML,而 WeKnora 的 chunker 只对 Markdown 管道表做”不可切割”保护。结果是:HTML 表格一旦超过 ChunkSize 就被暴力切断,产出大量残缺 chunk,同时 text-align 等纯视觉属性还浪费了宝贵的 token 预算。
PR #1755 的修复策略是在 docparser 层插入一个 HTML 表格归一化器:能转 Markdown 的转,不能转的(rowspan/colspan)剥离样式属性后保留 HTML。改动仅 149 行新增代码 + 66 行测试,零破坏性,当天合入。
核心启发:管线中间层的输出格式必须满足下游的保护条件——这一原则同样适用于 #1248 的 feedback 数据流。
1. 问题现象(What)
用户上传含表格的文档,经 PaddleOCR-VL 解析后,在知识库搜索时发现:
- chunk 中出现被截断的
<td style="text-align:center">标签片段,语义完全丢失 - 即便表格内容很短,token 计数也远超预期——大量 token 被
style属性消耗 - 检索质量下降:向量化时,HTML 标签噪声稀释了真正的表格数据语义
Issue #1725 的一句话描述:PaddleOCR-VL 解析结果表格 HTML 影响分块效果(中途切断、浪费 Token)。
修复前 / 修复后
| 指标 | 修复前 | 修复后 |
|---|---|---|
| HTML 大表超 ChunkSize | 暴力截断 <td> |
简单表→MD 管道表;复杂表去样式 |
| chunker protectedPatterns | 只认 \| 管道表 |
MD 表受保护 |
| Token 占用 | 大量 style= |
剥离或转 MD |
定位步骤
rg "HTML|table|Normalize|protectedPatterns" internal/infrastructure/docparser/
rg "PaddleOCR|paddleocr" internal/infrastructure/docparser/
go test ./internal/infrastructure/docparser/ -run HTML -v
paddleocr_vl_converter.go— 谁调用 normalizerhtml_table_normalizer.go— 转换逻辑html_table_normalizer_test.go— 变体覆盖
2. 根因分析(Why)
类比
想象一条流水线:第一个工人(docreader/OCR)把纸质表格抄成”带花体字的手写版”(HTML + 样式),第二个工人(chunker)负责把长文档裁成固定长度的卡片。裁卡片的工人知道”如果看到 Markdown 表格的竖线 |,就整张表不裁”——但他不认识花体字(HTML <table>),所以直接从中间一刀切下去。
管线数据流
flowchart LR
A[用户上传文档] --> B[docreader<br/>Python / PaddleOCR-VL]
B -->|HTML table with styles| C[docparser<br/>Go / 格式规范化]
C -->|Markdown or clean HTML| D[chunker<br/>Go / 分块]
D --> E[向量存储 & 检索]
style B fill:#f9d,stroke:#333
style C fill:#bfb,stroke:#333
style D fill:#bbf,stroke:#333
关键路径:问题出在 B→C→D 这段。docreader 输出了 HTML 表格(这是 PaddleOCR-VL 的默认行为,无法轻易修改),而 docparser 原本没有对 HTML 表格做任何处理就直接传给 chunker。chunker 的 protectedPatterns 正则只匹配 Markdown 管道表语法(|...|...|),对 <table>...</table> 完全无视。
两个问题的交织
| 问题 | 原因 | 影响 |
|---|---|---|
| Token 浪费 | 每个 <td> 携带 style="text-align:center" 等纯视觉属性 |
同样内容的表格,HTML 格式 token 数是 Markdown 的 2-3 倍 |
| 分块切断 | chunker 只保护 Markdown 管道表,不保护 HTML <table> |
表格超过 ChunkSize 时被粗暴截断,产生残缺 chunk |
关键文件
| 文件 | 角色 | 改动类型 |
|---|---|---|
internal/infrastructure/docparser/html_table_normalizer.go |
新增归一化器 | NEW (+77) |
internal/infrastructure/docparser/html_table_normalizer_test.go |
单元测试 | NEW (+66) |
internal/infrastructure/docparser/paddleocr_vl_converter.go |
接入归一化器 | MODIFIED (+6) |
3. 解决方案(How)
设计思路
在 docparser 的 PaddleOCR-VL converter 中,对所有 <table> 块执行归一化:
- 简单表格(无 rowspan/colspan):转为 GFM Markdown 管道表 → chunker 的 protectedPatterns 天然覆盖
- 复杂表格(含 rowspan/colspan):Markdown 语法无法表达合并单元格,保留 HTML 结构但剥离所有 presentational attributes(
style、align、width等)→ 减少 token 浪费
关键代码片段
// html_table_normalizer.go — 核心逻辑(简化)
func NormalizeHTMLTables(content string) string {
// 用正则提取所有 <table>...</table> 块
return tablePattern.ReplaceAllStringFunc(content, func(table string) string {
if hasComplexStructure(table) {
// 复杂表格:剥离样式但保留结构
return stripPresentationalAttrs(table)
}
// 简单表格:转为 GFM Markdown
md, err := convertToMarkdown(table)
if err != nil {
return stripPresentationalAttrs(table) // fallback
}
return md
})
}
两种策略对比
| 表格类型 | 判断条件 | 处理方式 | 输出格式 | chunker 保护 |
|---|---|---|---|---|
| 简单表格 | 无 rowspan/colspan | html-to-markdown/v2 table plugin 转换 | \| col1 \| col2 \| |
protectedPatterns 覆盖 |
| 复杂表格 | 含 rowspan/colspan | 剥离 style/align/width 等属性 | 干净的 <table> HTML |
依赖 ChunkSize 兜底(已减少 token 浪费) |
接入点
在 paddleocr_vl_converter.go 中,解析完成后调用归一化:
// paddleocr_vl_converter.go — 接入(+6 行)
func (c *PaddleOCRVLConverter) Convert(raw string) string {
result := c.parseRawOutput(raw)
// 新增:HTML 表格归一化
result = NormalizeHTMLTables(result)
return result
}
4. Review 与合入(Maintainer 视角)
PR #1755 由 lyingbug(WeKnora 主维护者)自己提交并合入,无外部 review 评论。从 PR 结构可以推断合入信心来源:
- 改动范围精确:只在 docparser 层新增文件,不碰 chunker 逻辑,不碰 docreader,零侵入
- 完整测试覆盖:66 行测试代码覆盖了简单表格转换、复杂表格降级、非表格内容不受影响三种场景
- fallback 安全网:转换失败时退回”剥离属性”策略,不会比修改前更差
- 第三方库成熟:使用
html-to-markdown/v2的 table plugin,不自己手写转换逻辑
这是一个典型的”信心足够则快速合入”场景——改动小、测试全、有 fallback、作者即维护者。
5. 可复用模式(Pattern)
模式一:管线中间层格式归一化
场景:上游输出格式 A,下游只对格式 B 有特殊保护/优化。
手法:在中间层插入 normalizer,将 A 转为 B;无法完美转换时做 graceful degradation(降级处理但不恶化现状)。
适用条件:
- 上游输出格式不可控或修改成本高(如第三方 OCR 引擎)
- 下游已有针对特定格式的优化逻辑(如 chunker 的 protectedPatterns)
- 格式转换是无损或可接受有损的
模式二:双策略 + Fallback
场景:输入有两种子类型,需要不同处理路径。
手法:
- 判断子类型(简单 vs 复杂)
- 优选路径处理(转 Markdown)
- 降级路径兜底(剥离属性)
- 转换出错时自动 fallback 到降级路径
模式三:只加不改
PR 净增 149 行,删除 0 行。新文件封装所有逻辑,老文件只加 6 行调用。这种”加法式修复”的好处:
- git blame 清晰——归一化逻辑的责任边界明确
- 回滚成本极低——删掉新文件 + 6 行调用即可
- 不影响其他 converter(只有 PaddleOCR-VL 路径接入)
6. 常见误区
错误认知:在 chunker 层用正则删 HTML 标签就能解决表格截断。
正确理解:chunker 只保护 Markdown 管道表;应在 docparser 把 HTML 转为 MD 或瘦身。
错误认知:rowspan/colspan 表转成 Markdown 一定更好。
正确理解:复杂表 保留 HTML 结构、剥离样式 比错误 MD 更安全。
错误认知:PaddleOCR 输出改上游比中间层归一化更干净。
正确理解:第三方 OCR 升级周期不可控;中间层归一化 掌控力强、回滚易。
错误认知:style 属性不影响语义检索,可以忽略。
正确理解:style 浪费 token、稀释表格数据向量,应剥离。
7. 思考点
Q1:请画出一份完整的数据流图,标注 #1725 修复前后,一个含 3 行表格的文档经过管线各阶段时的格式变化(包括 token 数估算的变化方向)。
Q2:如果 #1248 的 feedback 数据流是 用户点踩 → message 记录 → chunk 定位 → snippet 归因 → 知识库修正,其中哪一步最可能出现类似 #1725 的”格式假设不匹配”问题?你会如何设计防御?
Q3:假设未来 PaddleOCR-VL 升级后直接输出 Markdown 表格,html_table_normalizer.go 会怎样?这对你设计”只加不改”风格的中间层有什么启示?
8. 延伸阅读
- WeKnora chunker 源码:查看 protectedPatterns 的完整正则列表
- html-to-markdown/v2:PR 使用的 Go HTML→Markdown 转换库
- GFM 表格规范:GitHub Flavored Markdown 的表格语法定义
- 案例 #1785:同属管线修复,但在 Python docreader 层——对比两个案例可以理解”在哪层修复”的决策逻辑
- 模式提炼:§4 管线模式中包含本案例的抽象总结