上一篇把"可点击引用"落地了,答案终于能一键跳回原文位置。接下来就该把"会算账"补上:同一问题下,给出对照、给出公式、给出可核验的来源,别只丢一句"同比增长不错"。目标形态只有一句话------答案不止有结论,还要有一张"可审计的计算卡片":左边是两端或多端的被比对象和原始数值,中间是明确到单元格的公式,右边是算出来的百分比或差值,所有数字都能点回 PDF 里具体的单元格。
关键做法不是再去魔改大模型,而是在生成层旁边加一个极薄的"推导引擎"。思路很直:让模型只负责"规划计算",人和代码负责"真实计算与溯源"。规划以结构化 JSON 表达,执行用你自己的 Python 代码跑,最后把结果与证据一并回填到答案里。这样既避免了模型在数字上"自由发挥",也把责任链拉直了。
先把"计算计划"的样子定下来。它要能说清楚取哪些格子、做哪些运算、哪个是最终结果、用什么格式呈现;同时每个输入都要带上表格坐标和单位,方便核验与单位统一。长这样就够用:
json
{
"inputs": [
{"id":"a","table_id":"tbl-33-2","page":33,"row":4,"col":"2023收入(亿元)","unit":"亿元","label":"2023高端收入"},
{"id":"b","table_id":"tbl-33-2","page":33,"row":4,"col":"2024收入(亿元)","unit":"亿元","label":"2024高端收入"}
],
"steps": [
{"id":"d1","op":"diff","args":["b","a"],"label":"绝对增量"},
{"id":"r1","op":"ratio","args":["d1","a"],"label":"同比增幅"}
],
"result":"r1",
"format":"percent"
}
现在把执行器写出来。它只做三件事:按坐标把值从表格里"抠"出来并校验单位;用白名单运算把计划一步步算出来;把"对照卡"和"引用清单"一起吐回去,交给前端去渲染,也交给生成阶段填进自然语言。核心几十行代码就能跑通。
python
from decimal import Decimal, ROUND_HALF_UP
import json, math
# 复用第十九篇里解析得到的 TABLE_STORE
# TABLE_STORE[table_id] = {"df": pandas.DataFrame, "page": int, ...}
class CalcError(Exception): pass
def _num(x):
if x is None: raise CalcError("空值")
if isinstance(x, (int, float, Decimal)): return Decimal(str(x))
s = str(x).replace(",", "")
try:
return Decimal(s)
except:
raise CalcError(f"非数值: {x}")
def _same_unit(u1, u2):
if u1 == u2: return True
# 需要更复杂的口径换算时,把映射写在这里
return False
OPS = {
"diff": lambda x, y: x - y,
"sum": lambda *xs: sum(xs),
"avg": lambda *xs: sum(xs)/Decimal(len(xs)),
"ratio":lambda x, y: x / y if y != 0 else Decimal("NaN"),
"yoy": lambda cur, base: (cur - base) / base if base != 0 else Decimal("NaN"),
}
def execute_plan(plan: dict, table_store: dict):
df_cache = {}
inputs = {}
citations = []
for inp in plan["inputs"]:
tid, row, col, unit = inp["table_id"], inp["row"], inp["col"], inp.get("unit")
if tid not in table_store: raise CalcError(f"找不到表: {tid}")
df = df_cache.setdefault(tid, table_store[tid]["df"])
try:
val = df.iloc[row][col]
except Exception as e:
raise CalcError(f"取值失败: {tid} r{row} c{col}") from e
v = _num(val)
inputs[inp["id"]] = {"value": v, "unit": unit, "label": inp.get("label", inp["id"]),
"page": table_store[tid]["page"], "table_id": tid, "row": row, "col": col}
citations.append({
"id": f"{tid}-r{row}-{col}",
"doc_id": table_store[tid].get("doc_id","report.pdf"),
"type": "table_cell",
"page": table_store[tid]["page"],
"table_id": tid, "row": row, "col": col,
"cell_bbox": table_store[tid].get("cell_bbox_map",{}).get((row, col))
})
values = {k: v["value"] for k,v in inputs.items()}
units = {k: v["unit"] for k,v in inputs.items()}
for step in plan["steps"]:
op = step["op"]
if op not in OPS: raise CalcError(f"不支持的运算: {op}")
args = [values[a] for a in step["args"]]
# 简单的单位一致性检查
if op in ("diff","ratio","yoy") and len(step["args"])==2:
a, b = step["args"]
if not _same_unit(units[a], units[b]):
raise CalcError(f"单位不一致: {units[a]} vs {units[b]}")
out = OPS[op](*args)
values[step["id"]] = out
units[step["id"]] = units[step["args"][0]] # 结果的单位通常沿用第一个输入;百分比在渲染再处理
rid = plan["result"]
raw = values[rid]
fmt = plan.get("format", "number")
if fmt == "percent":
show = f"{(raw * 100).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP)}%"
else:
show = f"{raw.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}"
card = {
"comparisons": [
{"label": inputs["a"]["label"], "value": f"{inputs['a']['value']}", "unit": inputs["a"]["unit"],
"source": {"page": inputs['a']['page'], "table_id": inputs['a']['table_id'], "row": inputs['a']['row'], "col": inputs['a']['col"]}},
{"label": inputs["b"]["label"], "value": f"{inputs['b']['value']}", "unit": inputs["b"]["unit"],
"source": {"page": inputs['b']['page'], "table_id": inputs['b']['table_id'], "row": inputs['b']['row'], "col": inputs['b']['col"]}},
],
"formula": "(b - a) / a",
"result": show,
"result_raw": str(raw),
"result_unit": "%" if fmt=="percent" else inputs['a']['unit']
}
return card, citations
这段代码把"规划"和"执行"彻底分离了。你让模型只输出那个 JSON 计划就好,它对单位、行列和运算的"选择"统统写在结构里;你的代码按计划逐格取值,按白名单运算把数算出来;最后形成一张"对照卡"对象,再配上引用清单,和上一篇的可点击体系就无缝拼起来了。自然语言如何生产也不复杂,要么用模板把结果口述出来,要么把"对照卡 + 引用清单"当作额外上下文送给生成模型,提示词明确要求"先给一句话结论,再附一行公式与引用",并禁止改写数字。
有两个容易踩坑的地方必须老老实实做好。第一是单位与口径,别指望模型记得"亿元"和"万元"的差异,严格在语句化或输入层面统一口径,不行就直接把换算写死在执行器里;第二是四舍五入的策略,要在一个地方定死格式,所有展示都从原始值进同一个渲染函数,避免"明面数字"和"公式里算出来的数字"不一致。额外想稳一点,可以在生成完成后做一次"回读核验":用正则把答案里的数字抓出来和"对照卡"的结果比对,差距超过阈值就拒绝发布,让系统走"请人工复核"的兜底路径。
讲完骨架,再看一下整合点。检索命中国际惯例先走你在第十九篇做的"文本/表格路由",表格路径里除了召回相关单元格,还把"最可能的两端或多端"交给模型,请它输出上面的计算计划;执行器跑完后得到"对照卡",把它塞进上一篇定义的响应 JSON 里,多塞一个 derivation
字段即可。前端拿到它,渲染一张小卡片:两条原始值一左一右,中间一条简洁的公式,右上角是最终增长率,点击任意数字,都回跳到 PDF 里对应的格子,高亮框住。用户看到的是答案和证据一体化,工程侧拿到的是可追溯、可复算、可审计的闭环。
到这里,"证据能点、数字能算、公式能看、来源能核"已经成型了。再往前一步,就是让系统学会"遇到需要'外部实时数据'的问题不进向量库,而是走工具",比如汇率、商品价、天气、交易日历这些不在文档里的活数据。下一篇我们把"工具路由"接上,让 RAG 在需要时自动切到 API 查询或数据库函数上,既保真又准时。