6个步骤将RAG幻觉率降至1%

在受监管环境中,15%的幻觉率不是模型问题,也不是嵌入问题,而是管道问题——而管道有六个环节,任何一个都可能独立失败。

6个步骤将RAG幻觉率降至1%
梯形图转SCL | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo

我们当时正在为企业客户构建一个财务文档助手。分析师上传财报、10-K表格、投资者演示文稿,然后提问:"Q3营收是多少?""毛利率同比增长多少?""管理层对员工规模有何评论?"

在受监管的环境中,这个问题很简单——但一个错误的数字不仅仅是尴尬,而是合规问题。

该系统运行在Azure上:文档存储在Azure Blob Storage中,分块并索引到Azure AI Search,检索命中向量索引,通过Azure OpenAI GPT-4o生成答案。标准的企业RAG技术栈,没什么特别的。

然而,该系统在数字查询上的幻觉率高达15%——不是模糊问题,而是那些答案应该是特定文档中特定表格里特定数字的问题。

我确信这是检索问题。于是我做了所有人都会做的事:调整分块大小、调整重叠度、切换嵌入模型、添加更多上下文、将top_k从5增加到8。花了两周时间,幻觉率几乎没有变化。

然后我实际记录了LLM接收的内容与返回的内容。正确的分块就在上下文中,正确的数字就在那里,而模型却给出了不同的数字。

检索没问题。我花了两周时间调试错误的东西。

这正是我希望在浪费时间之前能读到的文章。

我使用的技术栈:

  • Azure Blob Storage — 文档存储
  • Azure AI Search — 混合检索(向量 + BM25)
  • Azure OpenAI GPT-4o — 生成
  • LangChain — 编排
  • Langfuse(在Azure Container Apps上自托管)— 追踪
  • Python — 其他所有

0、改变一切的思维模式

我把管道当作一个整体:查询进去,答案出来,答案错了就修复检索。

但它不是一个整体,而是一个链条:

查询 → 检索 → 上下文组装 → 生成 → 验证 → 答案

每个步骤都可能独立失败。由于我只衡量最终答案,完全不知道哪个步骤出了问题。一旦我单独衡量每个环节,问题在一天内就变得显而易见了。

1、在修改任何东西之前,先证明哪个层坏了

我构建了一个标注的评估集:50个查询,我知道预期答案、哪个分块包含证据以及涉及的确切数字。然后我单独衡量检索效果:

def evaluate_retrieval(eval_set, retriever, k=5):
    hits = 0
    for item in eval_set:
        retrieved = retriever.get_relevant_documents(item["query"])[:k]
        chunk_ids = [doc.metadata["chunk_id"] for doc in retrieved]
        if item["required_chunk_id"] in chunk_ids:
            hits += 1
    print(f"Recall@{k}: {hits / len(eval_set):.1%}")

# 结果:Recall@5: 91%
# 正确的分块每10次有9次被返回

91%的召回率。检索器在做它该做的事。每小时调节它都是浪费时间。问题在下游。

我现在的规则: 如果召回率高于85%,就不要再动检索器,看看它之后发生了什么。

2、看看LLM实际收到了什么

我一直在记录查询和最终答案,没有记录介于两者之间的组装上下文——问题就出在那里。

def generate_with_logging(query, retrieved_docs, llm):
    context = assemble_context(retrieved_docs)
    
    # 记录分块位置——这就是我发现问题的地方
    for i, doc in enumerate(retrieved_docs):
        score = doc.metadata.get("score", "?")
        print(f"[位置 {i+1}] 评分={score} | {doc.page_content[:150]}")
    
    return llm.invoke(f"""
仅使用以下上下文回答。
如果上下文不包含答案,请说明。

上下文:{context}
问题:{query}
    """)

我的发现:最高评分的分块每次都落在8个位置中的第4或第5位。最相关的证据被埋在了中间。

有一个关于这个的研究叫做"迷失在中间"——当关键证据在长上下文的中间时,模型的表现会显著下降。我听说过这个研究,以为不适用于我,但事实证明非常适用。

修复方法:

def assemble_context(retrieved_docs, scores):
    # 按评分排序——最佳证据放在前面,而不是检索顺序
    pairs = sorted(zip(retrieved_docs, scores),
                   key=lambda x: x[1], reverse=True)
    chunks = [f"[评分: {s:.2f}]\n{d.page_content}" for d, s in pairs]
    return "\n\n---\n\n".join(chunks)

幻觉率:15% → 9%。仅靠上下文排序,无需其他修改。

3、在生成前添加检索质量检查

即使有91%的召回率,仍有9%的查询带着薄弱证据进入生成阶段。我需要系统在生成可能编造的内容之前捕获这种情况。

def check_retrieval_quality(query, retrieved_docs, llm) -> float:
    sample = "\n".join([d.page_content[:300] for d in retrieved_docs[:3]])
    score = llm.invoke(f"""
查询:{query}
检索到的上下文(示例):{sample}

此上下文是否包含回答查询所需的内容?
仅回复数字:0.0(无用)到 1.0(直接回答)。
    """).content.strip()
    return float(score)

def rag_with_quality_check(query, retriever, llm):
    docs = retriever.get_relevant_documents(query)
    
    if check_retrieval_quality(query, docs, llm) < 0.5:
        # 检索薄弱——尝试重新表述查询
        better_query = llm.invoke(
            f"更具体地重新表述:{query}"
        ).content
        docs = retriever.get_relevant_documents(better_query)
    
    return docs

那些漏网的查询都存在检索薄弱问题,重新查询可以解决。这让我达到5%

4、停止评估整个答案。评估每个声明。

"营收从7.2亿美元增长到8.426亿美元,增长17%"不是一个评估对象,而是四个声明:

  • 营收为7.2亿美元
  • 营收为8.426亿美元
  • 增长率为17%
  • 这发生在全年

我之前评估整个答案的通过/失败。一个正确句子中的错误数字会被计为通过。难怪我的评估指标看起来比生产环境好。

def verify_claims(answer, retrieved_docs, llm):
    context = "\n\n".join([d.page_content for d in retrieved_docs])
    
    # 将答案分解为单独的声明
    claims_text = llm.invoke(f"""
将此分解为单独的事实声明。每行一个,无项目符号。
答案:{answer}
    """).content
    claims = [c.strip() for c in claims_text.strip().split("\n") if c.strip()]
    
    # 独立验证每个声明
    results = []
    for claim in claims:
        verdict = llm.invoke(f"""
上下文:{context}
声明:{claim}

是否被支持?回复:
SUPPORTED - 原因
CONTRADICTED - 原因
NOT_IN_CONTEXT - 原因
        """).content.strip()
        results.append({"claim": claim, "verdict": verdict})
    
    return results

# 我的发现:
# "营收为7.2亿美元"      → SUPPORTED
# "营收为8.426亿美元"    → SUPPORTED
# "增长率为17%"          → CONTRADICTED - 上下文显示为14.2%
# "全年增长"             → NOT_IN_CONTEXT

几乎所有剩余的幻觉都在一个类别:计算出的数字。模型在心算时出错了。

5、不要让LLM做数学计算

这是修复数字幻觉的实际方法。

模型擅长语言,但不是计算器。当我要求它在同一步骤中检索数字并计算百分比时,有时会检索正确但计算错误。或者更糟,部分幻觉出起始数字并基于此计算。

修复:用LLM提取,用Python计算,用LLM解释。

import json
def answer_with_deterministic_numbers(query, retrieved_docs, llm):
    context = assemble_context(retrieved_docs,
              [d.metadata["score"] for d in retrieved_docs])
    
    # 步骤1:仅提取原始值——不计算
    raw = json.loads(llm.invoke(f"""
提取与此查询相关的原始数值。不要计算。
返回JSON:{{"values": [{{"label": "名称", "value": 123.4, "unit": "USD_millions"}}]}}
上下文:{context}
查询:{query}
    """).content)
    values = {v["label"]: v["value"] for v in raw["values"]}
    
    # 步骤2:用Python计算——确定性的,不可能产生幻觉
    computed = {}
    if "revenue_prior" in values and "revenue_current" in values:
        growth = ((values["revenue_current"] - values["revenue_prior"])
                  / values["revenue_prior"]) * 100
        computed["growth_pct"] = round(growth, 2)
    
    # 步骤3:LLM解释——使用经过验证的数字,不重新计算
    return llm.invoke(f"""
使用这些经过验证的事实回答。精确使用给定的数字。不要重新计算。
事实:{json.dumps({**values, **computed})}
问题:{query}
    """).content

# 之前:"营收增长21%"(LLM猜错了)
# 之后:"营收增长14.2%"(Python计算,LLM解释)

数字幻觉率:5% → 2%

6、构建决策层。停止总是生成答案。

最后一块拼图:系统现在有三种明确的结果,而不是总是返回某些内容。

def production_rag(query, retriever, llm):
    docs   = rag_with_quality_check(query, retriever, llm)
    answer = answer_with_deterministic_numbers(query, docs, llm)
    claims = verify_claims(answer, docs, llm)
    
    supported    = [c for c in claims if "SUPPORTED"      in c["verdict"]]
    contradicted = [c for c in claims if "CONTRADICTED"   in c["verdict"]]
    unsupported  = [c for c in claims if "NOT_IN_CONTEXT" in c["verdict"]]
    
    if contradicted:
        # 某些内容与源矛盾——带修正重新生成
        corrections = "\n".join([c["verdict"] for c in contradicted])
        answer = llm.invoke(f"""
以下声明被源矛盾:
{corrections}

源:{[d.page_content for d in docs]}
问题:{query}

仅使用源支持的内容编写修正后的答案。
        """).content
        return {"answer": answer, "status": "corrected"}
    
    elif len(unsupported) > len(supported):
        # 不支持的比支持的多——不猜测
        return {
            "answer": "我没有足够的信息来可靠地回答这个问题。",
            "status": "abstained"
        }
    
    return {
        "answer": answer,
        "status": "answered",
        "confidence": f"{len(supported) / len(claims):.0%}"
    }

系统现在会在应该说"我不知道"的时候说"我不知道"。这消除了自信错误答案的长尾。

最终幻觉率:约1%

7、实际改变数字的因素

各步骤效果对比图

我纠结的检索器调节?基本上没有影响,因为检索根本不是问题。

8、我会对六周前的自己说什么

停止将RAG视为检索然后生成。它是一个链条:在调节任何环节之前先衡量每个环节。问题几乎从不出现在你预期的地方。

对于涉及受监管领域数字的任何内容——金融、医疗、法律、运营——不要让LLM计算。提取数值,用Python计算,用LLM解释。仅这一项改变就能解决大多数数字幻觉。

9、要点

在受监管环境中,15%的幻觉率不是模型问题,也不是嵌入问题,而是管道问题——而管道有六个环节,任何一个都可能独立失败。

我在检索器上度过的两周通过失败教会了我比修复它的六个步骤更多的东西,因为我现在确切知道首先该看哪里。

调节之前先衡量。计算之前先提取。回答之前先验证。当证据不存在时——说出来。

这就是将RAG演示与真正可信的合规系统区分开来的地方。


原文链接:6 Steps That Fixed My RAG Hallucination Rate From 15% to 1%

汇智网翻译整理,转载请标明出处