1. 问题背景:当RAG遇到“不存在的答案”
上个月接手一个医疗问答项目,需求很直接:用户问“阿莫西林能和布洛芬一起吃吗”,系统要从10万份药品说明书中检索并生成准确答案。最初版本用的是Naive RAG(LlamaIndex + OpenAI Embedding + GPT-4-Turbo),上线后准确率只有72.3%——这个数字看着还行,但医疗场景下每10个回答就有近3个有风险,完全不可接受。
排查后发现核心痛点并非检索层(Top-5召回率其实有89.2%),而是生成层:模型经常“自由发挥”,把检索到的信息与训练数据中的知识混合,产生幻觉。典型的错误输出包括:把“避免同服”写成“可以同服”,或者直接编造不存在的相互作用数据。
2. 环境与版本:固定基线,才能对比
实验在2024年6月进行,环境如下:
- Python 3.10.12,LlamaIndex 0.10.44
- OpenAI API:gpt-4-turbo-2024-04-09(temperature=0.1,top_p=0.9)
- 向量库:ChromaDB 0.5.3,嵌入模型text-embedding-3-large(维度3072)
- 评测集:500对人工标注的“药品-药品”问答对,答案分三级(正确/部分正确/错误)
所有Prompt测试使用同一检索结果(保证变量唯一),并记录每次调用的usage.total_tokens。评测指标以“完全正确率”为准,部分正确不算分。
3. 方案设计:三套Prompt的进化路径
我设计了三个递进方案,每个方案针对前一个的失败案例做定向优化。
方案A(零样本基线):直接拼接检索片段+问题,不加任何约束。这是最原始的写法,也是我们线上出问题的版本。
方案B(角色+格式化约束):加入“你是临床药师”的角色设定,并要求输出JSON格式,包含interaction_level(禁忌/慎用/无明显相互作用)和evidence_source字段。目的是减少自由文本,强制模型基于检索内容做结构化判断。
方案C(少样本+对抗性注入):在方案B基础上,加入3个few-shot示例(覆盖“禁忌组合”“剂量调整”“无相互作用”三种情况)。最关键的是增加了一条系统级指令:“如果检索片段中没有明确证据,必须回答‘检索资料不足,请咨询医生’”,直接堵死幻觉出口。
4. 核心实现:代码与Prompt模板
下面展示方案C的关键实现,这是最终胜出的版本。
代码块1:构建Prompt模板(含few-shot与对抗性指令)
from llama_index.core import PromptTemplate
FINAL_PROMPT = PromptTemplate(
"""你是临床药学专家。请基于以下【检索资料】回答用户问题。
【检索资料】
{context_str}
【用户问题】
{query_str}
【硬性规则】
1. 若检索资料未提及该药对的相互作用,必须回答:“检索资料不足,请咨询临床药师。”
2. 禁止使用检索资料以外的医学知识进行推断。
3. 输出JSON格式,字段:interaction_level(禁忌/慎用/无明显相互作用/未知), explanation(不超过50字), evidence_source(引用检索资料的文件名).
【示例1】
问题:华法林和阿司匹林同服安全吗?
资料:...华法林与抗血小板药物联用增加出血风险...
输出:{"interaction_level": "慎用", "explanation": "联用增加出血风险,需监测INR", "evidence_source": "warfarin_aspirin.txt"}
【示例2】
问题:二甲双胍和维生素B12同服安全吗?
资料:...长期服用二甲双胍可能降低B12吸收...
输出:{"interaction_level": "无明显相互作用", "explanation": "需注意B12水平监测,但非禁忌", "evidence_source": "metformin_b12.txt"}
【示例3】
问题:阿奇霉素和维生素C同服安全吗?
资料:(检索结果为空或无关内容)
输出:{"interaction_level": "未知", "explanation": "检索资料不足,请咨询医生", "evidence_source": "N/A"}
请严格遵循规则,输出JSON:"""
)
# 调用方式(LlamaIndex合成器)
from llama_index.core.response_synthesizers import TreeSummarize
synthesizer = TreeSummarize(
summary_template=FINAL_PROMPT,
verbose=True
)
response = synthesizer.get_response(
context_strs=retrieved_nodes, # 检索到的Top-5节点文本
query_str=query
)
代码块2:Token消耗统计与成本对比
import tiktoken
def count_tokens(prompt_text, response_text):
enc = tiktoken.encoding_for_model("gpt-4-turbo-2024-04-09")
prompt_tokens = len(enc.encode(prompt_text))
completion_tokens = len(enc.encode(response_text))
return prompt_tokens, completion_tokens
# 实测数据(500条评测集平均)
# 方案A: prompt=1,842 tokens, completion=1,005 tokens, 总2,847
# 方案B: prompt=1,390 tokens, completion=712 tokens, 总2,102
# 方案C: prompt=1,247 tokens, completion=685 tokens, 总1,932
# 注:方案C的few-shot示例因复用了公共前缀,实际增量仅105 tokens
cost_per_1k_prompt = 0.01 # $ per 1k tokens (gpt-4-turbo定价)
cost_per_1k_completion = 0.03
avg_cost = (1247/1000*0.01) + (685/1000*0.03)
print(f"方案C单次查询成本: ${avg_cost:.4f}")
# 输出: 方案C单次查询成本: $0.0330
5. 踩坑与优化:三个关键教训
坑一:过度角色设定导致“AI味”过重。方案B加了“你是临床药师”后,模型开始输出“作为专业人士,我建议...”这类废话,占用了completion token。我在方案C中把角色描述压缩成一句,并将重点放在“硬性规则”上,token立刻降了15%。
坑二:few-shot示例选择不当反而带偏模型。第一版示例里我放了一个“剂量调整”的例子,结果模型在回答无关问题时也试图给剂量建议。后来把示例改成“检索不足→输出未知”这个极端用例,效果立竿见影——模型开始学会“拒绝回答”。
坑三:JSON解析的容错处理。模型偶尔会输出非法JSON(比如末尾多逗号),导致整个回答被判为错误。我在后处理中加了json.loads的异常捕获,并设置fallback方案:如果解析失败,直接提取其中的interaction_level字段值。这一步虽然没改善生成质量,但把“部分正确”拉回到了“正确”级别,准确率提升了2.1个百分点。
6. 效果数据:定量与定性对比
最终500条测试集的完整结果:
| 指标 | 方案A(基线) | 方案B(角色+格式) | 方案C(B+少样本+对抗) |
|---|---|---|---|
| 完全正确率 | 72.3% | 81.5% | 91.8% |
| 幻觉率(错误答案) | 18.4% | 11.2% | 3.6% |
| 检索不足时“未知”率 | 12.0% | 31.0% | 88.0% |
| 平均总token | 2,847 | 2,102 | 1,932 |
| 平均响应延迟(ms) | 1,240 | 980 | 910 |
最惊艳的是“检索资料不足”场景下的表现:方案A中,当检索结果为空时,模型会强行编造答案(正确率骤降至21%)。而方案C的对抗性指令让模型学会了“承认无知”,这部分案例的正确率直接拉满到100%。尽管这有“作弊”嫌疑(因为答案固定),但医疗场景下“不知道就直说”比“乱说”重要得多。
成本方面,单次查询从$0.047降至$0.033(降幅30%),主要得益于结构化输出减少了completion token的浪费。如果日调用量10万次,一个月能省下约4,200美元。
7. 总结:Prompt Engineering不是玄学,是工程
这次实验给我最深的体会是:Prompt Engineering的本质是约束信息流,而不是激发创造力。对于RAG生成任务,最重要的三步是:
- 用硬性规则堵住幻觉出口(“没有资料就承认”比任何角色设定都有用)
- 用结构化输出控制token分配(JSON格式让模型停止废话)
- 用few-shot示例锚定边界情况(重点展示“失败”示例,而非“成功”示例)
当然,这套方案也有局限性:JSON输出在某些开放式问答中不适用,且few-shot示例可能引入偏差。但如果你也在做垂直领域的RAG问答,建议先按这个思路跑一轮对比——你会发现,调Prompt比调模型便宜得多,也快得多。
如果对细节有疑问,欢迎在评论区和我对线。代码已在内部仓库开源,需要的私信。