【每日一题】Python 编程题:核查模型回答里的「数字幻觉」(附题解,代码已跑通)

题目:RAG 问答最常见的幻觉是编造数字。给定检索到的原文 context 和模型基于原文生成的回答 answer,抽取 answer 中所有阿拉伯数字,判断每个数字能否在 context 中找到数值相同的依据,返回找不到依据的数字列表。

规则:

  1. 数字形式含整数、小数、负数,可能带千分位逗号(1,000 与 1000 视为同一个数);
  2. 数值相等即算有依据(2.50 == 2.5,42 == 42.0),-5 与 5 不算相等;
  3. 结果按在 answer 中首次出现顺序排列,同一数值只返回一次,返回 answer 里的原始写法;
  4. 百分号只作普通字符处理(30% 抽成 30)。

代码框架:

def find_unsupported_numbers(answer: str, context: str) -> list[str]:
    """返回 answer 中在 context 里找不到数值依据的数字。

    Args:
        answer: 模型生成的回答文本
        context: 检索到的原文文本
    Returns:
        按 answer 首次出现顺序去重后的「无依据数字」原始写法列表
    """
    pass

断言用例:

assert find_unsupported_numbers("本季度 GMV 为 1,000 万元,同比增速 30%。",
                                "本季度 GMV 1000 万元,同比增速 30%。") == []
assert find_unsupported_numbers("P99 时长 2.50 秒", "P99 时长 2.5 秒") == []
assert find_unsupported_numbers("共 12 台机器,故障 3 台", "集群共 12 台机器") == ["3"]
assert find_unsupported_numbers("错误率下降 -5%,单均成本 250 元",
                                "错误率下降 5%,单均成本 2500 元") == ["-5", "250"]
assert find_unsupported_numbers("A 为 42,B 也为 42.0", "本文档不含任何数字") == ["42"]
assert find_unsupported_numbers("成功率 99.99%,超时 0.5 秒", "成功率 99.99%") == ["0.5"]

难度:中级
题型:笔试(编程题)

:point_right:参考答案/答题要点:

解法一:预处理去千分位 + 简单正则(好写,面试先拿下这版)

import re

def _strip_thousand_sep(s: str) -> str:
    # 1,000 -> 1000;只删数字之间、后面正好 3 位的逗号
    return re.sub(r"(?<=\d),(?=\d{3}(?!\d))", "", s)

def find_unsupported_numbers(answer: str, context: str) -> list[str]:
    ctx = {float(t) for t in re.findall(r"-?\d+(?:\.\d+)?", _strip_thousand_sep(context))}
    seen, out = set(), []
    for tok in re.findall(r"-?\d+(?:\.\d+)?", _strip_thousand_sep(answer)):
        val = float(tok)
        if val not in ctx and val not in seen:
            seen.add(val)
            out.append(tok)
    return out

解法二:一次正则 + Decimal 归一化 + 保序去重(工程版,边界更稳)

import re
from decimal import Decimal

NUM_RE = re.compile(r"(?<![\w.,])(-?(?:\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?))(?![\w])")

def _norm(tok: str) -> Decimal:
    return Decimal(tok.replace(",", ""))

def find_unsupported_numbers(answer: str, context: str) -> list[str]:
    ctx = {_norm(t) for t in NUM_RE.findall(context)}
    first = {}                    # setdefault:保序,且保留首次出现的原始写法
    for tok in NUM_RE.findall(answer):
        first.setdefault(_norm(tok), tok)
    return [tok for v, tok in first.items() if v not in ctx]

两版对上面 6 条断言全部通过(本地 Python 3.14 实跑)。再补两条进阶断言,只有解法二能过:

# 长数字(订单号):解法一 float 精度丢失,会返回 [] 判成“有依据”,是漏判
assert find_unsupported_numbers("订单号 123456789012345678901 支付失败",
                                "订单号 123456789012345678902 支付失败") == ["123456789012345678901"]
# 连字符不是负号:release-2.0 里的 2.0 有依据
assert find_unsupported_numbers("版本 release-2.0", "基线版本 2.0") == []

采分点:

  • 先澄清需求再动手:版本号 v1.2.3、日期 2026-10-22、编号里的数字算不算「事实」?中文数字「三季度」、单位换算(1 万元 = 10000 元)要不要处理?这题真实场景是「规则要和产品对齐」,直接闷头写正则容易被判定缺乏沟通意识。
  • 数值归一化:千分位、尾零、大小写单位;用 Decimal/字符串规范化而不是 float,避免大整数精度丢失。
  • 顺序与去重:要求「首次出现顺序 + 同值只出一次」,用 dict/setdefault 保序,用 set 记已判定值;set(answer) - set(context) 这种差集写法会丢顺序、也会丢原始写法,是典型错解。
  • 负号与连字符:utf-8、release-2.0 不能识别成负数,靠后行断言或前置字符判断兜住。
  • 工程落地:输出除「无依据数字」外还应带上下文片段和位置,方便算幻觉率、做 badcase 归因;纯数字匹配只是弱校验,生产上通常规则校验 + 语义 NLI 模型双跑。
  • 复杂度:O(n+m),一次抽取 + 集合查询;能说出批量评测时的耗时与并行方案(数据集分片、按用例并发)是加分项。