数据STUDIO

10M+ 文档,RAG 幻觉压到 2%,这次又行了?

Image

文档越多,RAG 越容易遇到一个不太好看的事实:模型并不会因为语料库变大就自动变得更谨慎。相反,检索漏掉证据、上下文重复、引用指向错误,都会给生成器留下“补一个看起来合理的答案”的空间。

要把 RAG 的幻觉真正压下来,思路其实很朴素:先找证据,把生成限制在证据里,再逐条检查答案,最后把没有支持的结果变成拒答。

下面云朵君就从数据准备开始,把检索、生成、验证、拒答和规模测试一层层拆开。

从问题到有引用答案或拒答的完整 RAG 管道
从问题到有引用答案或拒答的完整 RAG 管道

01先把“近零”说清楚

这套系统解决的不是“如何让模型永远正确”,而是如何让错误尽量只有一种可见的形态:证据不够时拒答,而不是用流畅语言补齐事实。

整套设计可以压成四层:

  1. 找对证据:dense embedding、BM25、上下文感知 chunk 和 reranker 一起提高召回与精度。
  2. 限制生成:模型只能使用给定上下文,每个句子都要带 passage id;上下文没有答案时输出 INSUFFICIENT_EVIDENCE。
  3. 验证原子声明:把答案拆成可以单独核对的 claims,逐条和引用的文本比较。
  4. 必要时拒答:只要检索置信度或某条 claim 的支持度不够,就不把答案交给用户。

这里有两个目标同时存在。第一是信任:在它选择回答的问题上尽量少说错。第二是规模:检索骨架要能承载 10M 以上向量,并且保持可接受的查询延迟。前者依赖验证与拒答,后者依赖索引和磁盘布局,两件事不能相互替代。检索、约束生成、逐条验证、拒答四层控制链

检索、约束生成、逐条验证、拒答四层控制链

02先固定运行条件

第一步先把会影响结果的旋钮集中到一份配置里,并固定随机种子。这样做不只是为了让代码“能再次运行”,也是为了让后面的 risk-coverage、召回率和延迟数字有可比口径。

import json, os, random
import numpy as np

def set_determinism(seed: int) -> None:
    random.seed(seed)
    np.random.seed(seed)
    os.environ["PYTHONHASHSEED"] = str(seed)
try:
import torch
        torch.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
except Exception:
pass

set_determinism(42)

运行记录里的完整 profile 是 slice=20000、eval=100+100,运行在一张 NVIDIA H100 PCIe 上,显存约 79.6 GiB。生成器放在单独的 vLLM 服务中,客户端通过 OpenAI-compatible 接口访问本地服务;生成器是 Qwen/Qwen3-32B,嵌入模型和重排模型分别是 Qwen/Qwen3-Embedding-4B 与 Qwen/Qwen3-Reranker-4B。

关键配置如下:

chunk_tokens=256       chunk_overlap=32
retrieve_k=150         rerank_top_n=20
rrf_k=60               max_hops=3
crag_ok=0.7            crag_bad=0.4
tau_claim=0.3          tau_abstain=0.3
temperature=0          seed=42

生成器保持 temperature 0,并关闭 Qwen3 的 thinking trace。这样做主要是为了减少采样漂移,同时把更强的模型能力留给 faithfulness judge。整个设计的前提也是本地模型:文档和问题不离开这台机器。

项目设置:固定随机种子、检查 GPU、连接本地生成器并冻结配置
项目设置:固定随机种子、检查 GPU、连接本地生成器并冻结配置

03先构建一个能评测的语料

语料不是随手找一批文档就开始嵌入。这里没有把所有数据集混在一起,而是给它们分别安排不同角色:

  • HotpotQA distractor validation:既提供 Wikipedia 段落,也提供带有 sentence-level gold supporting facts 的可回答问题,用来评估检索召回。
  • SQuAD v2 impossible questions:补充不可回答问题。
  • 手写 false-premise questions:故意提出“艾因斯坦第二次获得诺贝尔物理学奖是哪一年”“牛顿在 1700 年发明了哪种编程语言”这类问题,检查系统会不会顺着错误前提编故事。
  • HaluBench:最后单独用来评估 verifier,而不是拿它直接构建主语料。

HotpotQA validation 一共读取 7,405 个问题,再把问题上下文中的段落合并成 corpus,同时保留每个 passage 对应的 gold question id。

from datasets import load_dataset

def load_hotpotqa(split: str = "validation"):
return load_dataset(
"hotpotqa/hotpot_qa",
"distractor",
        split=split,
        cache_dir=DS_CACHE,
    )

hotpot = load_hotpotqa()
print(f"[data] hotpotqa(validation) = {len(hotpot)} questions")

运行输出是:

[data] hotpotqa(validation) = 7405 questions
[corpus] passages=20007  qa_items=2073  gold-bearing passages=4072

样本 passage 平均约 89.2 个词,p95 约 236 个词。这里用“Were Scott Derrickson and Ed Wood of the same nationality?”作为贯穿示例:两个 gold passage 分别说明两人都是 American。这个例子很适合跟完整管道,因为它既不是简单关键词查找,也没有必要靠长篇生成才能回答。

HotpotQA、SQuAD v2 和 HaluBench 在管道中的分工
HotpotQA、SQuAD v2 和 HaluBench 在管道中的分工

04清洗、分块,再补一层上下文

清洗先做两件事:归一化文本,删除近重复。

清洗:归一化文本,再用 MinHash LSH 删除近重复,最终保留 19,987 个 passage
清洗:归一化文本,再用 MinHash LSH 删除近重复,最终保留 19,987 个 passage
import re
import unicodedata

def normalize_text(s: str) -> str:
    s = unicodedata.normalize("NFKC", s)
    s = s.replace("\u00ad", "")
    s = re.sub(r"[ \t]+", " ", s)
return s.strip()

NFKC 可以把兼容字符统一成同一形态,连续空格和 tab 也会收成一个空格。对 BM25 来说,这不是表面清洁:不同的 Unicode 形式可能让同一个词被分成不同 token,直接损失召回。

近重复用 MinHash LSH,而不是逐对比较。这里的配置是 threshold=0.9、num_perm=64。输入的 20,007 个 passage 经过一个过短片段的质量过滤和 19 个近重复删除后,保留 19,987 个。

这一步看起来只是清洗,实际上会直接影响后面的检索。索引当然会小一点,更重要的是,相同段落不会反复挤占 top results。三份内容一样的 passage 并不是三份独立证据,反而可能让生成器误以为某个事实得到了多次确认。

接下来不是直接把文本按固定字符数切开。这里改成按句子打包,并使用生成器自己的 tokenizer 计算 token:

class StructureAwareChunker:
def __init__(self, tokenizer, target_tokens=256, overlap=32):
self.tok = tokenizer
self.target = target_tokens
self.overlap = overlap

def chunk(self, passage):
        sents = split_sentences(passage.text) or [passage.text]
        chunks, cur, cur_tok = [], [], 0
for sent in sents:
            sent_tok = self._ntok(sent)
if cur and cur_tok + sent_tok > self.target:
                chunks.append(self._make(passage, cur))
                cur, cur_tok = (
                    ([cur[-1]], self._ntok(cur[-1]))
if self.overlap else ([], 0)
                )
            cur.append(sent)
            cur_tok += sent_tok
if cur:
            chunks.append(self._make(passage, cur))
return chunks

这样做是为了避免把实体所在的句子和解释它的句子切开,也避免静默截断刚好包含答案的那一行。19,987 个 passage 最终生成 21,259 个 chunk,平均 125 tokens,p95 为 236 tokens。

不过,句子边界仍然解决不了另一种问题。比如一个 chunk 只剩下“revenue grew 3 percent that quarter”,读者知道它说了什么,检索器却不知道是谁的 revenue、哪个 quarter。因此还要给每个 chunk 前置一条不超过 25 个词的上下文说明:

CONTEXTUALIZE_PROMPT = (
"Here is a document titled '{title}':\n<document>\n{doc}\n</document>\n\n"
"Here is a chunk from it:\n<chunk>\n{chunk}\n</chunk>\n\n"
"Give a short, single-sentence context (<=25 words) that situates this chunk "
"within the document so it can be retrieved on its own. Answer with the sentence only."
)

上下文生成通过 32 个并发 worker 执行,结果 checkpoint 后再进入索引。原文示例里,原本只有 “Ed Wood is a 1994 American biographical...” 的 chunk,前面多了一句“This chunk introduces the 1994 film Ed Wood...” 。原文不丢掉 chunk 本体,只把这句 situating sentence 放在它前面。

按句子和 token budget 分块,再为每个 chunk 加一行上下文
按句子和 token budget 分块,再为每个 chunk 加一行上下文模型加载与显存监控:生成器、嵌入模型、重排模型和 faithfulness judge 共用 H100
模型加载与显存监控:生成器、嵌入模型、重排模型和 faithfulness judge 共用 H100

05检索不是一个模型的工作

这里没有让 embedding 模型单独承担全部检索。原因很简单:dense embedding 擅长语义改写,但可能把罕见人名、ID、数字压进相近语义里;BM25 对这些精确 token 更敏感,却可能漏掉完全换了说法的 paraphrase。

因此每个 contextualized chunk 会进入两条索引:

  • dense vector 存在 LanceDB 的 on-disk 表中;
  • sparse posting 存在 BM25 索引中。

先用 embedding 得到归一化向量,再把 LanceDB 的 cosine distance 转成 0 到 1 的相似度。BM25 则用和文档相同的 stemmer 处理查询。

def rrf_fuse(rankings, k=60):
    scores = {}
for ranking in rankings:
for rank, cid in enumerate(ranking):
            scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores.items(), key=lambda item: -item[1])

RRF 不比较 BM25 分数和 cosine 分数,因为两种分数不在同一个尺度上。它只看排名:同一个 chunk 同时出现在两路结果的前面,就得到两份靠前排名的贡献。

一个很小的例子就能看出它的效果:

>>> rrf_fuse([["a", "b", "c"], ["b", "c", "a"]])
[('b', 0.03252), ('a', 0.03227), ('c', 0.03200)]

b 没有在任何一路排第一,却因为两路都靠前而胜出。混合检索阶段故意取宽一点的 150 个候选,随后再用 cross-encoder reranker 收窄到 20 个:

class RerankerStage:
def rerank(self, query, candidates, top_n):
        scores = self.reranker.score(
            query, [candidate.text for candidate in candidates]
        )
        ranked = sorted(
zip(candidates, scores),
            key=lambda item: -item[1],
        )[:top_n]
return [
self._mark(candidate, float(score), "reranked")
for candidate, score in ranked
        ]

cross-encoder 比 bi-encoder 更慢,但它仍按固定宽度处理 150 个候选。变化的是底层语料或索引规模:它可以从约 20,000 级别扩展到 10M;候选窗口并不会随语料规模一起变大。在这个贯穿示例中,两个 gold passage 都进入 reranked top-3,分数分别为 0.999 和 0.996,相关性较弱的 “Ed Wood (film)” 排在后面。

完整评估里的 context_recall@k=0.97。这个数字说明,在它的可回答评测集和配置上,后面的验证器通常有证据可以检查;它不等于所有语料都能得到同样的召回率。

一张卡里同时放 dense vector 和 BM25 posting
一张卡里同时放 dense vector 和 BM25 posting
RRF 只按 rank 融合,不要求两个检索器的分数可比
RRF 只按 rank 融合,不要求两个检索器的分数可比
150 个融合候选经过 reranker 后保留 20 个
150 个融合候选经过 reranker 后保留 20 个

06路由、引用生成与第一道拒答

不是所有问题都值得完整跑一遍检索。router 先把问题分成 no_retrieval、single_hop 和 multi_hop:

ROUTER_PROMPT = (
"Classify the question into exactly one label:\n"
"- no_retrieval: greetings/opinions or questions no document corpus could answer\n"
"- single_hop: answerable by finding one fact\n"
"- multi_hop: needs combining facts from multiple documents\n"
"Question: {q}\nReply with only the label."
)

multi_hop 问题会被拆成 2 到 3 个自洽子问题。false-premise detector 也会运行,但这里没有把它设成单独的硬门:一个小的 yes/no 检测器可能误判,最终仍要由证据 grade 和 claim verification 决定是否回答。

生成阶段是第一道真正的内容约束。system prompt 要求模型只使用编号 passage;每个句子末尾都要有引用;上下文没有答案时只能输出 INSUFFICIENT_EVIDENCE。

ABSTAIN_TOKEN = "INSUFFICIENT_EVIDENCE"
GENERATION_SYSTEM_PROMPT = (
"You answer strictly from the numbered context passages. Rules:\n"
"1. Use ONLY facts in the passages, never outside knowledge.\n"
f"2. If the passages do not contain the answer, reply with exactly: {ABSTAIN_TOKEN}\n"
"3. Every sentence MUST end with a citation to the passage id(s) it uses.\n"
"4. Be concise and factual."
)

模型输出之后还要过 citation parser。它的处理顺序有三步:先从文本中收集所有 citation,再把存在于当前候选 chunk 集合中的 id 与无效 id 分开,最后从原文清掉无效 citation,并返回有效 id 列表和清理后的文本。任何模型自己编出来的 [deadbeef0000] 都会被删除。

def parse_citations(text: str, valid_ids: set[str]) -> tuple[list[str], str]:
    found = _CITE_RE.findall(text)
    valid = [c for c in dict.fromkeys(found) if c in valid_ids]
    invalid = [c for c in dict.fromkeys(found) if c not in valid_ids]
    cleaned = text
for bad in invalid:
        cleaned = cleaned.replace(f"[{bad}]", "")
return valid, cleaned

例如,一条 citation 指向真实 passage,另一条使用模型虚构的 id:

>>> text = "Paris is the capital of France [a1b2c3d4e5f6]. The Louvre opened in 1793 [deadbeef0000]."
>>> parse_citations(text, valid_ids={"a1b2c3d4e5f6"})
(['a1b2c3d4e5f6'], 'Paris is the capital of France [a1b2c3d4e5f6]. The Louvre opened in 1793 .')

但到这里还远远不够。

citation parser 只能保证“这个引用 id 确实存在”,不能保证 passage 真正支持这句话。模型完全可能引用一段真实文本,然后把它读错。所以,有引用只是必要条件,不是充分条件。

查询路由到 no_retrieval、single_hop 或 multi_hop

查询路由到 no_retrieval、single_hop 或 multi_hop
生成阶段只使用上下文、逐句引用,证据不足时输出 abstain token
生成阶段只使用上下文、逐句引用,证据不足时输出 abstain token

07验证门:引用存在不等于说对

验证门把答案拆成 atomic claims,再逐条检查。这里最关键的判断是:答案不能用平均支持度通过,只要有一条声明明显不受支持,整条答案就要降级。

class VerificationGate:
def check(self, cited_answer, chunks):
        claims = self.extractor.extract(cited_answer.text)
        used = [
            chunk for chunk in chunks
if chunk.id in set(cited_answer.cited_ids)
        ] or chunks
        context = "\n\n".join(chunk.text for chunk in used)

        verdicts = []
for claim in claims:
            score = self.verifier.support(claim, context)
            verdicts.append({
"claim": claim,
"score": score,
"supported": score >= self.tau,
            })

        min_support = min(
            (item["score"] for item in verdicts),
            default=0.0,
        )
        passed = bool(verdicts) and all(
            item["supported"] for item in verdicts
        )
return passed, min_support, verdicts

tau 设为 0.3。贯穿示例的答案被拆成三条 claim:Scott Derrickson 是 American、Ed Wood 是 American、两人国籍相同,三条都得到 1.00,于是通过。

另一个 false-premise 示例里,模型试图回答“Marie Curie 飞到月球的飞船叫什么”。“Marie Curie was a physicist”得到 0.95,但“Marie Curie traveled to the Moon”只有 0.20。单条 claim 失败,整条答案被丢弃,最终输出拒答。

边界案例可以先经过一次 Chain-of-Verification 修订,让模型删除或弱化上下文不支持的句子,再重新运行验证。这里仍然是“重写后必须复查”,而不是相信修订模型自己说它已经修好了。

验证门把答案拆成 atomic claims,使用最弱 claim 决定是否通过
验证门把答案拆成 atomic claims,使用最弱 claim 决定是否通过

拒答因此是一个正式状态,而不是异常分支:

@dataclass
class FinalAnswer:
    status: str
    answer: str
    citations: list[str]
    min_support: float
    reason: str

class AbstentionPolicy:
def decide(self, route, cited, gate):
if route == "no_retrieval":
return self._abstain("routed_no_retrieval", gate)
if cited.abstained:
return self._abstain("model_abstained", gate)
if gate is None or not gate.passed:
return self._abstain("unsupported_claims", gate)
if gate.min_support < self.tau:
return self._abstain("unsupported_claims", gate)
return FinalAnswer(
"answered",
            cited.text,
            cited.cited_ids,
            gate.min_support,
"verified",
        )

示例里的拒答文本是:“I do not have enough supporting evidence in the available sources to answer this confidently.” 这句话的价值不在于措辞,而在于系统把它当成可统计、可调参的最终状态。

拒答策略把路由、模型主动拒答和验证失败统一成可审计结果

拒答策略把路由、模型主动拒答和验证失败统一成可审计结果

08用 CRAG 对弱证据重检索

如果前面的组件只是排成一条直线,第一次检索很弱时,系统就只能立刻拒答。

但有些问题不是没有答案,只是第一次 query 没找对。这时更合适的做法,是用 LangGraph 把它们连成一张带条件边的图:

route → retrieve → grade
                    ├─ strong   → generate → verify → finalize
                    ├─ weak     → finalize
                    └─ borderline → refine → retrieve

AgentState 里保存 question、route、evidence、grade、draft、gate、final、hops 和每个阶段的 latency。grade 节点先判断当前 passages 是否足够:

def after_grade(state):
    grade = state.get("grade", 0.0)
if grade >= CRAG_OK:
return "generate"
if grade < CRAG_BAD or state.get("hops", 0) >= MAX_HOPS:
return "generate" if grade >= CRAG_BAD else "finalize"
return "refine"

这里 crag_ok=0.7,高于它就生成;低于 crag_bad=0.4 就走 finalize,进入拒答路径。中间区域会拆分子问题、拓宽 query,再回到 retrieve;如果已经达到 3 个 hop,分支会停止继续 refine,此时仍处在可生成区间就生成,只有低于拒答阈值才 finalize。

贯穿示例的 grade 是 1.00,所以不需要重检索,最终 status=answered、reason=verified,端到端约 4.6 秒。另一个关于“牛顿在 1700 年发明了哪种编程语言”的问题,grade 只有 0.15,系统在生成之前就结束,返回拒答。

这个 loop 的重点不只是“多试几次”。它把重检索限制在明确的 hop budget 里,避免证据不足时无止境调用模型,也让每次重试都有可追踪的原因。

CRAG loop 在弱证据时 refine query,再回到 retrieve
CRAG loop 在弱证据时 refine query,再回到 retrieve

09评测:安全不是免费得到的

评测集用 200 个问题组成 balanced golden set:100 个 answerable,100 个 unanswerable。这个比例很重要。如果测试集几乎都是可回答问题,系统即使在不可回答问题上胡说,也可能拿到一个好看的总体分数。

运行结果的 2×2 矩阵是:

confusion (rows ans/unans, cols answered/abstained):
 [[46 54]
  [ 2 98]]

hallucinations (unanswerable answered): 2 / 100 unanswerable

底部一行说明,在 100 个不可回答问题里,系统拒答了 98 个,回答了 2 个。对应的 hallucination rate 是 2%。这不是 zero,而且只对这套测试集、模型和阈值成立。

代价在上面一行:100 个可回答问题只回答了 46 个,coverage 是 0.46。也就是说,这个系统宁愿放弃一部分本来可能答对的问题,也不愿把支持不足的答案交出去。

阈值不是只能凭感觉设置。阈值也不是凭感觉设置,而是通过 risk-coverage sweep,先找到 hallucination 不超过 5% 的 operating point,再从中选择 coverage 更高的阈值。其他指标如下:

faithfulness        0.908
answer_relevancy    0.817
context_recall@k    0.970
answerable_accuracy 0.580
coverage            0.460

这组数字共同说明:安全门不是免费层。提高阈值通常会带来更多拒答,降低错误风险,但也会减少覆盖率。高风险语料和普通知识问答,不应该共享同一组阈值。

100 个可回答、100 个不可回答问题组成的 balanced golden set
100 个可回答、100 个不可回答问题组成的 balanced golden set2x2 confusion matrix 中最危险的格子是 unanswerable answered
2x2 confusion matrix 中最危险的格子是 unanswerable answeredrisk-coverage curve 用来选择错误风险和覆盖率之间的 operating point
risk-coverage curve 用来选择错误风险和覆盖率之间的 operating point

还有一个不能跳过的检查:验证器自己是否可靠。verifier 还单独用 HaluBench 的 300 个样本做了评估,AUROC=0.702。这个结果高于随机,但离完美还有明显距离。

所以 verifier 是整个安全门的上限。它不需要完美到能证明每条 claim 绝对正确,但至少要足够经常把受支持声明排在不受支持声明前面,才能把 operating point 往安全方向移动。后续换更强的 verifier,可以不改动检索、生成和图结构。

HaluBench 上 verifier 的 ROC 曲线,AUROC=0.702
HaluBench 上 verifier 的 ROC 曲线,AUROC=0.702

1010M 向量的规模实验

质量管道和 20,000 级别的 curated slice 跑通之后,还要单独看规模问题。因为标题里写了 10M+,就必须把“能不能放下、查询有多慢”拿出来测,而不是只从小索引推断。

这次使用的是 1024 维随机单位向量,不是真实文档 embedding。每个规模都通过 Arrow 写入 LanceDB,并建立 IVF_PQ approximate nearest-neighbor index:

class ScaleBench:
def run(self, sizes):
        rows = []
for n in sizes:
            vecs = make_synthetic_vectors(n, dim=1024)
            db = lancedb.connect(str(SCRATCH_DIR / f"scale_{n}"))
            t0 = time.time()
            table = db.create_table(
"v",
                data=self._arrow(vecs),
                mode="overwrite",
            )
if n >= 100_000:
                table.create_index(
                    metric="cosine",
                    num_partitions=int(min(4096, max(256, n ** 0.5))),
                    num_sub_vectors=64,
                )
            build_s = time.time() - t0
            rows.append(self._measure(table, vecs, build_s))
return pd.DataFrame(rows)

结果如下:

向量数
build time
disk
p50
p95
recall@10
100,000
41.82s
0.39GB
8.50ms
10.59ms
0.135
1,000,000
81.22s
3.884GB
11.34ms
14.46ms
0.105
10,000,000
347.04s
38.825GB
16.91ms
18.48ms
0.105

10M 向量的 p95 是 18.48ms。相对于 100k 的 10.59ms,数据量扩大 100 倍,查询尾延迟不到两倍;磁盘则从 0.39GB 线性增长到 38.8GB。这个结果说明 on-disk ANN 的规模路径成立,但不说明真实语料的检索质量已经被证明。

特别是 recall@10 只有约 0.1。这里要特别注意:随机向量没有真实语义结构,近似索引几乎没有“正确邻居”可找。因此这部分 benchmark 主要测延迟、构建时间和磁盘占用,不能拿来替代真实 embedding 的 recall 评估。

100k、1M、10M 三个规模的 IVF_PQ benchmark
100k、1M、10M 三个规模的 IVF_PQ benchmark

在 10M 实测之后,又用 np.polyfit 线性外推到 100M:

def fit_and_extrapolate(df, target=100_000_000):
    n = df["n"].values.astype(float)
    out = {"target": target}
for col in ["build_s", "disk_gb", "p95_ms"]:
        a, b = np.polyfit(n, df[col].values, 1)
        out[col] = round(float(a * target + b), 2)
return out

projection 得到:

build_s = 3075.1
disk_gb = 388.23
p95_ms  = 77.58

这里必须把“projection”和“实测”分开。100M 没有实际构建,线性拟合只能说明这是根据三个测量点做出的估算,不能保证真实系统在更大规模仍按同一斜率增长。

100k 到 10M 的测量结果与 100M projection
100k 到 10M 的测量结果与 100M projection

11真正慢的地方可能不在向量库

规模实验测的是向量搜索,端到端 agent 还要把 route、embedding、BM25、reranker、grade、generate、verify 和可能发生的 refine 都算进去。

一次完整运行的阶段延迟汇总如下:

stage
p50(s)
p95(s)
mean(s)
total
4.001
17.668
5.823
retrieve
3.074
11.393
4.166
verify
1.534
3.878
1.758
generate
1.451
2.484
1.619
refine
1.471
2.888
1.575
route
0.168
0.206
0.170
grade
0.127
0.431
0.161

在这组运行条件下,retrieve 是平均耗时和 p95 都最高的阶段。这里的 retrieve 不只是向量库查询,还包括 query embedding、dense search、BM25 和对 150 个候选的 reranking;难题还可能重复经过纠错 loop。

所以真正要优化端到端延迟时,第一反应未必应该是“再换一个更快的向量数据库”。更直接的方向可能是减少模型调用、批处理 reranker、缓存 grade,或者减少不必要的重检索。

向量索引在 10M 上可以做到十几毫秒,但一次完整回答仍然可能花几秒。两者根本不是一个量级的问题。端到端 agent 的分阶段延迟,retrieve 占据主要预算

端到端 agent 的分阶段延迟,retrieve 占据主要预算

12写在最后

这套结果真正能说明的是一条工程路径,而不是“RAG 已经被解决”:

  • 数据清洗和结构感知分块,先减少检索阶段的噪声;
  • dense 与 BM25 互补,再用 RRF 和 reranker 把候选收窄;
  • 生成器不能只被要求“请引用”,还要检查引用 id 是否真实;
  • 一条答案要拆成 claims,任何一条不受支持都应该让整条答案进入拒答;
  • CRAG loop 可以给边界证据一次有限的重检索机会;
  • 200 题测试里,2% 的错误回答换来了 0.46 的可回答覆盖率;
  • verifier AUROC=0.702,说明验证器有效但仍是主要上限;
  • 10M 向量的 18.48ms p95 说明 on-disk IVF_PQ 可以把索引规模推上去,但随机向量 benchmark 不能代表真实语料质量;
  • 端到端延迟主要花在检索周边的模型调用,而不是单独的向量查找。

所以“近零幻觉”不是某个更大的模型参数带来的特性,而是一份更严格的输出契约:每个到达用户的句子,都要能回到检索文本;回不去的,就停在拒答。

后面还有四个很自然的优化方向:换更强的 verifier、用真实 embedding 重跑规模实验、在单机之外做 sharding 和 quantization,以及按领域重新校准 coverage。它们会改变性能和 operating point,但不会改变这条主线。

参考材料

  • 配套代码 — GitHub: FareedKhan-dev/rag-zero-hallucinations

Image