RAG 从 Demo 到生产:八个绕不过去的问题

Demo 阶段效果不错,上线后一堆问题。这八个是我们在实际项目里反复遇到的,附解法。

Demo 和生产的差距在哪

Demo 阶段你挑几个文档、问几个精心准备的问题,效果通常挺好。上线以后面对的是几万份格式混乱的真实文档,和用户五花八门的真实提问,准确率往往掉一半。

下面八个问题按我们遇到的频率排序,每个都给出实际用过的解法。

八个问题速查

#问题核心解法
1没有评测集,无法判断优化是否有效先花两周建 200-300 题的评测集
2只用向量检索,精确术语查不到加 BM25 通道,RRF 融合
3召回够了但排序不对引入 Rerank 模型精排
4表格数据回答错误表格单独解析成 Markdown 整块入索引
5模型编造答案提示词强制引用出处 + 无依据时拒答
6跨租户或跨权限数据泄露索引级过滤 + 应用层强制校验,双层保险
7成本超预算模型分层 + 提示词缓存 + 上下文精简
8文档更新后答案还是旧的增量索引 + 版本标记 + 定期全量重建

问题 1:没有评测集

这是最根本的问题。没有评测集,所有优化都是凭感觉。「我觉得这次改完好一点了」不能作为决策依据。

评测集要包含:真实用户会问的问题、标准答案、答案应该来自哪份文档的哪一段。还要刻意包含一些超出知识范围的问题,用来测试模型会不会编。200-300 题是比较实用的规模,太少覆盖不了,太多维护成本高。

评测脚本

python evaluate_rag.py
"""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 值得单独强调

权限隔离出问题是最严重的事故类型。不要只依赖提示词约束(「只回答该用户有权查看的内容」),模型不是权限系统。必须在检索层就按权限标签硬过滤,并在应用层再校验一遍返回的每个来源是否在用户可见范围内。

下一步

把云的复杂度交给我们,你只管做业务

留下需求,沐杉云的解决方案架构师会在一个工作日内联系你,提供免费的现状评估、迁移方案草案与 TCO 测算表。