Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

检索增强生成:RAG

“工坊周三几点关门?”

如果把整本手册交给模型,答案也许能找到。但手册变成几百页,里面又混着旧公告、其他设备的说明和员工内部记录,事情就没那么简单了。我们真正需要的是:先找出这个人有权阅读、目前生效、与问题相关的几段,再根据这些证据回答。

这就是检索增强生成,通常缩写为 RAG。它给模型增加了一个查资料的过程,但查到资料之后,模型仍可能读错、漏看或编造。上一章里“引用正确、时间却答错”的例子还没有消失。

1. 把“找资料”和“写回答”分开看

一个最小流程可以写成:

Dq=R(q,D)y∼pθ( ⋅∣q,Dq) \begin{aligned} D_q&=R(q,\mathcal D)\\ y&\sim p_\theta(\,\cdot\mid q,D_q) \end{aligned}

qq 是问题,D\mathcal D 是可检索资料库,RR 表示检索过程,DqD_q 是取回的片段,yy 是模型生成的回答。

与只依赖参数里的知识相比,资料可以单独更新,回答也有机会追溯来源。Lewis 等人的 RAG 研究 把检索与生成结合起来;本章采用更容易拆解的应用流程,不复现原论文的联合训练。

图 1:离线准备资料与在线处理问题是两条相接的路径。资料更新后,要检查受影响的索引和缓存,不能只改原文件。

这里至少有两个不同的失败位置:检索没有找到需要的证据,或者证据已经给到模型,它却没有正确使用。只看最后的回答,不容易知道应该改哪一部分。

2. 文档先变成可追查的片段

先读懂文件,再决定在哪里切

PDF、网页、扫描件、Markdown 并不是同一种输入。PDF 抽出的文字可能按错栏排列;扫描件需要 OCR;表格里的数字离开列标题,含义可能就变了。文档解析的任务,是尽量保留这些结构与来源。

本书的实现刻意从简单格式开始:十份自编文档放在 JSON 中,正文只包含二级 Markdown 标题和纯文本段落。解析器不冒充通用 PDF 或网页解析器。

切块时也有取舍。块太短,容易把“可以取消”与“至少提前 2 小时”拆散;块太长,又可能把很多不相关内容一起塞进上下文。

本例先按标题下的段落切分,每块最多 180 个字符,超长时优先在句号后断开。字符上限只是解析器的规则,送入模型前还会按实际分词器检查词元数。当前资料很短,得到的十个片段都不需要再次截开。

图 2:章节标题帮助解释片段,字符偏移帮助回到原文。仅保存一串向量,无法完成可靠引用。

每块至少保留:

字段例子或作用
idhours:1,用于本次回答中的引用
document 与 version指向哪份文档、哪一版
start 与 end在原始正文中的字符位置
content能逐字核对的原段落
text加上标题和小节名后用于检索的文本
文档指纹发现原文是否发生变化

固定长度滑动窗口和适当重叠也是常见办法。重叠能减少边界损失,但会产生近重复候选,多取几块时可能仍只得到同一条信息。应根据文档结构和问题类型比较,而不是寻找一个适合所有资料的块长。

旧资料和内部资料,不能靠模型自觉避开

十份原始文档里,有一份已经归档的旧开放时间,以及一份员工内部演示记录。程序先排除它们,再建立公开索引;其余八份文档产生十个片段。

旧版写着 20:00 关门,当前版是 18:00。如果两个版本同时进入证据,单纯要求模型“选对的”会增加歧义。版本、生效状态和访问权限应当作为检索流程中的条件。

真实系统里,访问范围由服务端验证过的身份决定,不能相信问题里自称“我是管理员”的一句话。本例只有公开读者这一种身份,用简单字段演示过滤顺序。若越权内容已经进入模型上下文,再要求最终答案不要泄露,并不能撤销这次暴露。

3. 关键词检索:文字重合仍然很有用

查“PLA”“STL”或一个具体订单号时,精确匹配常常能迅速缩小范围。关键词检索并没有因为出现了大模型就失去价值。

本章用 BM25 给片段打分。它考虑几个直观因素:问题里的词在这块中出现了多少次,这个词是不是到处都有,以及片段本身有多长。

一种常用写法是:

s(q,d)=∑t∈qIDF⁡(t) g(t,d)g(t,d)=f(t,d)(k1+1)f(t,d)+k1(1−b+b∣d∣/L‾) \begin{aligned} s(q,d)&=\sum_{t\in q}\operatorname{IDF}(t)\,g(t,d)\\ g(t,d)&=\frac{f(t,d)(k_1+1)} {f(t,d)+k_1(1-b+b|d|/\overline L)} \end{aligned}

这里 f(t,d)f(t,d) 是词 tt 在片段 dd 中的出现次数,∣d∣|d| 是片段长度,L‾\overline L 是平均长度。本例按问题中的不同词求和,同一个问题词重复出现不再额外加权,取 k1=1.2k_1=1.2、b=0.75b=0.75。

分子随词频增加,分母也增加,因此重复十次不会简单地得到十倍贡献。长度项则避免长片段只凭“文字多、碰巧命中多”占太大便宜。

本例使用非负的 IDF 形式:

IDF⁡(t)=log⁡(1+N−nt+0.5nt+0.5) \operatorname{IDF}(t) =\log\left(1+\frac{N-n_t+0.5}{n_t+0.5}\right)

NN 是索引中的片段数,ntn_t 是包含该词的片段数。一个词越少见,通常越有区分力。若十块中只有一块出现某词,这个值约为 1.992。

关于 BM25 的概率背景和不同形式,可以继续阅读 Robertson 与 Zaragoza 的综述。这里先把公式与计数对应起来。

中文还涉及怎样切词。为了减少依赖,本例把连续中文转成单字和相邻双字,英文与数字串各作为一个项。这是容易检查的教学分词,不是成熟中文分词器;同义表达、分词歧义和标点两侧的关系都可能成为弱点。

4. 稠密检索:比较学出来的表示

“几点关门”与“结束营业的时间”未必共享很多字,却可能在问同一件事。稠密检索用编码模型把问题和片段映射成向量,再比较它们。

设问题向量为 u\mathbf u、片段向量为 v\mathbf v。余弦相似度为:

cos⁡(u,v)=uTv∥u∥ ∥v∥ \cos(\mathbf u,\mathbf v) =\frac{\mathbf u^\mathsf T\mathbf v} {\|\mathbf u\|\,\|\mathbf v\|}

把两个向量都除以自己的长度,归一化后就可以直接计算点积。这与第一章的向量运算相连,只是这些坐标现在来自训练过的编码器。

DPR 研究 展示了使用问题和段落编码器进行检索的路线。用于检索的表示需要合适的训练目标;随便拿一个语言模型的某层向量,求个平均,不一定能得到好用的检索器。

本例实际使用 BAAI/bge-small-zh-v1.5,固定版本 7999e1d3359715c523056ef9478215996d62a620。按模型说明取首个词元的最终隐藏状态,得到 512 维向量并归一化;短问题前加上模型约定的检索指令,片段不加。原始权重约 96 MB。

十个片段与十二个固定问题的 22 个实测向量保存在向量文件中。默认脚本直接用它们计算相似度,省去每次下载、加载编码器的时间。这些向量来自真实模型前向计算,不是为答案手工安排的坐标。

文件同时保存文本指纹与模型版本。改了文档或问题后,旧向量就不能继续冒充新文本的表示。对于新问题,也必须重新编码;固定题目的向量表不是一个能回答任意输入的服务接口。

高相似度,不是正确概率

“无线网络密码是什么”即使在资料中没有答案,仍然会与某几块内容相对接近。取第一名只是排序行为,不是模型确认找到了答案。

相似度阈值也不能凭直觉设成 0.5 就结束。分数分布与编码器、资料类型、问题有关;应在有答案和无答案的开发样本上一起检查,再看拒答与误答的取舍。

5. 混合检索与重排序,分别改变哪一步

关键词检索擅长可直接匹配的文字,稠密检索可能更容易连接不同措辞。可以各取一批候选,再融合两份排名。

本例使用 RRF,即倒数排名融合:

sRRF(d)=∑j1c+rank⁡j(d) s_{\mathrm{RRF}}(d) =\sum_j\frac{1}{c+\operatorname{rank}_j(d)}

jj 表示第几路检索,名次从 1 开始。没有进入某一路候选的片段,在那一路贡献 0。本例每路取前 5 块,c=60c=60,融合后取前 2 块。原始来源见 Cormack 等人的研究。

为什么不直接把 BM25 分数与余弦相似度相加?因为两种分数的尺度没有天然可比性。RRF 只看名次,减少了直接混合尺度的麻烦,但也舍弃了原始分差信息。它不是“融合以后一定更好”的保证。

图 3:本例运行前两路检索与 RRF。虚线框中的交叉编码重排序器用于说明扩展位置,本例没有加载这类模型。

重排序是在候选已经产生之后,再更仔细地判断问题与每块资料的关系。例如,交叉编码器把问题和片段一起输入模型,得到一个相关性分数:

sψ(q,d) s_\psi(q,d)

它可以利用两者之间更细的交互,但通常需要逐对计算;相比预先编码全部片段,成本不同。常见安排是先快速召回较多候选,再对这一小批做重排。

重排也有边界。如果相关段落根本没进入候选,它不能把消失的证据变回来。对需要多个来源的问题,最后选出的几块还应覆盖不同证据,而不是被同一段的几个近重复版本占满。

6. 运行检索实验:先检查证据有没有找齐

默认实验只用 Python 标准库,读取随书保存的资料与实测向量:

python3 src/examples/retrieval_qa.py
原始文档=10;公开且生效的片段=10;每次取 2 块
bm25:首位命中=7/7;平均 Recall@2=1.000;证据齐全=7/7
dense:首位命中=7/7;平均 Recall@2=1.000;证据齐全=7/7
rrf:首位命中=7/7;平均 Recall@2=1.000;证据齐全=7/7
多来源问题:lost:1, hours:1
无答案问题仍会返回:tools:1, hours:1
排名靠前不等于资料中有答案;引用存在不等于引用支持结论。

八道测试题中,七道有答案,一道没有答案。检索召回指标只对七道有答案题计算,不能把无答案题的空集合当成“轻松召回成功”。

若问题的必要证据集合为 GqG_q,前 kk 块的集合为 Rk(q)R_k(q),则:

Recall@⁡k=∣Gq∩Rk(q)∣∣Gq∣ \operatorname{Recall@}k =\frac{|G_q\cap R_k(q)|}{|G_q|}

本例再对问题求平均。“首位命中”只要求第一块是某条必要证据;“证据齐全”要求所有必要片段都在返回集合中。

例如“周三几点关门,失物保管多少天”需要 hours:1 与 lost:1。只返回失物片段,首位可以命中,召回却只有一半,证据也没有齐全。两个指标回答的是不同问题。

三种方法在这个小集合上都找齐了证据。它说明流程可以运行,不能证明稠密检索或融合一定优于 BM25。资料只有十块,问题与原文关系也较直接;要比较检索方法,需要更多措辞变化、相近但错误的段落、较长文档和独立问题。

单独下载时,需要 prompt_contracts.py 同目录,以及 data/ 下的文档、问题和向量三个文件。

展开完整代码:切块、BM25、向量相似度、RRF 与检索指标
"""第 32 章:Markdown 片段、BM25、实测稠密向量与 RRF;默认不下载模型。"""

from collections import Counter
import math
import re
from prompt_contracts import read_json, fingerprint

EMBEDDING_MODEL = "BAAI/bge-small-zh-v1.5"
EMBEDDING_REVISION = "7999e1d3359715c523056ef9478215996d62a620"
QUERY_PREFIX = "为这个句子生成表示以用于检索相关文章:"


def chunk_documents(documents, max_chars=180):
    """只处理本书使用的二级标题与纯文本段落,保留正文字符偏移。"""
    if max_chars < 1:
        raise ValueError("块长必须为正")
    chunks = []
    for doc in documents:
        # 本例只有公开读者这一种身份;生产系统需由服务端身份决定 ACL。
        if not doc["current"] or doc["visibility"] != "public":
            continue
        body = doc["body"]
        sections = list(re.finditer(r"(?m)^## ([^\n]+)\n([\s\S]*?)(?=^## |\Z)", body))
        if not sections or body[:sections[0].start()].strip():
            raise ValueError("本例要求正文从二级标题开始")
        part = 0
        for section in sections:
            for paragraph in re.finditer(r"[^\n]+", section.group(2)):
                start = section.start(2)+paragraph.start()
                remaining = paragraph.group()
                while remaining:
                    end = min(max_chars, len(remaining))
                    if end < len(remaining):
                        boundary = remaining.rfind("。", 0, end)
                        if boundary >= 0:
                            end = boundary+1
                    content = remaining[:end]
                    part += 1
                    chunks.append(dict(
                        id=f"{doc['id']}:{part}", document=doc["id"], version=doc["version"],
                        start=start, end=start+end, content=content,
                        text=f"{doc['title']} / {section.group(1)}\n{content}",
                        document_sha256=fingerprint(body),
                    ))
                    start += end
                    remaining = remaining[end:]
    if len({chunk["id"] for chunk in chunks}) != len(chunks):
        raise ValueError("片段 ID 重复")
    return chunks


def terms(text):
    result = []
    for run in re.findall(r"[\u4e00-\u9fff]+|[a-z0-9]+", text.lower()):
        if re.fullmatch(r"[a-z0-9]+", run):
            result.append(run)
        else:
            result.extend(run)
            result.extend(run[i:i+2] for i in range(len(run)-1))
    return result


def bm25(query, chunks, k1=1.2, b=.75):
    counts = [Counter(terms(chunk["text"])) for chunk in chunks]
    lengths = [sum(count.values()) for count in counts]
    average = sum(lengths)/len(lengths) if lengths else 1
    scores = [0.0]*len(chunks)
    for term in set(terms(query)):
        df = sum(term in count for count in counts)
        idf = math.log(1+(len(chunks)-df+.5)/(df+.5))
        for i, (count, length) in enumerate(zip(counts, lengths)):
            tf = count[term]
            denominator = tf+k1*(1-b+b*length/average)
            scores[i] += idf*tf*(k1+1)/denominator if denominator else 0
    return sorted(zip(chunks, scores), key=lambda row: (-row[1], row[0]["id"]))


def vector_inputs(chunks, questions):
    return ([dict(key=chunk["id"], text=chunk["text"]) for chunk in chunks]
            + [dict(key=case["id"], text=QUERY_PREFIX+case["question"]) for case in questions])


def load_vectors(chunks, questions, name="workshop-vectors.json"):
    saved = read_json(name)
    expected = vector_inputs(chunks, questions)
    if (saved["inputs_sha256"] != fingerprint(expected)
            or saved["model"] != EMBEDDING_MODEL or saved["revision"] != EMBEDDING_REVISION):
        raise ValueError("向量对应的文本或模型已变化,请重新生成向量")
    vectors = saved["vectors"]
    if set(vectors) != {item["key"] for item in expected}:
        raise ValueError("向量条目不完整")
    for vector in vectors.values():
        if len(vector) != 512 or any(not math.isfinite(x) for x in vector):
            raise ValueError("向量形状或数值错误")
        if abs(sum(x*x for x in vector)-1) > 1e-5:
            raise ValueError("向量未归一化")
    return vectors


def dense(case, chunks, vectors):
    q = vectors[case["id"]]
    return sorted(((chunk, sum(a*b for a, b in zip(q, vectors[chunk["id"]])))
                   for chunk in chunks), key=lambda row: (-row[1], row[0]["id"]))


def reciprocal_rank_fusion(rankings, c=60, candidate_k=5):
    scores, by_id = Counter(), {}
    for ranking in rankings:
        for position, (chunk, _) in enumerate(ranking[:candidate_k], 1):
            scores[chunk["id"]] += 1/(c+position)
            by_id[chunk["id"]] = chunk
    return [(by_id[key], score) for key, score in
            sorted(scores.items(), key=lambda row: (-row[1], row[0]))]


def retrieve(case, chunks, vectors, method="rrf", k=2):
    sparse = bm25(case["question"], chunks)
    neural = dense(case, chunks, vectors)
    ranked = {"bm25": sparse, "dense": neural,
              "rrf": reciprocal_rank_fusion([sparse, neural])}
    if method not in ranked:
        raise ValueError("未知检索方法")
    return [chunk for chunk, _ in ranked[method][:k]]


def retrieval_metrics(cases, rankings):
    answerable = [case for case in cases if case["gold"]]
    if not answerable:
        raise ValueError("没有有答案的问题,召回率无定义")
    recall = complete = hit1 = 0
    for case in answerable:
        expected = set(case["gold"])
        found = [chunk["id"] for chunk in rankings[case["id"]]]
        recall += len(expected.intersection(found))/len(expected)
        complete += expected.issubset(found)
        hit1 += bool(found and found[0] in expected)
    count = len(answerable)
    return dict(count=count, recall=recall/count, complete=complete, hit1=hit1)


def run():
    docs = read_json("workshop-documents.json")
    chunks = chunk_documents(docs)
    questions = read_json("workshop-questions.json")
    vectors = load_vectors(chunks, questions)
    cases = [case for case in questions if case["split"] == "test"]
    report = {}
    for method in ["bm25", "dense", "rrf"]:
        rankings = {case["id"]: retrieve(case, chunks, vectors, method) for case in cases}
        report[method] = dict(metrics=retrieval_metrics(cases, rankings),
                              rankings={key: [row["id"] for row in value]
                                        for key, value in rankings.items()})
    return dict(documents=len(docs), chunks=len(chunks), methods=report)


def main():
    report = run()
    print(f"原始文档={report['documents']};公开且生效的片段={report['chunks']};每次取 2 块")
    for method, row in report["methods"].items():
        m = row["metrics"]
        print(f"{method}:首位命中={m['hit1']}/{m['count']};"
              f"平均 Recall@2={m['recall']:.3f};证据齐全={m['complete']}/{m['count']}")
    print("多来源问题:"+", ".join(report["methods"]["rrf"]["rankings"]["test-two"]))
    print("无答案问题仍会返回:"+", ".join(report["methods"]["rrf"]["rankings"]["test-unknown"]))
    print("排名靠前不等于资料中有答案;引用存在不等于引用支持结论。")


if __name__ == "__main__":
    main()

重新生成向量

如果改了资料或问题,使用上一章的本地运行器重新编码:

uv run --python 3.12 --with-requirements src/examples/requirements-inference.txt python src/examples/local_application_models.py vectors --out runs/workshop-vectors.json

这会下载或复用嵌入模型,输出新的文本、版本与向量记录。检查后将新文件放到 src/examples/data/workshop-vectors.json,再运行检索脚本。本例没有近似向量索引,直接对十块计算点积;数据量很大时才需要进一步比较索引结构、召回损失和查询成本。

7. 接入生成模型,再做一次分层诊断

现在让 Qwen2.5-0.5B-Instruct 阅读检索结果。为保持输出约定一致,本章固定使用上一章的 v2 提示,比较四种证据条件:

条件交给模型的资料
none空资料,用作无检索对照
bm25BM25 返回的前两块
rrf融合检索返回的前两块
oracle人工标注的必要证据,用作诊断参考

这里的 oracle 不是实际应用里能调用的“万能检索器”,只是为了问:如果直接把正确证据给到模型,它能完成多少题? 它也不是对所有生成行为的严格上界,不同上下文可能影响结果。

八道题已经在上一章公开,所以这里把它们当作固定回归集,观察各环节怎样影响同一任务;不把这个结果包装成新的独立基准。所有条件使用相同生成模型和设置,原始回答保存在生成实验记录。

重新生成的命令是:

uv run --python 3.12 --with-requirements src/examples/requirements-inference.txt python src/examples/local_application_models.py rag --out runs/workshop-rag.json

我们分别检查接口契约、预先约定的短答案、答案与来源集合是否同时正确,并保留无答案题的拒答情况。实测结果如下:

先看格式与完整任务:

证据条件接口契约通过任务合计
none1/81/8
bm255/82/8
rrf5/82/8
oracle6/84/8

再把任务分开看。有答案的七题要求答案与引用都通过;没有答案的一题要求正确拒答。

证据条件有答案题无答案题
none0/71/1
bm252/70/1
rrf2/70/1
oracle3/71/1

图 4:同一小模型、同一 v2 提示、同一组问题。这里比较的是完整应用结果;检索本身的证据覆盖在上一节单独统计。

所有 32 次生成都正常结束,但“生成结束”与“任务完成”显然不同。融合检索给出的证据是齐全的,仍出现了这些回答:

问题模型实际输出的要点失败在哪里
提前多久能自行取消?答“2小时”,引用数组为空答案内容符合约定,引用缺失
能把工具带回家吗?答“可以”,引用开放时间片段结论错误,引用也不支持结论
关门时间和失物期限?答“周三14:00关门,保管5天”正确证据在输入中,仍误读了两个事实

无答案题也暴露出问题。空资料时,v2 正确拒答;加入主题相近却没有密码的片段后,它输出空答案、非空引用,并设置 unknown=false,违反了接口约定。相关资料有时会干扰行为,不能把“检索到两块”当成成功信号。

oracle 比检索条件多完成两题,但仍只有 4/8。两者同时改变了证据数量、无关内容和部分顺序,这个对照不能单独证明究竟是哪一个因素造成差异。它能告诉我们:目前生成环节本身就有明显瓶颈,仅增加检索方法的复杂度,解决不了全部问题。

这套小模型与配置还不能作为可靠的自动资料服务。可以在开发阶段比较更合适的模型、简化证据表达或增加独立核验,再用新问题检查效果。不要用一个固定答案表替换模型输出,却仍把改善归功于 RAG。

不加载模型,复核已有实验记录

随书保留了真实生成结果,可以在不下载模型的情况下检查输入指纹、评分规则与汇总是否一致:

python3 src/examples/application_records.py
prompts:读取并复核 36 条真实生成记录
  v0/dev:字段形状=0/4;契约=0/4;答案与引用=0/4;正常结束=4/4
  v0/test:字段形状=0/8;契约=0/8;答案与引用=0/8;正常结束=8/8
  v1/dev:字段形状=2/4;契约=1/4;答案与引用=1/4;正常结束=4/4
  v1/test:字段形状=7/8;契约=4/8;答案与引用=0/8;正常结束=8/8
  v2/dev:字段形状=4/4;契约=2/4;答案与引用=1/4;正常结束=4/4
  v2/test:字段形状=8/8;契约=6/8;答案与引用=4/8;正常结束=8/8
schema:读取并复核 4 条真实生成记录
  v1-schema/dev:字段形状=4/4;契约=2/4;答案与引用=2/4;正常结束=4/4
rag:读取并复核 32 条真实生成记录
  bm25/test:字段形状=8/8;契约=5/8;答案与引用=2/8;正常结束=8/8
  none/test:字段形状=7/8;契约=1/8;答案与引用=1/8;正常结束=8/8
  oracle/test:字段形状=8/8;契约=6/8;答案与引用=4/8;正常结束=8/8
  rrf/test:字段形状=8/8;契约=5/8;答案与引用=2/8;正常结束=8/8

“字段形状”只检查字段集合与类型;“契约”进一步检查来源和拒答约定。这个脚本是在重新评分保存的回答,没有再次进行模型推理。要验证模型本身的生成,请运行前面的真实实验命令。

它与前三份示例脚本放在同一目录,并读取 data/ 中的文档、问题、向量和三份模型记录。完整仓库已经按这个结构组织。

展开完整代码:核对真实记录的输入、完整性与评分
"""核对并汇总第 31、32 章保存的真实模型记录;不会重新调用模型。"""

from prompt_contracts import read_json, fingerprint, messages, grade, strict_json
from retrieval_qa import chunk_documents, load_vectors, retrieve
from local_application_models import MODEL, REVISION


def read_record(mode):
    report = read_json(f"workshop-{mode}.json")
    docs = read_json("workshop-documents.json")
    questions = read_json("workshop-questions.json")
    chunks = chunk_documents(docs)
    by_id = {chunk["id"]: chunk for chunk in chunks}
    vectors = load_vectors(chunks, questions) if mode == "rag" else None
    assert report["complete"] and report["mode"] == mode, "实验未完成或类型错误"
    assert (report["model"], report["revision"]) == (MODEL, REVISION), "模型版本错误"
    assert report["documents_sha256"] == fingerprint(docs), "资料已变化"
    assert report["questions_sha256"] == fingerprint(questions), "问题已变化"
    assert report["max_new_tokens"] == 96 and report["do_sample"] is False
    assert report["environment"]["transformers"] == "4.57.3"
    settings = (["v0", "v1", "v2"] if mode == "prompts" else
                ["v1-schema"] if mode == "schema" else ["none", "bm25", "rrf", "oracle"])
    cases = (questions if mode == "prompts" else
             [q for q in questions if q["split"] == ("dev" if mode == "schema" else "test")])
    expected_keys = {(setting, case["id"]) for setting in settings for case in cases}
    actual_keys = [(row["setting"], row["case_id"]) for row in report["rows"]]
    assert len(actual_keys) == len(set(actual_keys)) and set(actual_keys) == expected_keys
    by_case = {case["id"]: case for case in cases}
    for row in report["rows"]:
        setting, case = row["setting"], by_case[row["case_id"]]
        version = setting if mode == "prompts" else "v1" if mode == "schema" else "v2"
        if mode in {"prompts", "schema"} or setting == "oracle":
            selected = [by_id[key] for key in case["gold"]]
        elif setting == "none":
            selected = []
        else:
            selected = retrieve(case, chunks, vectors, setting)
        conversation = messages(case["question"], selected, version)
        assert row["messages"] == conversation
        assert row["prompt_sha256"] == fingerprint(conversation)
        assert row["chunks"] == [chunk["id"] for chunk in selected]
        assert row["split"] == case["split"]
        assert 0 < row["output_tokens"] <= 96 and row["input_tokens"]+96 <= 2048
        for key, value in grade(row["text"], case, selected).items():
            assert row[key] == value, (mode, setting, case["id"], key)
    return report


def schema_shape(text):
    """单独统计字段集合与类型;引用存在性和拒答约定由 validate 继续检查。"""
    try:
        value = strict_json(text)
        return (isinstance(value, dict) and set(value) == {"answer", "sources", "unknown"}
                and isinstance(value["answer"], str) and type(value["unknown"]) is bool
                and isinstance(value["sources"], list)
                and all(isinstance(item, str) for item in value["sources"]))
    except ValueError:
        return False


def main():
    for mode in ["prompts", "schema", "rag"]:
        report = read_record(mode)
        groups = sorted({(row["setting"], row["split"]) for row in report["rows"]})
        print(f"{mode}:读取并复核 {len(report['rows'])} 条真实生成记录")
        for setting, split in groups:
            rows = [row for row in report["rows"] if (row["setting"], row["split"]) == (setting, split)]
            total = len(rows)
            print(f"  {setting}/{split}:字段形状={sum(schema_shape(r['text']) for r in rows)}/{total};"
                  f"契约={sum(r['valid'] for r in rows)}/{total};"
                  f"答案与引用={sum(r['grounded'] for r in rows)}/{total};"
                  f"正常结束={sum(r['stopped'] for r in rows)}/{total}")


if __name__ == "__main__":
    main()

8. 引用与拒答,是回答的一部分

一个可靠的引用至少要回答三个问题:这份来源存在吗?它确实是本次模型看过的版本吗?它是否支持当前这句话?

前两个问题通常可以程序化检查:把引用 ID 限制在取回的片段内,核对文档版本和指纹。第三个问题需要理解陈述与证据之间的关系,复杂场景还需要人工抽查、独立核对或经过评估的辅助模型。

本例因为问题很窄,可以预先标注短答案和必要来源集合,自动检查“18:00”是否配着 hours:1。真实长答案可能含多个事实:一条引用支持收费标准,不一定支持后面关于预约时限的另一句话。不能只统计“回答里有没有方括号”。

资料不足时,系统还要区分几种情况:根本没找到相关内容,找到了相关主题但没有所问信息,来源之间互相冲突,或者资料版本已经失效。这些情况不适合统一编一个答案。

拒答也不是越多越好。把所有问题都拒绝,可以让编造变少,却让助手失去用途。需要同时检查“无答案时是否停下”和“有答案时是否正确回答”,并为需要澄清或人工处理的情况留出入口。

RAG 能让知识更新和来源追查更直接,但不能自动保证事实正确,也不能消除恶意文档中的提示注入。权限过滤、资料边界、输出校验与执行控制,仍然是应用自身的责任。

9. 停下来试一试

  1. 为什么资料更新后,不能只改原文件而保留原来的片段和向量?
  2. 把一句“至少提前 2 小时才能自行取消”从中间切开,会怎样影响回答?
  3. 十块中只有一块出现某词,按本章公式计算 IDF,结果大约是多少?
  4. 两个向量已经归一化,余弦相似度为何等于点积?
  5. 某块在两路候选中分别排第 1、第 3,c=60c=60 时 RRF 分数是多少?
  6. 一个问题需要两块证据,检索只找回其中一块。首位命中、Recall@1 与证据齐全分别可能是什么?
  7. 检索返回的首位分数很高,是否就可以不检查资料不足?
  8. 把 hours:1 从候选库移除,重新检查关门时间题。这个实验能区分哪些失败?
  9. 为这份资料库添加三道原文里没有答案的问题,以及三道措辞差异较大的有答案问题。先固定答案和来源,再比较三种检索方法。
参考答案与提示
  1. 旧向量对应旧文本,片段位置和引用也可能失效。要更新受影响的表示,并记录版本。
  2. 模型可能只看到操作而没看到限制,或只看到时间却不知道限制什么。块边界应尽量保留完整条件。
  3. log⁡(1+9.5/1.5)≈1.992\log(1+9.5/1.5)\approx1.992。
  4. 两个长度都是 1,余弦公式的分母变为 1。
  5. 1/61+1/63≈0.032271/61+1/63\approx0.03227。不应与另一种检索器的原始分数直接比较。
  6. 首位命中可以为真;Recall@1 为 0.5;证据不齐全。命中一条不能代表完成整个问题。
  7. 不可以。排序和相似度不是答案存在或事实正确的概率。
  8. 它构造了明确的证据缺失。再与人工提供正确证据的结果比较,可以区分检索缺失与模型误读;不要把模型猜对当成检索成功。
  9. 同时记录有答案题的证据覆盖和无答案题的实际生成行为。若据此反复改配置,这些题就成为开发集,另留新题检验效果。

10. 继续阅读

资料助手现在能查阅手册,但“查询我的预约”和“按重量算报价”需要访问状态或执行计算。下一章把这些动作接成明确的工具接口。