第2309篇:Self-RAG的工程实现——让模型自主决定是否需要检索
第2309篇:Self-RAG的工程实现——让模型自主决定是否需要检索
适读人群:RAG系统开发工程师、AI应用架构师 | 阅读时长:约17分钟 | 核心价值:理解Self-RAG的工程原理,构建能自主判断何时检索、检索质量如何的智能RAG系统
我们的知识问答系统上线之初采用了最朴素的RAG:每个问题都去检索,把检索结果塞进上下文,然后让LLM回答。结果跑了几个月,我们做了一次质量分析,发现了一个令人哭笑不得的现象:
用户问"你好",系统也去检索了一堆文档,然后LLM在一堆文档的干扰下,回答了"您好!根据以下文档内容……"——但问候语根本不需要任何文档!
用户问"我们的退款政策是什么",检索到的文档已经说得很清楚了,但LLM还是会说"根据文档,退款政策是……同时我认为……"——它加进去了自己的"认为",而不是严格遵循文档。
这两个问题说明:传统RAG把"是否检索"和"如何使用检索结果"的决策权都交给了系统,而不是模型自己。Self-RAG把这些决策权还给了模型。
Self-RAG的核心思想
Self-RAG(Self-Reflective Retrieval-Augmented Generation)的核心是引入四种特殊的反思Token:
- [Retrieve]:模型认为当前需要检索外部知识
- [No Retrieve]:模型认为当前知识已足够,不需要检索
- [Relevant]:模型认为检索到的文档与问题相关
- [Irrelevant]:模型认为检索到的文档与问题无关
- [Supported]:模型认为自己的输出有文档支撑
- [Unsupported]:模型认为自己的输出没有文档支撑
工程实现上,我们不需要真的训练一个带特殊Token的模型(那需要大量标注数据和微调),而是用Prompt工程模拟这些决策:
检索决策器的实现
第一个关键组件:判断"这个问题需要检索吗"。
@Component
public class RetrievalDecider {
private final ChatClient chatClient;
private static final String RETRIEVAL_DECISION_PROMPT = """
你需要判断回答以下问题是否需要查阅外部文档/知识库。
判断标准:
- 需要检索:问题涉及具体事实、数据、政策、流程、产品信息等可能随时间变化的内容
- 不需要检索:问候语、简单计算、通用常识、对话性回复、用户反馈确认
只输出:RETRIEVE 或 NO_RETRIEVE,以及简短理由(一句话)。
格式:RETRIEVE|理由 或 NO_RETRIEVE|理由
""";
public RetrievalDecision decide(String question, ConversationContext context) {
// 先用规则快速过滤,避免每次都调LLM
QuickDecision quickDecision = quickRuleDecide(question);
if (quickDecision != null) {
return quickDecision.toRetrievalDecision();
}
String response = chatClient.prompt()
.system(RETRIEVAL_DECISION_PROMPT)
.user("问题:" + question + "\n\n对话历史:" + context.summarize())
.call()
.content();
return parseDecision(response);
}
private QuickDecision quickRuleDecide(String question) {
// 明确的问候语,不需要检索
if (GREETING_PATTERNS.stream().anyMatch(p -> p.matcher(question).matches())) {
return QuickDecision.NO_RETRIEVE;
}
// 包含明确的产品/政策关键词,需要检索
if (FACTUAL_KEYWORDS.stream().anyMatch(question::contains)) {
return QuickDecision.RETRIEVE;
}
return null; // 无法快速判断,需要LLM决策
}
private RetrievalDecision parseDecision(String response) {
String[] parts = response.trim().split("\\|", 2);
boolean shouldRetrieve = parts[0].trim().equals("RETRIEVE");
String reason = parts.length > 1 ? parts[1].trim() : "";
return new RetrievalDecision(shouldRetrieve, reason);
}
}文档相关性评估器
检索到文档后,要评估这些文档是否真的和问题相关:
@Component
public class DocumentRelevanceEvaluator {
private final ChatClient chatClient;
private static final String RELEVANCE_EVALUATION_PROMPT = """
你需要评估以下文档片段对回答用户问题的有用程度。
评估维度:
1. 主题相关性:文档内容是否与问题主题相关?
2. 信息充分性:文档是否包含回答问题所需的具体信息?
3. 时效性:文档是否是最新的、有效的信息?
输出格式(JSON):
{
"relevanceScore": 0-100,
"isRelevant": true/false,
"keyInfo": "文档中与问题最相关的核心信息(简短摘要)",
"gaps": "文档缺失的、回答问题还需要的信息(如有)"
}
相关性评分标准:80+为高度相关,50-79为部分相关,低于50为不相关。
""";
/**
* 评估文档列表的相关性,返回过滤和排序后的文档
*/
public List<ScoredDocument> evaluateAndRank(String question,
List<RetrievedDocument> documents) {
// 并行评估每个文档的相关性
List<CompletableFuture<ScoredDocument>> futures = documents.stream()
.map(doc -> CompletableFuture.supplyAsync(() -> evaluateDocument(question, doc)))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(CompletableFuture::join)
.filter(sd -> sd.isRelevant()) // 过滤不相关文档
.sorted(Comparator.comparingInt(ScoredDocument::relevanceScore).reversed())
.toList();
}
private ScoredDocument evaluateDocument(String question, RetrievedDocument doc) {
String response = chatClient.prompt()
.system(RELEVANCE_EVALUATION_PROMPT)
.user("用户问题:%s\n\n文档内容:\n%s".formatted(question, doc.content()))
.call()
.content();
RelevanceReport report = parseReport(response);
return new ScoredDocument(doc, report.relevanceScore(), report.isRelevant(),
report.keyInfo(), report.gaps());
}
}支撑性评估器:答案有没有文档支撑
生成答案后,评估答案中的每个声明是否有文档支撑:
@Component
public class SupportednessEvaluator {
private final ChatClient chatClient;
private static final String SUPPORTEDNESS_PROMPT = """
你需要评估AI生成的回答中,哪些内容有文档支撑,哪些是模型自己"编的"。
输出格式(JSON):
{
"isFullySupported": true/false,
"supportedClaims": ["有文档支撑的声明1", "有文档支撑的声明2"],
"unsupportedClaims": ["没有文档支撑的声明1"],
"overallConfidence": 0-100,
"recommendation": "ACCEPT | ADD_DISCLAIMER | REGENERATE"
}
推荐行动说明:
- ACCEPT:答案完全基于文档,可以直接输出
- ADD_DISCLAIMER:答案主要基于文档,但有部分推断,添加"根据现有信息"等声明
- REGENERATE:答案有重要部分没有文档支撑,建议重新生成
""";
public SupportednessReport evaluate(String question,
List<ScoredDocument> documents,
String generatedAnswer) {
String docsContent = documents.stream()
.map(sd -> "【文档%d(相关度%d)】\n%s".formatted(
documents.indexOf(sd) + 1, sd.relevanceScore(), sd.document().content()))
.collect(Collectors.joining("\n\n"));
String response = chatClient.prompt()
.system(SUPPORTEDNESS_PROMPT)
.user("""
用户问题:%s
参考文档:
%s
AI生成的回答:
%s
""".formatted(question, docsContent, generatedAnswer))
.call()
.content();
return parseSupportednessReport(response);
}
}完整的Self-RAG流水线
把各个组件串联起来:
@Service
public class SelfRAGPipeline {
private final RetrievalDecider retrievalDecider;
private final VectorSearchService vectorSearch;
private final DocumentRelevanceEvaluator relevanceEvaluator;
private final ChatClient generationClient;
private final SupportednessEvaluator supportednessEvaluator;
public SelfRAGResult answer(String question, ConversationContext context) {
// 阶段1:检索决策
RetrievalDecision retrievalDecision = retrievalDecider.decide(question, context);
log.info("检索决策: shouldRetrieve={}, reason={}",
retrievalDecision.shouldRetrieve(), retrievalDecision.reason());
List<ScoredDocument> relevantDocs = List.of();
if (retrievalDecision.shouldRetrieve()) {
// 阶段2:执行检索
List<RetrievedDocument> rawDocs = vectorSearch.search(question, 10);
// 阶段3:相关性评估与过滤
relevantDocs = relevanceEvaluator.evaluateAndRank(question, rawDocs);
log.info("检索到{}个文档,过滤后{}个相关", rawDocs.size(), relevantDocs.size());
if (relevantDocs.isEmpty()) {
// 检索到的文档全不相关,降级为无检索生成
log.warn("所有检索文档均不相关,降级为直接生成");
}
}
// 阶段4:生成回答
String generatedAnswer = generateAnswer(question, relevantDocs, context);
// 阶段5:支撑性评估
if (!relevantDocs.isEmpty()) {
SupportednessReport supportReport = supportednessEvaluator.evaluate(
question, relevantDocs, generatedAnswer
);
generatedAnswer = applyRecommendation(
generatedAnswer, supportReport, question, relevantDocs, context
);
}
return SelfRAGResult.of(generatedAnswer, retrievalDecision, relevantDocs);
}
private String generateAnswer(String question, List<ScoredDocument> docs,
ConversationContext context) {
if (docs.isEmpty()) {
return generationClient.prompt()
.system("你是一个专业助手,请根据自身知识直接回答问题。如果不确定,请明确说明。")
.user(question)
.call()
.content();
}
String docsContext = docs.stream()
.map(sd -> sd.document().content())
.collect(Collectors.joining("\n\n---\n\n"));
return generationClient.prompt()
.system("""
你是一个知识助手。请基于以下参考文档回答用户问题。
要求:
1. 只基于文档内容回答,不要添加文档中没有的信息
2. 如果文档中没有明确答案,请如实说明
3. 回答要简洁、准确
""")
.user("参考文档:\n%s\n\n用户问题:%s".formatted(docsContext, question))
.call()
.content();
}
private String applyRecommendation(String answer, SupportednessReport report,
String question, List<ScoredDocument> docs,
ConversationContext context) {
return switch (report.recommendation()) {
case "ACCEPT" -> answer;
case "ADD_DISCLAIMER" -> {
// 在回答前加上不确定性声明
yield "根据现有信息:\n" + answer;
}
case "REGENERATE" -> {
// 重新生成,这次明确要求只基于文档
log.warn("回答支撑性不足,重新生成。未支撑声明: {}", report.unsupportedClaims());
yield generateStrictAnswer(question, docs);
}
default -> answer;
};
}
private String generateStrictAnswer(String question, List<ScoredDocument> docs) {
String docsContext = docs.stream()
.map(sd -> sd.document().content())
.collect(Collectors.joining("\n\n"));
return generationClient.prompt()
.system("""
严格基于以下文档内容回答问题,不得添加任何文档中未提及的信息。
如果文档中找不到答案,直接回复"文档中没有相关信息"。
""")
.user("文档:\n%s\n\n问题:%s".formatted(docsContext, question))
.call()
.content();
}
}性能优化:缓存决策结果
Self-RAG引入了额外的LLM调用(决策+评估),为了控制延迟,需要缓存重复的决策:
@Component
public class CachedRetrievalDecider {
private final RetrievalDecider actualDecider;
private final Cache<String, RetrievalDecision> decisionCache;
public CachedRetrievalDecider(RetrievalDecider actualDecider) {
this.actualDecider = actualDecider;
this.decisionCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofHours(1))
.build();
}
public RetrievalDecision decide(String question, ConversationContext context) {
// 问题语义相似的情况下复用决策
String cacheKey = semanticNormalize(question);
return decisionCache.get(cacheKey, k -> actualDecider.decide(question, context));
}
private String semanticNormalize(String question) {
// 简单规范化:小写+去除标点(实际可用语义哈希)
return question.toLowerCase().replaceAll("[,。?!,?.!]", "").trim();
}
}我们在实际系统中上线Self-RAG后,有两个显著改善:一是对纯问候、简单对话的响应速度提升了40%(因为跳过了检索),二是事实准确率从87%提升到了94%(支撑性评估过滤掉了模型"幻想"的内容)。代价是整体延迟略有增加(约300ms),但用户感知质量的提升是值得的。
