RAG 每日一技(十九):当文本遇上表格,如何拿下“半结构化”PDF

先给结论。把整份 PDF 粘成一坨纯文本再去做 RAG,是处理年报、白皮书、技术规范这类"图文表混搭"的最快失败路径。真正可落地的方案,是把同一份文档拆成两套证据引擎:一套面向段落与叙述,一套面向表格与数字;前者走向量检索与重排,后者走"表格结构化 + 可计算"的通道。两路并行,最后在生成阶段合流,答案既有语义,也有数值依据,并且能落到具体页码和单元格坐标。

为什么难,心里要有数。PDF 的本质是排版结果而不是语义源,段落断成多列、脚注挤在页脚、单位写在表头、合并单元格暗藏分组含义。你把它"光学打浆"后再丢给向量库,模型看的是"像话的字",不是"可算的数"。因此第一要义,是保住结构与坐标;第二要义,是把"表里的事实"转成可检索、可回溯、可计算的证据。

一个可落地的蓝图很简单:先做布局保真的解析,把段落、标题、图表说明按页存下来,同时把每张表抽成 DataFrame 并做好单位与表头清洗;随后对"文字块"走常规分块与嵌入,对"表格"则额外生成一批"语句化"的事实片段(谁、在什么维度、对应什么数,单位是什么、坐标在哪),两类证据分别建索引;问答时先做快速路由,判定问题更像"讲道理"还是"要数字",再分别检索或计算,最后把文本证据和表格证据一起喂给 LLM,用强约束 Prompt 要求"回答+引用+坐标"。

下面动手把这条管线撸出来,尽量写成能直接塞进你项目的骨架。

bash 复制代码
pip install pdfplumber pymupdf pandas numpy chromadb sentence-transformers rapidfuzz

第一步是把 PDF 拆成两种资产:layout-aware 的段落块与结构化表格。段落用 PyMuPDF 取 block 与坐标,表格用 pdfplumber 抽成 DataFrame,同时把表头拉平、单位统一。记得每一块都带上 page、bbox、table_id 等元数据,后面要靠这些做可追溯。

python 复制代码
import fitz, pdfplumber, re, pandas as pd, numpy as np, uuid, json, os
from pathlib import Path

PDF_PATH = "sample.pdf"

def extract_text_blocks(pdf_path):
    doc = fitz.open(pdf_path)
    blocks = []
    for pno in range(len(doc)):
        page = doc[pno]
        for x0, y0, x1, y1, text, _, _ in page.get_text("blocks"):
            content = text.strip()
            if content:
                blocks.append({
                    "id": f"blk-{pno}-{uuid.uuid4().hex[:8]}",
                    "type": "paragraph",
                    "page": pno+1,
                    "bbox": [float(x0), float(y0), float(x1), float(y1)],
                    "text": content
                })
    doc.close()
    return blocks

def tidy_header(cols):
    # 简单的表头清洗:去空白、统一下划线、移除单位括号外多余空格
    clean = []
    for c in cols:
        c = re.sub(r"\s+", " ", str(c)).strip()
        c = c.replace("\n", " ")
        clean.append(c)
    return clean

def extract_tables(pdf_path):
    assets = []
    with pdfplumber.open(pdf_path) as pdf:
        for pno, page in enumerate(pdf.pages, start=1):
            tables = page.extract_tables() or []
            for t_idx, t in enumerate(tables, start=1):
                if not t or len(t) < 2: 
                    continue
                df = pd.DataFrame(t[1:], columns=tidy_header(t[0]))
                df.replace({"": None, "---": None, "-": None}, inplace=True)
                table_id = f"tbl-{pno}-{t_idx}"
                assets.append({
                    "id": table_id,
                    "type": "table",
                    "page": pno,
                    "df": df
                })
    return assets

第二步是把表格"语句化"。一个常用且实用的做法,是把表的第一列当作实体索引,其余列依次生成"事实句"。同时要把"证据锚点"保存下来:来自哪张表、哪一行哪一列、具体页码是多少。

python 复制代码
def normalize_number(x):
    if x is None: 
        return None
    s = str(x).replace(",", "")
    try:
        return float(s)
    except:
        return s  # 保留文本型单元格

def table_to_statements(tbl):
    df, page, table_id = tbl["df"], tbl["page"], tbl["id"]
    if df.empty or df.shape[1] < 2:
        return []

    df = df.copy()
    df.columns = tidy_header(df.columns)
    df = df.applymap(normalize_number)

    first_col = df.columns[0]
    statements = []
    for ridx, row in df.iterrows():
        entity = str(row[first_col])
        if not entity or entity.lower() == "nan": 
            continue
        for col in df.columns[1:]:
            val = row[col]
            if val is None or str(val).lower() == "nan":
                continue
            text = f"在表{table_id}的第{ridx+2}行,'{entity}'的'{col}'为 {val}。"
            statements.append({
                "id": f"st-{uuid.uuid4().hex[:8]}",
                "type": "table_statement",
                "page": page,
                "table_id": table_id,
                "row": int(ridx),
                "col": col,
                "entity": entity,
                "value": val,
                "text": text
            })
    return statements

第三步是把"段落块"和"语句化事实"分别进库,建立双索引。这里选 Chroma + 本地中文嵌入模型,轻到能直接落地。

python 复制代码
import chromadb
from chromadb.utils import embedding_functions

DB_DIR = "rag19_db"
client = chromadb.PersistentClient(path=DB_DIR)

embedder = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="moka-ai/m3e-base"  # 中文/中英皆可用的通用嵌入
)

text_col = client.get_or_create_collection(
    "text_blocks", embedding_function=embedder
)
fact_col = client.get_or_create_collection(
    "table_facts", embedding_function=embedder
)

# 解析与入库
text_blocks = extract_text_blocks(PDF_PATH)
tables = extract_tables(PDF_PATH)
statements = sum([table_to_statements(t) for t in tables], [])

if text_blocks:
    text_col.add(
        ids=[blk["id"] for blk in text_blocks],
        documents=[blk["text"] for blk in text_blocks],
        metadatas=[{k:v for k,v in blk.items() if k not in ("text",)} for blk in text_blocks]
    )

if statements:
    fact_col.add(
        ids=[s["id"] for s in statements],
        documents=[s["text"] for s in statements],
        metadatas=[{k:v for k,v in s.items() if k not in ("text",)} for s in statements]
    )

# 同时把原始表格存一份,便于后续计算与引用
TABLE_STORE = {t["id"]: t for t in tables}

第四步需要一个"路由器"。别把它想得太玄学,简单的启发式就够你跑起来。凡是问"同比、环比、增长、最高、最低、总计、平均、份额、排名、图/表X",或者带了明显数字与单位的,多半走表格引擎;其他优先走文本引擎。初级版本先用规则,后面不够用再加一个轻量分类器或让 LLM 做一次路由判定。

python 复制代码
import re

NUM_HINTS = r"(同比|环比|增长|下降|占比|份额|合计|总计|平均|最高|最低|排名|Top|TOP|第[一二三四五六]|%|\d+(\.\d+)?(万|亿|万元|亿元|%|m|M|k|K))"
TABLE_HINTS = r"(表\d+|表格|数据见|如下表)"

def route(query: str) -> str:
    if re.search(NUM_HINTS, query) or re.search(TABLE_HINTS, query):
        return "table"
    return "text"

第五步实现两条子流程。文本路线就是常规"向量检索 +(可选)精排 + 受约束 Prompt 生成";表格路线的关键,是先用"语句化事实"去定位最相关的表,再把涉及的几行几列切出来,用 Pandas 做必要的聚合,最后把"计算结果 + 原始单元格证据 + 页码坐标"一起交给 LLM 生成答案。生成阶段必须硬性要求引用与溯源,避免"算着算着就瞎编"。

python 复制代码
from rapidfuzz import fuzz

def retrieve_text(query, k=4):
    hits = text_col.query(query_texts=[query], n_results=k)
    docs = list(zip(hits["documents"][0], hits["metadatas"][0], hits["ids"][0]))
    return docs

def retrieve_facts(query, k=6):
    hits = fact_col.query(query_texts=[query], n_results=k)
    facts = list(zip(hits["documents"][0], hits["metadatas"][0], hits["ids"][0]))
    # 简单按表聚类,优先选择同一表内的高相关片段
    counter = {}
    for _, m, _ in facts:
        counter[m["table_id"]] = counter.get(m["table_id"], 0) + 1
    top_table = max(counter, key=counter.get) if counter else None
    table_hits = [f for f in facts if f[1]["table_id"] == top_table] if top_table else facts[:k]
    return top_table, table_hits[:k]

def compute_from_table(table_id, query):
    if not table_id or table_id not in TABLE_STORE:
        return None, []
    df = TABLE_STORE[table_id]["df"].copy()
    page = TABLE_STORE[table_id]["page"]
    # 粗暴一点:用模糊匹配找"目标列"和"实体列"
    cols = list(df.columns)
    entity_col = cols[0]
    metric_col = None
    target_kw = ["收入","营收","利润","销售","销量","成本","费用","毛利","均价","价格","占比","份额"]
    best = (-1,None)
    for c in cols[1:]:
        score = max(fuzz.partial_ratio(c, kw) for kw in target_kw)
        best = max(best, (score, c))
    metric_col = best[1] if best[0] >= 70 else cols[1] if len(cols)>1 else None
    if metric_col is None: 
        return None, []

    # 解析聚合意图
    q = query
    if "平均" in q or "均值" in q:
        val = pd.to_numeric(df[metric_col], errors="coerce").mean()
        op = "平均"
    elif "总" in q or "合计" in q:
        val = pd.to_numeric(df[metric_col], errors="coerce").sum()
        op = "合计"
    elif "最高" in q or "最大" in q or "Top" in q or "TOP" in q:
        idx = pd.to_numeric(df[metric_col], errors="coerce").idxmax()
        val = df.loc[idx, metric_col]
        ent = df.loc[idx, entity_col]
        return {"op":"最大","value":float(val) if isinstance(val,(int,float,np.floating)) else val,
                "entity":str(ent),"metric":metric_col,"page":page,"table_id":table_id}, [{"row":int(idx),"col":metric_col}]
    elif "最低" in q or "最小" in q:
        idx = pd.to_numeric(df[metric_col], errors="coerce").idxmin()
        val = df.loc[idx, metric_col]
        ent = df.loc[idx, entity_col]
        return {"op":"最小","value":float(val) if isinstance(val,(int,float,np.floating)) else val,
                "entity":str(ent),"metric":metric_col,"page":page,"table_id":table_id}, [{"row":int(idx),"col":metric_col}]
    else:
        # 回答某个具体实体的值
        target = None
        best = (-1,None)
        for i, ent in enumerate(df[entity_col].astype(str).fillna("")):
            score = fuzz.partial_ratio(q, ent)
            best = max(best, (score, i))
        idx = best[1]
        val = df.loc[idx, metric_col]
        ent = df.loc[idx, entity_col]
        return {"op":"取值","value":float(val) if isinstance(val,(int,float,np.floating)) else val,
                "entity":str(ent),"metric":metric_col,"page":page,"table_id":table_id}, [{"row":int(idx),"col":metric_col}]

    # 汇总型计算提供几个代表性证据点
    evid = []
    topk = df[metric_col].astype(float).nlargest(min(3, len(df))).index
    for i in topk:
        evid.append({"row":int(i),"col":metric_col})
    return {"op":op,"value":float(val),"metric":metric_col,"page":page,"table_id":table_id}, evid

第六步把一切接起来:先路由,再检索/计算,最后把证据与坐标喂进一个"强约束"提示词。提示词的要点很明确:只能依据提供的证据作答;必须在答案末尾给出可核验的来源元数据(页码、表格 ID、行列);如果证据不足,请明确说"根据提供的资料无法回答"。

python 复制代码
RAG_PROMPT = """你是一个严谨的业务分析助手。
请仅依据下面的证据回答问题,禁止脑补或外推。
如果证据不足,请直接回答:"根据提供的资料,我无法回答该问题。"。
回答后请给出"引用",格式为:(p{page} · {anchor}),其中 anchor 是表格ID+行列或段落ID。

问题:
{question}

证据文本:
{texts}

证据表格摘要(如有):
{tables}

请给出简洁、定量、可核验的回答,并附上引用:"""

def answer(question: str):
    path = route(question)
    if path == "text":
        docs = retrieve_text(question, k=5)
        texts = "\n\n".join([f"[{m['id']} p{m['page']}] {d}" for d,m,_ in docs])
        prompt = RAG_PROMPT.format(
            question=question, texts=texts, tables="(无)"
        )
        # 这里直接打印 prompt,实际接到你的 LLM 即可
        return prompt

    # 表格路径
    table_id, fact_hits = retrieve_facts(question, k=6)
    calc, anchors = compute_from_table(table_id, question)
    page = TABLE_STORE[table_id]["page"] if table_id in TABLE_STORE else "?"
    fact_texts = "\n".join([f"[{m['table_id']} p{m['page']} r{m.get('row','?')} c{m.get('col','?')}] {d}" 
                             for d,m,_ in fact_hits])
    tab_summary = json.dumps(calc, ensure_ascii=False) if calc else "(无)"
    anchor_str = ", ".join([f"r{a['row']} c{a['col']}" for a in anchors]) if anchors else "n/a"
    prompt = RAG_PROMPT.format(
        question=question, 
        texts=fact_texts, 
        tables=f"表 {table_id} · p{page} · anchors: {anchor_str} · 计算: {tab_summary}"
    )
    return prompt

# 示例:你可以把下面两句的输出贴到你的 LLM 里查看生成效果
print("\n------ 文本问题 ------")
print(answer("该报告如何定义公司未来三年的增长驱动?"))

print("\n------ 表格问题 ------")
print(answer("请给出产品线的平均收入,并说明引用位置。"))

这就是一条跑得动的"半结构化"RAG 骨架。它没有追求"学术上的最优",而是优先把"工程上的可用"落在地上:保住布局与坐标、把表格变成可检索的"事实语句"、用规则级路由把问题送到对的引擎、在生成时强制要求"可核验的引用"。哪怕你后面把嵌入模型、重排器、路由器都换成更强的版本,这条路线依然成立。

还差两味药,顺手补上。一味是"单位与口径",表头里藏着"亿元/千元/同比%",如果不统一就会把正确的算错;这一点最稳妥的做法,是在语句化时直接把单位拆出来放进 metadata,计算前做一次口径归一。另一味是"跨表引用",不少关键问题要同时看主表与注释表,工程上最省事的策略,是把注释表也抽成语句化事实并用 note_for=table_id 关联,在生成阶段把 note 的证据也一并拼进 Prompt 要求模型复核口径差异。

最后做一次收束。半结构化文档的 RAG 不靠"花式大模型",靠的是"朴素的工程纪律"。解析要保真,事实要可检索,数字要可计算,答案要可引用。做到这四点,你的系统就会从"看起来能回答"变成"真的敢上线"。

明天预告:RAG 每日一技(二十):别让答案'无源可查'------给RAG加上可点击引用与证据高亮

下一篇我们把"证据可视化"这件实事落地:服务端如何返回页码与坐标,前端如何在 PDF 里高亮片段与单元格,生成端如何保证引用格式稳定,让你的答案从第一天起就"可审计、可追责、可复现"。

相关推荐
QZQ541882 小时前
详解go中context使用
后端
用户904706683572 小时前
如何使用 Spring MVC 实现 RESTful API 接口
java·后端
刘某某.2 小时前
数组和小于等于k的最长子数组长度b
java·数据结构·算法
Java微观世界2 小时前
告别重复数据烦恼!MySQL ON DUPLICATE KEY UPDATE 优雅解决存在更新/不存在插入难题
后端
程序员飞哥2 小时前
真正使用的超时关单策略是什么?
java·后端·面试
大千AI助手2 小时前
Viterbi解码算法:从理论到实践
算法·动态规划·hmm·隐马尔可夫·viterbi解码·viterbi·卷积码
用户904706683572 小时前
SpringBoot 多环境配置与启动 banner 修改
java·后端
后端小肥肠2 小时前
公众号对标账号文章总错过?用 WeWe-RSS+ n8n,对标文章定时到你的邮箱(上篇教程)
人工智能·agent
chenyuhao20243 小时前
《C++二叉引擎:STL风格搜索树实现与算法优化》
开发语言·数据结构·c++·后端·算法