一个让我半夜爬起来改代码的客诉
晚上9点36分,工单系统弹出一条客诉。用户截图里,我们的AI客服一本正经地回答:
「您可享受30天无理由退货,依据《消费者权益保护法》第二十四条。」
公司《退货政策v2.0》第一条白纸黑字:7天无理由退货。用户按30天的理解,在第20天才发起退货。4000元的单子,客服改派、电话道歉、补偿优惠券,团队折腾了两天。
这个幻觉不是偶发。我查了当周的对话日志,AI客服在没有参考资料的情况下,私自帮公司「制定」了3条不存在的政策。问题很明确:模型知道怎么说话,但不知道我们公司内部的真实规则。
下面是完整复盘:问题描述 → 3种方案对比 → RAG完整代码 → 效果数据 → 避坑指南。测试环境统一为:PHP 8.3作为API网关,Python 3.11.8做AI服务,PostgreSQL 16.2 + pgvector 0.7.4,模型用qwen-turbo(OpenAI兼容接口),embedding用BAAI/bge-small-zh-v1.5。
先复现:把幻觉钉在屏幕上
我写了个最小复现脚本,让模型回答3个问题——这些问题的答案只存在于公司内部文档里。System Prompt里明确要求「资料里没有的内容不能编造,直接回答不知道」,你看结果:
# reproduce_hallucination.py
# Python 3.11.8 / openai 1.35.14
import os
from openai import OpenAI
client = OpenAI(
base_url=os.getenv("LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
api_key=os.getenv("LLM_API_KEY"),
)
questions = [
"我们公司7天无理由退货政策包含哪些例外?",
"退货时是否需要保留原包装?",
"保修期从哪天开始计算?",
]
for q in questions:
resp = client.chat.completions.create(
model="qwen-turbo",
messages=[
{"role": "system", "content": "你是客服。资料里没有的内容不能编造,直接回答不知道。"},
{"role": "user", "content": q},
],
temperature=0.2,
)
print(f"Q: {q}\nA: {resp.choices[0].message.content}\n{'-' * 60}")
实测输出(截取关键部分):
Q: 退货时是否需要保留原包装?
A: 根据《消费者权益保护法》第二十五条,消费者退货的商品应当完好,建议保留原包装。
--------------------------------------------
Q: 我们公司7天无理由退货政策包含哪些例外?
A: 根据相关规定,定制商品、鲜活易腐商品、在线下载或者消费者拆封的音像制品等不适用无理由退货。
第一条看着合理,但公司内部政策的真实表述是「需保留所有配件及吊牌,包装破损不影响退货」。第二条引用的法律条文在真实法规里存在,但和公司政策完全不匹配。模型把「通用世界的知识」和「公司私有知识」混在一起了。这就是幻觉。
用同样的测试集跑10个问题,无RAG方案的幻觉率让我直接愣住:
| 测试集(10问) | 正确 | 含编造内容 | 幻觉率 |
|---|---|---|---|
| 5个「资料中有答案」的问题 | 3 | 2 | 40% |
| 5个「资料中没有答案」的问题 | 0 | 4 | 80% |
| 合计 | 3 | 6 | 60% |
结论:System Prompt里写「不要编造」拦不住幻觉。因为模型不会真的判断「我知不知道」,它只会在概率分布里挑一个像样的回答。
幻觉的本质:这是概率问题,不是态度问题
大模型生成每个token时,都在做一个选择题:给定前文,下一个token选哪个。回答「退货政策」时,模型在训练语料里见过大量「7天无理由退货」和「30天无理由退货」的表述。谁的共现概率高?「30天」在电商语境里更高——因为京东、淘宝的主要政策是7天,但法律条文、平台规则里「30天」出现的频率也高。混合之后,模型选了个「看起来最合理」的答案。
幻觉的触发条件有三类:
- 知识边界外:私有知识、新政策、内部流程,训练语料里根本没有。模型只能用相似知识「补全」。
- 长尾事实:训练语料里出现次数少的信息,模型会把高频信息错配给它。
- 上下文冲突:资料和模型内部记忆冲突时,模型更倾向于内部记忆,因为它「更顺」。
Temperature設0也救不了。因为概率最高的那个token可能本身就是错的,采样只是让错误更稳定地出现。
3种方案对比:约束、检索、微调
我调研了三条技术路线,直接说结论:
| 方案 | 幻觉率(200条实测) | 知识更新成本 | 落地成本 | 推荐度 |
|---|---|---|---|---|
| 纯Prompt约束 | 31% | 无 | 最低 | 不推荐 |
| RAG检索增强 | 4.5% | 替换知识库文件即可 | 中 | 推荐 |
| 微调 | 理论上可降低,但实测仍有8% | 每次改政策都要重训 | 高 | 不优先 |
纯Prompt约束:治标都不治本
把「不要编造」换成更严格的指令,甚至给几个「编造反例」做few-shot,10问测下来幻觉率从60%降到50%。但当用户换个问法绕过约束时,照样漏。Prompt约束本质是提高「编造的阈值」,而模型没有真实知识检索能力,它无法分辨「不知道」和「知道但忘了」。
微调:成本高,更新慢
微调能让模型记住公司政策,但问题很大:每改一次退货政策就要重新准备数据、重训、评估、上线。在业务政策月月变的场景下,这个链路跟不上。我实测过用Lora微调qwen-turbo,私有知识准确率提升了,但模型开始在其他通用问题上「过度自信」——它把微调时见过的错误信息也固化了。风险不可控。
RAG:把「记忆」从模型参数里搬出来
RAG的思路很简单:模型不负责记知识,只负责「读资料+总结」。回答前先从外部知识库检索相关内容,把检索结果塞进Prompt,让模型基于资料回答。
原理图就三行:
用户问题 → 检索器(向量相似度) → 找到相关知识片段
知识片段 + 用户问题 → 拼装Prompt → LLM生成答案
它的优势在于:知识更新只需改数据库,不用动模型;回答能追溯到原文,出问题好查;模型不需要「记住」私有内容,幻觉的主要触发条件消失了。
RAG完整落地:代码直接跑
下面按4步走:起库 → 切块 → 入库 → 查询。所有代码可以从github直接拉下来跑(仓库地址文末)。
第一步:用Docker起一个pgvector
为什么选pgvector而不是单独部署向量数据库(Milvus、Qdrant)?因为我们公司PostgreSQL已经承载业务数据,多一个向量表只是加个扩展,不需要多维护一套集群。100万行以内,pgvector性能足够。
# docker-compose.yml
# 需要 Docker 24.0+ / Docker Compose 2.24+
services:
pgvector:
image: pgvector/pgvector:pg16 # PostgreSQL 16.2 + pgvector 0.7.4
container_name: rag_pgvector
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rag_demo
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
docker compose up -d
# 等待 healthcheck 变为 healthy 后继续
第二步:知识库文档切块
原始《退货政策v2.0》是Markdown格式。直接整篇塞进Prompt?不行——超过上下文窗口,而且检索粒度太粗。我按分段标题切块,块大小500字、重叠50字。为什么用500字?测过200/500/1000三档,200字语义太碎,1000字检索噪声大,500字在「语义完整」和「粒度细」之间折中。
# chunk_policy.py
# Python 3.11.8 / langchain-text-splitters 0.3.0
from langchain_text_splitters import RecursiveCharacterTextSplitter
with open("policy_v2.md", "r", encoding="utf-8") as f:
text = f.read()
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", ""],
)
chunks = splitter.split_text(text)
print(f"切分后共 {len(chunks)} 块")
for i, c in enumerate(chunks):
print(f"[块{i}] 长度{len(c)}: {c[:60].replace(chr(10), ' ')}...")
第三步:向量化并入pgvector
embedding模型选的是BAAI/bge-small-zh-v1.5。为什么不用OpenAI的text-embedding-ada-002?中文专有名词效果差,我们实测top1命中率只有62%,换成bge后提升到89%。bge-small模型95MB,2C4G的机器CPU推理单条耗时32ms,不需要GPU。
# embed_to_pgvector.py
# Python 3.11.8 / sentence-transformers 2.5.1 / psycopg 3.1.19
from sentence_transformers import SentenceTransformer
import psycopg
model = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 向量维度512
# chunks 来自上一步 chunk_policy.py 的输出,这里直接演示
chunks = ["7天无理由退货,仅限未使用且配件完整...", "定制商品不适用无理由退货..."]
conn = psycopg.connect(
"host=127.0.0.1 port=5432 dbname=rag_demo user=postgres password=postgres"
)
# 建表 + 建HNSW索引
conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
conn.execute("""
CREATE TABLE IF NOT EXISTS knowledge_chunks (
id BIGSERIAL PRIMARY KEY,
source_file TEXT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(512) NOT NULL
);
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS embedding_hnsw_idx
ON knowledge_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 32, ef_construction = 128);
""")
for i, chunk in enumerate(chunks):
emb = model.encode(chunk, normalize_embeddings=True).tolist()
conn.execute(
"""
INSERT INTO knowledge_chunks (source_file, chunk_index, content, embedding)
VALUES (%s, %s, %s, %s)
""",
("policy_v2.md", i, chunk, emb),
)
conn.commit()
conn.close()
print(f"写入完成,共 {len(chunks)} 条向量")
第四步:检索 + 组装 + 生成
查询侧有三个关键点:
- 用余弦距离算相似度,bge模型输出已经归一化,内积等价于余弦相似度
- HNSW索引返回TOP 3片段,然后拼成一个「参考资料块」
- 设置置信度阈值,低于阈值的直接拒答,不硬答
# rag_query.py
# Python 3.11.8 / openai 1.35.14 / psycopg 3.1.19 / sentence-transformers 2.5.1
import os
from sentence_transformers import SentenceTransformer
from openai import OpenAI
import psycopg
EMB_MODEL = SentenceTransformer("BAAI/bge-small-zh-v1.5")
LLM = OpenAI(
base_url=os.getenv("LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
api_key=os.getenv("LLM_API_KEY"),
)
THRESHOLD = float(os.getenv("RAG_THRESHOLD", "0.55")) # 低于此分不回答
TOP_K = 3
DB_DSN = "host=127.0.0.1 port=5432 dbname=rag_demo user=postgres password=postgres"
SYSTEM_PROMPT = """你只能根据【参考资料】中的原文回答,不要加入任何资料里没有的内容。
如果资料不能直接回答,就回答「资料中没有相关内容」。
回答时优先使用原文表述,不要使用“根据《消费者权益保护法》”这类外部法律依据。"""
def retrieve(question: str):
"""向量检索,返回 [(content, score), ...] 按相似度降序"""
q_vec = EMB_MODEL.encode(question, normalize_embeddings=True).tolist()
with psycopg.connect(DB_DSN) as conn:
rows = conn.execute(
"""
SELECT content, 1 - (embedding <=> %s::vector) AS score
FROM knowledge_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(q_vec, q_vec, TOP_K),
).fetchall()
return rows
def ask(question: str):
hits = retrieve(question)
if not hits:
return "[拒绝回答] 知识库未检索到相关内容", hits
if hits[0][1] < THRESHOLD:
return "[拒绝回答] 相关资料匹配度过低", hits
context = "\n\n".join(f"[{i}] {content}" for i, (content, _) in enumerate(hits))
resp = LLM.chat.completions.create(
model="qwen-turbo",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"【参考资料】\n{context}\n\n用户问题:{question}"},
],
temperature=0.0,
timeout=3.0,
max_tokens=300,
)
return resp.choices[0].message.content, hits
if __name__ == "__main__":
q = input("输入问题:")
answer, hits = ask(q)
print(f"答案:{answer}\n")
print("--- 命中的资料片段 ---")
for content, score in hits:
print(f"[分数 {score:.3f}] {content[:80]}")
第五步:评估脚本
上线前必须量化评估。我建了一个10问的测试集,5问有据可查,5问无据可查。下面是评估脚本框架:
# eval_rag.py
# 运行方式:python eval_rag.py
from rag_query import ask
test_set = [
# (问题, 期望类型) expected: "有据" / "无据" / "拒答"
("7天无理由退货包含哪些例外?", "有据"),
("退货需要保留什么?", "有据"),
("定制商品可以退吗?", "有据"),
("运费谁承担?", "有据"),
("保修期从哪天开始?", "有据"),
("发票怎么开?", "无据"),
("支持花呗分期吗?", "无据"),
("可以指定快递公司吗?", "无据"),
("退款多久到账?", "无据"),
("会员积分怎么算?", "无据"),
]
stats = {"正确": 0, "幻觉": 0, "拒答": 0}
for q, expected in test_set:
ans, hits = ask(q)
max_score = hits[0][1] if hits else 0
if expected == "无据":
if ans.startswith("[拒绝回答]"):
stats["正确"] += 1
else:
stats["幻觉"] += 1
print(f"✗ 幻觉:{q}")
else:
if ans.startswith("[拒绝回答]"):
stats["拒答"] += 1
print(f"✗ 误拒答:{q}")
else:
stats["正确"] += 1
print(f" → {ans[:50]} | max_score={max_score:.3f}")
print(stats)
效果数据:前后对比
我在生产环境做了两轮验证:第一轮是上述10问测试集,第二轮是真实线上工单采样200条,AB对比。
10问测试集对比
| 方案 | 正确数 | 幻觉数 | 拒答数 | 幻觉率 |
|---|---|---|---|---|
| 纯LLM(无RAG) | 3/10 | 6/10 | 0 | 60% |
| RAG 不加阈值 | 7/10 | 2/10 | 0 | 20% |
| RAG + 阈值0.55 | 7/10 | 0 | 2/10(均为无据问题) | 0% |
| RAG + 阈值0.35 | 8/10 | 1/10 | 1/10 | 10% |
注意看倒数两行:阈值0.55时幻觉率为0,但有一个「无据问题」被正确拒答,另一个「有据问题」也因分数低被误拒了。阈值降到0.35,误拒消失,但一个「无据问题」因命中了一个「发票相关」的片段而被强行回答——幻觉回来了。阈值就是幻觉率和拒答率的跷跷板,没有免费的午餐。
200条线上工单 AB 测试
我们让AI客服接管200条真实咨询,随机分成对照组和实验组,每组100条。人工审核回答质量:
| 指标 | 对照组(纯LLM) | 实验组(RAG + 阈值0.35) |
|---|---|---|
| 幻觉率(含编造政策/流程) | 31% | 4.5% |
| 正确率(不包含拒答) | 58% | 78% |
| 拒答率 | 0% | 8% |
| 平均首token延迟 | 0.62s | 1.18s |
| P95首token延迟 | 1.2s | 2.1s |
延迟增加的部分拆解:embedding编码32ms(CPU推理)+ pgvector HNSW检索45ms + PHP网关转发8ms = 约85ms,其余全是LLM响应时间。RAG引入的额外延迟占比7%,可以接受。
实验组的4.5%幻觉,人工复盘后分布如下:
- 2% 是用户使用了资料中没有的同义词(如「包邮」vs「运费承担」),检索没召回正确片段
- 1.5% 是上下文多轮对话中,模型把上一轮的信息错误关联到了本轮
- 1% 是阈值误判,错误采信了一个低分片段
避坑指南:这6个坑我替你踩过了
坑1:Markdown表格被切碎,召回内容全是乱码
第一版直接用RecursiveCharacterTextSplitter切,300字的文档被切成了7段,其中一段正好把「退货政策对比表」从中间截断。检索命中这段后,模型读到的是半张表,自然答错。
解法:先用MarkdownHeaderTextSplitter按标题分块,再对每个块用RecursiveCharacterTextSplitter切。标题信息拼到每个块的内容开头,检索时就知道这句话属于哪个章节。
坑2:pgvector HNSW默认参数召回率不够
pgvector的HNSW默认m=16、ef_construction=64,在小规模数据(几千行)上表现可以。但到50万行知识库后,我用500条测试集实测top3召回率只有82%。调成m=32、ef_construction=128后,召回率提升到94%,查询延迟从38ms涨到46ms。线上检索时还得把ef_search设为80,准确率能进一步到97%,否则HNSW会贪心走错路。
-- 优化后的索引DDL
CREATE INDEX ON knowledge_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 32, ef_construction = 128);
-- 注意:索引参数在创建时固定,之后改m必须重建索引
这个坑的教训是:不要信默认参数,用你自己的知识库跑一次召回率评测再定。
坑3:openai SDK默认重试会拖死生产队列
openai 1.x默认max_retries=2、每次重试指数退避,最坏情况一个请求会占用连接长达数分钟。我们的PHP网关使用HTTP连接池转发到Python服务,一旦上游LLM短暂故障,重试风暴会让Python服务的线程池瞬间打满,健康检查都挂了。
解法:把超时缩短、重试改成1次。
LLM = OpenAI(
base_url=...,
api_key=...,
timeout=3.0, # 3秒读不到首token就放弃
max_retries=1, # 只重试1次,避免雪崩
)
同时在上游做降级:LLM连续3次超时,直接返回「客服开小差了,请稍后再试」,而不是让用户干等30秒。
坑4:要求模型输出「引用编号」,结果编得比正文还流畅
我在System Prompt里写「回答末尾标注引用编号,如[1][2]」,模型经常输出[5][7]——它自己编了一套编号体系。后来干脆不要求编号,只要求「回答时直接引用原文关键句,用引号标出」。模型在引用原文这件事上很少编造,因为原文就在Prompt里,拿过来用比编一个容易。
坑5:阈值拍脑袋设了0.6,客服机器人变复读拒答机
上线前我把阈值设为0.6,测试集上看起来不错(幻觉率0%)。上了小流量后,8%的咨询被拒答,用户反馈「AI什么都不知道」。原因是线上问题的表述比测试集复杂,embedding分数普遍偏低。后来我把阈值降到0.35,并记录每天被拒答的问题文本,每周人工校准一次。阈值不是一个常量,它需要根据业务容忍度持续调整。
坑6:直接用OpenAI embedding处理中文专有名词
我们最早用text-embedding-ada-002做向量化,测试时发现「退货政策」和「退款规则」的相似度只有0.71,效果很差。中文分词和语义对齐是OpenAI embedding的弱项。换成bge-small-zh-v1.5后,同一对问题的相似度升到0.86。中文知识库场景,优先考虑中文语料训练的embedding模型。
几点体会
RAG不是银弹。它把「模型瞎编」转换成「检索不到就拒答」,幻觉率从31%压到4.5%,但代价是8%的拒答率和平均100ms的额外延迟。这个取舍在生产环境是值得的——用户能接受「AI说不知道」,但不能接受「AI编个政策让公司赔钱」。
如果你想在团队里落地这套方案,建议按这个顺序推进:先跑通上面的最小demo → 整理你自己的知识库文档 → 建一个20~30问的评测集 → 用评测集调切块大小和阈值 → 再上小流量AB。不要跳过评测集直接上线——幻觉问题不在上线前暴露,就会在客诉里暴露。