← 返回
AI技术

RAG 证据可验证:1500 万文档规模下的断言核查怎么做

✍️ zhirenhun 📅 2026/10/24 👁 120 阅读 ⏱ 65 分钟
RAG 证据可验证:1500 万文档规模下的断言核查怎么做

你的检索演示做得很漂亮:往向量数据库里塞了一千份PDF,接上嵌入模型,现在它能以惊人的自信回答各种问题。你上线了,大家都赞不绝口。

然后语料库开始膨胀。一万份文档。十万。一百万。一千五百万。

什么都没崩。日志里没有报错。延迟正常。答案照样源源不断地出现,依旧自信,依旧文笔流畅。

只是错的频率更高了。而且你分不清哪些是错的。

这就是大规模RAG的陷阱:它不会大声报错,而是错得头头是道。语料库越大,系统就越容易检索到一段看似正确的内容,然后在上面构建出一段流畅、引用规范的答案。失败的表现不是堆栈追踪,而是一句听起来像真话的假话。

有个不太中听的实话:这不是危言耸听,换更大的模型也解决不了。2025年,斯坦福RegLab测试了一批商业级、企业级的法律RAG工具——那些专为“可信”而打造的付费产品——结果发现它们在约17%–33%的情况下依然产生幻觉(Magesh等,《Journal of Empirical Legal Studies》,2025)。三到六个答案里就有一个是错的,而在法律领域,错误是会吃官司的。(这个数字仅针对那些法律产品和所测任务——把它理解为这件事有多难,而不是所有RAG系统都会撞上的通用失败率。)

RAG能减少幻觉,但无法消除。当文档量达到1500万以上时,容错空间会越来越窄,除非你改变架构——而不是换模型。

下面就是我为一千五百万份文档设计的流水线,目标是把“自信的”幻觉压到接近零。诀窍不在某个神奇的检索器,而在于四道关卡,每道都拦截不同类型的谎言;再加上一个真正宁可不答、也不胡编的系统。

先把丑话说在前面,免得有人觉得被推销了:检索和规模化部分是一套基于已发表研究构建的架构——并非我在1500万文档规模上实测过的产品。我引用的每个数字都来自有出处的论文,并且会明确标注;代码也只是说明性的骨架,不是拿来即用的库。但我真正在意的那条结论——核查依据、允许拒答,能减少“自信但错误”的答案——是我在一个小型标注数据集上亲自验证的。实测结果及其局限性在文末。


整条流水线一览:语料准备 → 索引 → **检索 · 约束 · 校验 · 弃答,以及各环节对应的评估指标。下文将逐一拆解每道关卡——目标是减少自信却缺乏依据的断言,而不是承诺零错误。

“接近零”到底意味着什么?它是一个设计目标,而非保证。不是零错误——目标是让零条自信却缺乏依据的断言到达用户手中,因为系统会把本来可能成为幻觉的内容,转化成一句坦诚的“我无法核实这一点”。仍会有部分内容逃过不完美的校验器,我们要做的是让这种情况变得罕见、可控且可观测——而不是承诺它永远不会发生。这个视角转换就是全文的核心。


首先,RAG 为什么会产生幻觉?

如果只让你记住一件事,那就是:RAG 系统的幻觉沿着三条常见失败路径产生——正是本架构要拦截的这三条——而扩大语料规模会让这三条路径都更加严重。(这并非幻觉的全部类型:检索到的来源还可能过时、被投毒、解析错误或归因错误。这些情况真实存在,但属于另外一套控制手段——来源可信度与溯源——我会在“系统在哪里失效”一节再谈。)

这张技术文章配图展示了三种可能的答案结果,分别是:错误答案、忽略答案和无支持但正确答案

基于证据的回答失败的三种方式:相似片段胜出、可用证据未被使用、证据缺失变成猜测。

1. 检索到了错误的证据。
答案明明在语料库里,但检索器找到的却是与问题相似的片段,而不是能回答问题的片段。当语料只有 1,000 篇文档时,关于“Q2 营收”的片段可能只有一条;到了 1,500 万篇,提到营收的片段有四万条,其中几百段近乎雷同的模板化文字,还有十几段看似完美却给出错误数字。文档越多,以假乱真的干扰项就越多。

2. 检索到了正确证据,却没用上。
相关内容明明就在上下文窗口里,模型却依旧靠自身参数记忆作答——或者这段内容被埋在长上下文的中段,模型注意力直接从它身上滑了过去。这就是著名的“迷失在中部”(lost in the middle)效应(Liu et al., 2023):模型会稳定地利用上下文开头和结尾的信息,而悄悄丢掉中间部分。塞进去 50 个内容块,排在第 26 位的那条答案就跟不存在一样。

3. 什么有用的都没检索到,却照答不误。
这才是真正的幻觉。top-k 里没有任何支撑证据,模型却不肯承认,而是按训练时的本能行事:生成一段流畅、听起来很有帮助的回答,把缺口填上。在 1500 万规模的语料库中,很多合理的问题在数据里根本没有好答案——而一个不会说“我不知道”的系统,只能硬编一个出来。

下文的所有技术,都是为了消灭这三种失败之一。如果你说不出某个组件针对的是哪种失败,那它就是盲目堆砌的复杂度。


核心原则:靠提示词救不了场

在搭建流水线之前,先摆正心态。“请只根据提供的上下文回答,不要编造”只是一句愿望,不是控制手段。它有点用,却偏偏在最需要的时候失灵——上下文错误、不完整或被埋没的时候。

接近零幻觉是流水线的属性,不是提示词的功劳。按顺序设四道闸门(开头的架构图就是地图):

  1. 检索——把正确的证据放进上下文,并且能证明它确实在。
  2. 约束——强制回答逐条引用这些证据。没有引用,就没有句子。
  3. 验证——独立检查每条论断是否真的有引用支撑。
  4. 拒答——支撑不足时,用恰当的措辞明说,而不是瞎猜。

检索解决第 1 种失败,约束解决第 2 种,验证和拒答合力解决第 3 种——最危险的那种。下面逐一构建。


闸门 1——检索到正确证据(并且证明这一点)

准确率的成败大多取决于检索,这也是 1500 万规模下最容易崩坏的一环。有四个关键动作。

向量化之前先去重——否则索引会骗你

到了大规模场景,语料库里到处都是近似重复:同一条免责声明出现在二十万个页面上,合同各版本里反复出现同样的模板条款,一篇文章被转载到六个网站。如果把这些全部嵌入,会带来三个恶果:索引膨胀(账单也跟着膨胀),检索返回十份同样的内容块,挤掉了多样性,而且相关性得分还会被出现频率扭曲。

标准解法是MinHash + LSH(局部敏感哈希):把每篇文档哈希成一个签名,把相似的签名放进同一个桶,在进入嵌入器之前就把近似重复合并掉。这个方法便宜、历史悠久、效果稳定得无聊,在 1500 万文档的规模下,它往往决定了你得到的是一份干净的索引,还是一座镜厅。至于你是否需要它、该做多激进,取决于语料的实际重复程度——先测量近似重复率,同时保留版本和来源信息,免得去重悄悄把两篇确实不同的文档合并了。

# Dedup before embedding — MinHash + LSH (datasketch). Streams; never holds 15M in RAM.
from datasketch import MinHash, MinHashLSH

def signature(text, num_perm=128):
    m = MinHash(num_perm=num_perm)
    for token in set(text.lower().split()):      # or k-gram shingles for finer dedup
        m.update(token.encode())
    return m

def dedup(docs, threshold=0.9):
    lsh, kept = MinHashLSH(threshold=threshold, num_perm=128), {}
    for doc_id, text in docs:
        sig = signature(text)
        if lsh.query(sig):                        # a near-duplicate is already kept
            continue
        lsh.insert(doc_id, sig)
        kept[doc_id] = text
    return kept                                   # near-dups collapsed pre-embedding

按结构分块,然后为每个块补回上下文

朴素的分块方式会把文档切成一个个 500 token 的窗口,切缝处 Meaning 全丢了。比如某块内容是"季度营收环比增长 3%",单看毫无意义——是谁的营收?哪个季度?embedding 对此一无所知,因为这句话是被硬生生从文档里撕出来的。

真正能提升指标的办法是上下文检索:在生成每个块的 embedding 之前,先用机器生成一句简短的说明,标明它在父文档中的位置。"本块来自 ACME 公司 2026 年第二季度 10-Q 文档中关于云业务营收的章节:季度营收环比增长 3%……"这样一来,向量索引和关键词索引就都带上了上下文。

# Prepend a one-line situating context, and KEEP the source id for later verification
def contextualize(chunk, doc_meta, llm):
    prefix = llm(
        f"In one sentence, situate this passage in its document "
        f"(title: {doc_meta['title']}, section: {doc_meta['section']}).\n\n{chunk.text}"
    )
    return {
        "id": chunk.id,                               # provenance → rides into the citation
        "doc_id": doc_meta["doc_id"],
        "embed_text": f"{prefix}\n\n{chunk.text}",    # what you embed AND BM25-index
        "source_text": chunk.text,                    # what Gate 3 later checks the claim against
    }
# tip: prompt-cache the full document so the per-chunk context call stays cheap.


上下文让一个分块可被检索到,而保留的源 ID 和文档来源信息则让它日后可被验证。

这是整个方案中唯一有公开数据佐证的架构选择。Anthropic 统计了正确分块未进入前 20 名的查询占比(Contextual Retrieval,2024 年 9 月):

配置 Top-20 失败率 降幅
基线(朴素分块) 5.7% —
+ 上下文化嵌入 3.7% 35%
+ 上下文化 BM25 2.9% 49%
+ 重排序 1.9% 67%

一个预处理步骤能把 5.7% 的失败率压到 1.9%,这在整个技术栈里都算得上回报最高的投入之一。老实说一句:这些是 Anthropic 测试中表现最好的嵌入配置的成绩,并非所有模型的平均水平——把它理解为“默认方案还有很大优化空间”,而不是“你的数据上一定能到 1.9%”。

混合检索:语义和字面词都要

稠密向量检索擅长语义——它知道“取消我的套餐”约等于“终止订阅”。但它在精确字符串上很糟糕:错误码 E-4021、生僻姓氏、零件编号、法条引用。这类查询中,语义嵌入恰恰会把最关键的那个词抹平。

所以要两者并行。稠密(向量)检索负责语义,稀疏(BM25)检索负责字面词,再用倒数排名融合(RRF)把两路排名合并——公式简单到不能再简单:对每篇文档,把它在各个列表中出现的 1 / (k + rank) 加总即可:

# RRF: robust rank fusion, no tuning or training. k=60 is a common default — tune it.
def rrf(dense_ranking, sparse_ranking, k=60):
    scores = {}
    for ranking in (dense_ranking, sparse_ranking):
        for rank, doc_id in enumerate(ranking, start=1):   # 1-based ranks (standard RRF)
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

稠密检索能抓住同义改写,BM25 能匹配精确代码,RRF 则让两者排名都不错的文档浮到最上面。仅这一处改动,就能在规模化场景下消除大量“检索到错误证据”的失败案例。

def hybrid_retrieve(query, dense_index, bm25_index, n=150):
    dense  = dense_index.search(embed(query), k=n)    # semantic similarity
    sparse = bm25_index.search(query, k=n)            # exact tokens / codes / names
    fused  = rrf([d.id for d in dense], [s.id for s in sparse])
    return [load_chunk(i) for i in fused[:n]]         # ~150 candidates, over-fetched

先多取一些,再重排序压缩

第一轮检索为了在 1500 万条索引上跑得快,用的是近似算法——撒出去的网又大又糙。那就不妨故意撒大网:先召回约 150 个候选,再交给交叉编码重排序器,让它把查询和每个片段放在一起通读、重新打分,只留下最好的约 20 个。

重排序又慢又准,检索则又快又糙。对 150 个候选(而不是 1500 万条)做重排序,成本完全可接受,而这正是上表中 5.7%→1.9% 那段降幅的最后一步功臣。

from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")    # reads (query, chunk) together

def rerank(query, candidates, keep=20):
    scores = reranker.predict([(query, c["source_text"]) for c in candidates])
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [c for c, _ in ranked[:keep]]              # 150 -> 20 (tune on your eval set)


先大范围召回,再小范围重排。候选数量仅为示意——请结合自己的标注数据集调整。重排负责把有价值的证据浮上来,但它并不能证明某个论断确实有依据。

人人都忽略的 1500 万规模问题。1500 万个向量,每个 1024 维 float32,原始向量就要占用约 61 GB——这还没算索引开销。想直接全放进内存是不现实的。两个抓手:一是磁盘上的 ANN 索引(HNSW 或 IVF-PQ),让索引驻留在 SSD 上;二是嵌入量化(float32 → int8,甚至二值化),可将内存占用压缩 4–32 倍,但召回率必然受损,具体损失多少,必须在自己的语料和嵌入模型上实测——int8 的损失通常不大,但"通常不大"不等于必然,二值量化尤其可能造成明显的召回率下滑。规模问题,本质上既是建模问题,也是内存布局问题。

另外,务必区分三类证据,它们经常被混为一谈:索引基准测试(在 1500 万条合成向量上测 ANN 延迟和召回率)不等于在真实、杂乱的文档上测得的端到端检索召回率,后者又不等于外推出来的容量上限。合成数据上的索引基准只能证明搜索本身可以扩展,却无法保证在你的语料上真的能召回正确的文本块。还要注意单位:1500 万个文档不等于 1500 万个向量——一个文档会切分成几十个文本块,所以 1500 万文档的语料库对应的是数亿个向量。上文 61 GB 的数字针对的是 1500 万个向量;文档数、文本块数、向量数要分开表述,任何具体的索引规模,在真正搭建并实测之前,都只能当作目标配置来对待。

哪种向量数据库能扛住 1500 万个向量?

大多数教程讲到“用向量数据库”时都会含糊带过,随手报一个自己恰好用过的产品。但到了 1500 万向量规模,这就是实打实的工程决策——存盘还是内存、是否支持量化、稠密和稀疏检索(BM25)是放在同一套系统里,还是拆成两套还得自己保持同步。下面是我对这个领域选型的真实看法:

存储方案 1500 万规模下的真实考量
Qdrant Rust 编写,基于 HNSW,存盘模式下的 payload 和向量是一等公民,内置标量/二值量化;原生支持稀疏向量,稠密混合检索可以放在同一套系统里。这个规模自部署时,我的默认选择。
Milvus 为数十亿级向量而生;支持 IVF-PQ/DiskANN 和水平分片。能力最强,运维负担也最重。我的粗略经验是:向量量进入数千万级才值得承担这套运维成本,几百万规模用它属于杀鸡用牛刀——当然,实际还得看向量维度、过滤条件、延迟目标和召回要求。
Weaviate 内置混合检索(稠密 + BM25),开发体验不错;不开存盘/压缩模式的话,内存占用要注意。
pgvector(Postgres) 如果数据本来就在 Postgres 里,HNSW 加上 halfvec/量化能走得很远,事务和 JOIN 也照常保留。调优后撑到一两千万向量没问题。
FAISS 它是个库,不是服务——做底层 ANN 实验无人能敌,但持久化、过滤和稀疏检索都得自己搭。适合藏在别的系统下面用,单独当整个存储层会很痛苦。
LanceDB 嵌入式、存盘列式存储,起步零运维。单机项目用它很舒服;一旦需要副本或分片,就会到瓶颈。

没有唯一正确答案——关键在于,到了 1500 万规模,“稠密加稀疏检索放同一套系统、存盘、支持量化”就是硬需求,你要挑的是能同时满足这三点、还不用额外维护同步流水线的那一个。我自己的选择是自部署 Qdrant;如果团队本来就深度使用 Postgres,在引入新基础设施之前不妨先试试 pgvector。


第二道关卡——约束生成:给出引用,否则闭嘴

此时上下文里已经有 20 个高质量片段了。但失败模式 #2 提醒我们,模型仍可能无视它们。第二道关卡就是要从结构上让“无视”变得困难。

强制逐条声明附引用

别指望模型会“主动利用上下文”。要强制要求它写出的每一句事实性陈述都附带引用——也就是chunk ID——并明确告知:凡是给不出引用的说法,要么删掉,要么标记为不确定。把输出固定成这样的格式:

{
  "answer": [
    {"claim": "ACME's Q2 2026 cloud revenue grew 3% QoQ.", "cites": ["chunk_8842"]},
    {"claim": "Growth was driven by enterprise seat expansion.", "cites": ["chunk_8847","chunk_8851"]}
  ],
  "unsupported": []
}

这么做有两个作用。一是把模型的注意力牵引到证据上(每句话都必须指向某个分块),二是给 Gate 3 提供了可以核实的具体对象。没有引用的论断,就是模型凭空编造的——而现在你能看出来,它再也无法藏进一段流畅的文字里。

有一条防线绝对不能省:模型可以输出格式完美的 JSON,引用一个它从未真正检索过的分块 id。格式正确≠引用真实。在信任任何论断之前,务必在服务端对每个被引用的 id 做校验,确认它存在于检索结果中——只有指向你手上实际存在的分块,引用才算真实。

# A citation is real only if it points at a retrieved chunk. Never trust the JSON shape.
def validate_citations(answer, retrieved_ids):
    valid = set(retrieved_ids)
    for item in answer["answer"]:
        item["cites"] = [cid for cid in item["cites"] if cid in valid]
    # a claim whose citations were all fabricated has nothing left — drop it
    answer["answer"] = [it for it in answer["answer"] if it["cites"]]
    return answer
CITED_SYSTEM = """Answer ONLY from the numbered context below.
Return JSON: {"answer": [{"claim": "...", "cites": ["<chunk_id>"]}], "unsupported": []}
Every claim must cite a chunk id that FULLY states it. If you cannot support a
sentence from the context, do not write it — name the topic in "unsupported" instead.
Never cite a chunk that does not state the claim."""

def generate_cited(question, chunks, llm):
    context = "\n\n".join(f"[{c['id']}] {c['source_text']}" for c in chunks)
    raw = llm(system=CITED_SYSTEM, user=f"{context}\n\nQuestion: {question}")
    return json.loads(raw)        # {"answer": [{claim, cites}], "unsupported": [...]}

用顺序和精简化解"中间迷失"问题

还记得失败案例二吗:正确的片段被埋在上下文中间。两个应对办法:


这是一种经验性规律,而非铁律——具体效应因模型、任务和上下文长度而异。证据排序应在你自己的目标模型上实测,不要当作固定规则。

诚实地补充一点:2025 年有一项复现研究发现,"中间迷失"效应并非普遍存在——当输入接近模型完整上下文窗口时,该效应会减弱,长上下文研究也并不总能复现它(arXiv:2508.07479)。所以把排序当成廉价的保险措施即可,别奉为圭臬。真正稳赚的优化仍然是"更少、更好的片段"。

检索之前先路由和分解

一次检索满足不了所有问题。"ACME 的退款政策是什么?"是简单查询。"ACME 过去三个季度的利润率如何变化、为什么?"则是多跳问题,没有任何单个片段能回答。

所以要先对问题分类,然后:

分解能直接防住一个隐蔽的坑:用一个碰巧排在最前面的片段,去回答含三个部分的问题。每个部分要么有各自的证据支撑,要么就别下结论。


第三道关卡——验证:给论断打分,而不是凭感觉

关于 Gate 2,有个不太好听的真相:你让模型引用来源,它有时会引用一段根本支撑不了论断的内容。它会把chunk_8842挂到一句那个片段压根没说过的话上。有引用是必要条件,不是充分条件——你必须逐一核查。


有引用不等于有支撑——论断可能指向一段真实存在但并不蕴含它的内容。逐条对照论断与其引用的来源:能支撑的保留,支撑不了的删除或放弃回答。

解法是论断级别的忠实性校验:

  1. 把生成的答案拆解成原子论断(每条只含一个断言)。
  2. 对每条论断,取出它引用的片段。
  3. 交给校验器判断:这些片段能蕴含这条论断吗?(可以用 NLI 模型,或者用大模型当裁判——提示词要严格限定在蕴含判断上,而不是回答质量。)
  4. 未通过蕴含检验的论断即无支撑——删掉它,或者把整个答案送进放弃门控。
# Check each claim against ONLY the chunks it cited — entailment, not helpfulness
NLI = ("Does the EVIDENCE fully entail the CLAIM? "
       "Reply SUPPORTED, NOT_ENTAILED, or CONTRADICTED.\nCLAIM: {c}\nEVIDENCE: {e}")

def verify(answer, chunks_by_id, judge):
    kept, rejected = [], []
    for item in answer["answer"]:
        evidence = "\n".join(chunks_by_id[cid]["source_text"] for cid in item["cites"])
        verdict = judge(NLI.format(c=item["claim"], e=evidence)).strip()
        (kept if verdict == "SUPPORTED" else rejected).append(item)
    return kept, rejected          # rejected → stripped, or trigger abstention (Gate 4)

为什么要做到原子级、逐条声明级?因为答案层面的分数会掩盖单条声明中的谎言。一条回答若有九条声明有据可查、一条是凭空捏造,在朴素的忠实度指标上能拿 0.9 分,捏造内容就这样溜进了线上系统。而只要逐条打分,捏造就无处遁形。这正是评估研究近年来的演进方向——RAGAS 框架将忠实度(faithfulness)确立为一等指标(Es et al., 2023),2025 年 Vectara 的FaithJudge等工作更是专门为检测无依据内容构建了排行榜(arXiv:2505.04847)。

提醒自己保持清醒:校验器本身也是模型,FaithJudge 自己的结论就是:检测无依据内容依然很难——评审模型也会漏判。校验只能减少抵达用户的幻觉,并不能清零。谁要向你兜售“100% 忠实”,谁就是在贩卖你本想消除的那种过度自信。

Self-RAG 和 Corrective-RAG 这两系列研究的存在也是出于同样的考量——它们把检查内嵌进了循环。Self-RAG(Asai et al., 2023)训练模型在生成过程中输出反思 token,自行判断相关性与支持度。Corrective RAG / CRAG(Yan et al., 2024)则在模型外部放置一个轻量评估器,对检索到的文档打分,一旦分数不佳就触发降级方案(比如网页搜索),而不是硬着头皮继续生成。两者与第三道关卡出自同一个直觉:不要轻信证据的第一稿。


第四道关卡——弃答:人人都当缺陷看待的功能

真正能让幻觉率趋近于零的就是这道关卡,但大多数团队偏偏拒绝实现它,因为弃答看起来像是认输。

当检索置信度偏低,或第三道关卡砍掉了太多声明,或剩余的支持依据过于单薄——系统就该直说。“我没有足够可靠的信息来回答这个问题。”而不是把猜测包装成答案。这是一次诚实、经过校准的弃答。

正确的思考方式是用一个 2×2 矩阵把两个不同的问题分开:证据是否充分?(可答 vs 不可答)以及系统是否作答了?(回答 vs 弃答)。这两者不在同一个维度上,把二者混为一谈正是自欺欺人的开始。


把“有没有证据”和“答没答上来”分开评估。最危险的格子是“无法回答 × 已回答”——也就是自信地给出没有依据的答案。有证据却弃答只是错失机会;没有证据时弃答则是安全的拒绝。覆盖率和风险要放在一起衡量。

最朴素的做法是设一个相似度阈值(“如果头部切片得分低于0.7就弃答”)。这招有点用,但在不同类型的问题上根本调不准。严谨的做法是经过校准、风险可控的弃答机制:

今天就能上线的工程做法:把(a)检索得分、(b)通过Gate 3检查的论断占比、(c)2到3次采样下答案的自洽程度,合成一个置信度,低于阈值就弃答。这个阈值必须在带标注的留出集上校准,否则它没有意义。同时要坦诚:你实际上线的是校准后的阈值;等你有了校准数据、也愿意为它的假设辩护时,才用上面的保形方法做严谨升级。调好的阈值不要称为“保证”。

def decide(kept, rejected, retrieval_score, samples, tau):
    # tau is CALIBRATED on a labeled held-out set — not guessed
    support_ratio = len(kept) / max(1, len(kept) + len(rejected))
    consistency   = agreement(samples)            # do 2-3 resampled answers agree?
    confidence = 0.4*retrieval_score + 0.4*support_ratio + 0.2*consistency
    if not kept or confidence < tau:
        return {"action": "abstain",
                "message": "I couldn't verify this from the available sources."}
    return {"action": "answer", "claims": kept}

换一个角度想,弃答就变得值得了:

在消费级搜索玩具里,答错顶多让人烦。但在法律、医疗、金融领域,信誓旦旦地答错才是灾难——正是那 17–33% 引起了斯坦福的注意。相比之下,弃答只是一次路由决策:转给人工处理、触发更深入的检索,或者坦诚地告诉用户。弃答是一条线索,自信的谎言才是负债。

一个说不出"我不知道"的 RAG 系统,在任何规模下都谈不上可信。它只是运气好,而运气总有耗尽的时候。

有个产品陷阱会悄悄废掉这道关卡(一位读者指出我之前低估了它):只有当下游没有任何环节把弃答当作可重试错误时,弃答才是安全的。只要给系统套上一层看似无害的"空结果就重试"循环,"我无法验证这一点"就会退化为"掷骰子掷出答案为止"——弃答关卡完好无损,却同时被彻底绕过,而且这一切在模型指标里毫无踪影。这道关卡是与整条调用链的契约,而不只是和模型之间的事:消费弃答结果的每个环节,都必须被允许接受它。


完整流水线,以及每道关卡拦截什么

关卡 组件 消灭的故障
检索 去重(MinHash/LSH)、上下文分块、混合检索 + RRF、重排 150→20 检索到错误的证据
约束 逐条声明的引用、小规模有序上下文、路由 + 分解 正确的证据被忽略或埋没
验证 原子声明分解 + 蕴含检查 声明引用了并不支持它的片段
弃答 校准/保形置信度 → 诚实地回答"我不知道" 没有证据却强行作答

让一个问题走完全部四道关卡,到达用户的理应是经过检索、引用并验证过的声明——或者一次坦诚的弃答。潜在的错误本应在途中被拦截,转化为"我无法验证这一点"。需要坦白的一点是:验证器本身也是模型,所以仍会有部分无支持的声明漏网,也会有一些有据可查的声明被误杀。这个残余率恰恰是你要测量的东西(下一节)——而不是可以假设为零的数字。

这就是目标:不是零失误,而是把自信却无依据的声明压向零,剩余比率靠测量和报告来交代,而不是靠承诺。


用数据说话——你真正该跑的评估

一个流水线若宣称"近乎零幻觉"却拿不出测量数据,那不过是换了个好听说法的幻觉。请在基于你自己的语料构建的标注评估集上,测量四项指标:

  1. Recall@k 检索失败率——标准答案片段未进入 top-k 的问题占比。这是上下文检索真正改善的数字。每次调整检索后都要对比前后变化。
  2. 论断支撑度——要用两种方式分别测量,因为二者并不等同。上下文忠实度(如 RAGAS 的 faithfulness):每条论断是否得到检索到的上下文整体支撑?引用蕴含(即第 3 道关卡实际做的事):论断所引用的具体段落是否支撑该论断?一条论断可能上下文忠实——结合全部检索内容看是真的——却引用了错误的片段,这是只看上下文的评分会漏掉的真实缺陷。两种都要测,而且要逐条评估论断,绝不评估整篇回答。
  3. 上下文精确率 / 召回率——正确的片段检索到了吗(召回率)?它们排在靠前位置吗(精确率)?
  4. 拒答校准——最容易被忽视的一项。系统作答时,正确率多高?系统拒答时,这些问题本会答错的概率多高?好的系统几乎只在本来会答错的问题上拒答。如果它在简单问题上拒答,或对难题充满信心地作答,说明阈值校准出了问题。

此外还有两条纪律,能区分出严谨的报告和华而不实的报告:

做消融实验,而不是只给一个分数。一个数字("忠实度 0.9")无法说明哪道关卡起了作用。在同一份评估集上按三种配置测量——(1) 基线 RAG,(2) 加上论断验证,(3) 再加上拒答——同时保持语料、问题、生成器和评判标准不变。对每种配置,在报告回答风险(已给出回答中的错误率)时,要同时报告回答覆盖率(选择作答的比例)。原因在于:靠几乎全部拒答换来的低风险毫无价值,覆盖率和风险只有放在一起看才有意义。真正证明某道控制关卡确实有效的,是这种消融实验,而不是架构图。

def coverage_and_risk(results):
    # results: [{"answered": bool, "correct": bool | None}]  (correct=None when abstained)
    answered = [r for r in results if r["answered"]]
    coverage = len(answered) / len(results)                        # how much it answered
    risk = sum(not r["correct"] for r in answered) / max(1, len(answered))  # error rate among answers
    return coverage, risk          # report BOTH — low risk at 3% coverage is worthless

要度量验证器本身,而不只是整个系统。你的 Gate-3 检查器也是一个模型,有自己的错误率,单一 AUROC 数字说明不了什么。要报告它在实际部署阈值下的精确率和召回率,报告误放行率(放过了多少缺乏依据的断言)和误拒绝率(错杀了多少有依据的断言),并人工抽检它的判断结果。更关键的是,要用贴近现实的失败案例去测试——比如论证有细微错误、引用看似合理却站不住脚的断言——而不是"牛顿发明了哪种语言"这种一眼假的荒谬前提。简单案例只会让验证器显得漂亮;真正会带到线上的,恰恰是那些细微错误。

样本量的陷阱。"我在 20 道测试题上零幻觉"并不能证明幻觉率接近零。要让 95% 单侧置信上界低于约 2%,需要大约 150 个零失败的干净案例——一旦出现一次失败,所需样本量立刻增加。请报告置信区间,而不是点估计。


我实测了最关键的那个命题——验证加弃答,用数据说话

整套架构目前只是提案。但它的核心赌注——先检查论据支撑,再让系统在没把握时弃答,就能减少"自信却错误"的回答——不需要 1500 万文档的集群就能验证。所以我测了。

实验设置(刻意做得小而聚焦)。SQuAD v2 是最合适的数据集,因为它同时包含可回答的问题(答案在文段中)和不可回答的问题(答案不在)——这正是测量拒答行为所需的标签。我使用每个问题的标准文段作为“检索到的证据”(这是有意把验证/拒答层与检索环节隔离开),用一个 81M 参数的抽取式问答模型(tinyroberta-squad2)同时充当生成器和验证器(利用其无答案分数),用一份校准集冻结拒答阈值,并留出一份包含 1,200 个问题的测试集(585 个不可回答 / 615 个可回答,seed 42)。

配置 覆盖率 回答风险 无依据回答率(仅统计不可回答问题)
基线——始终作答 100% 52.4% 100%(585/585)
+验证/拒答(默认) 54.3% 20.6% 18.3%
+校准拒答 46.8% 15.3% 11.6%

用数字看这笔交易:把“始终作答”改成“先验证,无依据时拒答”后,不可回答问题上的无依据回答率从 100% 降到 11.6%——下降了约 88 个百分点,且 95% 置信区间不重叠——代价是在真正可回答的问题上损失了约 20 个百分点的覆盖率(100% → 80.3%)。这就是全文的核心论点,用数据说话:以切实但有界的覆盖率损失,换来对自信编造的大幅削减。

两个诚实的发现,而不止一个。大部分收益来自引入任何验证/拒答层本身(无依据率 100% → 18.3%)。在此之上做校准,只是适度的增量提升(18.3% → 11.6%;总体风险 20.6% → 15.3%),而且以牺牲可回答问题的覆盖率为代价(88.5% → 80.3%)。我把它如实报告为增量改进,因为它确实是。

这套机制证明了什么、没证明什么。它展示的是机制本身——验证加弃答能减少无依据的回答,代价是可衡量的覆盖率下降——但实验规模很小,单一模型、单一数据集、使用黄金上下文。它并没有证明 1500 万文档规模下的端到端质量;它用抽取式 QA 模型替代了生成式模型来做演示;"验证器"是模型自带的"无法回答"判断头,而非独立的检查器;而且 SQuAD v2 里的不可回答问题也覆盖不了现实中各种类型的无依据回答。置信区间只反映抽样误差,不包括模型和数据集层面的差异。把它当作"控制机制有效"的证据来读——别指望你的语料库能跑出一模一样的数字,这也正是前面评估部分如此重要的原因。

95% Wilson 置信区间——无依据率:基线 100% [99.3, 100];+验证 18.3% [15.4, 21.6];+校准 11.6% [9.3, 14.5]。


它仍然会失效的地方(因为肯定会)

在一篇讲"不要胡说"的文章里,如果我把这套方案吹成无懈可击,那就是在打自己的脸。坦白说,这些地方仍是短板:

  • 超出拆解范围的多跳问题依然会失败。如果一个问题需要路由器没有预料到的推理链,任何单次检索都无法应付。
  • 成本和延迟是实打实的。对150个候选做重排、拆解问题、采样实现自洽、再跑一遍校验器,每个查询要调用好几次模型。接近零幻觉不是免费的;它是你花在值得的问题上的预算(简单的问题就走轻量路径)。
  • "迷失在中间"效应是有条件的。2025年的复现研究说明"给文本块排序"只是保险,不是保证——别围绕一个在你的模型和上下文长度下未必会触发的效应过度设计。
  • 这些都不是不建四道关卡的理由,而是提醒你:拒答关卡要保持诚实,评测集要持续扩充。


    带走这一个核心思想就够了

    大多数RAG建议都在讲如何让检索器更聪明。这有用,第一道关卡也很重要。但RAG在规模化时会说谎,根源不在检索器笨——而在于只要你不搭建一套能抓住漏洞、并且允许说"不"的系统,生成器永远会兴高采烈地用貌似合理的内容填补空白。

    检索到正确的证据,让答案只能引用这些证据,逐条核验每个论断是否真的有出处。而当证据不存在时,就拒答——大方地说出来,用校准过的措辞,把它当作一等公民级的输出。

    能在1500万篇文档规模上让人信赖的RAG系统,靠的不是答案最漂亮,而是它分得清答案和猜测的区别——并且绝不肯面不改色地把猜测塞给你。


    如果这篇文章对你有帮助,欢迎关注我——我写的都是如何构建对自身局限保持诚实的AI系统。下一篇:在没有标注数据的情况下,如何衡量拒答校准度。

    轮到你了:当检索结果为空时,你会怎么做——拒答、退回基础模型,还是触发一次网络搜索?有没有人真的在生产环境中上线过会说"我不知道"的RAG系统,而且用户还不反感?我是真心想知道。👇


    参考资料(每条资料的局限性已在文中相应位置注明):Anthropic《Introducing Contextual Retrieval》(2024 年 9 月);Liu 等人《Lost in the Middle》(arXiv:2307.03172)及 2025 年的复现研究(arXiv:2508.07479);Magesh 等人的法律 RAG 幻觉研究(Stanford RegLab,《J. Empirical Legal Studies》2025);Es 等人的 RAGAS(arXiv:2309.15217);Vectara FaithJudge——Tamber 等人,EMNLP 2025 Industry(arXiv:2505.04847);Asai 等人的 Self-RAG(arXiv:2310.11511);Yan 等人的 Corrective RAG(arXiv:2401.15884);Abbasi-Yadkori 等人的 Conformal Abstention(NeurIPS 2024)。文中法律 RAG 的数据基于 2024 年 5 月的产品版本测试得出——大方向上仍然成立,但新版本的表现可能有所不同。次要数据(17%–33% 的法律场景数字、上下文检索表格)均引自上述资料,本文未独立复现验证。

    ——

    广告 广告
    🧑‍💻

    zhirenhun

    一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

    rag ai llm machinelearning