RAG幻觉抑制实战:从索引到生成,四环节锁死模型瞎编
导读:大模型"瞎编"是 RAG 系统最大的敌人。你给了它 5 条参考资料,它却自己编了第 6 条;你让它只基于资料回答,它却在引用标注里写了不存在的来源。幻觉分两种——“知识幻觉”(没资料硬编)和"推理幻觉"(有资料但理解错)。RAG 不能彻底消灭幻觉,但可以从四个环节层层设防,把幻觉率压到最低。本文从政务政策 RAG 项目出发,完整拆解索引治理、检索控制、Prompt约束、生成后校验四环节的具体做法和代码实现。
适合读者:
- RAG 系统回答经常"跑偏"、需要抑制幻觉的开发者
- 想理解"RAG为什么不能彻底消灭幻觉"的工程师
- 准备 RAG 面试、需要系统回答"幻觉怎么解决"的同学
- 需要设计"引用校验"和"拒答机制"的技术负责人
阅读收益:
- 理解知识幻觉和推理幻觉的区别和各自解法
- 掌握四环节抑制策略:索引治理 → 检索控制 → Prompt约束 → 生成后校验
- 学会设计"证据不足就拒答"的兜底机制
- 掌握引用溯源校验的代码实现
- 获得一套可直接落地的幻觉抑制检查清单
目录
- 什么是幻觉:知识幻觉 vs 推理幻觉
- 为什么RAG不能彻底消灭幻觉
- 第一环节:索引治理(源头防脏)
- 第二环节:检索控制(不相关就拒答)
- 第三环节:Prompt约束(锁死模型行为)
- 第四环节:生成后校验(引用溯源)
- 拒答机制:什么时候应该不回答
- 完整幻觉抑制链路代码
- 踩坑清单:幻觉抑制的8个关键问题
- 面试速答版
- 总结与延伸
- 文末互动
1. 什么是幻觉:知识幻觉 vs 推理幻觉
1.1 知识幻觉
模型自身参数中没有该知识,凭空编造事实、数据、引用。
场景:用户问"你们公司2024年年假几天?"
模型参数中不可能有"你们公司"的具体政策 →
模型不会说"我不知道" →
模型编造:"根据一般企业惯例,年假为5-15天..."
→ 这是知识幻觉:模型没有知识,硬编一个通用答案
RAG解法:检索公司真实政策文档,让模型基于资料回答。
1.2 推理幻觉
输入上下文中存在相关信息,但模型错误理解、篡改或乱拼接上下文内容。
场景:用户问"怎么申请公积金提取?"
检索到的资料:
[1] "公积金提取需要身份证、公积金卡、提取申请表"
[2] "网上提取可以通过支付宝市民中心办理"
模型回答:
"公积金提取需要身份证、公积金卡、提取申请表,
并且可以通过支付宝市民中心办理网上提取。"
→ 看起来对?但如果资料[1]和[2]是互斥的两种渠道,
模型把"线下"和"线上"强行拼接成"线下+线上",就是推理幻觉
RAG解法:需要更精细的上下文组织和引用校验。
1.3 两种幻觉的对比
| 类型 | 原因 | 表现 | RAG解法 |
|---|---|---|---|
| 知识幻觉 | 模型没这个知识 | 编造事实、数据、来源 | 检索补充知识 |
| 推理幻觉 | 有资料但理解错 | 拼接错误、篡改原文、错误推理 | 精细约束+校验 |
2. 为什么RAG不能彻底消灭幻觉
2.1 RAG的局限性
RAG能解决的:
✓ 模型不知道的知识 → 检索补充
✓ 模型训练数据过时 → 检索最新资料
RAG不能彻底解决的:
✗ 检索结果本身错误(知识库质量差)
✗ 模型错误理解检索内容(推理幻觉)
✗ 模型自由发挥(Prompt约束不到位)
✗ 引用标注错误(生成后没校验)
2.2 核心认知
RAG 不是幻觉的"解药",而是"抑制器"。 它能把幻觉率从 30% 压到 5%,但不能压到 0%。要把幻觉率继续压低,需要四个环节层层把关。
3. 第一环节:索引治理(源头防脏)
3.1 知识库质量决定上限
如果知识库里的资料就是错的,检索再精准、模型再强也没用。
错误资料入库 → 检索召回错误资料 → 模型基于错误资料回答 → 回答还是错的
数据质量原则:Garbage In, Garbage Out
3.2 索引治理四步法
"""索引治理:确保入库资料质量"""
class IndexGovernance:
def process_document(self, raw_doc: str) -> list[Chunk]:
# 1. 清洗
cleaned = self.clean(raw_doc)
# 去除页眉页脚、页码、水印
# 过滤无效字符、乱码
# 2. 去重
unique = self.deduplicate(cleaned)
# 文件指纹(MD5)去重
# 语义去重(高相似度文档合并)
# 3. 分块保上下文
chunks = self.split(cleaned)
# 父子分块:子块420字用于检索,父块1200字用于提供上下文
# 每个切片保留章节名、页码、来源
# 4. 校验
validated = self.validate(chunks)
# 空内容检测
# 格式校验(JSON/XML非法字符)
# 敏感信息检测
return validated
4. 第二环节:检索控制(不相关就拒答)
4.1 核心原则:检索不到相关内容,就不回答
# 错误做法:不管检索到什么,都送给LLM
results = vectorstore.search(question, k=10)
answer = llm.generate(question, results) # 即使results全是垃圾也硬答
# 正确做法:检索不到就拒答
results = vectorstore.search(question, k=10)
if not results or max_score < threshold:
return "抱歉,知识库中没有找到相关资料。"
answer = llm.generate(question, results)
4.2 相似度阈值控制
"""检索控制:相似度阈值 + 重排序过滤"""
class RetrievalController:
def __init__(self, score_threshold: float = 0.3):
self.score_threshold = score_threshold
def retrieve(self, question: str) -> RetrievalResult:
# 1. 向量检索
vector_results = vector_search(question, k=10)
# 2. BM25检索
bm25_results = bm25_search(question, k=10)
# 3. RRF融合
fused = rrf_fusion(vector_results, bm25_results)
# 4. 重排序打分
reranked = rerank(question, fused[:15])
# 5. 过滤低分证据(关键!)
evidence = [
(doc, score) for doc, score in reranked
if score >= self.score_threshold
]
# 6. 没有合格证据 → 拒答
if not evidence:
return RetrievalResult(
documents=[],
refused=True,
reason="未找到相关资料",
)
return RetrievalResult(
documents=[doc for doc, _ in evidence[:5]],
refused=False,
)
4.3 为什么要设阈值
不设阈值的结果:
检索召回的Top-5文档相似度只有0.1-0.2(几乎无关)
全部送给LLM
LLM被迫在这些无关内容中"编"一个答案
→ 幻觉率飙升
设阈值的结果:
只有相似度>=0.3的文档才送给LLM
如果没有文档达标 → 直接拒答
→ 宁可不答,也不瞎答
5. 第三环节:Prompt约束(锁死模型行为)
5.1 约束Prompt设计
SYSTEM_PROMPT = """你是一个企业政策问答助手。
【核心规则】
1. 只能基于下面提供的参考资料回答用户问题
2. 如果参考资料中没有相关信息,请如实说明"知识库中没有相关资料"
3. 每条关键结论必须标注引用来源,格式为[1][2]
4. 不要引用参考资料之外的内容
5. 不要自行补充、推理或延伸资料中没有的信息
6. 如果参考资料之间有矛盾,优先采信权威等级更高的资料
【参考资料】
{context}
"""
5.2 四条核心规则解析
| 规则 | 作用 | 防止的幻觉类型 |
|---|---|---|
| 只能基于资料回答 | 锁死知识边界 | 知识幻觉 |
| 没有信息就拒答 | 兜底机制 | 知识幻觉 |
| 必须标注引用 | 强制溯源 | 推理幻觉(乱拼接) |
| 不引用资料外内容 | 禁止编造 | 知识幻觉 |
5.3 上下文组织方式
def format_context(docs: list[Document]) -> str:
"""带编号的上下文组织"""
lines = []
for i, doc in enumerate(docs, 1):
lines.append(
f"[{i}] 来源:{doc.metadata['filename']} "
f"(章节:{doc.metadata.get('section', '未知')})\n"
f"{doc.page_content}"
)
return "\n\n".join(lines)
# 生成的上下文格式:
# [1] 来源:员工手册v2.4(章节:考勤制度)
# 公司所有正式员工每年享有10天带薪年假...
#
# [2] 来源:项目组wiki(章节:请假流程)
# 请假需要提前3天在OA系统提交申请...
6. 第四环节:生成后校验(引用溯源)
6.1 引用校验
import re
from dataclasses import dataclass
@dataclass
class Citation:
index: int
text: str
@dataclass
class ValidationResult:
valid: bool
invalid_citations: list[Citation]
reason: str | None
def extract_citations(answer: str) -> list[Citation]:
"""提取回答中的引用标注 [1] [2] [3]"""
pattern = r'\[(\d+)\]'
matches = re.finditer(pattern, answer)
citations = []
for match in matches:
index = int(match.group(1))
# 提取引用前后的文本片段
start = max(0, match.start() - 50)
end = min(len(answer), match.end() + 50)
context = answer[start:end]
citations.append(Citation(index=index, text=context))
return citations
def validate_citations(
answer: str,
docs: list[Document],
) -> ValidationResult:
"""校验回答中的引用是否合法
检查项:
1. 引用编号是否在有效范围内(1 ~ len(docs))
2. 引用内容是否与对应文档语义匹配
"""
citations = extract_citations(answer)
invalid = []
for citation in citations:
# 检查1:编号越界
if citation.index < 1 or citation.index > len(docs):
invalid.append(citation)
continue
# 检查2:语义匹配(简化版:检查关键词重叠)
doc = docs[citation.index - 1]
doc_keywords = set(extract_keywords(doc.page_content))
cite_keywords = set(extract_keywords(citation.text))
overlap = len(doc_keywords & cite_keywords)
if overlap < 2: # 关键词重叠太少
invalid.append(citation)
if invalid:
return ValidationResult(
valid=False,
invalid_citations=invalid,
reason=f"发现 {len(invalid)} 处无效引用",
)
return ValidationResult(valid=True, invalid_citations=[], reason=None)
6.2 答案一致性校验
def validate_answer_consistency(
answer: str,
docs: list[Document],
) -> bool:
"""校验回答是否与参考资料一致
简单实现:检查回答中的关键事实是否能在文档中找到
"""
# 提取回答中的数字、日期、专有名词
answer_facts = extract_facts(answer)
# 在文档中搜索这些事实
doc_text = " ".join(d.page_content for d in docs)
for fact in answer_facts:
if fact not in doc_text:
return False # 回答包含文档中没有的事实
return True
7. 拒答机制:什么时候应该不回答
7.1 三种拒答场景
| 场景 | 判断条件 | 回复方式 |
|---|---|---|
| 检索无结果 | 向量+BM25都召回不到 | “抱歉,没有找到相关资料” |
| 证据质量低 | 所有片段重排序分数 < 0.3 | “抱歉,现有资料不足以回答” |
| 问题越界 | 与知识库领域无关 | “这个问题不在我的知识范围内” |
7.2 拒答不是失败,是保护
错误认知:
"系统必须回答每一个问题,不回答就是 bug"
正确认知:
"答错比不答更可怕——答错会传播错误信息,不答至少不害人"
设计原则:
宁可不答,也不瞎答
宁可说"不知道",也不编一个"看起来像对"的答案
8. 完整幻觉抑制链路代码
"""完整RAG幻觉抑制链路"""
from dataclasses import dataclass
from enum import Enum
class AnswerType(Enum):
NORMAL = "正常回答"
REFUSED = "拒答"
UNCERTAIN = "不确定"
@dataclass
class SafeAnswer:
content: str
answer_type: AnswerType
citations: list[str]
confidence: float
trace: dict
class HallucinationGuard:
def __init__(
self,
retriever,
llm,
score_threshold: float = 0.3,
citation_validator=None,
):
self.retriever = retriever
self.llm = llm
self.score_threshold = score_threshold
self.citation_validator = citation_validator
async def safe_answer(self, question: str) -> SafeAnswer:
trace = {}
# ========== 环节1:检索控制 ==========
retrieval_result = await self.retriever.retrieve(question)
trace["retrieval"] = retrieval_result.trace
if retrieval_result.refused:
return SafeAnswer(
content="抱歉,知识库中没有找到相关资料,请尝试换个问法。",
answer_type=AnswerType.REFUSED,
citations=[],
confidence=0.0,
trace=trace,
)
docs = retrieval_result.documents
# ========== 环节2:Prompt约束 ==========
context = self._format_context(docs)
messages = [
SystemMessage(content=SYSTEM_PROMPT.format(context=context)),
HumanMessage(content=question),
]
# ========== 环节3:生成(temperature=0降低随机性) ==========
response = await self.llm.ainvoke(messages, temperature=0)
answer = response.content
# ========== 环节4:引用校验 ==========
if self.citation_validator:
validation = self.citation_validator.validate(answer, docs)
trace["validation"] = {
"valid": validation.valid,
"reason": validation.reason,
}
if not validation.valid:
# 引用校验失败,降级处理
return SafeAnswer(
content=(
f"根据资料初步分析:{answer[:100]}...\n\n"
"(注:引用校验发现异常,建议人工复核)"
),
answer_type=AnswerType.UNCERTAIN,
citations=[],
confidence=0.5,
trace=trace,
)
# 提取引用
citations = self._extract_citations(answer)
return SafeAnswer(
content=answer,
answer_type=AnswerType.NORMAL,
citations=citations,
confidence=retrieval_result.confidence,
trace=trace,
)
def _format_context(self, docs: list[Document]) -> str:
lines = []
for i, doc in enumerate(docs, 1):
lines.append(
f"[{i}] 来源:{doc.metadata.get('filename', '未知')}\n"
f"{doc.page_content}"
)
return "\n\n".join(lines)
def _extract_citations(self, answer: str) -> list[str]:
import re
pattern = r'\[(\d+)\]'
return re.findall(pattern, answer)
9. 踩坑清单:幻觉抑制的8个关键问题
| 序号 | 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|---|
| 1 | 不设相似度阈值 | 无关内容送入LLM,模型硬编 | 检索召回质量低 | 设score_threshold=0.3 |
| 2 | Prompt约束弱 | 模型自由发挥,引用不存在来源 | 系统Prompt没锁死边界 | 四条核心规则强制约束 |
| 3 | 知识库质量差 | 检索到的资料本身就是错的 | 入库没做清洗校验 | 索引治理四步法 |
| 4 | 切片不带来源 | 引用标注无法溯源 | 元数据缺失 | 强制绑定filename/section/page |
| 5 | temperature太高 | 模型随机编造 | temperature>0导致创造性 | RAG生成设temperature=0 |
| 6 | 不做引用校验 | 回答引用[99]但只有5条资料 | 生成后没校验 | 正则提取+编号范围校验 |
| 7 | 拒答阈值太高 | 该答的不答,用户体验差 | threshold=0.8过于严格 | 设0.3,根据业务调整 |
| 8 | 不做定期评测 | 幻觉率缓慢上升 unnoticed | 缺乏监控 | 定期跑标准问答对评估 |
10. 面试速答版
RAG不能彻底消灭幻觉,只能抑制。幻觉分两种:知识幻觉(模型没知识硬编)和推理幻觉(有资料但理解错)。四环节层层设防:
- 索引治理:清洗去重,保证入库资料质量
- 检索控制:设相似度阈值,不相关就拒答,宁可不答也不瞎答
- Prompt约束:锁死四条规则(只能基于资料、没资料拒答、必须标引用、不引用资料外内容)
- 生成后校验:正则提取引用编号,校验是否在有效范围,语义匹配度检查
一句话:答错比不答更可怕,宁可拒答也不瞎编。
11. 总结与延伸
11.1 核心知识点回顾
幻觉抑制四环节:
索引治理 → 检索控制 → Prompt约束 → 生成后校验
索引治理:
清洗 → 去重 → 分块保上下文 → 校验
检索控制:
混合检索 → 重排序 → 阈值过滤 → 无证据则拒答
Prompt约束(四条规则):
1. 只能基于资料
2. 没资料就拒答
3. 必须标引用
4. 不引用资料外
生成后校验:
引用编号范围校验 → 语义匹配校验 → 事实一致性校验
11.2 延伸方向
- 自动评估体系:定期用标准问答对跑评估,监控幻觉率变化
- 人在回路(Human-in-the-loop):低置信度回答推人工审核
- 对抗测试:故意输入"陷阱问题",测试模型是否会编造
- 多模型交叉验证:同一问题用多个模型回答,不一致时标警示
12.文末互动
你的 RAG 系统有没有遇到过模型"硬编"答案的情况?你是怎么发现的——用户反馈、引用校验失败、还是定期评估?评论区聊聊你的幻觉对抗经验。
思考题:如果用户问了一个"知识库里没有"的问题,但这个问题和库里某条资料"看起来有点像",模型可能会"勉强"用这条资料回答。你的系统应该如何区分"真正相关"和"看起来相关"?欢迎在评论区讨论。
本文聚焦 RAG 幻觉抑制的四环节实战方案。如果觉得有帮助,欢迎点赞收藏,后续会更新自动评估体系和对抗测试的进阶内容。

392

被折叠的 条评论
为什么被折叠?



