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生成任务,最重要的三步是:

  1. 用硬性规则堵住幻觉出口(“没有资料就承认”比任何角色设定都有用)
  2. 用结构化输出控制token分配(JSON格式让模型停止废话)
  3. 用few-shot示例锚定边界情况(重点展示“失败”示例,而非“成功”示例)

当然,这套方案也有局限性:JSON输出在某些开放式问答中不适用,且few-shot示例可能引入偏差。但如果你也在做垂直领域的RAG问答,建议先按这个思路跑一轮对比——你会发现,调Prompt比调模型便宜得多,也快得多。

如果对细节有疑问,欢迎在评论区和我对线。代码已在内部仓库开源,需要的私信。