一、一条让我后背发凉的线上反馈
上线第三周,客服主管在群里甩了一张截图。
用户问:"你们的会员卡能退吗?"系统回答:"会员卡支持 7 天无理由退款。"客服照着答了,用户下单后要退,发现根本不能退------政策里写的是"会员卡一经开通不支持退款"。
我去查日志,检索到的文档是对的,Top-1 就是那条"不支持退款"的条款。但模型在生成时,被 Prompt 里另一段"普通商品 7 天无理由"的内容带偏了,把两个政策缝在了一起。
这就是 RAG 最阴险的一类幻觉:检索没错,答案错了。它不是"没找到资料",而是"找到了资料,但用错了"。
更难受的是,我们当时没有任何量化指标。业务方问"你们这个幻觉率多少",我答不上来。从那天起,我把幻觉治理和召回评估当成一个正经的工程问题来做,而不是靠调 Prompt 碰运气。
这篇博客讲两件事:四层手段怎么压住幻觉,以及怎么用召回评估流水线把它管起来。适合想转 AI 应用的工程师,也适合面试前梳理项目的人。
二、为什么"接上知识库"还是会有幻觉
先破除一个误区:RAG 不等于事实校验。
大模型的本质是下一个 token 的概率预测器,它没有内置的真伪判断模块。你给它一段上下文,它的首要目标是"生成通顺且看起来合理的文本",而不是"逐句验证是否被文档支持"。只要 token 序列在语言上说得通,它就会自信地输出。
幻觉在 RAG 场景下主要分三类:
事实型:编造数字、日期、条款(比如把 7 天说成 30 天);
关联型:把不同文档的片段强行拼接,构建虚假因果(我上面踩的就是这个);
引用型:声称"根据文档 A",但 A 根本没提这件事。
RAG 的价值在于:它把幻觉从"模型凭记忆瞎编"变成了"模型基于检索结果瞎编"。问题从"模型不知道"转移到了"检索质量 + 生成约束"这两个可控环节上。
换句话说,幻觉治理的本质是两件事:让检索结果更干净,让生成过程更守规矩。
三、整体方案:四层防线 + 一条评估流水线
先上架构,文字版数据流:
text
【在线问答主链路】
用户问题
↓
Query 改写(可选,消解指代/补全上下文)
↓
混合检索(向量 + BM25 双路召回)
↓
Rerank 精排(Cross-Encoder)
↓
动态阈值过滤 ──→ 证据不足则直接兜底,不进 LLM ← 【第一层:证据门控】
↓
Prompt 组装(强制引用 + 拒答约束) ← 【第二层:生成约束】
↓
LLM 生成(流式输出 + 引用编号)
↓
答案后校验(Groundedness 打分) ← 【第三层:事后校验】
↓
答案 + 引用来源 + 置信度
【离线评估流水线】
测试集(50~200条人工标注)
↓
跑检索 → 算 Precision@K / Recall@K / NDCG@K
↓
跑生成 → 算 Groundedness / 引用覆盖率
↓
失败案例分析 → 回流优化分块/检索/Prompt ← 【第四层:持续迭代】
技术选型 trade-off:
向量库:Milvus(亿级向量 + HNSW,性能稳)。数据量 < 100 万且团队已有 PG,可以选 pgvector,省一套运维;
Embedding:bge-m3(中文好,可本地部署,零 API 成本)。追求极致效果且预算充足,可以上 text-embedding-3-large;
Rerank:bge-reranker-base(轻量,CPU 也能跑)。对延迟不敏感可以换 large;
评估:RAGAS + 自建测试集。RAGAS 开箱即用,但指标口径要自己校准。
四、核心模块拆解
4.1 第一层:证据门控------让模型"无据不答"
核心思路:在调 LLM 之前,先判断检索到的证据是否足够支撑回答。不够就直接兜底,不给模型编的机会。
我的实现是一次低成本的 LLM 判定:
python
def evidence_gate(question: str, chunks: listdict) -> bool:
"""判断检索片段是否足以回答问题,不足则走兜底"""
if not chunks:
return False
context = "\n\n".join(c\["text" for c in chunks])
prompt = f"""判断以下资料是否足以回答问题。只输出 YES 或 NO,不要解释。
问题:{question}
资料:
{context:2000}
资料足以回答吗?"""
result = llm.invoke(prompt).content.strip().upper()
return result.startswith("YES")
这一步的成本很低(短 prompt,几十 token),但能拦掉大量"检索结果相关性不够"导致的幻觉。关键点:兜底话术要提前定好,别让模型自由发挥。
python
FALLBACK = "抱歉,我在知识库中没有找到与您问题相关的信息,建议联系人工客服核实。"
@app.post("/chat")
def chat(q: Query):
docs = retrieve(q.question)
if not evidence_gate(q.question, docs):
return {"answer": FALLBACK, "sources": \[\], "confidence": 0.0}
... 继续走生成
4.2 第二层:混合检索 + 动态阈值------让相关文档排前面
纯向量检索的问题:语义相似 ≠ 能回答问题。用户问"退款流程",向量可能返回"退货政策",语义近但不解决实际问题。
混合检索(向量 + BM25)+ Cross-Encoder Rerank 是更稳的组合:
python
from sentence_transformers import CrossEncoder
import numpy as np
reranker = CrossEncoder("BAAI/bge-reranker-base")
def hybrid_retrieve(query: str, top_k: int = 20, rerank_top: int = 5):
双路召回
vector_results = vector_search(query, limit=top_k) # 语义匹配
bm25_results = bm25_search(query, limit=top_k) # 关键词精确匹配
merged = deduplicate(vector_results + bm25_results)
Cross-Encoder 精排
pairs = (query, r\["text") for r in merged]
scores = reranker.predict(pairs)
ranked = sorted(zip(merged, scores), key=lambda x: x1, reverse=True)
动态阈值:用分数分布确定过滤线,而不是固定值
score_arr = np.array(s for _, s in ranked)
threshold = max(0.3, score_arr.mean() - 0.5 * score_arr.std())
kept = r for r, s in ranked if s \>= threshold
return kept:rerank_top if kept else r for r, _ in ranked\[:2]
动态阈值的好处:简单问题分数普遍高,阈值自动抬高,减少噪音;复杂问题分数分散,阈值降低,保证有证据可用。这比固定阈值(比如永远 0.3)鲁棒得多。
4.3 第三层:Prompt 约束------把"不许编"写进规则
Prompt 里必须明确边界,而且要针对幻觉的具体形态设计约束:
python
SYSTEM_PROMPT = """你是企业知识库助手,只能基于【资料】回答问题。
规则:
- 如果资料中没有明确答案,直接回复"未找到相关信息",禁止推测。
- 每个事实性陈述后标注来源编号,如 1。
- 不同资料出现冲突时,列出冲突点并说明"存在政策冲突,建议人工确认",不要自行选择。
- 禁止使用资料之外的任何知识,包括你的常识。
"""
第 3 条是我踩坑后加的。前面那个"会员卡退款"事故,本质就是模型遇到冲突信息时擅自做了选择。加上这条后,冲突场景会主动暴露而不是被掩盖。
4.4 第四层:答案后校验------给每个答案打"有据性"分
生成完之后,用一次独立判定算 Groundedness(有据性):答案里的事实性陈述,有多少被检索到的上下文支持。
python
def check_groundedness(question: str, context: str, answer: str) -> float:
"""返回 0~1 的有据性分数,低于阈值则触发告警或重试"""
prompt = f"""评估答案是否完全基于【资料】。只输出一个 0 到 1 的数字。
0 = 答案完全脱离资料,1 = 每句话都能在资料中找到依据。
问题:{question}
资料:{context}
答案:{answer}
分数:"""
try:
score = float(llm.invoke(prompt).content.strip())
return max(0.0, min(1.0, score))
except ValueError:
return 0.0 # 解析失败视为不可信
线上策略:分数 < 0.7 时记录日志并降低返回置信度;连续多条低于阈值触发告警,人工介入看是检索退化还是 Prompt 被改坏了。
4.5 FastAPI 服务封装(完整链路)
python
from fastapi import FastAPI
from pydantic import BaseModel
import redis, hashlib, json
app = FastAPI()
r = redis.Redis(host="localhost", port=6379)
class Query(BaseModel):
question: str
top_k: int = 5
@app.post("/chat")
def chat(q: Query):
缓存高频问题,省 token
key = "qa:" + hashlib.md5(q.question.encode()).hexdigest()
if cached := r.get(key):
return json.loads(cached)
docs = hybrid_retrieve(q.question, rerank_top=q.top_k)
if not evidence_gate(q.question, docs):
return {"answer": FALLBACK, "sources": [], "confidence": 0.0}
context = "\n\n".join(
[f"[{i+1}] {d['text']}" for i, d in enumerate(docs)]
)
answer = llm.invoke(SYSTEM_PROMPT + f"\n\n资料:\n{context}\n\n问题:{q.question}").content
confidence = check_groundedness(q.question, context, answer)
result = {
"answer": answer,
"sources": [d["source"] for d in docs],
"confidence": round(confidence, 2),
}
r.setex(key, 3600, json.dumps(result, ensure_ascii=False))
return result
五、召回评估方案:用数字管住幻觉
光有手段不够,你得量化到底有没有用。评估分两侧:检索侧管"找得准不准",生成侧管"答得有没有据"。
5.1 检索侧指标
指标 回答的问题 计算方式
Precision@K 检索的 K 条里多少条相关? 相关条数 / K
Recall@K 相关文档里检索到了多少? 检索到的相关条数 / 总相关条数
NDCG@K 排序质量如何? DCG@K / 理想 DCG@K
Precision 高说明噪音少,Recall 高说明没漏关键信息,NDCG 高说明好的结果排在前面。
5.2 生成侧指标
Groundedness(有据性) 是最直接的幻觉指标,就是上面 check_groundedness 算的那个分数。另一个是引用覆盖率:答案里标了 1 的陈述占比,覆盖率低说明模型在"裸答"。
5.3 最小评估流水线
python
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset
1. 准备测试集(人工标注,至少 50 条)
test_data = {
"question": "退款政策是几天?", "会员卡能退吗?",
"answer": "购买后 7 天内可申请退款。", "会员卡一经开通不支持退款。",
"contexts": [
"退款政策:购买后 7 天内可申请退款。",
"会员卡条款:一经开通不支持退款。",
],
"ground_truth": "7 天", "不能退",
}
dataset = Dataset.from_dict(test_data)
2. 跑评估
result = evaluate(
dataset,
metrics=faithfulness, answer_relevancy, context_precision,
)
print(result)
faithfulness 0.94 ← 幻觉率约 6%
answer_relevancy 0.91
context_precision 0.86
faithfulness 低就去查 failures 列表,回溯是检索问题还是 Prompt 问题。测试集要覆盖正常问题、边缘问题、冲突问题三类,否则评估会失真。
六、效果评估
上线前后对比(基于 200 条人工标注的客服问题测试集):
指标 治理前 治理后
答案准确率 72% 89%
幻觉率(Groundedness < 0.7 占比) 24% 4%
引用覆盖率 51% 83%
平均响应时间 2.4s 1.9s
冲突场景主动暴露率 0% 76%
响应时间反而降了,因为证据门控和动态阈值拦掉了一部分无效 LLM 调用。此处数据需结合你的真实项目补充。
评估方法:人工标注 200 条问题,覆盖正常、边缘、冲突三类;幻觉判定用 Groundedness 分数 + 人工抽检校准(抽 30 条验证 LLM Judge 的准确率约 92%)。
七、我踩过的坑
只做向量检索就上线:Top-5 里经常混进语义相近但不解决问题的片段,加 BM25 双路 + Rerank 后明显改善。
固定阈值过滤:简单问题留了一堆噪音,复杂问题又过滤太狠。改成基于分数分布动态计算后才稳。
Prompt 没约束冲突场景:模型遇到两条矛盾政策时会擅自选一条,加上"列出冲突"规则后才暴露出来。
评估测试集全是简单问题:第一版测试集 faithfulness 0.97,我还挺得意,结果线上照样翻车。后来补了边缘和冲突案例,分数掉到 0.89,才是真实的。
LLM Judge 没校准:一开始直接用 LLM 打 groundedness 分,阈值定 0.7,误报很多。后来抽 30 条人工标注校准,把阈值调到 0.75,误报率从 18% 降到 6%。
八、团队协作与落地过程
推动:先拿 50 条测试集跑出量化报告,用"幻觉率 24%"这个数字说服业务方投入;
灰度:先对 5 个客服开放,每天收集 badcase,一周迭代一次 Prompt 和阈值;
监控:Grafana 看板盯三个核心指标------Groundedness 均值、兜底触发率、P99 延迟;
兜底机制:Groundedness < 0.7 的答案自动标记"建议人工核实",客服侧有显式提示;
文档:写了 SOP,运维能独立重建索引、跑评估、看监控。
九、面试延伸
这个主题的高频追问:
"RAG 能消除幻觉吗?" ------ 不能,只能降低。幻觉源于 Transformer 的概率生成本质,RAG 把"模型自编"变成"基于检索结果编",治理重点转移到检索质量和生成约束。
"怎么量化幻觉率?" ------ 用 Groundedness 指标,衡量答案中事实陈述被上下文支持的比例。批量评估用 LLM-as-Judge,但要先校准 Judge 模型。
"检索侧怎么评估?" ------ Precision@K、Recall@K、NDCG@K,覆盖"准不准、全不全、排得对不对"三个维度。
"动态阈值检索的原理?" ------ 根据检索分数的均值、标准差动态调整过滤线,避免简单查询引入噪音、复杂查询遗漏证据。
"混合检索为什么比纯向量好?" ------ 向量擅长语义但可能漏关键词,BM25 擅长精确匹配但不懂语义,互补后 Rerank 精排,准确率有可观测提升。
"证据门控会不会误杀?" ------ 会。所以门控 prompt 要保守,兜底话术要引导人工,宁可少答不可错答。误杀率要在测试集上评估。
"冲突场景怎么处理?" ------ Prompt 强制列出冲突并标注,不自行选择。这是我从真实事故里学到的。
十、总结
RAG 幻觉治理的本质是两件事:让检索结果更干净,让生成过程更守规矩。
四层防线分层做:证据门控拦无据回答,混合检索 + 动态阈值提升相关性,Prompt 约束强制不越界,答案后校验兜住漏网之鱼。
配套一条评估流水线:Precision/Recall/NDCG 管检索,Groundedness 管幻觉。有数字才能迭代,凭感觉调 Prompt 迟早翻车。