解决 RAG 场景下的表格痛点:一种 Python 自动线性化 HTML/MD 表格的工程实践
在构建 RAG(检索增强生成)系统或 LLM 应用时,处理原始数据中的"表格"一直是一个令人头疼的问题。
为什么表格需要"线性化"?
虽然当前的 LLM(如 GPT-4、Claude 3.5 或本地的 DeepSeek)已经能理解简单的 Markdown 表格,但在实际工程中,我们面临的问题往往更复杂:
- HTML 嵌套与合并单元格 :大量历史文档转换出的 HTML 包含
rowspan和colspan,直接解析成 Markdown 往往会导致数据错位。 - Token 浪费:原始 HTML 的标签噪音极大,非常消耗 Context Window。
- 语义丢失:对于"交叉表头"或"KV 表",LLM 有时很难准确对应起第一行(表头)与第 N 行(数据)的关系,导致召回后的问答出现偏差。
为了解决这些问题,我实现了一个 MarkdownTableLinearizer 工具类。它的核心思路是将"二维"的网格表格降维打击,转换成 LLM 极易理解的"一维"自然语言列表。
技术实现核心逻辑
该工具类主要解决了以下几个工程难点:
- HTML 合并单元格填充 :利用一个二维网格
grid预先占位,解析rowspan和colspan时,自动将内容填充到被覆盖的虚拟单元格中,确保数据不丢、不乱。 - 智能表头探测 :通过
<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 在处理财务报表、参数对比表等复杂场景下的答题准确率。
希望这个小工具能给你处理文档清洗带来启发。如果你有更复杂的表格场景(如多级表头嵌套),欢迎交流优化思路。