检索增强生成:RAG
“工坊周三几点关门?”
如果把整本手册交给模型,答案也许能找到。但手册变成几百页,里面又混着旧公告、其他设备的说明和员工内部记录,事情就没那么简单了。我们真正需要的是:先找出这个人有权阅读、目前生效、与问题相关的几段,再根据这些证据回答。
这就是检索增强生成,通常缩写为 RAG。它给模型增加了一个查资料的过程,但查到资料之后,模型仍可能读错、漏看或编造。上一章里“引用正确、时间却答错”的例子还没有消失。
1. 把“找资料”和“写回答”分开看
一个最小流程可以写成:
是问题, 是可检索资料库, 表示检索过程, 是取回的片段, 是模型生成的回答。
与只依赖参数里的知识相比,资料可以单独更新,回答也有机会追溯来源。Lewis 等人的 RAG 研究 把检索与生成结合起来;本章采用更容易拆解的应用流程,不复现原论文的联合训练。
图 1:离线准备资料与在线处理问题是两条相接的路径。资料更新后,要检查受影响的索引和缓存,不能只改原文件。
这里至少有两个不同的失败位置:检索没有找到需要的证据,或者证据已经给到模型,它却没有正确使用。只看最后的回答,不容易知道应该改哪一部分。
2. 文档先变成可追查的片段
先读懂文件,再决定在哪里切
PDF、网页、扫描件、Markdown 并不是同一种输入。PDF 抽出的文字可能按错栏排列;扫描件需要 OCR;表格里的数字离开列标题,含义可能就变了。文档解析的任务,是尽量保留这些结构与来源。
本书的实现刻意从简单格式开始:十份自编文档放在 JSON 中,正文只包含二级 Markdown 标题和纯文本段落。解析器不冒充通用 PDF 或网页解析器。
切块时也有取舍。块太短,容易把“可以取消”与“至少提前 2 小时”拆散;块太长,又可能把很多不相关内容一起塞进上下文。
本例先按标题下的段落切分,每块最多 180 个字符,超长时优先在句号后断开。字符上限只是解析器的规则,送入模型前还会按实际分词器检查词元数。当前资料很短,得到的十个片段都不需要再次截开。
图 2:章节标题帮助解释片段,字符偏移帮助回到原文。仅保存一串向量,无法完成可靠引用。
每块至少保留:
| 字段 | 例子或作用 |
|---|---|
id | hours:1,用于本次回答中的引用 |
document 与 version | 指向哪份文档、哪一版 |
start 与 end | 在原始正文中的字符位置 |
content | 能逐字核对的原段落 |
text | 加上标题和小节名后用于检索的文本 |
| 文档指纹 | 发现原文是否发生变化 |
固定长度滑动窗口和适当重叠也是常见办法。重叠能减少边界损失,但会产生近重复候选,多取几块时可能仍只得到同一条信息。应根据文档结构和问题类型比较,而不是寻找一个适合所有资料的块长。
旧资料和内部资料,不能靠模型自觉避开
十份原始文档里,有一份已经归档的旧开放时间,以及一份员工内部演示记录。程序先排除它们,再建立公开索引;其余八份文档产生十个片段。
旧版写着 20:00 关门,当前版是 18:00。如果两个版本同时进入证据,单纯要求模型“选对的”会增加歧义。版本、生效状态和访问权限应当作为检索流程中的条件。
真实系统里,访问范围由服务端验证过的身份决定,不能相信问题里自称“我是管理员”的一句话。本例只有公开读者这一种身份,用简单字段演示过滤顺序。若越权内容已经进入模型上下文,再要求最终答案不要泄露,并不能撤销这次暴露。
3. 关键词检索:文字重合仍然很有用
查“PLA”“STL”或一个具体订单号时,精确匹配常常能迅速缩小范围。关键词检索并没有因为出现了大模型就失去价值。
本章用 BM25 给片段打分。它考虑几个直观因素:问题里的词在这块中出现了多少次,这个词是不是到处都有,以及片段本身有多长。
一种常用写法是:
这里 是词 在片段 中的出现次数, 是片段长度, 是平均长度。本例按问题中的不同词求和,同一个问题词重复出现不再额外加权,取 、。
分子随词频增加,分母也增加,因此重复十次不会简单地得到十倍贡献。长度项则避免长片段只凭“文字多、碰巧命中多”占太大便宜。
本例使用非负的 IDF 形式:
是索引中的片段数, 是包含该词的片段数。一个词越少见,通常越有区分力。若十块中只有一块出现某词,这个值约为 1.992。
关于 BM25 的概率背景和不同形式,可以继续阅读 Robertson 与 Zaragoza 的综述。这里先把公式与计数对应起来。
中文还涉及怎样切词。为了减少依赖,本例把连续中文转成单字和相邻双字,英文与数字串各作为一个项。这是容易检查的教学分词,不是成熟中文分词器;同义表达、分词歧义和标点两侧的关系都可能成为弱点。
4. 稠密检索:比较学出来的表示
“几点关门”与“结束营业的时间”未必共享很多字,却可能在问同一件事。稠密检索用编码模型把问题和片段映射成向量,再比较它们。
设问题向量为 、片段向量为 。余弦相似度为:
把两个向量都除以自己的长度,归一化后就可以直接计算点积。这与第一章的向量运算相连,只是这些坐标现在来自训练过的编码器。
DPR 研究 展示了使用问题和段落编码器进行检索的路线。用于检索的表示需要合适的训练目标;随便拿一个语言模型的某层向量,求个平均,不一定能得到好用的检索器。
本例实际使用 BAAI/bge-small-zh-v1.5,固定版本 7999e1d3359715c523056ef9478215996d62a620。按模型说明取首个词元的最终隐藏状态,得到 512 维向量并归一化;短问题前加上模型约定的检索指令,片段不加。原始权重约 96 MB。
十个片段与十二个固定问题的 22 个实测向量保存在向量文件中。默认脚本直接用它们计算相似度,省去每次下载、加载编码器的时间。这些向量来自真实模型前向计算,不是为答案手工安排的坐标。
文件同时保存文本指纹与模型版本。改了文档或问题后,旧向量就不能继续冒充新文本的表示。对于新问题,也必须重新编码;固定题目的向量表不是一个能回答任意输入的服务接口。
高相似度,不是正确概率
“无线网络密码是什么”即使在资料中没有答案,仍然会与某几块内容相对接近。取第一名只是排序行为,不是模型确认找到了答案。
相似度阈值也不能凭直觉设成 0.5 就结束。分数分布与编码器、资料类型、问题有关;应在有答案和无答案的开发样本上一起检查,再看拒答与误答的取舍。
5. 混合检索与重排序,分别改变哪一步
关键词检索擅长可直接匹配的文字,稠密检索可能更容易连接不同措辞。可以各取一批候选,再融合两份排名。
本例使用 RRF,即倒数排名融合:
表示第几路检索,名次从 1 开始。没有进入某一路候选的片段,在那一路贡献 0。本例每路取前 5 块,,融合后取前 2 块。原始来源见 Cormack 等人的研究。
为什么不直接把 BM25 分数与余弦相似度相加?因为两种分数的尺度没有天然可比性。RRF 只看名次,减少了直接混合尺度的麻烦,但也舍弃了原始分差信息。它不是“融合以后一定更好”的保证。
图 3:本例运行前两路检索与 RRF。虚线框中的交叉编码重排序器用于说明扩展位置,本例没有加载这类模型。
重排序是在候选已经产生之后,再更仔细地判断问题与每块资料的关系。例如,交叉编码器把问题和片段一起输入模型,得到一个相关性分数:
它可以利用两者之间更细的交互,但通常需要逐对计算;相比预先编码全部片段,成本不同。常见安排是先快速召回较多候选,再对这一小批做重排。
重排也有边界。如果相关段落根本没进入候选,它不能把消失的证据变回来。对需要多个来源的问题,最后选出的几块还应覆盖不同证据,而不是被同一段的几个近重复版本占满。
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
排名靠前不等于资料中有答案;引用存在不等于引用支持结论。
八道测试题中,七道有答案,一道没有答案。检索召回指标只对七道有答案题计算,不能把无答案题的空集合当成“轻松召回成功”。
若问题的必要证据集合为 ,前 块的集合为 ,则:
本例再对问题求平均。“首位命中”只要求第一块是某条必要证据;“证据齐全”要求所有必要片段都在返回集合中。
例如“周三几点关门,失物保管多少天”需要 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 | 空资料,用作无检索对照 |
| bm25 | BM25 返回的前两块 |
| 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
我们分别检查接口契约、预先约定的短答案、答案与来源集合是否同时正确,并保留无答案题的拒答情况。实测结果如下:
先看格式与完整任务:
| 证据条件 | 接口契约通过 | 任务合计 |
|---|---|---|
| none | 1/8 | 1/8 |
| bm25 | 5/8 | 2/8 |
| rrf | 5/8 | 2/8 |
| oracle | 6/8 | 4/8 |
再把任务分开看。有答案的七题要求答案与引用都通过;没有答案的一题要求正确拒答。
| 证据条件 | 有答案题 | 无答案题 |
|---|---|---|
| none | 0/7 | 1/1 |
| bm25 | 2/7 | 0/1 |
| rrf | 2/7 | 0/1 |
| oracle | 3/7 | 1/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. 停下来试一试
- 为什么资料更新后,不能只改原文件而保留原来的片段和向量?
- 把一句“至少提前 2 小时才能自行取消”从中间切开,会怎样影响回答?
- 十块中只有一块出现某词,按本章公式计算 IDF,结果大约是多少?
- 两个向量已经归一化,余弦相似度为何等于点积?
- 某块在两路候选中分别排第 1、第 3, 时 RRF 分数是多少?
- 一个问题需要两块证据,检索只找回其中一块。首位命中、Recall@1 与证据齐全分别可能是什么?
- 检索返回的首位分数很高,是否就可以不检查资料不足?
- 把
hours:1从候选库移除,重新检查关门时间题。这个实验能区分哪些失败? - 为这份资料库添加三道原文里没有答案的问题,以及三道措辞差异较大的有答案问题。先固定答案和来源,再比较三种检索方法。
参考答案与提示
- 旧向量对应旧文本,片段位置和引用也可能失效。要更新受影响的表示,并记录版本。
- 模型可能只看到操作而没看到限制,或只看到时间却不知道限制什么。块边界应尽量保留完整条件。
- 。
- 两个长度都是 1,余弦公式的分母变为 1。
- 。不应与另一种检索器的原始分数直接比较。
- 首位命中可以为真;Recall@1 为 0.5;证据不齐全。命中一条不能代表完成整个问题。
- 不可以。排序和相似度不是答案存在或事实正确的概率。
- 它构造了明确的证据缺失。再与人工提供正确证据的结果比较,可以区分检索缺失与模型误读;不要把模型猜对当成检索成功。
- 同时记录有答案题的证据覆盖和无答案题的实际生成行为。若据此反复改配置,这些题就成为开发集,另留新题检验效果。
10. 继续阅读
- Lewis 等,Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks:检索与生成结合的原始研究。
- Robertson 与 Zaragoza,The Probabilistic Relevance Framework: BM25 and Beyond:BM25 背后的建模思路。
- Karpukhin 等,Dense Passage Retrieval:训练问题和段落的稠密表示。
- Cormack 等,Reciprocal Rank Fusion:使用排名融合检索结果。
- BGE 模型说明:本例的向量提取、查询前缀与归一化方式。
资料助手现在能查阅手册,但“查询我的预约”和“按重量算报价”需要访问状态或执行计算。下一章把这些动作接成明确的工具接口。