一、一个丢单的真实事故
今年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中的表格与图片。
<<>>