生产级 RAG:离线评测、在线监控与增量索引
我以前也做过一个很像样的 RAG Demo。上传几份 Markdown,切成若干段,调用 Embedding,丢进向量数据库,再让模型根据召回内容回答问题。第一次问的时候,答案漂亮得让人有点得意:引用有了,回答也像那么回事。
真正上线以后,问题才一个个冒出来。
产品同事问:“为什么昨天还能搜到的内容,今天搜不到了?”测试同学问:“这次模型升级到底变好了,还是只是回答风格变了?”用户则更直接:“你说得很肯定,但这段内容在原文里根本没有。”
这时候你会发现,RAG 的难点从来不只是“把资料喂给模型”。生产级 RAG 需要一套持续运转的闭环:上线前用离线数据判断质量,上线后用监控发现退化,知识库变化时只更新真正变化的内容。没有这三件事,RAG 就像一辆没有仪表盘、没有刹车、还要每天手动换轮胎的车。
一、先把生产级 RAG 想清楚
一个完整请求大概会经过下面这条链路:
用户问题
↓
问题改写 / 权限过滤
↓
向量检索 + 关键词检索
↓
Reranker 重排
↓
上下文拼接与压缩
↓
大模型生成答案
↓
引用、评测、日志与反馈
其中任何一层出错,最后都会表现成一句模糊的“模型答错了”。但这句话没有工程价值。我们必须继续追问:
- 是问题改写错了,导致检索方向偏了?
- 是目标文档根本没有进入索引?
- 是召回了正确文档,但排名太靠后?
- 是上下文太长,模型忽略了关键段落?
- 还是模型看到了证据,却自己编了一个答案?
生产系统的第一原则,就是把一次回答拆成可以分别观察、分别测量的步骤。否则所有问题都会被丢进“模型不聪明”这个黑箱里,最后只能靠换模型碰运气。
二、离线评测:上线之前先知道它好不好
1. 先做黄金数据集,而不是先选评测框架
离线评测的核心不是某个框架,而是一组稳定的问题和答案。建议先建立这样的数据结构:
type RagCase = {
id: string
question: string
expectedAnswer: string
relevantChunkIds: string[]
category: '事实查询' | '多跳推理' | '无答案' | '权限边界'
}
这里最重要的是 relevantChunkIds。只有标准答案还不够,因为我们要分别判断“检索对不对”和“生成对不对”。
数据集不要只收集那些一眼就能回答的问题。真正有价值的样本,应该包含几类麻烦问题:
- 答案明确存在的事实查询,例如“退款申请需要几个工作日”。
- 需要跨两段甚至多份文档才能回答的多跳问题。
- 知识库没有答案的问题,用来测试模型会不会诚实地说“不知道”。
- 用户有意越权的问题,用来验证权限过滤是否在检索前生效。
- 相似词很多但答案不同的问题,用来发现关键词检索的误导。
我特别建议保留失败样本。成功样本只能告诉你系统“曾经能答对”,失败样本才能告诉你系统的边界在哪里。每次线上出现一个有代表性的错误,都应该回流到黄金数据集,变成下一次发布的回归测试。
2. 把检索和生成分开打分
检索阶段至少看三个指标:
Recall@K:正确片段有没有出现在前 K 个结果里。它回答的是“有没有找着”。MRR:第一个正确片段排得靠不靠前。它回答的是“找得快不快”。nDCG@K:多个相关片段的排序是否合理。它比简单的命中更适合复杂知识库。
例如,正确片段在第 1、3、8 位,和在第 8、9、10 位,Recall@10 可能一样,但用户体验显然不同。Reranker 的价值,往往就体现在把正确内容从后面提到前面。
生成阶段则要看:
- 忠实性:答案是否能被召回上下文支持。
- 相关性:是否真正回答了问题,而不是复述一堆背景。
- 完整性:需要多个事实时,是否漏掉关键点。
- 拒答准确率:没有证据时,是否拒绝编造。
别把“语言流畅”当成质量。语言越流畅,错误答案有时越危险,因为用户会更容易相信它。生产评测宁可把一句生硬但有证据的回答判为可接受,也不能让没有证据的漂亮废话拿高分。
3. 评测必须能比较版本
每一次改动都应该留下版本号:模型版本、Embedding 版本、切分参数、检索参数、Prompt 版本都要记录。一次评测结果至少要像这样:
{
"dataset": "support-v3",
"retriever": "hybrid-bm25-vector",
"embedding": "embedding-v2",
"recall_at_5": 0.92,
"answer_faithfulness": 0.88,
"no_answer_accuracy": 0.95,
"p95_latency_ms": 1860
}
这样你才能看出,某次 Prompt 优化是不是让忠实性上升,却把延迟推高了;某次换 Embedding 是不是提高了英文召回,却伤害了中文问题。没有版本记录的评测,最后只会变成一张好看的截图。
三、在线监控:让系统自己告诉你哪里坏了
离线评测解决“发布前好不好”,在线监控解决“今天还好不好”。用户问题永远比测试集复杂,知识库也会持续变化,所以线上必须记录一条完整 Trace,而不是只记最终答案。
建议为每次请求生成一个 traceId,至少记录这些字段:
type RagTrace = {
traceId: string
queryHash: string
embeddingLatencyMs: number
retrievalLatencyMs: number
rerankLatencyMs: number
generationLatencyMs: number
retrievedChunkIds: string[]
retrievedScores: number[]
inputTokens: number
outputTokens: number
citationCount: number
answerStatus: 'answered' | 'no_evidence' | 'error'
userFeedback?: 'helpful' | 'unhelpful'
}
注意,监控不等于把用户的全部原文永久写进日志。真实系统里,问题和答案可能包含手机号、订单号、公司内部资料。可以保存哈希、脱敏文本和抽样样本,原文只在有权限的短期调试存储中保留。为了排查问题而制造新的隐私问题,这笔账不划算。
1. 先监控基础设施,再监控答案质量
基础指标包括成功率、超时率、P50/P95/P99 延迟、Token 消耗、检索为空的比例、向量数据库错误率。质量指标包括用户负反馈率、无引用回答比例、低相似度上下文比例、人工抽检的幻觉率。
几个很实用的告警例子:
retrieval_empty_rate突然升高:可能索引没有更新,或者过滤条件把所有内容都过滤掉了。answer_latency_p95上升:可能 Reranker 变慢,也可能 Prompt 变长导致生成耗时增加。citation_missing_rate上升:可能 Prompt 结构被改坏,或者引用字段没有正确传给前端。unhelpful_feedback_rate连续上升:这是最接近用户真实感受的信号,应回放对应 Trace。
监控的目的不是做一块漂亮的大屏,而是让团队能回答:“问题从哪一层开始变坏?”如果告警只告诉你“错误率 5%”,却不能关联到具体模型、索引版本和请求样本,那它离真正的故障排查还差得很远。
四、增量索引:知识库更新不能每次从头再来
最初写索引脚本时,很多人会采用一个简单办法:清空向量库,扫描全部文档,重新切分,重新生成 Embedding。数据少的时候当然没问题,但文档达到几万份以后,这个方案会带来三种麻烦:成本高、更新时间长、更新期间容易出现空窗。
增量索引的关键是给每份源文档和每个切片建立稳定身份:
type ChunkRecord = {
sourceId: string
sourceVersion: string
chunkId: string
contentHash: string
content: string
embedding: number[]
updatedAt: string
deletedAt?: string
}
sourceId 表示文档是谁,sourceVersion 表示它是哪一版,contentHash 用来判断内容是否真的变化。不要直接用数组下标当 chunkId,因为文档前面插入一句话后,后面所有切片的下标都可能变化,最终导致整篇文档被误判为全部更新。
一个可靠的增量流程通常是:
- 扫描源文件,计算文件哈希。
- 与索引清单比较,找出新增、修改、删除的文档。
- 只对新增和修改文档重新解析、切分和向量化。
- 对删除文档执行软删除或删除标记。
- 批量写入新切片,校验数量和版本。
- 更新索引清单,最后切换到新版本。
伪代码可以很简单:
for (const source of changedSources) {
const chunks = split(source.content)
const records = await Promise.all(
chunks.map(async (content, index) => ({
sourceId: source.id,
sourceVersion: source.hash,
chunkId: `${source.id}:${hash(content)}:${index}`,
contentHash: hash(content),
content,
embedding: await embed(content),
updatedAt: new Date().toISOString(),
}))
)
await vectorStore.upsert(records)
await vectorStore.removeOldVersions(source.id, source.hash)
}
这里有一个容易被忽略的坑:切分策略改变时,即使源文档没有改变,旧切片也必须重建。因此索引清单里还要保存 chunkerVersion 和 embeddingVersion。只要这两个版本变化,就应该把受影响的文档加入重建队列。
更新过程要可恢复
不要让一次批处理失败后只能从头重跑。每批任务都应有状态,例如 pending、processing、succeeded、failed,并记录重试次数和最后错误。Embedding API 暂时超时,只重试失败批次;某个坏 PDF 无法解析,也不能阻塞全库更新。
更稳妥的做法是使用索引版本:先写入 index-v42,完成校验后,再把线上读取别名从 index-v41 切换到 index-v42。这样用户不会在更新过程中读到一半旧数据、一半新数据。对于不支持别名切换的向量数据库,也至少要使用版本字段过滤,避免新旧版本混在一起。
五、我最后留下的一份上线清单
在我看来,RAG 是否“生产级”,不取决于用了哪个热门框架,而取决于出了问题以后能不能解释、能不能恢复、能不能持续变好。上线前至少检查:
- 是否有包含失败样本、无答案样本和权限样本的黄金数据集?
- 是否能分别看到召回质量、重排质量和生成质量?
- 每次模型、Prompt、Embedding、切分策略是否都有版本号?
- 一次请求能否通过
traceId找到检索结果、Token、延迟和最终反馈? - 知识库更新是否支持新增、修改、删除,而不是每次全量重建?
- 增量任务失败后能否重试,更新中断后能否继续?
- 监控日志是否做了脱敏,是否避免把敏感资料长期暴露?
- 是否有旧索引或旧配置,可以在质量异常时快速回滚?
我研究 RAG 越久,越觉得它像一位需要被长期培养的学生。第一次答对不代表学会了,偶尔答错也不可怕,可怕的是老师没有作业、没有考试、没有错题本,第二天还要求它继续进步。
离线评测是错题本,在线监控是课堂观察,增量索引则是持续更新教材的方法。三者合起来,RAG 才从一个“看起来很聪明的 Demo”,变成一个能够被验证、被维护、被信任的系统。