作者审计一家诊所的 61 页知识库,发现 24 页包含过期或冲突信息,涉及价格、人员、服务和营业时间。案例说明,检索相似度不能证明事实有效,RAG 系统需要将知识内容与实际业务数据核对。
一家牙科诊所请我检查他们的 AI 接待员,因为它总是报出一个“错误”的价格。价格其实没错,只是已经是两年前的了,而且还写在一篇始终没人删除的帮助中心文章里。
我导出了他们的整个知识库:61 个 Markdown 页面,距离上次修改的时间从 14 天到 1,100 天不等。我逐页对照了正在使用的业务系统,包括预约日历、价目表、员工名册和承保机构名单。
61 个页面中,有 24 个(39%)至少包含一条在当天早上已经不属实的陈述。
6 个页面提到了一位已经离职的牙医。5 个页面引用了已经调整的价格。4 个页面介绍了诊所已经停止提供的服务。3 个页面的营业时间只在 2024 年之前有效。2 个页面列出了一家已经不再合作的保险机构。还有 4 个页面与其他页面重复,却对同一个问题给出了不同答案。
AI 对所有这些页面的检索置信度都达到了 0.94,因为检索衡量的是相似度,而不是真实性。一个写着“检查费用为 £45”的页面,完全匹配“检查多少钱”这个问题。检索系统并不知道,现在的价格已经是 £62。
这就是文档腐化。在 RAG 系统中,它是导致 AI 信心十足地给出错误答案的最大单一来源,再多的 prompt engineering 也解决不了。
大多数团队把它当成一个问题:“我们应该更新文档。”但这并不是一个问题,每一种腐化都需要不同的解决办法。
诊所的 24 个问题页面可以分为:11 个事实过期页面、6 个幽灵页面、4 个分叉页面、3 个孤儿页面。只有第一类能靠“提醒大家更新文档”来解决。
幽灵页面最危险。一个介绍已停办服务的页面,不只是会导致错误回答,还会让 AI 回答一个客户原本根本不该有机会提出的问题。AI 推荐了一项诊所无法提供的治疗,客户却成功预约了。
到目前为止,我已经对 7 个知识库做过这种审计:规模从 61 页到 840 页不等,大多数在 100–300 页之间。结果始终一致。
注意最后一行,也注意第四行。检索次数最多的页面,通常也是最老的那一批,因为它们是在产品刚推出时写的,此后就再也没人重写过。在我检查过的几乎每个知识库里,检索量与内容新鲜度都呈负相关。
人工季度审查之所以失败,有一个结构性原因:你无法靠通读来审查 200 个页面,因为“这件事现在还是真的吗?”这个问题,无法从页面本身得到答案。只有作为权威记录来源的业务系统才能回答,比如价目表、日历、CRM。审查者读一篇文档,最多只能判断它听起来是否还符合现状。
所以,审计必须通过与业务系统比对差异来完成,而不是靠阅读。
没有基准,就无法检测腐化。为每个页面的 front matter 添加三个字段,包括那些你马上就要删除的页面。
---
owner: revenue-ops # a team, never a person who can resign
source_of_truth: pricing-sheet-v3 # where the facts come from
verified_at: 2026-09-30 # last time a human or script confirmed it
---
owner 必须是一个团队或角色。在我审计过的知识库中,只要用人名作为负责人,一年内就会有超过 40% 的页面变成孤儿页面,因为人会离职,而文档无人接手。
source_of_truth 是实现自动检查的关键。如果页面里的价格信息无法追溯到一张明确指定的表格,就无法验证,只能选择相信。
verified_at 不是 updated_at。改正一个错别字不等于核验。整个审计的关键,就在这个区别上。
缺少这三个字段的页面,从定义上说就是无人负责、无法验证的。应该把它们视为可疑对象,而不是可信文档。
下面的脚本处理 300 个页面大约需要 4 秒,并会生成待处理清单。它刻意保持简单:不用 LLM,也不用 embeddings。它只负责找出候选页面,再由人工确认。
# rot_audit.py — python3 rot_audit.py ./kb > rot_report.csv
import os, re, sys, csv, datetime, collections
ROOT = sys.argv[1]
STALE_DAYS = 180
NUM = re.compile(r'(?:[$£€]\s?\d[\d,.]*|\b\d+(?:\.\d+)?\s?%|\b\d{1,2}:\d{2}\b)')
def front_matter(text):
m = re.match(r'^---\n(.*?)\n---\n', text, re.S)
if not m: return {}
out = {}
for line in m.group(1).splitlines():
if ':' in line:
k, v = line.split(':', 1)
out[k.strip()] = v.strip().strip('"\'')
return out
rows, seen = [], collections.defaultdict(list)
today = datetime.date.today()
for dirpath, _, files in os.walk(ROOT):
for f in files:
if not f.endswith(('.md', '.mdx')): continue
p = os.path.join(dirpath, f)
text = open(p, encoding='utf-8').read()
fm = front_matter(text)
body = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.S)
nums = NUM.findall(body)
verified = fm.get('verified_at')
age = ''
if verified:
try: age = (today - datetime.date.fromisoformat(verified)).days
except ValueError: age = 'BAD_DATE'
rows.append({
'file': os.path.relpath(p, ROOT),
'owner': fm.get('owner', ''),
'source_of_truth': fm.get('source_of_truth', ''),
'verified_at': verified or '',
'days_since_verified': age,
'numeric_claims': len(nums),
'stale': 'YES' if (not verified or (isinstance(age, int) and age > STALE_DAYS)) else '',
'risk_score': 0,
})
# fork detection: same title-ish heading, different numeric content
head = (fm.get('title') or body.strip().splitlines()[0:1] or [''])[0]
key = re.sub(r'[^a-z0-9 ]', '', str(head).lower())[:40].strip()
if key: seen[key].append((os.path.relpath(p, ROOT), tuple(sorted(nums))))
for r in rows:
score = 0
if r['stale']: score += 2
if not r['owner']: score += 2
if not r['source_of_truth']: score += 2
score += min(len(re.findall(r'(?i)\b(currently|now|as of|starting|from january|this year)\b', open(os.path.join(ROOT, r['file']), encoding='utf-8').read())), 3)
r['risk_score'] = score
for key, items in seen.items():
if len(items) > 1 and len({i[1] for i in items}) > 1:
for f, _ in items:
for r in rows:
if r['file'] == f: r['risk_score'] += 3
rows.sort(key=lambda r: -r['risk_score'])
w = csv.DictWriter(sys.stdout, fieldnames=list(rows[0].keys()))
w.writeheader(); w.writerows(rows)
stale:没有 verified_at,或者距离上次核验的时间超过阈值。
没有 owner / 没有 source_of_truth:无法验证的页面会获得更高分数,从而排到列表顶部,让你优先检查。
时间性措辞:页面里的“currently”“now”“as of”,相当于内容自己声明了一个有效期。
numeric_claims:每个数字都是一项可能发生变化的事实;没有数字,意味着出错时的影响范围较小。
分叉:两个页面标题相同,数字却不同。这类问题,人靠逐页阅读根本找不出来。
扫描器找出可疑页面,探测题则找出实际的错误陈述。写下 12 个问题,答案必须能从业务系统中查到,不能是观点,也不能是总结。以这家诊所为例:
1. What is the price of a standard check-up?
2. What is the price of a filling (composite)?
3. What are Saturday opening hours?
4. Which dentists are currently taking new patients?
5. Do you accept [insurer]?
6. How long is the wait for a hygiene appointment?
7. What is the cancellation window and fee?
8. Is [discontinued service] available?
9. What is the emergency out-of-hours number?
10. How much is a first consultation for a child?
11. Which locations are open on Sunday?
12. What is the deposit for a treatment plan over £1,000?
把每个问题原封不动地问给已部署的 AI。将它的答案与权威业务系统中的答案并排放在一起。每一处不一致,都是一次带有“指纹”的文档腐化事件:AI 给出的答案会告诉你,它来自哪个页面。
在这家诊所,12 道探测题得到了 7 个不一致的答案,对应 24 个问题页面中的 9 个。其余 15 个页面没有被探测题覆盖。这本身就是一个发现:如果没有任何探测题会触及某个页面,你就没有理由保留它。
每周运行一次探测题。只需 15 分钟,就能在几天内发现信息偏移,而不用等到第一位客户气冲冲地找上门。
人的本能是把所有问题都修好。别这么做。按下面的顺序处理,并根据每一行的类别选择操作。
诊所这 24 个页面总共花了 3 小时 40 分钟处理,其中大部分时间花在了那 6 个分叉页面上。两周后,再次运行这 12 道探测题,不一致的数量降到了 0,“价格报错”的投诉也停止了。
重建索引很重要:每次删除内容后,都要强制重新生成 embeddings。文件虽然删了,却仍然留在向量存储中,就像驱魔之后仍未离开的幽灵。
Month: ____________________
Total pages: ____
Pages verified this month: ____ (target: ≥25% of base, so full cycle ≤4 months)
Pages with no owner: ____ (target: 0)
Probe questions live: ____ (target: ≥12)
Probe mismatches: ____ (target: 0)
Median days since verified, top-20 retrieved: ____ (target: <90)
Ghost pages deleted: ____
Forks merged: ____
最该盯住的是中位数那一行。如果检索次数最多的 20 个页面,距离上次核验已经超过 90 天,那么你的 AI 就是在信心十足地描述过去某个版本的业务。而让你发现这件事的会是客户,不是仪表盘。
[ ] Every page has owner, source_of_truth, verified_at
[ ] No owner is a person's name
[ ] Every page with numbers names where the numbers come from
[ ] No page says "currently", "now", or "as of" without a date
[ ] No two pages answer the same question with different answers
[ ] No page describes a service that can't be bought today
[ ] No page names a person who has left
[ ] 12 probes defined, each answerable from a system of record
[ ] Probes run weekly against the live agent, mismatches triaged in 48h
[ ] Re-embed forced after every deletion
这些都不是 prompt engineering。prompt engineering 改变的是模型如何表达,文档腐化改变的是模型相信什么。对于一份读起来毫无破绽的文档,任何指令都无法让模型对它产生怀疑。
诊所的解决办法不是一个更好的 prompt,而是删除 24 个文件,再添加三行 front matter。
如果你正在基于知识库部署 Agent,我使用的工具包 Agent Audit Pack 中包含了审计模板、扫描器,以及这份包含 12 道探测题的工作表。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。