RAG落地实录:幻觉率从31%降到4.5%
发布日期: 2026/08/12 阅读总量: 1

一个让我半夜爬起来改代码的客诉

晚上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个「资料中有答案」的问题3240%
5个「资料中没有答案」的问题0480%
合计3660%

结论: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/106/10060%
RAG 不加阈值7/102/10020%
RAG + 阈值0.557/1002/10(均为无据问题)0%
RAG + 阈值0.358/101/101/1010%

注意看倒数两行:阈值0.55时幻觉率为0,但有一个「无据问题」被正确拒答,另一个「有据问题」也因分数低被误拒了。阈值降到0.35,误拒消失,但一个「无据问题」因命中了一个「发票相关」的片段而被强行回答——幻觉回来了。阈值就是幻觉率和拒答率的跷跷板,没有免费的午餐。

200条线上工单 AB 测试

我们让AI客服接管200条真实咨询,随机分成对照组和实验组,每组100条。人工审核回答质量:

指标对照组(纯LLM)实验组(RAG + 阈值0.35)
幻觉率(含编造政策/流程)31%4.5%
正确率(不包含拒答)58%78%
拒答率0%8%
平均首token延迟0.62s1.18s
P95首token延迟1.2s2.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。不要跳过评测集直接上线——幻觉问题不在上线前暴露,就会在客诉里暴露。