RAG 每日一技(二十一):让证据“会算账”——差异对照与可核验公式的最小闭环

上一篇把"可点击引用"落地了,答案终于能一键跳回原文位置。接下来就该把"会算账"补上:同一问题下,给出对照、给出公式、给出可核验的来源,别只丢一句"同比增长不错"。目标形态只有一句话------答案不止有结论,还要有一张"可审计的计算卡片":左边是两端或多端的被比对象和原始数值,中间是明确到单元格的公式,右边是算出来的百分比或差值,所有数字都能点回 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 查询或数据库函数上,既保真又准时。

相关推荐
开心猴爷2 小时前
FTP 抓包分析实战,命令、被动主动模式要点、FTPS 与 SFTP 区别及真机取证流程
后端
电鱼智能的电小鱼2 小时前
服装制造企业痛点解决方案:EFISH-SBC-RK3588 预测性维护方案
网络·人工智能·嵌入式硬件·算法·制造
_Mya_3 小时前
后端接口又改了?让 Apifox MCP 帮你自动同步类型定义
前端·人工智能·mcp
本当迷ya3 小时前
最新2025年SpringBoot集成PowerJob分布式任务调度
后端
西柚小萌新3 小时前
【深入浅出PyTorch】--7.1.PyTorch可视化1
人工智能·pytorch·python
骈拇3 小时前
ConcurrentHashMap细节分析
后端
我是华为OD~HR~栗栗呀3 小时前
华为OD-23届考研-Java面经
java·c++·后端·python·华为od·华为·面试
兔子小灰灰3 小时前
论文笔记:π0.5 (PI 0.5)KI改进版
人工智能·深度学习
PKNLP3 小时前
Transformer模型
人工智能·深度学习·transformer