事故现场
周一早上10:37,客服主管甩了一张截图到群里。截图里,AI客服信誓旦旦地说「MQ-2000降噪耳机的续航时间是42小时,充电10分钟可用5小时」。然后补了一句「以上信息来自公司产品库,请放心」。事实是:MQ-2000的真实续航是28小时,充电10分钟是2.5小时。
客户已经据此安排了出差行程,在机场等降噪耳机用。结果续航不够,投诉,退货。
我查了日志,发现AI客服那句「来自公司产品库」是编的。向量库里根本没有这条记录。模型在训练语料里见过另一款类似定位的耳机,就自行脑补了参数。
这就是LLM幻觉。不是小概率事件,不是模型「笨」,是架构决定的。
这篇文章记录我怎么把这个问题压下去:准确率从62%提到89%,端到端耗时从980ms降到720ms,加缓存后449ms。代码全部给出,版本号标清楚,你直接复制就能跑。
幻觉的根源:不是修prompt能解决的
先说清楚幻觉为什么必然存在。了解原理,你才知道什么方案有效、什么方案是心理安慰。
1. 生成机制是概率采样,不是查数据库
LLM逐token生成,每个token是条件概率采样。P(token) = P(token | 上文)。当相关事实不在训练数据里,模型只能按概率猜。猜得多了,整段话就变成「合理但错误」。
2. 注意力机制长程衰减
Self-Attention的复杂度是O(n²)。面对长上下文窗口(比如32K tokens),有效注意力集中在局部邻域。实验数据:当关键事实位于上下文第8000~12000个token区间时,GPT-4的召回准确率比放在开头时下降34%(Li et al., 2023)。
所以「把知识库全塞进prompt」不work。塞得越多,真实信息在attention里越容易被淹没。
3. 参数化记忆是压缩损失的过程
预训练把互联网文本压缩成几十B的参数。压缩必然有损。你训练语料里关于自家产品的描述,可能只出现了几十次,模型根本没学进去。
结论:幻觉不是bug,是概率采样的副产物。消除幻觉只有两条路:
- 引入外部知识源,让生成过程「查得到」再「答得出」——RAG
- 训练时对齐修正——成本太高,且无法覆盖长尾
本文只谈RAG。
三个方案对比:prompt约束 vs 基础RAG vs RAG+重排序
为了让你清楚每种方案的边界,我在同一套环境、同一批问题上做了对比。
测试环境
- Python 3.11.8
- langchain 0.2.14
- Qdrant 1.10.1(Docker部署)
- sentence-transformers 3.0.1(Embedding模型:BAAI/bge-small-zh-v1.5)
- Google Gemini 1.5 Flash(生成模块,temperature=0.1)
- 重排序模型:BAAI/bge-reranker-base
- 知识库数据:公司产品文档 187篇,共3186个chunk
- 评估集:200个问题,由客服团队实际高频问题构成,每问至少关联1个知识chunk
方案A:纯prompt约束(基线)
不接任何外部数据,靠system prompt告诉模型「只能基于已知信息回答」。
# prompt_baseline.py
# Python 3.11.8 | langchain 0.2.14 | google-generativeai 0.7.2
import google.generativeai as genai
genai.configure(api_key="YOUR_API_KEY")
SYSTEM_PROMPT = """你是XX公司产品客服。
规则:
1. 只回答与公司产品相关的问题。
2. 如果不确定信息是否属实,直接回复"我不确定,请联系人工客服"。
3. 禁止编造参数、型号、功能、价格。
4. 回答要简短,不超过3句话。"""
model = genai.GenerativeModel(
"gemini-1.5-flash",
system_instruction=SYSTEM_PROMPT,
generation_config={"temperature": 0.1, "max_output_tokens": 200}
)
def ask(query: str) -> str:
resp = model.generate_content(query)
return resp.text.strip()
if __name__ == "__main__":
questions = [
"MQ-2000降噪耳机的续航时间是多少?",
"HX-30智能手表的防水等级是IP68吗?",
"VL-100行车记录仪支持几路摄像头?"
]
for q in questions:
print(f"Q: {q}\nA: {ask(q)}\n")
结果:准确率62%,召回率58%。模型的「编造能力」确实被限制了一部分,但只要知识库里没有的东西它大概率还是会编——「规则」只是概率分布里的几行字,参数量是它的几千倍。
方案B:基础RAG
检索流程:query → embedding → 向量库召回top_k=5 → 拼接prompt → 生成。
# basic_rag.py
# Python 3.11.8 | langchain 0.2.14 | qdrant-client 1.10.1
# sentence-transformers 3.0.1 | BAAI/bge-small-zh-v1.5
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer
import google.generativeai as genai
# ---------- 初始化 ----------
encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
client = QdrantClient(url="http://localhost:6333", api_key=None)
COLLECTION_NAME = "product_docs"
genai.configure(api_key="YOUR_API_KEY")
model = genai.GenerativeModel(
"gemini-1.5-flash",
generation_config={"temperature": 0.1}
)
SYSTEM_PROMPT = """你是XX公司产品客服。回答问题时:
1. 只能使用下面的【参考资料】。
2. 如果参考资料里没有答案,直接回复"我不确定,请联系人工客服"。
3. 不要添加参考资料之外的任何信息。
4. 注明信息来源编号【来源:id】。
【参考资料】
{context}"""
# ---------- 检索 ----------
def retrieve(query: str, top_k: int = 5) -> list[dict]:
query_vec = encoder.encode(query, normalize_embeddings=True).tolist()
hits = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vec,
limit=top_k,
with_payload=True
).points
return [
{"id": h.id, "text": h.payload["text"], "score": h.score}
for h in hits
]
# ---------- 生成 ----------
def generate(query: str, docs: list[dict]) -> str:
context = "\n\n".join(
f"[来源:{d['id']}] {d['text']}" for d in docs
)
prompt = SYSTEM_PROMPT.replace("{context}", context)
resp = model.generate_content(f"{prompt}\n\n用户问题:{query}")
return resp.text.strip()
def ask(query: str) -> tuple[str, list[dict]]:
docs = retrieve(query, top_k=5)
answer = generate(query, docs)
return answer, docs
结果:准确率78%。比方案A提升了16个百分点,但仍有22%的错。分析错误案例,发现两类问题:
- 正确的chunk被排在第6~8位,top_5没召回到(占比7.5%)
- 纠错了真实相关chunk,模型被其它相似chunk带偏(占比14.5%)
核心瓶颈在retriever的召回精度以及「检索结果的质量是模型唯一输入,检索错,生成必错。」
方案C:RAG + 重排序
思路:先粗召回top_k=20,再用Cross-Encoder相关性重排,取前5。Cross-Encoder把query和doc拼成一个序列喂给BERT-like模型,精度高于双塔的Dot-Product。代价是速度慢(一次推理几ms~几十ms),但换来的精度提升值得。
# rag_rerank.py
# Python 3.11.8 | langchain 0.2.14 | qdrant-client 1.10.1
# sentence-transformers 3.0.1 | FlagEmbedding 1.2.10
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer, CrossEncoder
import google.generativeai as genai
encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
reranker = CrossEncoder("BAAI/bge-reranker-base", max_length=512)
client = QdrantClient(url="http://localhost:6333", api_key=None)
genai.configure(api_key="YOUR_API_KEY")
model = genai.GenerativeModel(
"gemini-1.5-flash",
generation_config={"temperature": 0.1}
)
SYSTEM_PROMPT = """你是XX公司产品客服。回答问题时:
1. 只能使用下面的【参考资料】。
2. 如果参考资料里没有答案,直接回复"我不确定,请联系人工客服"。
3. 不要添加参考资料之外的任何信息。
4. 按相关性排列引用来源【来源:id】。
【参考资料】
{context}"""
# ---------- 两阶段检索 ----------
def retrieve_with_rerank(query: str, top_k: int = 20, final_k: int = 5) -> list[dict]:
query_vec = encoder.encode(query, normalize_embeddings=True).tolist()
# 阶段1:双塔粗召回,取20条
hits = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vec,
limit=top_k,
with_payload=True
).points
candidates = [
{"id": h.id, "text": h.payload["text"], "score": h.score}
for h in hits
]
# 阶段2:Cross-Encoder精排,取前5
pairs = [[query, d["text"]] for d in candidates]
rerank_scores = reranker.predict(pairs)
for d, score in zip(candidates, rerank_scores):
d["rerank_score"] = float(score)
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:final_k]
def generate(query: str, docs: list[dict]) -> str:
context = "\n\n".join(
f"[来源:{d['id']}] {d['text']}" for d in docs
)
prompt = SYSTEM_PROMPT.replace("{context}", context)
resp = model.generate_content(f"{prompt}\n\n用户问题:{query}")
return resp.text.strip()
def ask(query: str) -> tuple[str, list[dict]]:
docs = retrieve_with_rerank(query, top_k=20, final_k=5)
answer = generate(query, docs)
return answer, docs
结果:准确率89%,召回率84%。「正确chunk掉出top5」问题解决;同时因为精排后噪声减少,已有正确答案时模型扯淡的概率下降。
三个方案全量对比数据:
| 方案 | 准确率 | 召回率 | 端到端延迟(中位数) | 失败主因 |
|---|---|---|---|---|
| A. Prompt约束 | 62% | 58% | 850ms | 知识遗忘/编造 |
| B. 基础RAG | 78% | 73% | 980ms | 检索噪声 |
| C. RAG+Rerank | 89% | 84% | 720ms | chunk边界切碎 |
延迟额外说明:Rerank本身要40~60ms,但检索top_k提高后,正确chunk更稳定进入上下文,模型「读得懂」反而减少了底层模型的兜底重试,端到端降低。加一层摘要缓存后,常见问题命中缓存走449ms。
完整生产实现:从零搭一套RAG服务
上面是核心推理路径。要跑在生产环境,还缺:数据管道的准入脚本、索引schema、参数配置、服务化封装。以下是我最终落地的完整代码。
1. 知识清洗与chunk切分
最常见的错误是直接按固定字符数切chunk。经典翻车:把产品型号MQ-2000切成「MQ-」和「2000」,检索精度直接崩塌。我用「先按Markdown/HTML结构切块,再按段落合并」的组合策略。
]*>.*?<\/script>/is', '', $html);
$text = preg_replace('/