RAG 幻觉检测:在 Agent 输出答案前自动交叉验证引用和事实的一致性
RAG 幻觉检测在 Agent 输出答案前自动交叉验证引用和事实的一致性一、深度引言与场景痛点大家好我是赵咕咕。上面这句话是我们 RAG 系统生成的真实回答。问题出在哪我们的知识库里没有任何关于 Python 3.15 的信息LLM 根据检索到的 Python 版本演进历史文档推理出了一个不存在的版本号。用户看到后截图发到技术群里你们的知识库也太离谱了吧。这是 RAG 幻觉中最隐蔽的一种——事实编造型幻觉。LLM 拿到检索到的文档后不是老老实实引用文档内容而是合理推理出文档里根本没有的信息。表面上看答案很流畅、很专业实际上内容是凭空编造的。传统 RAG 的检索-生成流程缺少一个关键环节答案发出前的真实性校验。这篇文章我分享一套在 Agent 输出答案前做自动交叉验证的方案——在幻觉还新鲜热乎的时候就把它拦截掉。二、底层机制与原理深度剖析2.1 RAG 幻觉的分类不是所有 RAG 幻觉都一样。按来源可以分为三类检索失败型幻觉检索没找到相关文档LLM 只能用自身知识回答。这是最常见的也是最容易通过提高检索质量来解决的。引用错误型幻觉检索到了文档但 LLM 引用了错误的内容或者把文档 A 的信息错误地归因到文档 B。事实编造型幻觉检索到了文档LLM 也引用了但它脑补出了文档中不存在的信息。这是最难检测的——因为引用和脑补混在一起不细看根本分辨不出来。第三种是我最头疼的也是本文的重点。2.2 交叉验证的原理交叉验证的核心思路很简单让 LLM 回答后再让另一个独立的校验器检查答案中的每条声明是否被引用的文档支持。具体而言该流程始于用户提问与文档检索随后 LLM 生成带引用的答案。接着系统通过声明提取器将答案拆解为原子声明并送入交叉验证引擎。引擎内部并行运行自然语言推理、关键词共现检查及引用跨度校验最终根据验证结果决定放行答案、生成修正版或退回告知不确定性。整个验证流程分四步第一步声明拆解。把 LLM 的完整回答拆成一条条原子声明——每一句话拿掉修饰词后剩下的核心事实判断。例如Python 3.15 将于 2025 年发布它引入了新的模式匹配语法会拆成Python 3.15 将于 2025 年发布和Python 3.15 引入新的模式匹配语法两条声明。第二步文档段匹配。对于每条声明在检索到的文档中找出最相关的段落。不是用向量检索太慢了而是用 BM25 关键词匹配找到可能支撑这条声明的文档段。第三步三重交叉验证。对每条声明-文档段对跑三个独立的验证器NLI 引擎用一个轻量级 NLINatural Language Inference模型判断文档段是否蕴含该声明。输出 entailment / contradiction / neutral 三分类。关键词共现检查声明中的关键实体人名、产品名、版本号是否在匹配的文档段中出现。这能抓住最明显的编造。引用跨度校验如果 LLM 在答案中标注了引用来源检查该来源的文档段是否确实覆盖了被引用的声明。第四步仲裁与修正。如果所有声明的验证结果都是支持或可接受答案放行。如果有疑似编造的声明要么在答案中标注出来以下信息未经文档确认要么退回重新生成。三、生产级代码实现import asyncio import re import logging from dataclasses import dataclass, field from typing import Any logger logging.getLogger(__name__) dataclass class AtomicClaim: 从答案中拆解出的原子声明。 text: str entities: list[str] field(default_factorylist) citation_refs: list[int] field(default_factorylist) verification: str pending # pending / supported / unsupported matched_segment: str | None None confidence: float 0.0 dataclass class HallucinationReport: 幻觉检测报告。 original_answer: str claims: list[AtomicClaim] hallucination_count: int hallucination_ratio: float is_safe: bool revised_answer: str | None None class HallucinationDetector: RAG 幻觉检测器在答案返回前做交叉验证。 def __init__( self, nli_model, # 替换为你的 NLI 模型 bm25_index, # 替换为 BM25 索引 hallucination_threshold: float 0.3, # 幻觉比例超过此值则拒绝 ): self._nli nli_model self._bm25 bm25_index self._threshold hallucination_threshold async def validate( self, answer: str, retrieved_docs: list[dict[str, str]], timeout: float 10.0, ) - HallucinationReport: 交叉验证答案中的每条声明。 # Step 1: 拆解声明 claims await self._extract_claims(answer) if not claims: return HallucinationReport( original_answeranswer, claims[], hallucination_count0, hallucination_ratio0.0, is_safeTrue, ) # Step 2 3: 逐条验证 async def _verify_one(claim: AtomicClaim) - AtomicClaim: try: return await asyncio.wait_for( self._verify_claim(claim, retrieved_docs), timeouttimeout / max(len(claims), 1), ) except asyncio.TimeoutError: claim.verification pending claim.confidence 0.3 return claim verified await asyncio.gather( *[_verify_one(c) for c in claims], return_exceptionsTrue ) # 过滤异常 final_claims [] for item in verified: if isinstance(item, AtomicClaim): final_claims.append(item) elif isinstance(item, Exception): logger.error(声明验证异常: %s, item) # Step 4: 汇总报告 unsupported [c for c in final_claims if c.verification unsupported] ratio len(unsupported) / max(len(final_claims), 1) report HallucinationReport( original_answeranswer, claimsfinal_claims, hallucination_countlen(unsupported), hallucination_ratioratio, is_saferatio self._threshold, ) if not report.is_safe: report.revised_answer await self._revise_answer( answer, unsupported ) return report async def _extract_claims(self, answer: str) - list[AtomicClaim]: 将答案拆解为原子声明。 prompt f请将以下文本拆解为原子级别的声明列表。 每条声明应该是一个独立的事实判断。 只输出 JSON 数组每个元素包含 text 和 entities 字段。 文本: {answer} 输出格式: [{{text: 声明文本, entities: [实体1, 实体2]}}] try: import json response await self._nli.llm.generate(prompt) # 清理 markdown 包装 response response.strip().removeprefix(json).removesuffix().strip() raw json.loads(response) return [ AtomicClaim(textitem[text], entitiesitem.get(entities, [])) for item in raw ] except Exception as e: logger.error(声明拆解失败: %s, e) # 降级按句号做简单分句 sentences re.split(r[。\n], answer) return [ AtomicClaim(texts.strip(), entities[]) for s in sentences if len(s.strip()) 5 ] async def _verify_claim( self, claim: AtomicClaim, docs: list[dict[str, str]] ) - AtomicClaim: 对单条声明做交叉验证。 # 1) BM25 找到最相关的文档段 best_segment await self._find_best_segment(claim.text, docs) if best_segment is None: claim.verification unsupported claim.confidence 0.0 return claim claim.matched_segment best_segment # 2) NLI 验证文档段是否蕴含声明 try: nli_result await self._nli.check( premisebest_segment, hypothesisclaim.text ) except Exception as e: logger.error(NLI 验证异常: %s, e) claim.verification pending claim.confidence 0.3 return claim # 3) 实体共现检查 entity_overlap self._check_entity_overlap( claim.entities, best_segment ) # 4) 综合判定 if nli_result[label] entailment and entity_overlap 0.5: claim.verification supported claim.confidence nli_result.get(score, 0.8) elif nli_result[label] contradiction: claim.verification unsupported claim.confidence 0.1 elif entity_overlap 0.3: claim.verification unsupported claim.confidence float(entity_overlap) else: claim.verification pending claim.confidence 0.4 return claim async def _find_best_segment( self, claim: str, docs: list[dict[str, str]] ) - str | None: 用 BM25 找到最匹配的文档段。 try: # 将所有文档按段落拆分为候选段 segments [] for doc in docs: content doc.get(content, ) paragraphs content.split(\n\n) for p in paragraphs: if len(p.strip()) 20: segments.append(p.strip()) if not segments: return None results await asyncio.to_thread( self._bm25.search, claim, segments, top_k1 ) return results[0] if results else None except Exception: return None def _check_entity_overlap( self, entities: list[str], segment: str ) - float: 检查声明中的实体在文档段中的覆盖率。 if not entities: return 0.5 # 没有实体时不作为负面信号 found sum(1 for e in entities if e.lower() in segment.lower()) return found / len(entities) async def _revise_answer( self, answer: str, unsupported: list[AtomicClaim] ) - str: 生成修正版答案标注不可信部分。 warning \n\n 以下声明未经检索文档验证请注意甄别\n for c in unsupported: warning f - {c.text}\n return answer warning class AsyncNLIEngine: 异步 NLI 推理引擎包装器。 def __init__(self, model_name: str MoritzLaurer/DeBERTa-v3-base-mnli): self._model_name model_name self._pipeline None async def __aenter__(self): from transformers import pipeline self._pipeline await asyncio.to_thread( pipeline, zero-shot-classification, modelself._model_name, device-1 # CPU 推理避免 GPU 争抢 ) return self async def __aexit__(self, *args): self._pipeline None async def check( self, premise: str, hypothesis: str ) - dict[str, Any]: 检查 premise 是否蕴含 hypothesis。 if self._pipeline is None: raise RuntimeError(NLI 引擎未初始化) result await asyncio.to_thread( self._pipeline, hypothesis, candidate_labels[entailment, neutral, contradiction], multi_labelFalse, ) top_label result[labels][0] top_score result[scores][0] return {label: top_label, score: float(top_score)} # ─── 使用示例 ─── async def main(): async with AsyncNLIEngine() as nli: detector HallucinationDetector( nli_modelnli, bm25_indexNone, # 占位实际使用你的 BM25 实现 hallucination_threshold0.3, ) answer ( 根据文档所述Python 3.15 将于 2025 年发布。 新版本引入了模式匹配语法的增强以及对 JIT 编译的原生支持。 ) docs [ {content: Python 3.12 于 2023 年发布。 Python 3.13 于 2024 年发布引入了实验性 JIT 编译器。} ] report await detector.validate(answer, docs) print(f安全: {report.is_safe}) print(f幻觉比例: {report.hallucination_ratio:.0%}) if report.revised_answer: print(f修正后答案:\n{report.revised_answer}) if __name__ __main__: asyncio.run(main())关键设计决策NLI 引擎用 DeBERTa-v3-base-mnli这是一个 184M 参数的精调模型在 MNLI 数据集上准确率超过 91%。比用 GPT-4 做验证便宜 100 倍延迟低一个数量级。BM25 做文档段匹配向量检索embedding cosine similarity的延迟通常在 20-50msBM25 不到 1ms。在每条声明都需要做匹配的场景下这个差距累积起来很可观。声明拆解降级如果 LLM 拆解失败JSON 解析异常降级为按句号分句。缺点是原子性不够但至少不会让整个验证流程卡住。阈值可调hallucination_threshold让你可以根据业务场景调整严格程度。客服场景可以宽松一点用户自己能判断医疗/金融场景必须极严。四、边界分析与架构权衡4.1 NLI 模型的局限NLI 模型本质上是判断文本 A 是否蕴含文本 B它只能做语义层面的判断无法做事实层面的校验。如果文档本身内容就是错的NLI 验证也会通过——因为它只是检查声明是否被文档支撑而不检查文档本身是否正确。这是整个方案的根本边界你只能验证内部一致性答案 vs 引用不能验证外部真值答案 vs 真实世界。4.2 性能开销每条声明都要做 BM25 检索 NLI 推理。如果答案包含 10 条声明那就是 10 次 BM25 10 次 NLI。单次 NLI 推理约 50msCPU总开销约 500ms——这在 RAG 服务的端到端延迟中不可忽略。优化方向并行验证用asyncio.gather并发验证所有声明。跳过明显安全的声明如果声明中的关键词 100% 出现在文档段中直接标记为 supported跳过 NLI。模型量化用 ONNX 或 TensorRT 把 NLI 模型量化到 INT8推理延迟可以降到 10ms 以下。4.3 误报与漏报的权衡保守策略低阈值容易漏掉幻觉但不会误杀正常的回答。适合内部工具。激进策略高阈值能拦截大部分幻觉但也可能误杀一些文档语义蕴含但不字面匹配的合理回答。适合对精度要求极高的场景。建议从 0.3 的阈值起步允许最多 30% 的声明未被严格验证然后在生产环境中根据用户反馈调整。4.4 与其他方案的对比方案准确率延迟成本无验证—0ms0NLI 模型~85%~50ms/声明低本地推理LLM-as-Judge~92%~500ms高调用费人工审核~99%分钟级很高NLI 模型在准确率 vs 延迟的权衡中处于最佳甜点区。对于 80% 的生产场景85% 的拦截率已经足够了。五、总结幻觉检测不是要在100% 拦截和0 开销之间二选一而是要找到一个工程上可行的平衡点。NLI BM25 的方案给出了一个不错的答案在 RAG 输出前增加一层轻量级交叉验证用 50ms 的延迟换取 85% 的幻觉拦截率。如果发现不可信声明要么标注出来、要么退回重试。更重要的是一种工程思维不要让 LLM 的输出直接面对用户。在中间加一层质检就像代码上线前的 CI 流水线一样。LLM 不是可靠的质检层才是你的最后一道防线。对我个人来说上线这个检测器之后用户投诉回答内容不实的数量下降了约 70%。剩下的 30% 是知识库本身内容错误导致的那属于数据治理的范畴了。下一篇预告Redis 集群数据迁移如何在不影响线上服务的前提下迁移百万级向量索引。