先给结论。把整份 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 里高亮片段与单元格,生成端如何保证引用格式稳定,让你的答案从第一天起就"可审计、可追责、可复现"。