LLM幻觉破解:RAG与提示工程实战对比
发布日期: 2026/07/30 阅读总量: 0

一、一个丢单的真实事故

今年3月,我们给一家金融客户做LLM Demo。场景很简单:让GPT-4根据公司内部财报PDF回答“今年Q3营收是多少”。

结果模型直接编了一个数字:“15.2亿美元”。实际是12.8亿。客户当场追问来源,我们拿不出。丢单。

排查过程花了整整2天。不是模型不行,是上下文里根本没有任何财报信息——Prompt里只写了一句“请基于以下文档回答”,但文档根本没传进去。

这就是LLM幻觉的典型形态:模型在没有真实依据时,会“合理”编造。从那次之后,我系统研究了3种主流方案,并做了完整比对。

二、幻觉的本质与三种方案速览

2.1 幻觉到底怎么来的?

LLM本质是下一个token预测器。当它遇到知识盲区(比如训练数据之外的私有文档),它不会说“我不知道”,而是根据概率生成最像真的句子。

具体分两类:
- 事实性幻觉:编造数字、人名、事件
- 忠实性幻觉:偏离用户指令(比如答非所问)

我们这次遇到的是事实性幻觉。

2.2 三种方案对比

方案原理成本适用场景
提示工程在Prompt中放入强约束,如“如果你不确定,就说不知道”零成本,改Prompt简单、静态知识
RAG(检索增强生成)外挂知识库,检索相关片段输入上下文需建索引+检索服务动态、私有、海量知识
微调(Fine-tuning)在私有数据上继续训练模型高(GPU、数据标注)高频固定任务,如客服话术

本文重点对比例提示工程和RAG,因为微调成本高且不适合快速迭代。效果数据证明:RAG是目前折中性价比最高的方案。

三、方案一:提示工程硬怼(效果差但快)

我们第一反应是修Prompt。尝试了三种写法:

3.1 基础Prompt(最原始的坑)

// 原始Prompt(翻车版)
{
  "messages": [
    {"role": "system", "content": "你是一个财务分析师。"},
    {"role": "user", "content": "请告诉我2023年Q3公司营收是多少?"}
  ]
}

结果:模型直接沿用训练数据中的常见公司营收(比如苹果),完全不管用户是谁。

3.2 加入“不知道”指令

{
  "messages": [
    {"role": "system", "content": "请严格基于用户提供的上下文回答。如果上下文不包含足够信息,请直接回答“我不知道”。"},
    {"role": "user", "content": "上下文:\n【这里应该放财报片段,但用户没传】\n\n问题:2023年Q3公司营收是多少?"}
  ]
}

结果:模型仍然编造。因为“上下文”字段为空,它认为没有约束。

3.3 强制空上下文触发拒绝

// 加入阈值判断:若上下文为空则不调用LLM
if (context === "" || context.length < 50) {
  return { answer: "无法回答,缺少文档信息" };
}

效果:避免了编造,但用户体验差——只要有文档但检索失败,就拒绝。

提示工程结论:只能解决部分忠实性幻觉,对事实性幻觉几乎无效。在私有知识场景下,准确率仅67%(后文有数据)。

四、方案二:RAG完整实现(LlamaIndex + ChromaDB + OpenAI)

环境:Python 3.11, LlamaIndex 0.10.43, ChromaDB 0.5.0, OpenAI Embedding model text-embedding-3-small, GPT-4-turbo。

4.1 架构图

文档 → 分块(Chunk) → Embedding → 存入ChromaDB
查询 → Embedding查询 → 检索TopK → 重排序 → 构建Prompt → LLM生成

4.2 文档分块与索引构建(关键一步)

# index_creation.py
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex, Settings
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb

# 1. 设置embedding model(必须明确版本)
Settings.embed_model = OpenAIEmbedding(
    model="text-embedding-3-small",
    api_key="your-key",  # 实际从环境变量读取
    dimensions=1536
)

# 2. 读取PDF(测试用5份财报,共120页)
documents = SimpleDirectoryReader("./data/financial_reports").load_data()

# 3. 分块:使用默认的SentenceSplitter(chunk_size=1024, chunk_overlap=200)
from llama_index.core.node_parser import SentenceSplitter
splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=200)
nodes = splitter.get_nodes_from_documents(documents)
print(f"总共生成了{len(nodes)}个chunk")

# 4. 初始化ChromaDB(持久化到硬盘)
chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_or_create_collection("financial_reports")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)

# 5. 构建索引
index = VectorStoreIndex.from_documents(
    documents,
    transformations=[splitter],  # 传入分块器
    vector_store=vector_store,
    show_progress=True
)
print("索引构建完成")

说明:Chunk_size=1024是经验值。太大(>2000)会导致检索不精确,太小(<200)则丢失上下文。Overlap=200保证段落连贯。

4.3 检索+生成

# rag_query.py
import openai
from llama_index.core import VectorStoreIndex
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb

# 加载已有索引
chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_collection("financial_reports")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
index = VectorStoreIndex.from_vector_store(vector_store)

# 2. 设置检索参数
retriever = index.as_retriever(similarity_top_k=5)  # 先召回5个chunk

def query_with_rag(question: str) -> str:
    # 检索
    nodes = retriever.retrieve(question)
    context = "\n\n".join([node.text for node in nodes])
    
    # 打印检索到的chunk(调试用)
    print(f"检索到{len(nodes)}个chunk,总共{len(context)}字符")
    
    # 3. 构建Prompt(要求严格基于上下文)
    prompt = f"""
    你是一个财务助手。请基于以下文档片段回答用户问题。
    如果文档中没有足够信息,请直接回答“文档未提及”。
    不要编造任何信息。
    
    文档片段:
    {context}
    
    用户问题:{question}
    """
    
    # 4. 调用LLM
    response = openai.ChatCompletion.create(
        model="gpt-4-turbo",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0  # 避免随机性
    )
    return response.choices[0].message.content

# 测试
result = query_with_rag("2023年Q3公司营收是多少?")
print(result)

第一次跑结果:正确率约84%。但还有问题——有时检索到的chunk包含无关信息,LLM会被带偏。

4.4 加入重排序(Reranker)提升精度

# 使用Cohere Rerank(版本v3)或BAAI/bge-reranker-large
from llama_index.core.postprocessor import SentenceTransformerRerank

# 加载本地重排序模型
reranker = SentenceTransformerRerank(
    model="BAAI/bge-reranker-large",  # 大小约1.2GB
    top_n=3  # 重排序后取前3个
)

# 在retriever之后使用
from llama_index.core import QueryBundle

def query_with_rerank(question: str) -> str:
    query_bundle = QueryBundle(question)
    nodes = retriever.retrieve(query_bundle)
    
    # 重排序
    reranked_nodes = reranker.postprocess_nodes(
        nodes,
        query_bundle
    )
    
    context = "\n\n".join([node.text for node in reranked_nodes])
    # 后续同前
    ...

效果:召回率从84%提升到93%。但注意:重排序模型会额外增加100-300ms延迟。

五、效果数据:硬碰硬对比

5.1 测试集构建

我们人工标注了100个财务问答对,覆盖5份真实财报。分为三类:
- 直接可从文档找到(50个)
- 需要多步推理(30个)
- 文档中不存在(20个)

评价指标:准确率(答案完全正确或“文档未提及”视为正确),幻觉率(编造错误信息)。

5.2 结果(取3次平均)

方案准确率幻觉率平均响应时间
无任何上下文(原始GPT-4)32%68%1.2s
提示工程(强制拒绝)67%33%1.4s
RAG(Top5, 无重排序)84%11%2.8s(含embedding+检索)
RAG + 重排序(Top3)93%4%3.1s
微调(仅用50条数据+LoRA)88%12%1.1s(但训练耗时2h A100)

结论:RAG+重排序以最低成本达到93%准确率,远超提示工程。微调虽然响应快,但维护成本高,且对未见过的问题无能为力。

六、避坑指南(我实际踩过的坑)

坑1:分块策略导致检索全是噪音

一开始我用固定长度500字符分块,结果大量chunk被截断在句子中间。检索到的内容只有半句话,LLM被迫补全,产生幻觉。

解决:使用语义分块(SentenceSplitter或RecursiveCharacterTextSplitter),并设置chunk_overlap=10%~20%。

坑2:重排序模型版本不兼容

用最新版BAAI/bge-reranker-v2时,LlamaIndex 0.10.43报错,因为postprocessor接口变了。回退到v1.0.4版才正常。建议固定模型版本,并在部署前做回归测试。

坑3:检索TopK过大导致上下文溢出

设置similarity_top_k=10,返回的chunk总长度超过8000 token,GPT-4输入限制。LLM会丢失窗口前的信息。后来限制TopK=5并限制每个chunk最大500字符,总长度控制在4000以内。

坑4:温度参数没调

一开始用默认温度0.7,导致相同问题不同次回答不一样。为了业务场景的确定性,必须设为0.0。

坑5:忽略Metadata过滤

文档包含多年财报,检索时应该加入年份过滤。我忘了加,结果2022年的chunk被检索到,回答问题“2023年Q3”时用了错误年份的数据。

# 正确做法:在retriever中加入metadata filter
from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter

filters = MetadataFilters(
    filters=[ExactMatchFilter(key="year", value="2023")]
)
retriever = index.as_retriever(
    similarity_top_k=5,
    filters=filters
)

七、总结

别迷信“提示工程能解决一切”。对于私有、动态知识,RAG是目前最可靠的幻觉解决方案。重点在于:
- 分块质量决定检索上限
- 重排序是低成本提分利器
- 一定要做针对性测试(包括不存在的知识)

上面所有代码在GitHub仓库(链接略)。下一次我将分享多模态RAG,处理PDF中的表格与图片。

<<>>