logo

生产级 RAG:离线评测、在线监控与增量索引

Published on

生产级 RAG:离线评测、在线监控与增量索引

我以前也做过一个很像样的 RAG Demo。上传几份 Markdown,切成若干段,调用 Embedding,丢进向量数据库,再让模型根据召回内容回答问题。第一次问的时候,答案漂亮得让人有点得意:引用有了,回答也像那么回事。

真正上线以后,问题才一个个冒出来。

产品同事问:“为什么昨天还能搜到的内容,今天搜不到了?”测试同学问:“这次模型升级到底变好了,还是只是回答风格变了?”用户则更直接:“你说得很肯定,但这段内容在原文里根本没有。”

这时候你会发现,RAG 的难点从来不只是“把资料喂给模型”。生产级 RAG 需要一套持续运转的闭环:上线前用离线数据判断质量,上线后用监控发现退化,知识库变化时只更新真正变化的内容。没有这三件事,RAG 就像一辆没有仪表盘、没有刹车、还要每天手动换轮胎的车。

一、先把生产级 RAG 想清楚

一个完整请求大概会经过下面这条链路:

用户问题
问题改写 / 权限过滤
向量检索 + 关键词检索
Reranker 重排
上下文拼接与压缩
大模型生成答案
引用、评测、日志与反馈

其中任何一层出错,最后都会表现成一句模糊的“模型答错了”。但这句话没有工程价值。我们必须继续追问:

  • 是问题改写错了,导致检索方向偏了?
  • 是目标文档根本没有进入索引?
  • 是召回了正确文档,但排名太靠后?
  • 是上下文太长,模型忽略了关键段落?
  • 还是模型看到了证据,却自己编了一个答案?

生产系统的第一原则,就是把一次回答拆成可以分别观察、分别测量的步骤。否则所有问题都会被丢进“模型不聪明”这个黑箱里,最后只能靠换模型碰运气。

二、离线评测:上线之前先知道它好不好

1. 先做黄金数据集,而不是先选评测框架

离线评测的核心不是某个框架,而是一组稳定的问题和答案。建议先建立这样的数据结构:

type RagCase = {
  id: string
  question: string
  expectedAnswer: string
  relevantChunkIds: string[]
  category: '事实查询' | '多跳推理' | '无答案' | '权限边界'
}

这里最重要的是 relevantChunkIds。只有标准答案还不够,因为我们要分别判断“检索对不对”和“生成对不对”。

数据集不要只收集那些一眼就能回答的问题。真正有价值的样本,应该包含几类麻烦问题:

  1. 答案明确存在的事实查询,例如“退款申请需要几个工作日”。
  2. 需要跨两段甚至多份文档才能回答的多跳问题。
  3. 知识库没有答案的问题,用来测试模型会不会诚实地说“不知道”。
  4. 用户有意越权的问题,用来验证权限过滤是否在检索前生效。
  5. 相似词很多但答案不同的问题,用来发现关键词检索的误导。

我特别建议保留失败样本。成功样本只能告诉你系统“曾经能答对”,失败样本才能告诉你系统的边界在哪里。每次线上出现一个有代表性的错误,都应该回流到黄金数据集,变成下一次发布的回归测试。

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,因为文档前面插入一句话后,后面所有切片的下标都可能变化,最终导致整篇文档被误判为全部更新。

一个可靠的增量流程通常是:

  1. 扫描源文件,计算文件哈希。
  2. 与索引清单比较,找出新增、修改、删除的文档。
  3. 只对新增和修改文档重新解析、切分和向量化。
  4. 对删除文档执行软删除或删除标记。
  5. 批量写入新切片,校验数量和版本。
  6. 更新索引清单,最后切换到新版本。

伪代码可以很简单:

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)
}

这里有一个容易被忽略的坑:切分策略改变时,即使源文档没有改变,旧切片也必须重建。因此索引清单里还要保存 chunkerVersionembeddingVersion。只要这两个版本变化,就应该把受影响的文档加入重建队列。

更新过程要可恢复

不要让一次批处理失败后只能从头重跑。每批任务都应有状态,例如 pendingprocessingsucceededfailed,并记录重试次数和最后错误。Embedding API 暂时超时,只重试失败批次;某个坏 PDF 无法解析,也不能阻塞全库更新。

更稳妥的做法是使用索引版本:先写入 index-v42,完成校验后,再把线上读取别名从 index-v41 切换到 index-v42。这样用户不会在更新过程中读到一半旧数据、一半新数据。对于不支持别名切换的向量数据库,也至少要使用版本字段过滤,避免新旧版本混在一起。

五、我最后留下的一份上线清单

在我看来,RAG 是否“生产级”,不取决于用了哪个热门框架,而取决于出了问题以后能不能解释、能不能恢复、能不能持续变好。上线前至少检查:

  • 是否有包含失败样本、无答案样本和权限样本的黄金数据集?
  • 是否能分别看到召回质量、重排质量和生成质量?
  • 每次模型、Prompt、Embedding、切分策略是否都有版本号?
  • 一次请求能否通过 traceId 找到检索结果、Token、延迟和最终反馈?
  • 知识库更新是否支持新增、修改、删除,而不是每次全量重建?
  • 增量任务失败后能否重试,更新中断后能否继续?
  • 监控日志是否做了脱敏,是否避免把敏感资料长期暴露?
  • 是否有旧索引或旧配置,可以在质量异常时快速回滚?

我研究 RAG 越久,越觉得它像一位需要被长期培养的学生。第一次答对不代表学会了,偶尔答错也不可怕,可怕的是老师没有作业、没有考试、没有错题本,第二天还要求它继续进步。

离线评测是错题本,在线监控是课堂观察,增量索引则是持续更新教材的方法。三者合起来,RAG 才从一个“看起来很聪明的 Demo”,变成一个能够被验证、被维护、被信任的系统。

🤪 您也可以编辑此页: