RAG 的幻觉检测——找到了相关内容,为什么还在胡说
RAG 的幻觉检测——找到了相关内容,为什么还在胡说
有一次,我们的法规知识库给出了一个让我特别难受的答案。
用户问的是关于某个具体政策的执行截止日期。系统检索到了正确的政策文档,文档里明确写着截止日期是 2024 年 3 月 31 日。但 LLM 最后给出的答案是"2024 年 6 月 30 日"。
我重新跑了一遍,同一个问题,检索结果完全正确。但答案就是错的。
这件事让我很困惑。我以为只要检索准确,RAG 就能给出准确答案。但显然不是这样。
这是 RAG 的幻觉问题——不是检索失败,而是在检索成功的情况下,LLM 还是给出了错误的信息。
幻觉的三种来源
我后来系统地研究了这个问题,把幻觉分成三类,每一类的成因不同,处理方式也不同。
类型 1:检索不准导致的幻觉
检索本身出问题——要么没找到相关文档,要么找到的文档和问题不相关。LLM 拿着错误或无关的上下文,只能靠自己的训练数据来生成答案,这就是幻觉。
这类幻觉的解法是改进检索:Query 改写、Rerank、上下文压缩。前几篇文章都讲过了。
类型 2:上下文理解错误导致的幻觉
检索到了对的文档,但 LLM 对文档的理解出了问题。
这种情况比较难排查,因为"文档检索对了"容易让人误以为系统没问题。
几种常见的理解错误:
- 时态混淆:文档里说"原来的规定是 X",LLM 误以为现在还是 X
- 否定理解:文档说"不允许做 Y",LLM 给出了"允许做 Y"的答案
- 数字误读:这是我遇到的情况,日期或数字被错误提取
- 条件忽略:文档里有个前提条件"在满足 Z 的情况下才能 W",LLM 忽略了前提条件
类型 3:模型过度生成导致的幻觉
这是最难防的一类。LLM 检索到了相关内容,也理解对了,但在生成答案时"发挥"过度——把没在文档里但"应该存在"的内容也写进去了。
GPT-4 这类强大模型在这方面特别"有想象力"。它的训练数据里有大量相关领域知识,很容易把训练数据里的知识和当前检索到的文档内容混在一起,生成一个"看起来合理但实际上有部分不在文档里"的答案。
幻觉检测的技术方案
方案 1:基于 NLI 的事实核查
NLI(Natural Language Inference,自然语言推理)是判断两个句子之间关系的任务:蕴含(entailment)、矛盾(contradiction)、中性(neutral)。
我们可以用 NLI 来判断:答案里的每个陈述,是否被检索到的文档所支持?
具体流程:
- 把 LLM 生成的答案拆分成多个原子性陈述
- 对每个陈述,以检索到的文档为前提,以陈述为假设,用 NLI 模型判断关系
- 如果大量陈述被判为"中性"或"矛盾",说明答案存在幻觉
@Service
@Slf4j
public class NliHallucinationDetector {
// 使用本地部署的 NLI 模型服务
@Value("${nli.service.url:http://localhost:9091/nli}")
private String nliServiceUrl;
private final RestTemplate restTemplate;
private final ChatClient chatClient;
private static final String DECOMPOSE_PROMPT = """
请将以下答案分解为独立的、可以单独验证的陈述。
每行一个陈述,不要加编号,只保留有实质性信息的句子。
答案:{answer}
""";
public HallucinationCheckResult check(String answer, List<Document> retrievedDocs) {
// Step 1: 分解答案为原子陈述
List<String> statements = decomposeToStatements(answer);
log.debug("Decomposed answer into {} statements", statements.size());
// Step 2: 合并检索到的文档作为前提
String premise = retrievedDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining(" "));
// 前提太长要截断(NLI 模型有 token 限制)
if (premise.length() > 2000) {
premise = premise.substring(0, 2000);
}
// Step 3: 对每个陈述做 NLI 判断
List<StatementCheckResult> statementResults = new ArrayList<>();
for (String statement : statements) {
NliResult nliResult = callNliService(premise, statement);
statementResults.add(new StatementCheckResult(statement, nliResult));
}
// Step 4: 汇总结果
long entailedCount = statementResults.stream()
.filter(r -> r.getNliResult() == NliResult.ENTAILMENT)
.count();
long contradictionCount = statementResults.stream()
.filter(r -> r.getNliResult() == NliResult.CONTRADICTION)
.count();
double faithfulnessScore = statements.isEmpty() ? 1.0 :
(double) entailedCount / statements.size();
boolean hasHallucination = contradictionCount > 0 || faithfulnessScore < 0.7;
return HallucinationCheckResult.builder()
.answer(answer)
.statementResults(statementResults)
.faithfulnessScore(faithfulnessScore)
.contradictionCount((int) contradictionCount)
.hasHallucination(hasHallucination)
.build();
}
private List<String> decomposeToStatements(String answer) {
try {
String response = chatClient.prompt()
.user(u -> u.text(DECOMPOSE_PROMPT).param("answer", answer))
.call()
.content();
return Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty() && s.length() > 10)
.collect(Collectors.toList());
} catch (Exception e) {
// 降级:简单按句号分割
return Arrays.asList(answer.split("[。?!]"));
}
}
private NliResult callNliService(String premise, String hypothesis) {
Map<String, String> request = Map.of(
"premise", premise,
"hypothesis", hypothesis
);
try {
Map<String, Object> response = restTemplate.postForObject(
nliServiceUrl, request, Map.class
);
String label = (String) response.get("label");
return switch (label.toUpperCase()) {
case "ENTAILMENT" -> NliResult.ENTAILMENT;
case "CONTRADICTION" -> NliResult.CONTRADICTION;
default -> NliResult.NEUTRAL;
};
} catch (Exception e) {
log.warn("NLI service call failed, defaulting to NEUTRAL", e);
return NliResult.NEUTRAL;
}
}
public enum NliResult {
ENTAILMENT, // 文档支持这个陈述
NEUTRAL, // 文档既不支持也不反对
CONTRADICTION // 文档与这个陈述矛盾
}
@Data
@AllArgsConstructor
public static class StatementCheckResult {
private String statement;
private NliResult nliResult;
}
@Data
@Builder
public static class HallucinationCheckResult {
private String answer;
private List<StatementCheckResult> statementResults;
private double faithfulnessScore;
private int contradictionCount;
private boolean hasHallucination;
public String getSummary() {
return String.format(
"Faithfulness: %.2f, Contradictions: %d, HasHallucination: %s",
faithfulnessScore, contradictionCount, hasHallucination
);
}
}
}方案 2:答案和来源的一致性校验
这个方案更工程化,不需要部署 NLI 模型:直接让 LLM 校验答案是否有文档支持。
@Component
@Slf4j
public class SourceConsistencyChecker {
private final ChatClient chatClient;
private static final String CONSISTENCY_CHECK_PROMPT = """
你是一个严格的事实核查员。
请检查以下"系统回答"的每个关键信息点,判断它是否可以在"参考文档"中找到直接支持。
特别注意:
1. 数字、日期、百分比等具体数值
2. 表示否定或限制的表达(不能、禁止、除非等)
3. 条件前提(在满足X的情况下才能Y)
参考文档:
{context}
系统回答:
{answer}
请输出 JSON 格式:
{
"overall_supported": true/false,
"unsupported_claims": ["claim1", "claim2"],
"contradictions": ["contradiction1"],
"confidence": 0.0-1.0
}
""";
public ConsistencyCheckResult check(String answer, List<Document> sources) {
String context = sources.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
try {
String response = chatClient.prompt()
.user(u -> u.text(CONSISTENCY_CHECK_PROMPT)
.param("context", context)
.param("answer", answer))
.call()
.content();
return parseConsistencyResult(response);
} catch (Exception e) {
log.error("Consistency check failed", e);
return ConsistencyCheckResult.unknown();
}
}
private ConsistencyCheckResult parseConsistencyResult(String json) {
try {
String extractedJson = extractJson(json);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(extractedJson);
boolean overallSupported = root.path("overall_supported").asBoolean(true);
double confidence = root.path("confidence").asDouble(0.5);
List<String> unsupportedClaims = new ArrayList<>();
root.path("unsupported_claims").forEach(node -> unsupportedClaims.add(node.asText()));
List<String> contradictions = new ArrayList<>();
root.path("contradictions").forEach(node -> contradictions.add(node.asText()));
return ConsistencyCheckResult.builder()
.overallSupported(overallSupported)
.unsupportedClaims(unsupportedClaims)
.contradictions(contradictions)
.confidence(confidence)
.hasIssues(!contradictions.isEmpty() || !unsupportedClaims.isEmpty())
.build();
} catch (Exception e) {
log.warn("Failed to parse consistency result JSON", e);
return ConsistencyCheckResult.unknown();
}
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return (start >= 0 && end > start) ? text.substring(start, end + 1) : "{}";
}
@Data
@Builder
public static class ConsistencyCheckResult {
private boolean overallSupported;
private List<String> unsupportedClaims;
private List<String> contradictions;
private double confidence;
private boolean hasIssues;
public static ConsistencyCheckResult unknown() {
return ConsistencyCheckResult.builder()
.overallSupported(true)
.unsupportedClaims(Collections.emptyList())
.contradictions(Collections.emptyList())
.confidence(0.5)
.hasIssues(false)
.build();
}
}
}幻觉检测和修复的完整流程
幻觉修复的实现
发现幻觉之后,不是直接拒绝,而是尝试修复:
@Service
@Slf4j
public class HallucinationFixService {
private final ChatClient chatClient;
private final SourceConsistencyChecker consistencyChecker;
private static final String FIX_PROMPT = """
你之前给出的回答中,存在以下与参考文档不一致的内容:
矛盾点:
{contradictions}
未在文档中找到支持的内容:
{unsupported_claims}
请重新基于以下参考文档,给出修正后的回答。
严格要求:
1. 只陈述在参考文档中有直接支持的内容
2. 对于文档中找不到的信息,明确说"文档中未提及"
3. 不要添加文档中没有的内容
参考文档:
{context}
用户问题:{question}
修正后的回答:
""";
public AnswerWithMetadata generateWithHallucinationCheck(
String question,
List<Document> documents,
String initialAnswer) {
// 第一次校验
SourceConsistencyChecker.ConsistencyCheckResult checkResult =
consistencyChecker.check(initialAnswer, documents);
if (!checkResult.isHasIssues()) {
return AnswerWithMetadata.ok(initialAnswer, checkResult);
}
log.warn("Hallucination detected: {} contradictions, {} unsupported claims",
checkResult.getContradictions().size(),
checkResult.getUnsupportedClaims().size());
// 尝试修复
String context = documents.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
String fixedAnswer = chatClient.prompt()
.user(u -> u.text(FIX_PROMPT)
.param("contradictions", String.join("\n", checkResult.getContradictions()))
.param("unsupported_claims", String.join("\n", checkResult.getUnsupportedClaims()))
.param("context", context)
.param("question", question))
.call()
.content();
// 二次校验
SourceConsistencyChecker.ConsistencyCheckResult reCheckResult =
consistencyChecker.check(fixedAnswer, documents);
if (!reCheckResult.isHasIssues()) {
return AnswerWithMetadata.fixed(fixedAnswer, checkResult, reCheckResult);
}
// 二次修复还是有问题,降级处理:返回文档摘录
log.error("Still has hallucination after fix, falling back to document excerpt");
String excerpt = extractRelevantExcerpt(question, documents);
return AnswerWithMetadata.degraded(excerpt, checkResult);
}
/**
* 降级策略:直接从文档里摘取最相关的段落
* 不经过 LLM 改写,保证100%忠实于原文
*/
private String extractRelevantExcerpt(String question, List<Document> documents) {
// 返回相关性最高的第一个文档块的原文
if (documents.isEmpty()) return "未找到相关信息。";
Document topDoc = documents.get(0);
String source = (String) topDoc.getMetadata().getOrDefault("source_file", "文档");
return String.format(
"【系统说明:以下为文档原文摘录,未经 AI 改写,以确保准确性】\n\n" +
"来源:%s\n\n%s",
source, topDoc.getContent()
);
}
@Data
@Builder
public static class AnswerWithMetadata {
private String answer;
private AnswerStatus status;
private SourceConsistencyChecker.ConsistencyCheckResult initialCheck;
private SourceConsistencyChecker.ConsistencyCheckResult afterFixCheck;
public static AnswerWithMetadata ok(String answer,
SourceConsistencyChecker.ConsistencyCheckResult check) {
return AnswerWithMetadata.builder()
.answer(answer)
.status(AnswerStatus.OK)
.initialCheck(check)
.build();
}
public static AnswerWithMetadata fixed(String answer,
SourceConsistencyChecker.ConsistencyCheckResult initial,
SourceConsistencyChecker.ConsistencyCheckResult afterFix) {
return AnswerWithMetadata.builder()
.answer(answer)
.status(AnswerStatus.FIXED)
.initialCheck(initial)
.afterFixCheck(afterFix)
.build();
}
public static AnswerWithMetadata degraded(String answer,
SourceConsistencyChecker.ConsistencyCheckResult check) {
return AnswerWithMetadata.builder()
.answer(answer)
.status(AnswerStatus.DEGRADED)
.initialCheck(check)
.build();
}
public enum AnswerStatus {
OK, // 一次通过,无幻觉
FIXED, // 检测到幻觉,修复后通过
DEGRADED // 修复失败,降级返回原文
}
}
}幻觉检测的代价与权衡
我必须说实话:幻觉检测不是免费的。
完整的幻觉检测流程(一致性校验 + 可能的修复),会额外增加至少 1-2 次 LLM 调用,延迟增加 1-3 秒,成本增加 50-150%。
所以不是所有场景都需要全套幻觉检测。我们的策略是分级处理:
低风险场景(比如查公司福利政策):只做轻量级校验,发现矛盾就在答案末尾加免责声明,不重新生成。
高风险场景(比如查法规条文、合同条款、医疗信息):全套检测 + 修复,宁可慢,不能错。
@Service
public class HallucinationCheckPolicy {
public CheckLevel determineCheckLevel(String question, String businessType) {
// 高风险业务类型直接走全套检测
if (isHighRiskBusiness(businessType)) return CheckLevel.FULL;
// 包含数字、日期、百分比的问题,风险更高
if (containsSpecificValues(question)) return CheckLevel.FULL;
// 包含否定词的问题(不能、禁止等),容易被 LLM 误解
if (containsNegation(question)) return CheckLevel.STANDARD;
return CheckLevel.LIGHTWEIGHT;
}
private boolean isHighRiskBusiness(String businessType) {
return Set.of("legal", "medical", "financial", "compliance").contains(businessType);
}
private boolean containsSpecificValues(String question) {
return question.matches(".*[0-9].*") ||
question.contains("多少") ||
question.contains("几") ||
question.contains("deadline") ||
question.contains("截止");
}
private boolean containsNegation(String question) {
return question.contains("不") || question.contains("禁止") ||
question.contains("不允许") || question.contains("除外");
}
public enum CheckLevel {
LIGHTWEIGHT, // 只检查,不修复,加免责声明
STANDARD, // 检查 + 简单修复
FULL // 完整检测 + 修复 + 降级策略
}
}实际效果
在我们的法规知识库上跑了两个月之后的数据:
| 指标 | 加幻觉检测前 | 加幻觉检测后 |
|---|---|---|
| 答案事实错误率 | 8.3% | 2.1% |
| 数字/日期错误率 | 12.7% | 3.4% |
| 平均响应时间 | 1.2s | 2.8s(全套检测场景) |
| API 成本增幅 | - | +68% |
事实错误率从 8.3% 降到 2.1%,对于法规查询场景来说这个改进非常关键——告诉用户错误的法规截止日期,可能导致真实的法律风险。
延迟和成本的代价是真实的,但对于高风险场景来说是值得的。
总结
幻觉不只是"检索没找到对的内容"时才有,检索对了 LLM 照样可能胡说。三类幻觉要分别应对:检索层、理解层、生成层。
对于企业级知识库,尤其是涉及法规、合同、财务的高风险场景,幻觉检测不是可选项,是必须。代价是延迟和成本,需要根据业务风险等级来决定是否全套检测。
