去年排查过一个财报问答系统。用户的问法很普通:「2025 年第三季度,华东区营业收入同比变化多少?」系统答:「同比下降 99.2%」,附了三条引用。三条引用都是真的,点开都能在原文里找到对应数字。
错在引用和答案之间那一步。第一条证据写「华东区 Q3 营业收入 8.7 亿元」,第二条写「上年同期华东区营业收入 87,432 万元」。抽取层把 8.7 和 87432 放进了同一个减法,再除以 87432,得到 -99.99%,四舍五入成 -99.2%(展示层又用了另一套舍入)。
这个错误里没有一处是「模型不会算」。8.7 减 87432 它算得很准。它错在把两个不同单位制下的量当成了同一个坐标系里的数。同一批问题里还有第二种错法:第三条证据是年报里的「前三季度累计营业收入 35.1 亿元」,模型有时拿它当 Q3 单季,因为「2025 年」这个词在同一段里出现过。
两类错误的共同点是:算术发生在一个没有被对齐的状态上。要修的不是模型,是算术之前的那几步。
这篇文章讲一条改造路径:模型只做语义定位——指出哪句话里有数、那句话声明的是什么口径;抽取、单位换算、口径一致性检查、聚合、舍入全部交给一个确定性、可复现、能写单元测试的计算层;最后把算出来的数和一个原文片段绑定,回填成带出处的答案。
先说它不解决什么,免得读完发现期待错位。
它不解决证据缺失。 如果文档里根本没有 2025 年 Q3 的分部数据,抽取层只能报「找不到」,不能替你想一个。计算层对空输入的处理是抛错,不是补零。
它不解决口径该由谁定。 系统能校验「这两条证据口径是否一致」,但「同比应该用单季还是累计」是业务规则,得人来定,写成配置或口径表。模型可以提出候选,不能拍板。
它不解决假设选择。 如果问题需要加权、预测或者敏感性分析,计算层只能忠实执行给定的假设。假设本身选错了,算得再准也是错的。
1. 模型在哪一步可信,哪一步不可信
把「从文档里得到一个数字」拆成三个子能力,可靠性差别很大。
语义定位:在一堆候选片段里判断哪句话涉及「华东区 Q3 营业收入」。这是模型的强项,检索和重排已经在做这件事,继续交给它。
转录:把原文的「87,432 万元」抄成答案里的数字。这一步看着简单,实际上是模型最容易悄悄出错的地方——它会在抄的过程中做四舍五入(87432 变 8.7 万),会改写单位(万元变元),会把「约」「超过」这类修饰词丢掉。而这些改动在自然语言答案里几乎无法检测。
确定性换算:万元换算成元、百分比换算成小数、跨币种折算。这一步是纯函数,模型做它等于引入不可复现的误差,而且同一个输入问两次可能给出两个结果。
所以改造的第一个决定是:模型不输出数字,只输出引文和口径声明。
模型看到候选片段后,返回这样的结构:
{
"evidence_id": "doc-17#p3",
"quote": "华东区 Q3 营业收入 8.7 亿元",
"metric": "revenue",
"entity": "east_china",
"basis": "single_quarter",
"period": ["2025-07-01", "2025-10-01"],
"tax": "na"
}
quote 必须逐字摘自原文。程序拿这个引文去原文里定位,切出真实的字符片段,再交给确定性解析器。这样「抄错」这个失败模式从不可检测变成可检测:quote 在原文里找不到,或者找到的位置解析不出数字,直接报错。
用引文而不是字符偏移量,是因为模型输出偏移量本身就有误差——它按 token 计数,返回的是字符索引,中间隔着分词器的映射。让它抄一段短引文,程序用 str.find 定位,比让它数位置可靠得多。Anthropic 的 Citations 接口也是这个思路:模型返回 cited_text,由调用方在自己的文档里做匹配。
代价是引文长度要控制。太短(比如只摘「8.7」)会在文档里匹配到多处;太长模型容易改写。经验上卡在包含数字与单位的完整短语,通常 15 到 80 个字符,并且要求引文里至少有一个数字。
有了这一步,「模型算错数」的整个类别从系统里消失了。剩下的错误只有两类:定位错了(引文不匹配或指错片段),口径声明错了(把累计当单季)。这两类都能通过校验和评测单独统计。
2. 链路与四个不变量
改造后的链路是这样的:
图里有两个箭头是从校验失败回到「拒答」的,这两个出口是整条链路的价值所在——它们让系统在算不出来的时候承认算不出来,而不是编一个看着合理的数。
链路上有四个不变量,后面每一节都在钉其中一个:
I1 溯源:每一个进入计算的数,必须绑定一个原文片段,片段能被独立重现。
I2 归一:单位在进入计算之前就归一到基本单位,归一过程是纯函数,不依赖上下文。
I3 口径闭合:只有口径键完全相等的量才能聚合;口径键不同时计算层抛错,不做隐式转换。
I4 舍入固定:舍入规则在计算开始时确定,写入计算记录,展示层不得再次舍入。
I3 是最容易被跳过的一条。很多实现的做法是「尽量对齐」,比如发现一边是累计一边是单季,就用累计除以 3 估算单季。这个估算引入的误差无法在答案里表达,而且掩盖了数据本身的粒度问题。正确的做法是让口径冲突显式失败,由业务方补数据或者补充规则。
I4 常被忽略,因为展示层通常会「保留两位小数」。如果计算层已经舍入过一次,展示层再舍一次,会出现 12.345 → 12.35 → 12.4 这种双重舍入。单次误差是半个最低位,两次叠加可能跨过一整个最低位。
3. 抽取与单位归一
先写解析器。它的输入是一段原文文本,输出是一个已经归一到基本单位的 Decimal 和一个维度标记。
核心设计是把「单位 → 乘数」做成显式的数据表,而不是散落在代码里的 if-else。表里每一条都是可审计的:加一条新单位就是在表里加一行,删掉一条会立刻在测试里暴露。
"""parse.py —— 从原文片段到 (Decimal, 基本单位) 的纯函数。
基本单位的选择:金额用「元」,比率用「1」。
维度(CNY / USD / RATIO)参与口径比较,不做隐式汇率折算。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
# 单位 -> (维度, 换算到基本单位的乘数)
SCALE: dict[str, tuple[str, Decimal]] = {
"亿元": ("CNY", Decimal("100000000")),
"亿美元": ("USD", Decimal("100000000")),
"百万元": ("CNY", Decimal("1000000")),
"万元": ("CNY", Decimal("10000")),
"万美元": ("USD", Decimal("10000")),
"千元": ("CNY", Decimal("1000")),
"元": ("CNY", Decimal("1")),
"美元": ("USD", Decimal("1")),
"%": ("RATIO", Decimal("0.01")),
"个百分点": ("RATIO", Decimal("0.01")),
}
# 正则里的单位必须长词在前,否则「亿元」会被「元」先吃掉
_UNITS = "|".join(sorted(SCALE, key=len, reverse=True))
NUM_RE = re.compile(
rf"(?P<num>-?\d{{1,3}}(?:,\d{{3}})+(?:\.\d+)?|-?\d+(?:\.\d+)?)"
rf"\s*(?P<unit>{_UNITS})?"
)
class ParseError(ValueError):
"""解析失败一律向上抛,不返回 None、不补默认值。"""
@dataclass(frozen=True)
class Quantity:
value: Decimal # 已归一到基本单位
dimension: str # CNY / USD / RATIO
raw: str # 原文数字串,回填答案时原样带出
def parse_quantity(text: str) -> Quantity:
m = NUM_RE.search(text)
if m is None:
raise ParseError(f"片段里没有数字: {text!r}")
unit = m.group("unit")
if unit is None:
# 「12.4」到底是什么单位无从判断,宁可失败
raise ParseError(f"数字缺单位: {m.group(0)!r}")
digits = m.group("num").replace(",", "")
dimension, scale = SCALE[unit]
try:
base = Decimal(digits) * scale
except InvalidOperation as exc:
raise ParseError(f"无法转换: {digits!r}") from exc
return Quantity(value=base, dimension=dimension, raw=m.group(0))
def _selfcheck() -> None:
q = parse_quantity("华东区 Q3 营业收入 8.7 亿元")
assert q.value == Decimal("870000000"), q.value
assert q.dimension == "CNY"
# 千分位与无单位的情况
q2 = parse_quantity("上年同期营业收入 87,432 万元")
assert q2.value == Decimal("874320000"), q2.value
# 百分比必须归一成小数,否则 5% 会被当成 5
q3 = parse_quantity("毛利率提升 5.2%")
assert q3.value == Decimal("0.052"), q3.value
assert q3.dimension == "RATIO"
# 缺单位要失败,不能猜
try:
parse_quantity("营收 12.4")
except ParseError:
pass
else:
raise AssertionError("缺单位应当抛 ParseError")
print("parse selfcheck ok")
if __name__ == "__main__":
_selfcheck()
三个细节值得单独说。
百分比归一成小数。 5.2% 存成 0.052。如果保留 5.2 并打上 RATIO 标记,所有下游代码都要记得除 100,漏一次就是 100 倍错误,而且这种错误在数值上看不出异常——「增长率 520%」在财报里并非不可能,人工复核时不会立刻报警。归一之后,比率天然落在 -1 到 1 之间(极端情况除外),越界可以当异常检测。
缺单位直接抛错。 这是 fail-closed 的体现。可以做一个缓解:允许调用方传入「上下文默认单位」,比如表格的列头声明了「单位:万元」,那么该列下所有裸数字按万元解析。这个默认值必须来自明确的文档结构,不能靠模型猜。
Decimal 而不是 float。 金额聚合几百条之后,float 的二进制表示误差会累积到分的量级。Decimal("0.1") + Decimal("0.2") 精确等于 Decimal("0.3"),而 0.1 + 0.2 是 0.30000000000000004。财务口径对分位敏感,这个差别是实质的。反过来说,如果只是做检索排序打分,用 float 完全够,别把 Decimal 用到不该用的地方——它的运算比 float 慢一个量级。
引文定位函数和解析器搭配使用:
import difflib
class QuoteNotFound(LookupError):
pass
def locate(doc: str, quote: str, *, min_ratio: float = 0.9) -> tuple[int, int]:
"""返回引文在 doc 中的半开区间 [start, end)。定位不到就抛错。"""
hit = doc.find(quote)
if hit >= 0:
return hit, hit + len(quote)
# 模型偶尔会把引文改写几个字,退回到近似匹配,但要求足够高的覆盖率
sm = difflib.SequenceMatcher(None, doc, quote, autojunk=False)
a, b, size = sm.find_longest_match(0, len(doc), 0, len(quote))
if size < max(6, int(len(quote) * min_ratio)):
raise QuoteNotFound(quote)
start = a - b # 由最长公共块反推 quote 的起点
end = start + len(quote)
if start < 0 or end > len(doc):
raise QuoteNotFound(quote)
return start, end
近似匹配这一支要克制。它只处理模型把「营业收入」写成「营收收入」这类一两个字的偏差,覆盖率门槛设在 90%,低于这个比例就当作定位失败。定位失败的处理是拒答,不是拿相似度最高的一段凑合——凑合出来的片段可能属于另一家公司另一个季度,而数字看起来完全正常。
4. 口径键:把「能不能相加」变成一次相等比较
单位对齐只解决了量纲。剩下的是语义层:两个量说的到底是不是同一件事。
把口径做成一个不可变的记录类型,这是整篇文章里最关键的建模决定。字段选得准,后面所有的聚合、比较、审计都是免费的。
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@dataclass(frozen=True)
class Caliber:
metric: str # revenue / net_profit
entity: str # east_china / group_total
period_start: date # 闭区间起点
period_end: date # 开区间终点
basis: str # single_quarter / cumulative
dimension: str # CNY / USD / RATIO
tax: str # inclusive / exclusive / na
TARGET_Q3_2025 = Caliber(
metric="revenue",
entity="east_china",
period_start=date(2025, 7, 1),
period_end=date(2025, 10, 1),
basis="single_quarter",
dimension="CNY",
tax="na",
)
用 frozen=True 的 dataclass 而不是 dict,有两个直接好处:字段名拼错会在构造时抛 TypeError(dict 要到比较时才发现键对不上);自动生成的 __eq__ 和 __hash__ 让它可以直接做集合成员和缓存键。
期间用半开区间 [start, end)。 闭区间在跨月、跨年、跨闰日时会出现重叠或者空洞:[2025-01-01, 2025-03-31] 和 [2025-04-01, 2025-06-30] 看起来连续,但如果有人把 end 写成 2025-04-01,就凭空多出一天。半开区间让「上一期的结束等于下一期的开始」成为恒等式,可以直接断言。
basis 是必须的字段。 它是「单季」和「累计」的区分。年报里同一家公司会同时给出 Q3 单季营收和前三季度累计营收,两个数字都在文档里,都带「2025 年」,模型如果只按年份和实体匹配就会选错。把 basis 提为一等字段之后,这个错误在 Caliber 比较时就被拦下来了。
tax 在文档没有声明时填 na,不填默认值。 na 和 exclusive 不相等,这正是想要的:一份声明「不含税」的证据和一份没声明税制的证据,不应该被当成同一口径相加。如果业务确认文档默认不含税,那就在入库时把 na 补成 exclusive,这个补全动作是显式的、可审计的,不是计算层猜的。
聚合函数因此只做一件事——检查相等,然后求和:
from decimal import ROUND_HALF_UP, localcontext
class CaliberMismatch(Exception):
def __init__(self, a: Caliber, b: Caliber) -> None:
self.a, self.b = a, b
diff = [f for f in a.__dataclass_fields__ if getattr(a, f) != getattr(b, f)]
super().__init__(f"口径不一致,差异字段: {diff}")
def add_all(items: list[tuple[Caliber, Decimal]]) -> tuple[Caliber, Decimal]:
if not items:
raise ValueError("add_all: 空输入")
base = items[0][0]
for cal, _ in items[1:]:
if cal != base:
raise CaliberMismatch(base, cal)
with localcontext() as ctx:
ctx.prec = 28
ctx.rounding = ROUND_HALF_UP
total = sum((v for _, v in items), Decimal("0"))
return base, total
def _same_shape(a: Caliber, b: Caliber) -> bool:
"""同比只允许期间不同,其余口径字段必须完全一致。"""
fields = ("metric", "entity", "basis", "dimension", "tax")
return all(getattr(a, f) == getattr(b, f) for f in fields)
def yoy(cur: tuple[Caliber, Decimal],
prev: tuple[Caliber, Decimal]) -> Decimal:
c1, v1 = cur
c2, v2 = prev
if not _same_shape(c1, c2):
raise CaliberMismatch(c1, c2)
if c2.basis != "single_quarter":
raise ValueError("yoy 只对单期口径有意义")
if v2 == 0:
raise ZeroDivisionError("yoy: 基期为零")
if v2 < 0:
raise ValueError("yoy: 基期为负,比率无经济含义")
with localcontext() as ctx:
ctx.prec = 28
ctx.rounding = ROUND_HALF_UP
return (v1 - v2) / v2
CaliberMismatch 里把差异字段列出来,是很实际的一条:线上报警时运维第一眼要看到的是「basis 不一致」还是「entity 不一致」,这决定了去找数据团队还是去找代码。
yoy 拒绝基期为负,也是刻意的。两家亏损分部的「增长率」在数学上有定义,在业务上没有解释力,正负号会翻转。让它在计算层失败,比让它生成一个 -350% 然后被写进 PPT 要好。
问题侧同样要解析成 Caliber。用户问「Q3 同比」,目标口径是 basis=single_quarter、期间 [2025-07-01, 2025-10-01),比较基期是 [2024-07-01, 2024-10-01)。这一步可以由模型把自然语言映射到结构化字段,但映射结果必须落到公司的财年日历上——很多公司的财年不等于自然年,Q3 可能是 4 月到 6 月。财年日历是一份配置,不是模型的常识。
5. 舍入与可复现
计算层里最容易埋雷的是舍入。Python 的 decimal 默认上下文用 ROUND_HALF_EVEN(银行家舍入),0.5 会舍到 0。财务口径通常要 ROUND_HALF_UP,0.5 进位到 1。两者对单笔金额的差异是 1 分,对汇总表可能拉开几块钱,月末对账时会被财务找上门。
处理方式是:每次进入计算都开一个 localcontext(),显式设 prec 和 rounding,退出时上下文自动恢复。不用全局 decimal.setcontext(),那会污染同进程的其他代码路径。
prec=28 覆盖了绝大多数业务量级。真正的约束不是精度,而是在哪里舍入。规则只有一条:中间结果不舍入,只在最终输出时舍入一次。
def to_display(value: Decimal, places: int = 2,
rounding: str = ROUND_HALF_UP) -> Decimal:
"""只允许在输出边界调用,中间计算不得使用。"""
exp = Decimal(1).scaleb(-places) # 2 -> Decimal('0.01')
with localcontext() as ctx:
ctx.prec = 28
ctx.rounding = rounding
return value.quantize(exp)
if __name__ == "__main__":
with localcontext() as ctx:
ctx.prec = 28
ctx.rounding = ROUND_HALF_UP
a = Decimal("1.005")
b = Decimal("1.015")
assert to_display(a) == Decimal("1.01"), to_display(a)
assert to_display(b) == Decimal("1.02"), to_display(b)
# 银行家舍入下 1.005 -> 1.00,这是另一套规则,不能混用
assert to_display(a, rounding="ROUND_HALF_EVEN") == Decimal("1.00")
print("rounding selfcheck ok")
quantize 而不是 round()。Python 内置的 round() 对 Decimal 走的是 __round__,用的是上下文舍入模式;对 float 走的是另一套逻辑,且 round(2.675, 2) 得到 2.67 而不是 2.68,因为 2.675 在二进制里存不下。财务代码里出现 round(float, 2) 基本可以判定是个待爆的雷。
计算记录要把舍入规则写进去:
@dataclass(frozen=True)
class Computation:
target: Caliber
inputs: tuple[tuple[str, str, str], ...] # (evidence_id, quote, raw_value)
operation: str # "yoy" / "sum"
rounding: str # "ROUND_HALF_UP@2"
result: Decimal
有了这条记录,同一个问题在同一个证据集上重跑,结果必然相同;如果不同,说明证据集变了或者口径表变了,这两件事都该被审计。这是「可复现」的全部含义——不是随机种子固定,是输入和规则都被完整记录。
回填答案时,数字取自 Computation.result,出处取自 Computation.inputs 里的 evidence_id 和 quote。答案里的每个数字都能点到原文的那一句话,中间没有模型参与。
6. 用数值准确率和口径错误率验收
一个笼统的「准确率」会把三类完全不同的失败混在一起:检索没找到、口径选错了、算错了。它们的修复方向完全不同,混在一起看只会得出「效果不好」这种没有行动价值的结论。
拆成三个指标,共用同一个评测集:
数值准确率(numeric accuracy):答案数值与参考答案在容差内一致,且口径键完全相同的样本占比。
口径错误率(caliber error rate):答案所用证据的口径键与目标口径不一致的样本占比。这个指标专门抓「算对了但答非所问」。
出处命中率(citation hit rate):答案里的每个数字都能在参考答案的证据集合里找到来源的样本占比。
三个指标的分母都是评测集总数,所以可以相加看总量,也可以单独看某一类。但更有用的是条件准确率:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Prediction:
qid: str
value: Decimal | None
caliber: Caliber | None
cited: tuple[str, ...] # evidence_id 列表
@dataclass(frozen=True)
class GoldCase:
qid: str
value: Decimal
caliber: Caliber
evidence_ids: frozenset[str]
abs_tol: Decimal = Decimal("0.01") # 金额按分位
rel_tol: Decimal = Decimal("0.0001") # 比率按 1e-4
def matches(pred: Decimal, gold: Decimal,
abs_tol: Decimal, rel_tol: Decimal) -> bool:
"""金额用绝对容差,比率用相对容差,二者取或。"""
diff = abs(pred - gold)
if diff <= abs_tol:
return True
return gold != 0 and diff / abs(gold) <= rel_tol
def evaluate(preds: dict[str, Prediction],
cases: list[GoldCase]) -> dict[str, float]:
n = len(cases)
num_ok = cal_ok = cite_ok = answered = 0
for case in cases:
p = preds.get(case.qid)
if p is None or p.value is None or p.caliber is None:
continue
answered += 1
if p.caliber == case.caliber:
cal_ok += 1
if matches(p.value, case.value, case.abs_tol, case.rel_tol):
num_ok += 1
if set(p.cited) & case.evidence_ids:
cite_ok += 1
aligned = cal_ok or 1
return {
"numeric_accuracy": num_ok / n,
"caliber_error_rate": (n - cal_ok) / n,
"conditional_numeric_accuracy": num_ok / aligned,
"citation_hit_rate": cite_ok / n,
"answer_rate": answered / n,
}
conditional_numeric_accuracy 是这里最有用的一格:它回答的是「口径对齐之后,抽取和算术到底对了多少」。如果这个数接近 1 而 numeric_accuracy 很低,说明问题全在口径解析;反过来如果这个数很低,说明抽取或解析器有 bug,去看 ParseError 的分布。
answer_rate 单独统计,是为了防止系统靠拒答刷分。拒答在口径冲突时是正确行为,但如果 60% 的样本都拒答,那准确率的分子再漂亮也没有意义。这两个数要一起看。
容差按展示精度定。金额按分位(0.01 元)用绝对容差,因为业务上小于一分的差异不存在;比率用相对容差,因为 1.2% 和 1.2001% 在业务上等价,但绝对差 0.0001 在 0.5% 这种小基数上可能就是 2% 的相对误差。
评测集要按失败模式分层构造,不能随机抽样。至少包含这几类:
- 单期 vs 累计:同一实体同一期间,文档里同时存在两种口径的数字。
- 单位混用:同一指标在正文用「亿元」、在表格用「万元」。
- 币种混用:集团报表用人民币,海外分部用美元,问题只问分部。
- 缺失:问题涉及的期间或实体在文档里不存在,正确答案是拒答。
- 基期为零或负:增长率无定义,正确答案是拒答并说明原因。
前两类是这个链路的正题,后两类测的是拒答边界。评测集里没有缺失样本,系统就永远学不会说「不知道」。
7. 失败模式与生产加固
上线后最常见的问题不在算法,在数据和运维。
引文定位的失败率要单独监控。 精确 find 失败但近似匹配置信度高的比例、近似匹配也失败的比例,这两个数分开统计。前者高说明模型改写引文的倾向在增强(可能换了模型版本),后者高说明检索给的片段质量在下降。把它们混在一起看,就分不清该调 prompt 还是该调检索。
单位表要有缺项告警。 ParseError 里带「数字缺单位」的那一类,把原始文本采样记录下来。如果某个单位反复出现(比如「亿港元」「千股」),说明表该扩了。缺项率是这张表最直接的维护信号。
口径冲突不以模型裁决。 当两条证据的 Caliber 不一致,系统可以拒答,也可以追问用户。让模型「判断哪个更可信」是危险的——它会倾向于选那个数字更大或者表述更权威的,而这个倾向和正确答案没有关系。冲突的裁决权在业务方,系统只负责把冲突记录清楚。
计算层的缓存键必须是完整输入。 用 (frozenset(evidence_ids), target_caliber, rounding_rule) 做键,不能用问题文本。同一个问题在不同时间点检索到的证据可能不同,用问题文本做键会把旧结果喂给新证据。这类缓存命中看起来是性能优化,实际是数据一致性 bug。
每一次计算落一条审计记录。 Computation 结构体直接序列化入库,字段包括目标口径、输入片段、运算类型、舍入规则、结果、时间戳。财务场景被问到「这个数怎么来的」时,能一次拿出完整链条和点的确答案,比事后复现有用得多。
答案文本里不要重复数字。 回填时用模板,把 Computation.result 格式化进去一次。如果让模型把算好的数字再写进一段自然语言里,它就有机会改写——「约 12.3 亿元」里的「约」是模型加的,「12.3」也可能是它从 12.34 舍过来的。模板化之后,数字只有一处来源。
8. 适用边界
这套链路适合的场景有共同特征:答案是数值、口径可以被形式化、错误有明确代价。财务报表、运维指标、合同金额、实验测量数据都属于这一类。
不适合的场景也很清楚。
口径无法形式化时,Caliber 建不起来。 比如「用户满意度」这种指标,不同团队用不同的问卷、不同的量表、不同的加权方式。强行统一口径会得到一个精确但无意义的数。
问题本身是开放式的。 「业绩怎么样」需要的是叙述和判断,不是数值。这类问题交给普通的 RAG 就好,硬套计算层会得到一个答非所问的精确数字。
证据以图片或扫描件为主。 这条链路依赖文本片段能被逐字定位。如果关键数字只存在于需要 OCR 的图表里,抽取层拿不到可靠的 quote,整条链路在第一步就断了。OCR 结果要当成另一路证据源,单独标定它的转录准确率。
跨币种、跨会计准则的合并。 系统能做的是把不同 dimension 的量拒绝相加。真正的折算需要汇率来源、时点选择和会计准则的确认,这些都应该在数据准备阶段完成,而不是在问答时由计算层临时决定。
还有一条容易被忽略的边界:这套设计不会让系统更聪明,只会让它更诚实。 它把「模型编了一个数」变成「系统说找不到证据」。在内部工具里后者明显更好;在面向终端用户的产品里,拒答率高会直接影响体验,需要配合更好的检索和更完整的文档库来降低拒答率,而不是放松计算层的检查。
总结
如果 RAG 系统的数字总答错,先看算术之前的那几步,而不是换一个更强的模型。
具体到可执行的三件事:
第一,让模型输出引文和口径声明,不输出数字。引文能被 str.find 定位,定位失败就拒答。这一步把「转录错误」从不可检测变成可检测。
第二,单位归一是纯函数,用显式的换算表,缺单位时抛错而不是猜。百分比归一成小数,金额用 Decimal。换算表是数据,不是代码逻辑,加一行就能扩一种单位。
第三,口径做成 frozen dataclass,聚合前做一次相等比较,不等就抛 CaliberMismatch。期间用半开区间,basis(单期/累计)和 tax(含税/不含税)是必填字段,没有声明就填 na,让它在比较时失败。
验收看两个数:口径错误率和条件数值准确率。前者高说明问题解析或证据选择有问题,后者高说明计算层是对的。只看总体准确率会把这两类问题混成一个说不清的百分比。
参考资料
- Python Software Foundation. decimal — Decimal fixed point and floating point arithmetic. https://docs.python.org/3/library/decimal.html
- Python Software Foundation. dataclasses — Data Classes. https://docs.python.org/3/library/dataclasses.html
- Python Software Foundation. difflib — Helpers for computing deltas. https://docs.python.org/3/library/difflib.html
- Anthropic. Citations. https://docs.anthropic.com/en/docs/build-with-claude/citations
- PostgreSQL Global Development Group. Numeric Types (PostgreSQL 17). https://www.postgresql.org/docs/current/datatype-numeric.html
- IEEE. IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019). https://standards.ieee.org/ieee/754/6210/
- ISO. ISO 8601-1:2019 Date and time — Representations for information interchange — Part 1: Basic rules. https://www.iso.org/standard/70907.html
- Regenstrief Institute. Unified Code for Units of Measure (UCUM). https://ucum.org/ucum
- Chen, Z. et al. FinQA: A Dataset of Numerical Reasoning over Financial Data. EMNLP 2021. https://arxiv.org/abs/2109.00122
- Zhu, F. et al. TAT-QA: A Question Answering Benchmark on a Hybrid of Tabular and Textual Content in Finance. ACL 2021. https://arxiv.org/abs/2105.07624
- Dua, D. et al. DROP: A Reading Comprehension Benchmark Requiring Discrete Reasoning Over Paragraphs. NAACL 2019. https://arxiv.org/abs/1903.00161
- IFRS Foundation. IAS 1 Presentation of Financial Statements. https://www.ifrs.org/issued-standards/list-of-standards/ias-1-presentation-of-financial-statements/
- OpenAI. Structured Outputs. https://platform.openai.com/docs/guides/structured-outputs
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_50684356/article/details/170348443




