RAG 准确率暗降,先查索引是否过期
RAG 即使没有模型或提示词变更,也可能因源文档与向量索引不同步而逐渐失准。文章指出文档修改未触发重新嵌入、任务静默失败,以及删除文档后残留分块等常见原因。
AI 深度解读
RAG 上线时答得准,几个月后却开始引用旧政策、介绍已下架产品,未必是模型变差了。作者指出,一个容易被忽略的原因是:源文档已经变化,向量索引却没有同步,系统还在把过期材料组织成流畅的答案。
这篇文章的价值,在于把「回答质量下降」拆成可以检查的工程问题:文档与分块是否一致、向量是否来自同一模型、正确来源还能否被检索到,以及系统有没有承认资料不足的能力。不过,作者给的是生产经验和示例代码,没有提供效果对照数据。
没人发版,为什么答案也会变差
检索增强生成(RAG)维护着两份知识:源文档,以及从文档切分、计算嵌入后得到的分块。它们通过不同代码路径、在不同时间更新,只要同步出现缺口,检索就可能拿到旧信息。
作者列出了五种变化。
第一,文档更新,分块没有更新。政策正文改了,但嵌入任务可能还在排队,可能三周前就静默失败,也可能接入流程只支持首次导入,压根没处理编辑。
第二,文档删除,分块仍然存在。产品已经下架,说明文档也删了,但索引里留下的分块仍会被召回。生成模型看到它们,自然还会继续回答相关问题。
第三,新增资料改变排序。加入两百篇文档后,旧文档虽然一字未改,也可能被语义更接近的新内容挤出前五名。旧问题因此失去了原来的依据。
第四,嵌入模型发生变化。作者设想,供应商在相同 API 名称背后更新模型,新查询与旧分块使用了不同版本。如果两者的向量空间不兼容,相似度仍然可能返回数值,但已经失去比较意义。原文没有给出具体供应商或实际事故。
第五,用户开始问资料库没有覆盖的问题。检索总能找出某个「最近的结果」,但最近不代表相关,更不代表足以回答。
没有代码变更,不等于检索系统的输入和行为没有变化。
这些变化通常不会触发异常。请求成功、模型正常返回、文字也很顺,错误藏在答案所依赖的材料里。
先检查文档和索引是否一致
作者提出的低成本做法,是在导入时对源文档计算 SHA-256,将结果作为 source_hash 写入每个分块,同时记录 embedding_model 和 indexed_at。
示例中的 content_hash(text: str) 使用 hashlib.sha256(text.encode()).hexdigest()。ingest_document(doc: Document, tenant_id: UUID) 先读取已有分块的哈希;内容相同就跳过,否则通过 chunk_text 重新切分,再调用 embed_batch 计算向量。
旧分块的删除和新分块的插入放在同一个 db.transaction() 中,避免替换过程中出现旧数据已经删除、新数据尚未写入的空档。新记录同时保存 document_id、tenant_id、content、embedding、source_hash、embedding_model、indexed_at,模型标识取自 EMBEDDING_MODEL。
这样,索引是否过期就能转化为数据库查询:
- 将
source_hash与源内容重新计算的encode(sha256(d.content::bytea), 'hex')比较,可以发现正文已改、分块未更新的记录。 - 用
LEFT JOIN查找documents中已不存在的文档,可以发现孤儿分块。 - 按
embedding_model分组并执行count(*),可以检查索引里是否混入多个模型标识。
作者建议定时执行并告警,而不是等用户指出答案过期。
这里也有一个代码层面的缺口:示例只凭内容哈希决定是否跳过。如果正文没变,但嵌入模型需要升级,这个判断会阻止重新计算。真正做模型迁移时,还要把模型版本纳入更新条件。
把检索失败和生成失败分开看
只检查最终答案,很难定位问题:究竟是模型理解错了正确资料,还是模型拿到的资料从一开始就错了?
作者建议单独维护检索用例,标注「这个问题必须检索到哪篇文档」,并每晚执行。下面这段代码检查目标文档是否出现在前五个结果中:
RETRIEVAL_CASES = [
{
"query": "what is the refund window for annual plans",
"must_retrieve": "billing-policy-v4",
},
{
"query": "how do I rotate an API key",
"must_retrieve": "api-authentication",
},
]
async def check_retrieval(tenant_id: UUID) -> list[dict]:
failures = []
for case in RETRIEVAL_CASES:
results = await search(case["query"], tenant_id=tenant_id, limit=5)
retrieved_docs = {r["document_slug"] for r in results}
if case["must_retrieve"] not in retrieved_docs:
failures.append({
"query": case["query"],
"expected": case["must_retrieve"],
"got": list(retrieved_docs),
})
return failures
要看的是 must_retrieve 和 limit=5:这里验证的是指定来源能否进入候选集合,并没有验证最终答案正确,也没有证明返回的具体分块包含完整依据。
它仍然很有用。新增文档改变排序、嵌入模型变化,或者分块方式调整,都可能让这组测试失败,从而先把排查范围缩到检索链路。
作者还要求把「没有足够相关的材料」作为正常状态处理。下面的示例先过滤弱结果,全部不合格时记录缺失并停止生成:
MIN_SIMILARITY = 0.35
async def answer(query: str, tenant_id: UUID):
results = await search(query, tenant_id=tenant_id, limit=6)
relevant = [r for r in results if r["similarity"] >= MIN_SIMILARITY]
if not relevant:
await log_retrieval_miss(query, tenant_id)
return "I don't have information about that."
return await generate(query, context=relevant)
这里要留意 MIN_SIMILARITY = 0.35。原文没有说明这个值的测定过程,也没有给出所用相似度度量,不能直接把它当作通用阈值。
log_retrieval_miss 留下的问题,则可以帮助团队判断:用户正在问什么,资料库又缺少什么。作者认为,缺失率持续上升可能比用户投诉更早暴露问题。
我的判断
我赞同作者把排查重点放在知识同步和检索质量上。对于依赖政策、产品文档或操作手册的问答系统,这比一遇到错误就换模型、改提示词,更有针对性。提示词无法替索引删除已经失效的资料。
但文章中的「多数漂移可以被这些检查抓住」,属于经验判断。原文没有提供样本规模、故障占比或实施前后的准确率,不能据此推算收益。
模型版本检查尤其需要谨慎。embedding_model 分组只有一行,只能证明存储的标识一致;如果供应商真的在同名 API 后更换了不兼容模型,字符串可能根本不变。多个标识也需要结合向量兼容性和索引隔离方式判断,不能只数行数。
同样,哈希一致只能证明内容一致,不能证明内容仍然正确;目标文档进入前五,也不能证明模型拿到了足够证据。缺失率上升还可能来自阈值变化或检索故障,不能全部归因于资料不足。
这些检查能缩短定位路径,但不能替代最终答案评估。
相比只做端到端问答测试,分层检查增加了一些维护工作,却能让失败原因更清楚。两者应当并行保留。
能拿来做什么
- 给现有 RAG 导入流程补上
source_hash、模型版本和索引时间,并定时检查内容差异与孤儿分块。先让同步问题可见,再决定是否需要重建索引。 - 为团队知识库整理一组「问题与预期来源」用例,沿用
limit=5的检查思路每晚执行。政策和文档发生替换时,也要更新预期来源,避免测试固守旧资料。 - 在 Node 问答服务中明确区分正常召回、弱召回和无结果,并用实际问题校准阈值。前端应展示服务返回的资料不足状态,让用户知道系统缺少回答依据。
- 定期查看
log_retrieval_miss对应的问题,同时检查检索链路。确认确实缺资料后,再安排补充文档,避免把基础设施故障误当成内容需求。