Python 自动线性化 HTML/MD 表格的工程实践(一个读取表格并且提供输出的工具)

解决 RAG 场景下的表格痛点:一种 Python 自动线性化 HTML/MD 表格的工程实践

在构建 RAG(检索增强生成)系统或 LLM 应用时,处理原始数据中的"表格"一直是一个令人头疼的问题。

为什么表格需要"线性化"?

虽然当前的 LLM(如 GPT-4、Claude 3.5 或本地的 DeepSeek)已经能理解简单的 Markdown 表格,但在实际工程中,我们面临的问题往往更复杂:

  1. HTML 嵌套与合并单元格 :大量历史文档转换出的 HTML 包含 rowspancolspan,直接解析成 Markdown 往往会导致数据错位。
  2. Token 浪费:原始 HTML 的标签噪音极大,非常消耗 Context Window。
  3. 语义丢失:对于"交叉表头"或"KV 表",LLM 有时很难准确对应起第一行(表头)与第 N 行(数据)的关系,导致召回后的问答出现偏差。

为了解决这些问题,我实现了一个 MarkdownTableLinearizer 工具类。它的核心思路是将"二维"的网格表格降维打击,转换成 LLM 极易理解的"一维"自然语言列表。


技术实现核心逻辑

该工具类主要解决了以下几个工程难点:

  • HTML 合并单元格填充 :利用一个二维网格 grid 预先占位,解析 rowspancolspan 时,自动将内容填充到被覆盖的虚拟单元格中,确保数据不丢、不乱。
  • 智能表头探测 :通过 <th> 标签、左上角是否为空、列数比例等特征,自动判断该表是"标准表"、"交叉表"还是"KV 键值对表"。
  • 语义映射 :输出采用 【项目】(对应表头):属性为内容 的格式,这种格式在向量化(Embedding)后具有极高的检索亲和度。

核心代码实现

python 复制代码
import re
from typing import List, Match
from bs4 import BeautifulSoup

class MarkdownTableLinearizer:
    """
    Markdown 与 HTML 表格线性化解析器,支持复杂合并单元格与交叉表头转换。
    """

    HTML_TABLE_PATTERN = re.compile(r"<table.*?>.*?</table>", re.IGNORECASE | re.DOTALL)
    MD_TABLE_PATTERN = re.compile(
        r'((?:^[ \t]*\|.*\|[ \t]*\n)'
        r'(?:^[ \t]*\|[ \t]*[-:]+[-| :]*\|[ \t]*\n)'
        r'(?:^[ \t]*\|.*\|[ \t]*(?:\n|$))*)',
        re.MULTILINE
    )

    @classmethod
    def process(cls, content: str) -> str:
        """入口函数:识别并替换文本中的所有表格"""
        if not content:
            return content

        if "<table" in content.lower():
            content = cls.HTML_TABLE_PATTERN.sub(cls._replace_html_table, content)

        if "|" in content:
            content = cls.MD_TABLE_PATTERN.sub(cls._replace_md_table, content)

        return content

    @classmethod
    def _replace_html_table(cls, match: Match) -> str:
        html_content = match.group(0)
        soup = BeautifulSoup(html_content, "html.parser")
        table = soup.find("table")
        if not table: return html_content

        rows = table.find_all("tr")
        if not rows: return html_content

        has_th = bool(table.find_all("th"))
        grid: List[List[str]] = [[] for _ in range(len(rows))]

        # 处理合并单元格的核心逻辑
        for row_idx, row in enumerate(rows):
            col_idx = 0
            for cell in row.find_all(['td', 'th']):
                while col_idx < len(grid[row_idx]) and grid[row_idx][col_idx] is not None:
                    col_idx += 1

                rowspan = int(cell.get('rowspan', 1))
                colspan = int(cell.get('colspan', 1))
                text = cell.get_text(separator=" ", strip=True)

                for r in range(row_idx, row_idx + rowspan):
                    while len(grid) <= r: grid.append([])
                    while len(grid[r]) < col_idx + colspan: grid[r].append(None)
                    for c in range(col_idx, col_idx + colspan):
                        grid[r][c] = text
                col_idx += colspan

        return cls._grid_to_text(grid, is_md=False, has_th=has_th)

    @classmethod
    def _replace_md_table(cls, match: Match) -> str:
        lines = match.group(0).strip().split('\n')
        grid = []
        for line in lines:
            if re.match(r'^[ \t]*\|[ \t\-|:]+\|[ \t]*$', line): continue
            grid.append([c.strip() for c in line.strip('|').split('|')])
        return cls._grid_to_text(grid, is_md=True, has_th=False)

    @classmethod
    def _grid_to_text(cls, grid: List[List[str]], is_md: bool, has_th: bool) -> str:
        if not grid or not grid[0]: return ""
        cols_count = max(len(r) for r in grid)
        for r in grid:
            if len(r) < cols_count: r.extend([""] * (cols_count - len(r)))

        # 判定表头策略:满足其一即视为有表头
        is_header_row = is_md or has_th or grid[0][0] == "" or cols_count > 2
        res = []

        if not is_header_row and cols_count == 2:
            # 策略:处理两列键值对表
            for r in grid:
                k, v = (r[0] or "未知属性"), (r[1] or "无")
                res.append(f"- 【{k}】:{v}。")
        else:
            # 策略:处理标准/交叉表
            headers = grid[0]
            for r in grid[1:]:
                if not any(r): continue
                subject = r[0] or "未知项目"
                subject_header = headers[0] or ""
                props = [f"{headers[c] or f'属性{c}'}为{r[c]}" for c in range(1, cols_count) 
                         if r[c] and r[c] not in ('-', '/', '无')]
                if props:
                    prefix = f"- 【{subject}】(对应{subject_header})" if subject_header else f"- 【{subject}】"
                    res.append(f"{prefix}:{','.join(props)}。")
        return "\n\n" + "\n".join(res) + "\n\n"

测试验证

为了确保在不同环境下解析的鲁棒性,我编写了一组基于 unittest 的测试用例。特别关注了交叉表头和复杂合并单元格的场景。

python 复制代码
import unittest

class TestLinearizer(unittest.TestCase):
    def test_cross_header(self):
        """测试典型的交叉表头 HTML 表格"""
        html = """
        <table>
            <tr><th></th><th>2023年</th><th>2024年</th></tr>
            <tr><th>营收</th><td>100w</td><td>200w</td></tr>
        </table>
        """
        res = MarkdownTableLinearizer.process(html)
        # 预期输出:- 【营收】:2023年为100w,2024年为200w。
        self.assertIn("2023年为100w", res)

    def test_md_table(self):
        """测试标准 Markdown 表格解析"""
        md = "| 姓名 | 年龄 |\n|---|---|\n| 张三 | 25 |"
        res = MarkdownTableLinearizer.process(md)
        self.assertIn("【张三】(对应姓名):年龄为25", res)

if __name__ == '__main__':
    unittest.main()

结语

在数据预处理阶段多花一分功夫,在模型推理阶段就能少出十分差错。通过这种线性化处理,我们不仅减少了 50% 以上的无效 Token,还显著提升了 RAG 在处理财务报表、参数对比表等复杂场景下的答题准确率。

希望这个小工具能给你处理文档清洗带来启发。如果你有更复杂的表格场景(如多级表头嵌套),欢迎交流优化思路。


相关推荐
测试1998几秒前
Selenium 无法定位元素的几种解决方案
自动化测试·软件测试·python·selenium·测试工具·职场和发展·测试用例
半亩码田20 分钟前
C#转Python第3.6篇:Python 的 @property 比 C# 的 get/set 更灵活
java·python·c#
程序员与背包客_CoderZ35 分钟前
高性能分布式KV存储引擎RocksDB入门与C/C++编码实战
c语言·开发语言·数据库·c++·分布式·分布式数据库·rocksdb
欧叶冲冲冲44 分钟前
Python常见数据结构的CRUD(LeetCode高频版速查)
数据结构·python·leetcode
钱栈up44 分钟前
Mac 开发机一键发版不用切环境:我这样改造了团队的后端部署脚本Maven编译卡住20分钟?我靠两步定位到2处隐蔽编译错误
开发语言·python·macos
william_yangshun1 小时前
【大模型部署实战】kimi-k3-in-c 中文版:用 176KB 纯 C99 引擎在 CPU 上跑 Kimi K3 上手指南
c语言·开发语言
CSND7401 小时前
DeepSeek Harness实测+入门教程
人工智能·python
gb42152871 小时前
ai中agent,skill Package,skill,tool,prompt,mcp等等概念的关系
python
Sagittarius_A*1 小时前
Burst Lab | PHP 审计 14|反序列化(四):字符串逃逸与绕过技巧
开发语言·安全·php·web·反序列化
名字还没想好☜1 小时前
Go 用 slices/maps 标准库泛型函数:告别手写 Contains、Sort、去重(Go 1.21)
开发语言·后端·算法·golang·go