搭建知识库以后,回答不准确不一定是模型不会写。有时真正需要的资料根本没被检索出来,有时前三条塞满了相似却无关的内容。只看最终回答“像不像正确答案”,很难知道应该改资料、改检索,还是改生成提示词。

本文用一个六份虚构售后文档、三道问题的小练习,解释 Precision@3 与 Recall@3。你会得到一个无需密钥、无需联网的 Python 检查脚本,能够比较两组手工填写的检索排名。它不是完整知识库,也没有调用向量模型;重点是把“找得好不好”变成可复查的记录。

一、先区分检索质量和回答质量

知识库问答通常先找资料,再根据资料组织回答。检索评测关心哪些文档进入候选列表,回答评测关心结论是否正确、有没有依据、引用是否匹配。两者有联系,却不能互相替代。

如果退货问题只拿到了发票说明,模型写得再流畅也缺少合适证据。如果检索已经包含退货条件,最终回答却漏掉期限,就要继续检查生成环节。先观察失败发生在哪一步,再决定改哪里,可以减少反复换模型的试错。

本练习只评估文档是否相关,不评价最终回答。真实系统还应分别检查引用真实性、资料有效期、拒答和响应速度,不能用一个检索分数宣布整个系统已经可靠。

二、两个分数分别回答什么

Precision 的基本含义是:找出来的内容里,有多少真正相关。Recall 的基本含义是:所有应该找到的相关内容里,实际找到了多少。官方 scikit-learn 文档给出了查准率与查全率的基本定义;下面是把相关文档当作正例、截取前几条结果后的教学约定。

本例每次最多看前三条,固定 k 为三。Precision@3 等于前三条里相关文档数除以三;返回不足三条时,空缺位置不算命中,分母仍为三。这个约定必须在报告里写明,不能有时按三算、有时按实际返回数算。

Recall@3 等于前三条命中的相关文档数,除以该问题全部相关文档数。一个问题若只有一份相关文档,只要它出现在前三条,Recall@3 就是一;此时 Precision@3 可能仍只有三分之一。

这两个结果并不矛盾:你找全了需要的资料,但也给用户带来两份无关资料。反过来,前三条全部相关也不保证找全,因为可能还有更多关键文档落在后面。分数反映的是指定语料、标注和截断位置下的表现。

三条检索结果中的命中数与全部相关文档的不同分母示意

三、准备可核对的小语料

建立一个练习文件夹,准备六份虚构材料,分别使用稳定编号。编号只代表文档身份,不包含排序含义。

  • D1:A 型设备电池更换前的断电要求。
  • D2:A 型设备电池更换步骤与复装检查。
  • D3:普通商品退货条件与所需凭证。
  • D4:维修寄送包装和地址填写说明。
  • D5:个人电子发票申请材料。
  • D6:电子发票补发与邮箱更正说明。

问题一是“A 型设备更换电池前后要做什么”,相关集合标为 D1、D2。问题二是“普通商品退货需要满足什么条件”,相关集合标为 D3。问题三是“申请和补发个人电子发票要准备什么”,相关集合标为 D5、D6。

这些标注来自本例明确给定的文档说明,并不代表真实维修或售后政策。生产环境要阅读完整原文,核对设备型号、适用人群和规则版本,不能只根据标题猜相关性。存在分歧的文档可以先记录理由,再由熟悉业务的人复核。

尤其注意,“维修寄送”和“退货”都可能包含地址、快递等词,但不是同一业务。相关标注的价值,正是在这里把表面词语接近与真正能回答问题区分开。

四、用两组排名演练计算

方案 A 为三个问题分别返回 D1、D3、D4;D3、D1、D6;D4、D5、D3。方案 B 分别返回 D2、D1、D3;D3、D4、D6;D5、D6、D4。

这两组是人为构造的排名,不是某款模型或数据库的实测结果,不能据此宣传方案 B 比某产品更强。保留手工排名的目的,是让任何读者都能直接用纸笔检查程序。

对问题一,方案 A 命中 D1,所以 Precision@3 为三分之一,Recall@3 为二分之一。方案 B 命中 D1、D2,所以分别为三分之二和一。对问题二,两组都命中唯一的 D3,所以都是三分之一和一。

对问题三,方案 A 只找到 D5,两个分数分别为三分之一和二分之一;方案 B 找到 D5、D6,分别为三分之二和一。逐题列出命中编号,比只展示一个平均数字更能解释差异。

五、保存并运行离线脚本

安装官方 Python 三,确认终端能显示版本。下面只用标准库,不需要安装向量数据库或机器学习库。保存为 retrieval_check.py,在文件所在目录运行 python retrieval_check.py。

from statistics import mean

CORPUS = {f'D{i}' for i in range(1, 7)}
LABELS = {'q1': {'D1', 'D2'}, 'q2': {'D3'}, 'q3': {'D5', 'D6'}}
A = {'q1': ['D1', 'D3', 'D4'], 'q2': ['D3', 'D1', 'D6'],
     'q3': ['D4', 'D5', 'D3']}
B = {'q1': ['D2', 'D1', 'D3'], 'q2': ['D3', 'D4', 'D6'],
     'q3': ['D5', 'D6', 'D4']}

def score(ranked, relevant, k=3):
    if type(k) is not int or k <= 0:
        raise ValueError('k must be a positive integer')
    if not isinstance(ranked, list) or not isinstance(relevant, set):
        raise ValueError('expected list and set')
    if any(not isinstance(x, str) for x in ranked):
        raise ValueError('document IDs must be strings')
    if len(ranked) != len(set(ranked)):
        raise ValueError('duplicate document IDs')
    if not set(ranked) <= CORPUS or not relevant <= CORPUS:
        raise ValueError('unknown document ID')
    hits = set(ranked[:k]) & relevant
    return len(hits) / k, len(hits) / len(relevant) if relevant else None

for name, ranking in [('A', A), ('B', B)]:
    if ranking.keys() != LABELS.keys():
        raise ValueError('query set mismatch')
    rows = [score(ranking[q], LABELS[q]) for q in LABELS]
    for q, (precision, recall) in zip(LABELS, rows):
        print(name, q, f'P@3={precision:.3f}', f'R@3={recall:.3f}')
    print(name, 'macro', round(mean(x[0] for x in rows), 3),
          round(mean(x[1] for x in rows if x[1] is not None), 3))

按给定数据,方案 A 的逐题平均 Precision@3 约为零点三三三,Recall@3 约为零点六六七;方案 B 分别约为零点五五六和一。这里是先逐题计算再平均,每道题权重相同,并非把所有文档判断合并后计算。

不要只检查最后一行。先用问题一的命中编号手算,再检查程序中问题编号与排名是否对应,最后核对平均值。小数显示做了四舍五入,不要把显示位数当成评测精度或置信范围。

六、把异常输入也列入验收

把某道题的排名改成空列表,应得到零命中,不能让程序崩溃。把排名写成 D1、D1、D2,程序应报重复编号,不能靠同一份资料重复出现来增加命中数。把编号写成 D99,应提示未知文档。

试着把 k 设成零、负数或布尔值,都应该拒绝。布尔值在 Python 中与整数存在类型关系,所以代码使用严格类型检查,避免 True 被误当成合法截断长度。

当相关集合为空时,Recall 的分母为零。本例返回 None,不直接写成一,也不把它混入可回答问题的平均 Recall。无答案问题应单独评估是否正确拒答、是否返回误导资料;它与“已经找到全部相关文档”是不同情况。

排名少于三条时,Precision@3 的空位惩罚来自本例约定。若团队使用另一种口径,应改指标名称和说明,并固定历史比较方式,不能把口径变化解释成质量提升。

检索评测中正常命中、重复编号、未知编号与无答案问题的检查示意

七、迁移到真正的知识库

先固定一份可公开或获准使用的语料,记录每份文档的编号和版本。再收集来自真实任务的问题,人工给出相关集合,并留出没有用于调参的一组题。只反复优化同三道练习题,很容易得到好看的分数,却不能说明新问题也能回答。

从真实检索日志导出问题编号与排序后的文档编号,替换脚本中的 A 和 B。一次只改变一个环节,例如分块方式或是否增加重排,保持语料、问题、标注和 k 一致,再比较逐题变化。没有比较条件一致的记录,就不应说新版本提升了多少。

分块系统还要确定评估单位。一个文档拆成十块,如果标注仍按整篇文档,可能需要先把块映射回文档并去重;如果任务确实依赖某一段,则应另设段落级标注。不要把十个同源块当十份独立证据。

八、不要漏掉排序位置的差别

本例两个指标只检查前三条里的相关集合,不区分相关资料排在第一还是第三。把 D1、D3、D4 改成 D3、D4、D1,两个分数保持不变,但用户第一眼看到的内容已经不同。如果业务很看重第一条,应另外记录首条是否命中或相关资料首次出现的位置,不要期望一个指标解释所有体验。

另外,检索到了正确编号也不代表内容可以直接采用。假设 D1 对应旧型号,而文档编号映射没有随更新维护,分数可能看起来正常,实际证据却不适用。因此评测记录还应保存正文版本、索引构建时间和标注依据,发现资料变更后重新核对相关集合,而不只是复用旧答案。

九、发现分数不好后怎么改

Recall 较低时,先查看漏掉哪些相关资料:是否没入库、解析失败、编号错误,或者问题表达与资料差异太大。确认资料完整以后,再检查检索与分块设置,避免用扩大返回数量掩盖缺失文档。

Precision 较低时,逐条阅读无关候选,检查它们为什么进入前三条。可能是同名产品、过期政策或相似关键词,也可能是标注本身不够明确。提高 k 常能增加召回,但也可能带来更多干扰和上下文成本,不存在适用于所有任务的固定最优值。

完成这一轮练习后,应留下语料说明、问题与标注、两组排名、指标口径和失败记录。检索评测的作用是帮助定位改进方向;把证据保存下来,才有可能在下一次修改后判断变化是否真实。

官方参考资料