第2388篇:RAG的Prompt工程深度实践——上下文注入的艺术与科学
大约 7 分钟
第2388篇:RAG的Prompt工程深度实践——上下文注入的艺术与科学
适读人群:希望系统提升RAG回答质量的AI工程师 | 阅读时长:约20分钟 | 核心价值:掌握RAG专项Prompt工程的核心技巧,包括上下文结构化、指令优化和Anti-pattern规避
我主导过三个RAG项目,每次在Prompt工程上都要花大量时间,而且每次都踩不同的坑。
第一个项目,Prompt写得太简单,AI经常"发挥"——参考了文档内容,还加了很多自己的推断,用户一验证,发现AI说的是一半对一半错。
第二个项目,我把Prompt写得极度严格:"只能使用文档中明确出现的内容,不得有任何推断。"结果AI变成了照搬文档的复读机,明明文档里有所有信息,AI就是不帮用户综合一下。
第三个项目,才找到平衡点。这篇把这些经验系统整理一下。
RAG Prompt的独特挑战
/**
* RAG场景的Prompt和普通Chat的核心差异
*
* 普通Chat:
* - LLM用自己的训练知识回答
* - 目标:准确、有帮助、安全
*
* RAG:
* - LLM必须基于检索到的文档回答
* - 要防止:使用文档外的知识(幻觉)
* - 要允许:基于文档做合理推断和综合
* - 要处理:文档可能不完整、文档可能相互矛盾
*
* 这个独特性决定了RAG的Prompt需要专项设计
*/上下文注入的结构化设计
@Service
public class RAGPromptBuilder {
/**
* RAG Prompt的最优结构
*
* 经过大量实验,这个结构效果最好:
* 1. 系统角色和行为准则(确立LLM的角色)
* 2. 结构化的文档上下文(供检索参考)
* 3. 使用规则(告诉LLM如何使用文档)
* 4. 用户问题
*/
public String buildOptimalPrompt(String question, List<Document> retrievedDocs,
RAGConfig config) {
// 构建结构化的文档上下文
String contextSection = buildContextSection(retrievedDocs);
// 构建使用规则(根据场景调整)
String usageRules = buildUsageRules(config);
return String.format("""
你是一个%s,帮助用户基于知识库回答问题。
===参考文档===
%s
===参考文档结束===
%s
用户问题:%s
""",
config.getAssistantRole(),
contextSection,
usageRules,
question
);
}
/**
* 结构化的文档上下文
*
* 关键设计决策:
* 1. 给每个文档加编号(方便LLM引用)
* 2. 显示来源信息(增加可信度)
* 3. 文档之间用分隔符隔开(帮助LLM区分不同来源)
* 4. 相关度高的文档放前面(LLM对前面的内容更重视)
*/
private String buildContextSection(List<Document> docs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document doc = docs.get(i);
String source = (String) doc.getMetadata().getOrDefault("source", "未知来源");
String updatedAt = (String) doc.getMetadata().getOrDefault("updated_at", "");
sb.append(String.format(
"【文档%d】来源:%s%s\n%s\n",
i + 1,
source,
updatedAt.isEmpty() ? "" : "(更新于" + updatedAt + ")",
doc.getContent()
));
if (i < docs.size() - 1) {
sb.append("\n---\n\n");
}
}
return sb.toString();
}
/**
* 使用规则:这是Prompt工程里最微妙的部分
*
* 不同场景需要不同的规则
*/
private String buildUsageRules(RAGConfig config) {
return switch (config.getMode()) {
case STRICT -> buildStrictRules();
case BALANCED -> buildBalancedRules();
case CREATIVE -> buildCreativeRules();
};
}
private String buildStrictRules() {
return """
回答规则(严格模式):
1. 只能引用上方文档中明确出现的内容
2. 不得添加文档中没有的信息,即使看起来合理
3. 如果文档中找不到答案,直接说"文档中没有相关信息"
4. 引用时注明来源(如"根据文档1...")
5. 不同文档有冲突时,列出冲突而不是选择一个
""";
}
private String buildBalancedRules() {
return """
回答规则:
1. 主要基于上方文档回答,文档内容优先
2. 可以对文档内容做合理的综合和推断,但要有文档依据
3. 涉及数字、日期、规定等具体事实,必须来源于文档
4. 文档中没有的内容,如果需要回答,要明确标注"根据通用知识..."
5. 不确定时,说明不确定
""";
}
private String buildCreativeRules() {
return """
回答规则:
1. 参考提供的文档内容
2. 可以结合文档和通用知识,给出综合性的回答
3. 鼓励对文档内容做深度解读和延伸
4. 保持回答的准确性,但允许合理推断
""";
}
}上下文长度管理
@Service
public class ContextLengthOptimizer {
private static final int MAX_CONTEXT_TOKENS = 6000; // 预留给上下文的Token数
/**
* 智能截断:在有限的上下文空间里放最有价值的内容
*/
public List<Document> optimizeContext(List<Document> docs, String question) {
// 计算每个文档的token数
List<DocumentWithTokenCount> docsWithTokens = docs.stream()
.map(d -> new DocumentWithTokenCount(d, estimateTokenCount(d.getContent())))
.collect(Collectors.toList());
int totalTokens = 0;
List<Document> selected = new ArrayList<>();
for (DocumentWithTokenCount dwt : docsWithTokens) {
if (totalTokens + dwt.getTokenCount() <= MAX_CONTEXT_TOKENS) {
selected.add(dwt.getDocument());
totalTokens += dwt.getTokenCount();
} else if (totalTokens < MAX_CONTEXT_TOKENS * 0.5) {
// 还有空间,但这个文档太长了,截断后加入
int remainingTokens = MAX_CONTEXT_TOKENS - totalTokens;
String truncated = truncateToTokens(dwt.getDocument().getContent(), remainingTokens);
Document truncatedDoc = Document.builder()
.id(dwt.getDocument().getId())
.content(truncated + "\n...(内容已截断)")
.metadata(dwt.getDocument().getMetadata())
.build();
selected.add(truncatedDoc);
break;
} else {
break;
}
}
return selected;
}
/**
* 文档内容的关键信息提取
*
* 当文档太长,但又不想丢失信息时,
* 用LLM先提取与问题相关的部分
*/
public String extractRelevantContent(String documentContent, String question) {
if (estimateTokenCount(documentContent) <= 1000) {
return documentContent; // 短文档直接用
}
String prompt = """
请从以下文档中提取与问题最相关的内容(不超过500字)。
保留原文,不要改写,直接摘取最相关的段落。
文档:
%s
问题:%s
最相关的内容:
""".formatted(documentContent, question);
return chatClient.prompt(prompt).call().content().trim();
}
}特殊场景的Prompt技巧
@Service
public class SpecialCasePromptHandler {
/**
* 场景1:文档内容相互矛盾
* 检索到的两个文档说了不一样的话
*/
public String handleContradictingDocs(String question, List<Document> docs) {
// 检测矛盾
if (!hasContradiction(docs)) {
return null; // 没有矛盾,用标准Prompt
}
return """
参考文档中存在相互矛盾的信息,请按以下方式回答:
===参考文档===
%s
===参考文档结束===
回答要求:
1. 指出文档中的矛盾之处
2. 分别说明各文档的观点
3. 如果能判断哪个更权威(根据来源和日期),说明理由
4. 建议用户以最新的官方文件为准
问题:%s
""".formatted(buildContextSection(docs), question);
}
/**
* 场景2:问题超出知识库范围
* 检索到的文档相关性都很低
*/
public String handleOutOfScope(String question, List<Document> docs, float topScore) {
if (topScore >= 0.6f) {
return null; // 不是超出范围,用标准Prompt
}
// 提供最相关的文档(即使相关性不高),但明确告知可能不够
return """
注意:以下文档与您的问题相关性有限(相关度:%.0f%%),可能无法完整回答您的问题。
===有限参考===
%s
===结束===
要求:
1. 如果以上文档能部分回答问题,请基于文档回答能回答的部分
2. 对于文档未涵盖的部分,明确说明知识库中没有相关信息
3. 建议用户通过其他渠道获取更完整的信息
问题:%s
""".formatted(topScore * 100, buildContextSection(docs), question);
}
/**
* 场景3:用户要求引用原文
* "给我看原文是怎么说的"类型的问题
*/
public String handleExactQuoteRequest(String question, List<Document> docs) {
return """
===参考文档===
%s
===参考文档结束===
用户希望看到原文。请:
1. 找到最相关的原文段落
2. 用引用格式("> ")展示原文
3. 在原文后加简短说明,解释这段话与问题的关系
4. 注明来源(文档编号)
问题:%s
""".formatted(buildContextSection(docs), question);
}
}Anti-patterns:这些Prompt写法会降低质量
/**
* 常见的RAG Prompt反模式
*
* 反模式1:指令过于宽泛
* 差:"根据文档回答问题"
* 好:明确区分"什么要从文档来"和"什么可以推断"
*
* 反模式2:矛盾指令
* 差:"只能用文档内容...但要给出全面的分析"
* 问题:全面分析可能需要文档外的知识
*
* 反模式3:过度约束
* 差:"不得使用任何推断,只能原文复述"
* 问题:用户要的是理解和帮助,不是复读机
*
* 反模式4:忽略不确定性处理
* 差:只告诉LLM怎么回答,没有告诉它无法回答时怎么处理
* 好:明确说明"无法回答时说什么"
*
* 反模式5:上下文无结构
* 差:把所有文档内容拼在一起,没有区分
* 好:每个文档有明确的标识和来源标注
*/Prompt版本管理
@Service
public class PromptVersionManager {
/**
* 不同版本的Prompt需要版本管理
* 这样才能知道当前用的是哪个版本,回退时能找到旧版本
*/
public PromptTemplate getActivePromptTemplate(String templateName) {
return promptTemplateRepository
.findActiveByName(templateName)
.orElseThrow(() -> new PromptTemplateNotFoundException(templateName));
}
/**
* 发布新版本Prompt(先在测试环境验证,再上线)
*/
public void publishNewVersion(String templateName, String newContent,
String changedBy, String changeReason) {
// 标记旧版本为非活跃
PromptTemplate oldActive = getActivePromptTemplate(templateName);
oldActive.setActive(false);
promptTemplateRepository.save(oldActive);
// 创建新版本
PromptTemplate newVersion = PromptTemplate.builder()
.name(templateName)
.content(newContent)
.version(oldActive.getVersion() + 1)
.isActive(true)
.publishedBy(changedBy)
.publishedAt(LocalDateTime.now())
.changeReason(changeReason)
.build();
promptTemplateRepository.save(newVersion);
log.info("Prompt template {} updated to version {} by {}: {}",
templateName, newVersion.getVersion(), changedBy, changeReason);
}
}Prompt工程没有通用的最优解,关键是建立"假设-测试-验证"的循环。每次改Prompt,都要在评估集上跑一遍,用数据说话,不要靠感觉。
