RAG 从 Demo 到生产:八个绕不过去的问题
Demo 阶段效果不错,上线后一堆问题。这八个是我们在实际项目里反复遇到的,附解法。
Demo 和生产的差距在哪
Demo 阶段你挑几个文档、问几个精心准备的问题,效果通常挺好。上线以后面对的是几万份格式混乱的真实文档,和用户五花八门的真实提问,准确率往往掉一半。
下面八个问题按我们遇到的频率排序,每个都给出实际用过的解法。
八个问题速查
| # | 问题 | 核心解法 |
|---|---|---|
| 1 | 没有评测集,无法判断优化是否有效 | 先花两周建 200-300 题的评测集 |
| 2 | 只用向量检索,精确术语查不到 | 加 BM25 通道,RRF 融合 |
| 3 | 召回够了但排序不对 | 引入 Rerank 模型精排 |
| 4 | 表格数据回答错误 | 表格单独解析成 Markdown 整块入索引 |
| 5 | 模型编造答案 | 提示词强制引用出处 + 无依据时拒答 |
| 6 | 跨租户或跨权限数据泄露 | 索引级过滤 + 应用层强制校验,双层保险 |
| 7 | 成本超预算 | 模型分层 + 提示词缓存 + 上下文精简 |
| 8 | 文档更新后答案还是旧的 | 增量索引 + 版本标记 + 定期全量重建 |
问题 1:没有评测集
这是最根本的问题。没有评测集,所有优化都是凭感觉。「我觉得这次改完好一点了」不能作为决策依据。
评测集要包含:真实用户会问的问题、标准答案、答案应该来自哪份文档的哪一段。还要刻意包含一些超出知识范围的问题,用来测试模型会不会编。200-300 题是比较实用的规模,太少覆盖不了,太多维护成本高。
评测脚本
"""RAG 评测:分别测量召回质量与答案质量。
分开测很重要。如果只看最终答案准确率,
分不清是「检索没找到」还是「找到了但生成错了」,优化就没有方向。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable
@dataclass
class EvalCase:
question: str
expected_answer: str
expected_sources: list[str] # 应该命中的文档标识
should_refuse: bool = False # 是否属于应拒答的问题
@dataclass
class EvalResult:
total: int = 0
recall_hit: int = 0 # 召回命中数
answer_correct: int = 0 # 答案正确数
refuse_correct: int = 0 # 拒答正确数
hallucinated: int = 0 # 编造数
failures: list[dict[str, Any]] = field(default_factory=list)
@property
def recall_rate(self) -> float:
return self.recall_hit / self.total if self.total else 0.0
@property
def accuracy(self) -> float:
return self.answer_correct / self.total if self.total else 0.0
def evaluate(
cases: list[EvalCase],
retrieve: Callable[[str], list[dict]],
generate: Callable[[str, list[dict]], dict],
judge: Callable[[str, str, str], bool],
) -> EvalResult:
"""judge 用一个较强的模型做答案一致性判定(LLM-as-judge)。"""
result = EvalResult()
for case in cases:
result.total += 1
chunks = retrieve(case.question)
hit_sources = {c["source"] for c in chunks}
# 召回评估:期望的来源是否至少命中一个
recalled = bool(set(case.expected_sources) & hit_sources)
if recalled or case.should_refuse:
result.recall_hit += 1
output = generate(case.question, chunks)
answer = output["answer"]
refused = any(k in answer for k in ("无法回答", "没有找到", "资料不足"))
if case.should_refuse:
if refused:
result.refuse_correct += 1
result.answer_correct += 1
else:
result.hallucinated += 1
result.failures.append({
"question": case.question,
"issue": "应拒答但给出了答案",
"answer": answer[:200],
})
continue
if refused:
result.failures.append({
"question": case.question,
"issue": "过度拒答",
"recalled": recalled,
})
continue
if judge(case.question, case.expected_answer, answer):
result.answer_correct += 1
else:
result.failures.append({
"question": case.question,
"issue": "答案不正确",
"recalled": recalled, # 关键:区分召回问题还是生成问题
"expected": case.expected_answer[:150],
"actual": answer[:200],
})
return result
def report(result: EvalResult) -> str:
lines = [
f"总题数:{result.total}",
f"召回率:{result.recall_rate:.1%}",
f"答案准确率:{result.accuracy:.1%}",
f"编造次数:{result.hallucinated}",
",
"失败案例归因:",
]
# 按「召回失败」和「生成失败」分类,指明优化方向
retrieval_fail = sum(1 for f in result.failures if f.get("recalled") is False)
generation_fail = sum(1 for f in result.failures if f.get("recalled") is True)
lines.append(f" 召回阶段问题:{retrieval_fail} 例 → 优化切分与检索")
lines.append(f" 生成阶段问题:{generation_fail} 例 → 优化提示词与重排")
return "\n".join(lines)
failures 里记录 recalled 字段是这个脚本最有价值的部分:它直接告诉你该去优化检索还是优化生成。
问题 4:表格数据
这个问题被低估得最厉害。企业文档里大量关键信息在表格里:差旅标准、审批权限、参数配置、价格档位。而按固定长度切分文本时,表格会被切得七零八落,模型看到的是「北京 800 上海 750 广州」这种失去表头关联的碎片。
解法是把表格当作独立单元处理:用能识别表格结构的解析器(PDF 用 Textract 的表格提取,Word 直接读表格对象),转成 Markdown 表格后整块入索引,不参与常规切分。大表格按行分组,但每组都带上完整表头。
问题 5:怎么让模型不编
效果有限的做法
- 在提示词里写「不要编造」——模型经常无视
- 把 temperature 设成 0——降低随机性但不解决无依据生成
- 只靠后置的事实校验——成本高且不可靠
- 扩大召回量试图「总能找到」——反而引入更多噪声
实际有效的做法
- 要求每个结论标注 [来源N],无来源可标的内容不许输出
- 明确给出拒答话术模板,让拒答成为一个「正常选项」
- 召回分数过低时直接短路,不调用生成模型
- 输出后校验引用编号是否真实存在于上下文中
- 评测集里放入必须拒答的题目,把拒答能力纳入验收
问题 6 值得单独强调
权限隔离出问题是最严重的事故类型。不要只依赖提示词约束(「只回答该用户有权查看的内容」),模型不是权限系统。必须在检索层就按权限标签硬过滤,并在应用层再校验一遍返回的每个来源是否在用户可见范围内。