#大模型#RAG

RAG 处理表格与结构化数据

表格是 RAG 的硬骨头——切分破坏结构、向量丢语义、检索找不到。本文讲清表格 RAG 的完整方案:表格检测与抽取、切分策略(整表/行级)、结构化存储 vs 向量、Text-to-SQL 模式,以及混合检索,让 RAG 能真正处理含表格的文档。

本文要点

  • 主题聚焦:大模型、RAG
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2025年12月19日·更新于 2026年7月23日

RAG 处理表格与结构化数据

大多数 RAG 教程讲到"切文档 → 嵌入 → 检索"就结束了,好像世界上只有流水账式文本。但真实的企业文档——财报、产品规格、价格表、统计报告——表格才是信息密度最高的部分。把表格按普通文本塞进 RAG,结果几乎一定是灾难:切分腰斩、语义丢失、数值查不准。

这篇文章把表格 RAG 的完整链路拆开讲:抽取、切分、存储、检索、引用,每一环都有坑,每一环都有取舍。

一、表格为什么是 RAG 的"老大难"

通用 RAG pipeline 的底层假设是文本是线性的——按 token 或段落切,嵌入,检索。但表格是二维的,不是线性的。三个经典痛点:

1. 切分腰斩表格。 绝大多数 splitter(RecursiveCharacterTextSplitter 之类)是在扁平文本上工作的。一张 500 行的表撞上 chunk 边界,被切成 3 段。每一段都丢了表头,丢了列含义。检索召回了第 2 段,里面有 150 行数字,但谁都不知道每一列是什么意思。

2. 行/列语义丢失。 一行只有在配上表头时才有意义。单独的 3200 毫无信息——只有"Q3 营收 3200 万"才成立。把裸行 iPhone 15, 3200, 12%, 0.87 嵌入,得到的向量在语义空间里近似噪声。

3. 向量对精确数值天然弱。 Embedding 捕捉的是语义相似度,不是数值精度。"营收增长 12%" 和 "营收增长 13%" 的向量几乎一模一样,但含义天差地别。问"哪些产品毛利率低于 10%",向量检索基本是瞎的。

结论很直接:别把表格当普通文本处理,需要一条专门的表格 pipeline。

二、表格检测与抽取

整条链路的地基是把表格按结构化形式抽出来,而不是抽成一坨乱文本。按来源分情况处理:

HTML 表格 是最好的情况,DOM 结构本身就是结构化的。用 cheerio 或 jsdom 遍历 <table>,把 <thead> 和 <tbody> 分开,正确处理 colspan / rowspan 合并单元格即可。

Excel / CSV 直接就是结构化的,openpyxl / pandas 一把梭。注意 sheet 名、合并单元格、表头到底在第几行——这是最容易踩的坑。

PDF 表格 是最难啃的。PDF 没有结构,只能靠视觉布局去推断。工具栈按场景选:

  • pdfplumber:纯 Python,基于 pdfminer.six,适合有边框的规则表格,精度不错,工程上最常用。
  • camelot:lattice 模式适合带框线表格,stream 模式适合无框线,底层依赖 OpenCV / ghostscript。
  • tabula-py:Java Tabula 的封装,效果稳定,但要带 JVM,部署麻烦。
  • unstructured / docling:新一代多模态文档解析,把版面理解和表格抽取做在一起,适合复杂 PDF。

一个能跑的 Python 示例:

python
import pdfplumber

def extract_tables(pdf_path: str):
    tables = []
    with pdfplumber.open(pdf_path) as pdf:
        for page_idx, page in enumerate(pdf.pages):
            for table in page.extract_tables():
                if not table or len(table) < 2:
                    continue
                header, *rows = table
                tables.append({
                    "source_page": page_idx,
                    "header": [h.strip() for h in header if h],
                    "rows": [[(c or "").strip() for c in row] for row in rows],
                })
    return tables

几条经验法则:

  • 抽出来的表必须先清洗:None 转空字符串,去掉换行符,数字统一格式("1,234.5" 解析成 1234.5),否则后面的 SQL 和向量都会被脏数据坑死。
  • 别相信 PDF 抽表的 100% 精度。给每张表配一个置信度评分(行数、列数一致性、空单元格比例),低于阈值的转人工复核。
  • 复杂报表用 VLM 兜底。合并单元格嵌套、跨页表格这种传统 CV 方法搞不定的,直接把整页图像喂给多模态 LLM,让它输出 Markdown/JSON 表格,效果远好于纯算法。这是这两年最明显的趋势。

三、切分策略:整表 vs 行级

表抽出来怎么进索引?两条主流路线,各有取舍。

策略 A:整表作为单位。 把整张表序列化成 Markdown(或 HTML),作为单个 chunk 进向量库。

typescript
function tableToMarkdown(table: Table): string {
  const header = `| ${table.header.join(" | ")} |`;
  const sep = `| ${table.header.map(() => "---").join(" | ")} |`;
  const rows = table.rows.map(r => `| ${r.join(" | ")} |`).join("\n");
  return `${table.caption}\n${header}\n${sep}\n${rows}`;
}

优点:结构完整,LLM 看到的是一张整表,可以跨行聚合。适合小表(几十行以内)和需要跨行计算的查询("找出销售额前三的产品")。

缺点:大表直接爆 token,embedding 只算一次,整张表的所有查询都靠这一个向量召回,粒度太粗——一张 500 行的表,向量根本表达不了那么多查询意图。

策略 B:行级切分 + 表头回挂。 每一行作为一个 chunk,但每个 chunk 都把完整表头和表级元数据拼回去。这是处理大表的标准做法。

typescript
function tableToRowChunks(table: Table): Chunk[] {
  return table.rows.map((row, i) => ({
    id: `${table.id}:row:${i}`,
    text: `${table.caption}\n${table.header.join(" | ")}\n${row.join(" | ")}`,
    metadata: {
      type: "table_row",
      tableId: table.id,
      rowIndex: i,
      header: table.header,
      source: table.source,
    },
  }));
}

优点:粒度细,大表也能精准召回某几行;chunk 体积小,embedding 质量高。

缺点:跨行查询会失败。"Q3 总营收是多少"需要聚合所有 Q3 的行,但行级切分只能召回一行。这也是为什么后面必须上混合检索。

经验法则:

  • 小表(行数 < 50)整表存。
  • 大表行级切,同时额外保留一份表级摘要向量(用 caption + 表头 + 前几行做 embedding),作为表级召回的入口。
  • 不管哪种切法,元数据必须带上 tableId、表头、行号、来源——后面 rerank 和引用都靠它。

四、存储选择:向量 vs 结构化 vs 混合

这一步决定整体架构。三条路线:

纯文本向量化:表序列化成文本,丢进向量库。最简单,但数值精确查询能力为零。只适合表格纯粹作为"背景信息"的场景。

结构化表(Text-to-SQL):把表存到关系数据库(Postgres / SQLite / DuckDB),查询时让 LLM 生成 SQL。数值、聚合、过滤都是 SQL 的主场。

混合(推荐):两套都建。文本、描述、长文走向量;表格数据走结构化查询,按问题类型路由。

我的判断很明确:只要文档里有需要精确查询的表格(财务、规格、价格、统计),就必须上结构化层。纯向量在这块就是不及格。代价是要维护两套索引加一个路由层——但这是表格 RAG 的"必要复杂度",省不掉。

五、Text-to-SQL 模式

让结构化数据走 SQL,本质是把"模糊检索"换成"精确查询"。工作流:

  1. Schema 索引:把每张表的表名、列名、列含义、外键关系存起来,作为 LLM 写 SQL 的 context。
  2. 问题改写:用户问"上个季度哪些产品毛利低于 10%",映射成 SQL 意图。
  3. SQL 生成:LLM 在 schema 约束下生成 SQL。
  4. 执行 + 回灌:在 DB 上执行,把结果(行 + 列名)回灌给 LLM 组织自然语言回答。
python
from sqlalchemy import create_engine, text
from openai import OpenAI

engine = create_engine("duckdb:///docs.duckdb")
client = OpenAI()

SCHEMA = """
表 products: product_id, name, category, price
表 sales: sale_id, product_id, quarter, revenue, cost
-- 毛利率 = (revenue - cost) / revenue
"""

def answer_with_sql(question: str):
    sql = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": f"基于以下 schema 写 DuckDB SQL,只返回 SQL:\n{SCHEMA}"},
            {"role": "user", "content": question},
        ],
    ).choices[0].message.content.strip()

    with engine.connect() as conn:
        result = conn.execute(text(sql))
        cols = list(result.keys())
        rows = result.fetchall()
    return {"sql": sql, "columns": cols, "rowCount": len(rows)}

避免 SQL 翻车的几条铁律:

  • 只读账户:给 LLM 用的 DB 连接,永远是只读、带超时、带 LIMIT。否则一不留神一个 DELETE FROM 就够你喝一壶。
  • 白名单 schema:不要把无关的表暴露给 LLM,既降幻觉也降误查询风险。
  • SQL 验证层:生成的 SQL 先 EXPLAIN 跑一遍,语法错或扫全表的直接拦截重生成。
  • few-shot 不可省:准备 10–20 个"问题 → SQL"样例,复杂查询的准确率会显著提升。
  • 列名要语义化:rev_q3 远不如 revenue_q3_2024。schema 描述的质量,直接决定 Text-to-SQL 的上限。

六、混合检索:结构化 + 向量,RRF 融合

真实文档里,一个问题经常同时要表格和文字:"iPhone 15 Pro 的电池续航和上一代比怎么样"——既要查规格表(结构化),又要查评测文字(向量)。

路由先行:用一个轻量分类器(规则或小 LLM)判断问题类型。纯数值/聚合走 SQL,语义/描述走向量,拿不准就两路并发。路由器宁可保守:延迟换召回,值。

两路结果用 RRF(Reciprocal Rank Fusion)融合:

typescript
function rrf(rankings: string[][], k = 60): Map<string, number> {
  const scores = new Map<string, number>();
  for (const ranking of rankings) {
    ranking.forEach((docId, rank) => {
      scores.set(docId, (scores.get(docId) ?? 0) + 1 / (k + rank));
    });
  }
  return scores;
}

// sqlDocs: SQL 返回的行 id;vecDocs: 向量召回的 chunk id
const fused = rrf([sqlDocs, vecDocs]);
const topK = [...fused.entries()]
  .sort((a, b) => b[1] - a[1])
  .slice(0, 10)
  .map(([id]) => id);

RRF 的好处是不需要归一化分数——SQL 的行数和向量的 cosine 完全不可比,但只用排名就稳了。这是表格 RAG 里融合两路异构结果的标准做法。

经验法则:结构化结果喂给 LLM 前,必须带上列名和表名,否则 LLM 根本不知道那个 12% 是什么。跨表关联类问题(Q3 财报里多张表的数据),SQL 比向量靠谱十倍。

七、工程要点

这些细节决定你的表格 RAG 是玩具还是生产系统。

1. 表格元数据不能省。 每个表格 chunk 必须能追溯到来源:source_doc、page、version、extracted_at、confidence、tableId。问答引用时才能告诉用户"这张表来自 2024 Q3 财报第 12 页"。其中 version 特别关键——财报会修订,旧版本的数字绝不能混进新查询。

2. 表格专用 rerank。 通用 rerank 模型对表格序列化文本效果一般。两个改进方向:训练时混入表格数据;或者在 rerank 阶段叠加规则——表头匹配加权、数值匹配加权(问题里出现"毛利",优先表头含"毛利率"的表)。

3. 引用表格片段。 回答时必须能定位到具体表格、甚至具体行列。给 LLM 的 context 里,每个 chunk 带 [TBL: tableId, row: i] 标记,让它在回答里引用:根据 [TBL: t_03, row: 7],iPhone 15 Pro 的续航为 23 小时。前端再把这个标记渲染成可点击的溯源链接。

4. 缓存 SQL 结果。 高频问题(固定报表口径)的 SQL 结果要缓存。一次查询从 800ms 降到 5ms,体验完全不同。

5. 监控抽取质量。 表格抽取是整条 pipeline 最脆弱的环节。建立抽样人工标注 + 抽取置信度监控,准确率掉到阈值以下就报警——这是生产系统里最容易悄悄烂掉的地方。

小结

  • 表格不是文本,别走纯向量。 表格是二维结构化数据,需要专门的抽取、切分、存储、检索链路。
  • 抽取是地基。 pdfplumber / camelot / docling / VLM,按文档复杂度选;PDF 抽表必须有置信度和人工兜底。
  • 切分看表大小:小表整存,大表行级切 + 表头回挂 + 表级摘要向量。
  • 数值查询走 SQL,语义查询走向量,RRF 融合——这是表格 RAG 的标准架构。
  • 元数据、rerank、引用、缓存、监控,这些工程细节决定 RAG 是 demo 还是生产系统。

金句:纯向量 RAG 能回答"讲了什么",却回答不了"具体是多少";让表格走结构化,才是 RAG 从"读文档"走向"查数据"的关键一跃。

Related

相关推荐