这个Bug让我加班到凌晨3点
去年我们给公司内部做了一个开发助手,对接公司私有API文档。上线第三天,有同事截图问:“这个接口参数明明是POST,为什么AI说是GET?我按你说的发请求失败了。”查日志发现大模型回答:调用 create_order 接口使用 GET 方法,参数直接在URL中拼接。但实际文档写的是POST。这不是个例——相同问题重复问两次,答案可能完全不同。我们统计了100条问答,幻觉率高达35%(完全错误或无法验证的内容)。
这不是AI“聪明”,是它真的在瞎编。LLM没有事实记忆能力,它只会根据概率组合token。解决方案就是RAG(Retrieval-Augmented Generation)——让模型先查真实文档,再回答。
三种方案的血泪对比
方案1:无检索(直接扔Prompt)
最简单:把知识库文档塞进System Prompt。但GPT-4 context window也就128K,我们文档3000+页,根本装不下。哪怕只摘要,也会丢失细节。测试结果:幻觉率35%,准确率52%(正确但含糊的也算正确)。
方案2:Prompt工程 + Few-Shot
写长Prompt要求“如果不知道就回答不知道”,给3个幻觉反例。效果有限:幻觉率28%,准确率58%。而且每次修改Prompt需人工审查,维护成本高。
方案3:RAG(检索-重排序-生成)
核心流程:用户问题 → 检索相关文档块 → 重排序 → 拼接Prompt → LLM生成。我们实现后:幻觉率降至3%,准确率92%。代价是平均响应延迟增加800ms(检索200ms + 重排序150ms + 生成增加450ms)。
三个方案耗时对比如下(100次请求平均):
| 方案 | 准确率 | 幻觉率 | 平均耗时 |
|---|---|---|---|
| 无检索 | 52% | 35% | 1.2s |
| Prompt增强 | 58% | 28% | 1.4s |
| RAG | 92% | 3% | 2.1s |
完整RAG代码实现
环境:Python 3.10.12, LangChain 0.1.16, OpenAI 1.12.0, ChromaDB 0.4.24, sentence-transformers 2.2.2。
1. 安装依赖
pip install langchain openai chromadb sentence-transformers pypdf tiktoken
2. 文档加载与分块
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
loader = PyPDFLoader("company_api_docs.pdf")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ".", " "]
)
chunks = text_splitter.split_documents(documents)
print(f"共切分 {len(chunks)} 个文档块")
chunk_size=500 是我们经过对500个问题评测后选出的最优值(击中率最高),chunk_overlap=50避免关键句被切分。
3. 构建向量索引
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
embedding = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embedding,
persist_directory="./chroma_db"
)
vectorstore.persist()
使用BAAI/bge-small-zh-v1.5(维度512,速度比text-embedding-ada-002快3倍,准确率略低但够用)。
4. 检索 + 重排序
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_openai import ChatOpenAI
# 基础检索器
retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# 重排序使用GPT-3.5(便宜且够用)
llm = ChatOpenAI(model="gpt-3.5-turbo-0125", temperature=0)
compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=retriever
)
def search_with_rerank(query: str) -> list:
# 第一次检索:取20个候选
docs = retriever.get_relevant_documents(query)
# 第二次用LLM重排序(只保留最相关的3-5个)
compressed_docs = compression_retriever.get_relevant_documents(query)
return compressed_docs # 通常返回3个
5. 生成最终回答
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
prompt_template = """你是一个严谨的工程师助手。只有当你从以下文档中找到确切依据时才能回答,否则必须回答“该信息不存在于知识库中”。
文档内容:
{context}
问题:{question}
请基于以上文档给出准确、简明的答案。如果文档中没有明确说明,请回答“未找到相关信息”。
"""
PROMPT = PromptTemplate(template=prompt_template, input_variables=["context", "question"])
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(model="gpt-4-1106-preview", temperature=0, max_tokens=512),
chain_type="stuff",
retriever=compression_retriever,
return_source_documents=True,
chain_type_kwargs={"prompt": PROMPT}
)
def answer(question: str):
result = qa_chain({"query": question})
return {
"answer": result["result"],
"sources": [doc.metadata["source"] for doc in result["source_documents"]]
}
效果数据(基于我们自己标注的500条QA)
测试集:从公司内部API文档中人工构造500个问题(100个简单查询,400个需多步推理)。
- 准确率:92%(461/500正确)
- 幻觉率:3%(15次回答出现虚构信息)
- 上下文召回率:检索模块的正确上下文命中率88%(需评估检索结果中是否含真正答案)
- 平均端到端耗时:2.1s(其中检索+重排序0.8s,LLM生成1.3s)
- 成本:每100次问答约$0.15(GPT-4输入token约1500,输出150)
与无检索方案对比,成本增加了约2.3倍,但幻觉率下降12倍。
避坑指南(全是真金白银换来的)
坑1:Embedding模型选择不当导致检索漂移
开始我们用了text-embedding-ada-002,但中文API文档许多专业术语(如“分页查询”、”HMAC签名”)编码效果差。换成BGE-small-zh后,检索命中率从72%升到85%。建议:先用小规模测试集评估,不要迷信OpenAI。
坑2:Chunk Size不是越大越好
我们尝试200/500/800/1200四种大小。200太碎丢失上下文,1200太多噪音。500最优(击中率最高,且不超LLM输入限制)。注意:如果文档有大量短句列表,chunk_overlap设为20%最佳。
坑3:重排序LLM太强反而增加幻觉
用GPT-4做重排序时,它有时会“脑补”文档中不存在的逻辑,导致压缩后的文档包含幻觉。换成GPT-3.5(temperature=0)后改善。重排序不要用gpt-4,用gpt-3.5或专门的rerank模型(如BAAI/bge-reranker-base)。
坑4:检索阈值过高导致召回为0
设置相似度阈值0.9,结果大部分问题都找不到相关文档,导致LLM直接说“不知道”,用户投诉。改为0.7后召回正常。建议:不做阈值过滤,依靠重排序淘汰不相关文档。
坑5:多轮对话中历史信息污染检索
用户连续问“他叫什么?”“他的工号是多少?”,如果不处理历史,检索会带上“他”导致无结果。我们在检索前用LLM重写问题为独立形式(Query Rewrite),将“他”替换为上一轮提到的实体。实现方案:使用单独LLM对用户最新问题做重写,不超过50ms。
原理深度:RAG为什么能抑制幻觉?
LLM的本质是概率分布生成,它没有记忆或数据库。给定一个前缀,它计算下一个token的概率。当问题超出训练分布(例如私域API),模型只能靠“猜测”填充,这就是幻觉根源。
RAG引入外部知识源,相当于给模型一个“开卷考试”。检索到的相关文本块提供了条件概率的强约束,模型生成时更倾向于从已给文本中抽取关键信息。经验证,当检索命中正确答案时,模型生成幻觉的概率从35%降到2%;当检索未命中时,幻觉率仍高达30%(因为它还是会凭记忆编)。所以关键在提高检索命中率。
我们测试了不同检索策略的命中率:
- 单纯BM25:62%
- 仅Embedding(余弦相似度):81%
- Embedding + 重排序(LLM):88%
- Embedding + HyDE(假设文档检索):90%
后续我们将重排序换成bge-reranker-base(0.2s),命中率89%,且比LLM重排序快3倍。最终生产环境采用:Embedding + bge-reranker + 查询重写。
总结(不说废话)
RAG是当前解决LLM幻觉最实用的方案。代码照着上面撸,chunk=500,检索top-k=20,重排序取top-3,temperature=0。避坑记住了:别用大模型做重排序、别设相似度阈值、多轮对话必须做查询重写。
如果你也踩过类似坑,欢迎在评论区留言。下一篇我会写:如何用RAG做长文档摘要(超出context window的处理)。