←返回大模型学习资料
学习资料第 10 章 · Embedding 与 RAG

CHAPTER 10 · EMBEDDING & RAG

向量检索
从表示到证据

Embedding 与 RAG 把“大模型会不会回答”改写成一条可以检查的链路:文本如何变成向量,向量如何找到证据,证据如何进入上下文,答案如何引用并接受评估。本章从零解释表示、距离、索引、文档分块、查询改写、混合检索、引用和上线门槛,最终交付一个可复现的知识库问答项目。

难度:基础进阶建议:先完成第 9 章代码:Python 3.11+ / 可选向量库小节:27 张独立学习卡片
TEXTBOOK MAP · EMBEDDING & RAG

本章学习地图

从一个向量到一条可核验的证据链。

阅读方式:先按大节顺序学习;需要回看时从地图跳到具体小节。页面把每个大节显示为一张独立“书页”,底部的上一节/下一节只在大节之间翻页;左侧目录会展开 10.x.x 的小节编号。

本章的判断标准:不是把文档塞进 Prompt 就算完成,而是能解释向量模型和距离、证明召回找到了正确证据、让答案逐条引用、在资料不足时拒答,并用指标、成本和权限边界说明系统可靠到什么程度。

本章可运行实验 · 真实 Embedding + FAISS + RAG

10_embedding_faiss_rag.ipynb 实际加载多语言 SentenceTransformer、生成归一化向量并建立 FAISS 索引,回答保留 chunk ID、来源和分数,再用冻结问题计算 Recall@3。

查看首次模型下载说明

01 · VECTOR REPRESENTATION

向量表示:把文本变成可比较的坐标

Embedding 不是把文字“压缩成一个神秘数字”,而是把对象映射到一个可计算的向量空间。本节从表示目标、训练信号、维度和模型契约开始,建立语义相似度的直觉,并说明向量何时能帮忙、何时会把表面相似误当成事实相关。

10.1.1 Embedding 到底表示什么

Embedding 可以写成一个函数 fθ(x) → v:输入文本、图像或其他对象 x,输出固定长度的向量 v。向量的每一维通常没有独立的人类可读含义,含义来自整个空间中的相对位置。两个文本靠近,表示模型认为它们在训练目标下有相似的使用场景;这不是一句“它们事实相同”的保证。

最早的词表示常用 one-hot:词表有一万个词,每个词用一万个维度表示,只有一个位置是 1。one-hot 能区分身份,却不能表达“猫”和“狗”比“猫”和“发动机”更接近。稠密向量用较少维度承载统计关系,距离由训练数据塑造,因此也会继承语料中的领域偏见和缺失。

训练信号决定空间的形状。Skip-gram 让词向量预测上下文,分类任务让表示区分标签,双塔检索让问题向量靠近正确文档、远离负例,句向量模型还会使用正负句对和对比损失。相同一句话用不同目标训练,得到的向量未必适合相同的检索任务。

向量的“相似”是条件性的。一个新闻语料训练的模型可能把“苹果”更多理解为公司,把食品语料训练的模型更多理解为水果;技术文档里的“接口”与社交文本里的“接口”也可能落在不同邻域。使用前先说明领域、语言、时间范围和目标任务,不要把通用模型当作永远正确的语义地图。

Embedding 还会受输入粒度影响。词向量适合查词和组成短语,句向量适合段落级检索,文档向量适合粗粒度聚类;把整本书压成一个向量,虽然省空间,却很难定位具体证据。RAG 通常需要文档级元数据和 chunk 级向量同时存在,不能只保留一个“整篇文档向量”。

向量维度不是越大越好。维度增加可能提高表达容量,也会增加存储、内存带宽和索引成本;低维模型更快,但可能把细粒度差异压在一起。比较模型时同时记录维度、dtype、归一化方式、最大输入长度和每千条文本的计算时间,才能讨论“质量提高是否值得”。

图 10.1 · 文本进入向量空间的路径本站自制示意
原始文本句子/段落
→
TokenizerToken IDs
→
编码器hidden states
→
Pooling句向量
→
归一化可比较坐标

模型输出的不是“意义标签”,而是服务于某种训练目标的坐标;是否适合检索要用任务数据验证。

表 10.1A · 表示粒度的选择
表示优点限制常见用途
Token/词向量细粒度、可组成难表达整句意图词义分析、特征初始化
句向量查询和短段落方便比较长文档细节被平均语义搜索、聚类
chunk 向量可返回具体证据依赖切分边界RAG 召回
文档向量索引少、粗筛快定位不到原句分类、路由、去重
多向量表示保留多个局部语义存储和查询复杂长文档、高召回场景
PYTHON · embedding_shape.py
import numpy as np

texts = ["退款需要订单号", "订单退款要提供编号", "今天适合散步"]
# 这里用可解释的 toy 向量模拟编码器输出
vectors = np.array([
    [0.90, 0.80, 0.10],
    [0.88, 0.77, 0.12],
    [0.05, 0.10, 0.95],
], dtype=np.float32)

print("count, dimension:", vectors.shape)
print("dtype:", vectors.dtype)
print("text/vector:", list(zip(texts, vectors.tolist())))

示例故意不用真实模型,先让你检查最基本的契约:有多少条文本、每条多少维、数据类型是什么、文本和向量是否按同一顺序对应。接入真实编码器后,再补充最大输入长度、批次、设备、归一化和模型版本;这些字段缺失时,向量库里的分数很难复现。

EMBEDDING LAB 10.1.1画出一个向量契约

任选三段短文本,写出输入 ID、向量维度、dtype、归一化和所属模型。说明为什么“距离近”只能说明训练目标下相似,不能直接说明事实相同。

至少区分文本、向量和相似度;写出模型版本与最大长度;为一个领域歧义词设计反例。

10.1.2 词向量、句向量与多语言表示

词向量通常把一个词或 Token 作为单位,但现代文本编码器往往先把词拆成多个子词,再通过上下文得到 hidden state。同一个词在不同句子里可以有不同表示,这叫上下文表示;句向量则需要把多个 Token 的信息聚合成一个固定长度结果。聚合方式可能是特殊 Token、平均池化、加权池化或模型专门训练的 pooling head。

句向量模型常见的训练信号是对比学习:给定一个查询和正确文本作为正对,再给出其他文本作为负例,让正对的相似度更高。负例怎么选非常关键。随机负例容易区分,难负例更接近真实检索边界;如果负例本身也正确,模型会被迫把两个正确答案推远,造成空间扭曲。

多语言模型希望不同语言中表达相同意图的句子靠近,但对齐不是自动完美的。语言覆盖、脚本、形态变化、翻译质量和训练数据比例都会影响结果。中文查询配英文文档、简体配繁体、代码注释配自然语言时,都要用自己的跨语言样本测试,不要只看模型卡上的语言列表。

Embedding 的各向异性是一个常见现象:大量向量集中在空间的一小块区域,导致任意两段文本都显得相似。中心化、白化、归一化或模型专门的训练目标可能改善分布,但处理方式要在验证集上比较。盲目做后处理可能提高平均分,却伤害某些长尾类别。

句向量还会受到长度和格式影响。把标题、正文、表格和页脚直接拼在一起,模型可能把页码、导航词当作语义;过长输入被截断后,关键结论可能完全没有进入向量。入库前需要保留标题路径、文档类型和字符范围,必要时分别编码标题与正文,再在检索阶段融合。

选择 pooling 时不要凭接口名称判断质量。平均池化可能对短句稳定,特殊 Token 可能依赖模型训练方式,最后一个 Token 对因果模型有时更自然,但每个模型的预训练和后训练不同。最小做法是取一个小验证集,比较不同 pooling、归一化和长度策略的 Recall@k 与延迟。

表 10.1B · 表示模型的验证维度
维度要问的问题最小测试失败表现
语言跨语言意图是否对齐?中英同义句按语言分簇
粒度句子还是长段落?标题/正文切片关键细节被平均
长度超长如何处理?逐档长度扫描截断后召回掉落
负例难负例是否合理?近邻错配集相关文档被推远
分布向量是否塌缩?均值/方差/余弦分布所有分数相近
PYTHON · sentence_pooling.py
import numpy as np

# [token_count, hidden_size] 的 toy hidden states
hidden = np.array([
    [1.0, 0.0, 0.0],
    [0.8, 0.2, 0.0],
    [0.0, 0.8, 0.2],
], dtype=np.float32)

mean_vector = hidden.mean(axis=0)
mean_vector = mean_vector / np.linalg.norm(mean_vector)
print("mean pooled:", np.round(mean_vector, 3))

# 只保留有效 Token,避免 padding 参与平均
valid = hidden[:2]
masked = valid.mean(axis=0)
masked = masked / np.linalg.norm(masked)
print("masked pooled:", np.round(masked, 3))

这里的关键不是某个 pooling 永远最好,而是 padding 和有效长度必须明确。实际批处理会补齐不同长度,如果把补齐向量也平均进去,短文本会被系统性拉向某个方向。记录 attention mask、有效 Token 数和 pooling 版本,才能解释两个实现为什么分数不同。

EMBEDDING LAB 10.1.2做一次跨语言和长度对照

准备同一意图的中文、英文、繁体和带标题版本,再准备一条超长版本。比较向量距离、有效 Token 数和检索排名,指出哪些差异来自语言,哪些可能来自截断或格式。

不要只报告平均相似度;保存每个文本、模型版本、长度、pooling 和最邻近样本,并解释一个反常结果。

10.1.3 选择模型与建立向量契约

选 Embedding 模型先从任务反推。查询是短问题还是完整段落?文档是中文政策还是代码?需要跨语言吗?索引规模和更新频率如何?是否允许把数据发送到外部服务?这些问题比“排行榜第几名”更能缩小选择范围。一个在通用语义相似度上高分的模型,可能不理解你的产品术语。

向量契约至少写出 model_id、版本或 hash、维度、dtype、归一化、最大输入 Token、pooling、批次、语言、相似度函数和更新时间。查询端与文档端必须使用兼容契约;只要更换模型、维度或归一化,就不能把新向量直接写进旧索引。迁移时保留双写或双索引,比较一段时间后再切换。

外部 API 的向量还要记录供应商、区域、请求版本和失败重试。服务返回的浮点精度、截断策略和模型别名可能变化,调用方如果只保存一个友好的模型名称,就无法重现旧结果。对敏感资料要考虑数据出境、保留策略、加密和删除接口,Embedding 并非自动匿名化。

本地模型则要关注权重许可、Tokenizer 文件、硬件、量化和并行。量化可能改变向量方向和近邻顺序,不能只比较生成质量。首次部署时跑一个固定向量基准,保存若干文本的向量摘要、范数、近邻 ID 和耗时,后续升级才能做回归。

契约还应规定异常输入。空字符串、只有标点、超长文本、未知语言、包含秘密的文本和编码错误应该分别处理。是返回零向量、报错、跳过入库还是切成多个 chunk,必须由上层流程决定;悄悄把异常输入送进模型会让索引混入无法解释的点。

最后建立“质量—资源—风险”三维选择表。质量包括召回和切片表现,资源包括单条编码时间、显存、存储和查询成本,风险包括许可、隐私和供应商依赖。只有当三维条件都满足,模型才适合进入生产,而不是因为一个分数漂亮就上线。

表 10.1C · Embedding 模型契约
字段示例为什么重要改变后的动作
model_id/versionembed-v2.1确定训练和服务版本建立新索引
dimension768决定存储和索引结构禁止混写
dtypefloat32影响精度和内存重测距离
normalizeL2=true决定距离解释重算分数
max_tokens512决定截断边界改分块策略
poolingmean-mask决定句向量来源重新编码
license/regioninternal / CN决定可否使用审查或迁移
PYTHON · embedding_manifest.py
import json
from pathlib import Path

manifest = {
    "model_id": "example-embedding-v2",
    "model_revision": "sha256:demo",
    "dimension": 768,
    "dtype": "float32",
    "normalize": True,
    "pooling": "mean_mask",
    "max_input_tokens": 512,
    "similarity": "cosine",
}

Path("embedding_manifest.json").write_text(
    json.dumps(manifest, ensure_ascii=False, indent=2),
    encoding="utf-8",
)
print("contract saved:", manifest["model_id"])

把 manifest 当作数据的一部分保存,而不是只放在部署文档里。索引中的每条记录都可以引用 manifest_id;查询日志也保存它,出现排名变化时能回答“是不是模型或归一化变了”。当你准备切换模型时,先用离线集比较新旧近邻和下游答案,再决定是否迁移。

EMBEDDING LAB 10.1.3做一张模型选型卡

为两个候选模型填写任务适配、语言、维度、长度、速度、许可、隐私、成本和离线 Recall@k。最后写出一个“不能上线”的阻塞条件。

必须区分查询端和文档端;维度、归一化或版本变化要触发重建;结论引用任务切片,而不是只引用公开排行榜。

本节深挖:向量不是事实标签

看到两个向量接近时,正确表述是“在这个模型、这个输入处理和这个距离函数下,它们被映射得接近”。不要把它改写成“模型判断两段话是真的”或“它们一定回答同一个问题”。相似度是排序信号,事实判断仍需要原文、规则或外部验证。

向量空间的几何性质依赖训练数据。语料中常见的词会形成稳定邻域,缺少样本的领域术语可能被拆成奇怪的子词,拼写错误可能落在无关区域。遇到新业务词,先做近邻可视化和人工抽样,必要时用领域数据微调或增加关键词通道。

维度、范数和角度要分开理解。两个向量长度不同,点积会同时反映方向和大小;L2 归一化后,点积等价于余弦相似度。归一化不是免费的“质量增强”,它会抹去范数携带的某些信息,因此必须和索引距离、训练目标一起验证。

输入清洗也会改变向量。去掉标题可能丢失主题,去掉标点可能改变代码和数字,统一大小写可能损害专有名词,删除页眉页脚可能需要版面识别。清洗规则要带版本,原文与清洗后文本都保留 hash 和字符范围,便于追溯。

Embedding 模型通常把长度压到固定维度,因此信息必然有损。越长的输入越容易把多个主题平均到一个点上;越短的 chunk 越可能丢失限定条件。分块与模型选择必须一起评估,不能先固定一个 chunk_size 再要求模型解决所有文档。

判断模型是否适合自己的数据,优先做小而难的验证集。包含同义改写、词面相似但意图不同、否定句、数字差异、版本差异、跨语言和资料不足。若只用随机句子,模型很容易得到漂亮但没有产品价值的分数。

近邻样例是最有效的解释材料之一。对每条查询展示 Top-k 文本、分数、来源、版本和人工标签,按“正确、部分相关、错误、危险”分类。错误近邻能告诉你是词面、领域、长度、语言还是权限元数据出了问题。

当你阅读新模型论文时,记录训练目标、正负样本、池化、硬负例、评估集和推理限制。不要只记一个维度和榜单分数;这些细节决定模型的向量空间是否能迁移到你的 RAG 任务。

表 10.1D · 向量回归的最小证据
证据保存内容它能排除什么
输入快照原文、清洗版、hash文本悄悄变化
模型契约版本、维度、pooling编码器漂移
向量统计范数、均值、方差归一化/塌缩
近邻回放Top-k、分数、标签排序退化
资源记录批次、设备、耗时成本异常
PYTHON · nearest_examples.py
import numpy as np

query = np.array([0.8, 0.6], dtype=np.float32)
docs = {
    "refund": np.array([0.79, 0.61], dtype=np.float32),
    "shipping": np.array([0.60, 0.80], dtype=np.float32),
    "weather": np.array([-0.2, 0.1], dtype=np.float32),
}

def cosine(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

ranked = sorted(
    ((name, cosine(query, vector)) for name, vector in docs.items()),
    key=lambda item: item[1],
    reverse=True,
)
print(ranked)

练习时把排序结果与人工标签并排展示。若“shipping”因为词面相似排在“refund”前面,说明模型或查询需要更多上下文、关键词过滤或重排,而不是简单把阈值调高。每一次修复都保留前后的近邻,避免只报告改进后的样例。

VECTOR REVIEW 10.1建立一张向量解释卡

为一个错误近邻写出:原始查询、Top-k、模型契约、距离、人工判定、可能根因和下一步实验。卡片中不能出现“语义不够好”这种没有证据的结论。

至少指出一个可测变量;保留原文和向量版本;下一步可以是改 chunk、增加关键词、换模型或加入重排器。

本章任务卡:建立你的第一份向量实验报告

先固定一个小语料集,包含同义问题、词面相似但意图不同的问题、数字不同的问题、中文与英文改写、一个超长段落和一个完全无关段落。为每条文本分配稳定的 doc_id 与版本,不要在实验过程中随意替换样本。小数据集的优势是每个近邻都能人工检查。

为同一批文本运行两个 Embedding 模型或两种 pooling。记录维度、范数、最大 Token、批次、设备、编码耗时和模型版本。不要只比较一个查询的第一名;把每条查询的 Top-k、人工标签、Recall@k 和错误类型写进 CSV 或 JSONL。

再做一次清洗对照:保留标题、去掉标题、保留页脚、去掉页脚、合并换行、保持原换行。观察向量和近邻如何变化。若页码被召回到前面,说明文档管道而不是模型需要修复。所有清洗规则写成版本号,便于回滚。

将一个长文档分别编码成整篇向量和多个 chunk 向量。整篇向量适合粗略判断主题,却难以回答一个局部问题;chunk 向量能返回证据,但需要保存父文档、标题路径和字符范围。比较两种表示在定位证据上的差异,理解为什么 RAG 通常以 chunk 为检索单位。

检查向量分布。计算每个向量的范数、每一维的均值和标准差,并画出随机对的相似度分布。若所有相似度都挤在很窄的区间,先检查池化、padding 和归一化;不要急着用一个阈值把结果切成“相关/不相关”。

把模型契约放在仓库里,并让索引记录 manifest_id。查询日志同时保存 query_manifest_id 与 corpus_manifest_id;二者不兼容时直接报错。这样当近邻顺序突然变化时,你能先排除模型、维度、归一化和清洗规则漂移。

对于敏感资料,Embedding 服务的输入输出都应纳入数据治理。向量虽然不易直接阅读,但可能泄露主题、身份或相似关系;要规定谁能查询、保存多久、如何删除和是否允许跨租户比较。不要把“向量不可读”当成匿名化证明。

最后写出一个人类能读懂的结论:模型 A 在中文短问句上召回更好,模型 B 在跨语言长段落上更稳;模型 A 速度更快但罕见术语失败更多。结论必须带条件和反例,不能只写“模型 A 更强”。

表 10.1E · 第一份向量报告应回答的问题
问题需要的证据可接受的结论不能这样写
近邻是否相关?Top-k + 人工标签按切片报告相关率距离小所以一定正确
模型是否稳定?版本、范数、回放固定契约下可复现跑一次看起来一样
长文如何表示?整篇/chunk 对照任务决定粒度整篇向量永远更省
清洗是否有害?前后近邻和 hash规则带来可解释变化清洗越多越干净
是否合规?来源、权限、删除记录有治理边界向量不可读就安全
PYTHON · vector_audit.py
import numpy as np

def audit(vectors):
    vectors = np.asarray(vectors, dtype=np.float32)
    norms = np.linalg.norm(vectors, axis=1)
    pairwise = vectors @ vectors.T
    return {
        "shape": vectors.shape,
        "norm_min": float(norms.min()),
        "norm_max": float(norms.max()),
        "mean_per_dim": np.round(vectors.mean(axis=0), 4).tolist(),
        "self_similarity_ok": bool(np.allclose(np.diag(pairwise), 1.0, atol=1e-3)),
    }

vectors = np.array([[1, 0], [0.8, 0.6], [-1, 0]], dtype=np.float32)
vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
print(audit(vectors))

这个审计器只检查几何和形状,不判断文本是否真的相关。它适合放在入库前,及早发现空向量、维度错误、未归一化或向量塌缩。语义质量仍要通过带人工标签的检索集验证。

VECTOR LAB 10.1.4提交向量回归报告

为 20 条文本建立固定语料,比较两个编码方案;交付 manifest、向量统计、Top-k 回放、三个错误近邻、Recall@5、编码耗时和你的模型选择结论。

每个结果都能追溯到文本和版本;至少包含一个词面相似的错误;结论同时考虑质量、资源和合规。

易错与复盘:把向量当作工具而不是答案

易错一:认为 Embedding 直接保存了完整语义。固定维度必然有信息损失,长文本中的限定条件尤其容易被平均掉;要用 chunk、元数据和原文回查补足。

易错二:查询端和文档端用了不同模型,却仍然把分数放在一起排序。即使维度相同,空间也可能完全不同;契约不一致时应拒绝查询,而不是默默返回低质量结果。

易错三:只凭余弦分数设置阈值。不同任务、语言和长度的分数分布不同,阈值要在带标签的验证集上校准,并报告误召回和漏召回。

易错四:把清洗当成越多越好。删除标题、数字、否定词和代码标点都可能损害检索;每条规则都做前后对照,保留原文和 hash。

易错五:用公开榜单替代自己的数据。业务术语、法规版本、跨语言和权限边界常常不在榜单里;最小的领域验证集比盲信一个总分更有价值。

复盘时问五个问题:向量代表的粒度是什么?训练目标和检索目标是否一致?距离函数与归一化是否匹配?错误近邻能否定位到清洗或模型?如果更换模型,索引和日志如何迁移?这五问会贯穿后面所有 RAG 实验。

表 10.1F · 向量章节口试题
问题合格回答要点最小证据
Embedding 是什么?函数、固定维度、训练目标向量契约
句向量怎么来?Token 表示、pooling、mask长度对照
相似度代表什么?条件性排序信号近邻标签
模型如何选?任务、资源、风险三维选型卡
模型升级怎么办?新 manifest、新索引、回归迁移计划

动手检查:从一条查询追到一个坐标

选一句真实问题,逐步记录原始字符串、规范化字符串、Token 数、向量维度和范数。再列出排名靠前的五个 chunk,并在表格里写出标题、来源、版本和人工相关性。这个过程会让“搜索结果不对”变成一组可以分工排查的字段。

把同一问题改写三次:加入产品术语、换成口语、加入否定条件。若排名变化过大,检查模型是否只依赖词面;若排名几乎不变,检查改写是否真正进入编码器。不要在没有日志的情况下凭肉眼判断查询改写有效。

为一个向量写出它的生命周期:何时生成、保存在哪里、谁可以查询、何时更新、如何删除、如何从备份恢复。RAG 的安全和新鲜度从数据生命周期开始,而不是在生成 Prompt 末尾补一句“请注意隐私”。

这一节完成后,你应能向同伴解释:向量是训练目标塑造的坐标,距离是有条件的排序信号,模型契约决定结果能否比较,原文和元数据负责把坐标还原成可核验的证据。

再做一个可解释性对照:把每个向量的最近邻和最远邻都保存下来。最近邻告诉你模型把什么视为相似,最远邻能暴露异常方向、空文本或编码失败。检查这些极端样本,通常比只看平均范数更快发现问题。

如果你的语料中有同一事实的多个版本,向量相似并不表示版本可以互换。把日期、地区、适用对象和生效状态作为元数据保存;后续检索要先做权限和版本过滤,再用向量排序。向量只负责发现候选,不负责替你决定哪一版有效。

记录 Embedding 的失败成本。一次批量编码失败可能阻塞索引,一条异常文本可能污染近邻,模型升级可能让所有分数重新分布。为批处理设置重试、坏记录清单和断点续跑,确保修复单条文本时不用从头猜测整个数据集发生了什么。

把实验结果写成可迁移的规则:短查询用句向量,长资料用 chunk;精确编号走关键词,语义改写走向量;敏感租户先过滤,公共语料再排序。规则带条件和证据,未来换模型或换数据库仍然成立。

不要把向量可视化图当作质量证明。降维会改变距离,二维图只适合观察大致簇和异常点;真正的检索质量仍要在原始维度上用 Top-k、标签和声明级结果评估。可视化是诊断工具,不是最终指标。

本节的最终产物是一份向量卡:模型契约、输入处理、近邻示例、失败样本、资源账本、隐私边界和迁移办法。下一节会在这张卡上加入距离函数、阈值和召回评价。

再把向量卡交给一个没有参与实验的同伴阅读:他能否只根据卡片判断向量维度、距离方向、输入清洗和版本迁移?如果不能,说明契约还依赖口头知识。把疑问改成字段和示例,后续的索引、召回和评估才有共同语言。

向量表示的学习也应留下反例。保存一个词面相似却事实冲突的近邻、一个跨语言正确近邻、一个空文本异常和一个版本过期近邻;解释模型为什么可能做出这些判断,并写出数据或规则层面的补救。

02 · SIMILARITY

相似度与召回:从距离公式到可用基线

向量只是坐标,检索系统还需要定义距离、归一化、Top-k 和评价基线。本节把余弦、点积、欧氏距离写成可运行的小实验,再解释为什么一个相似度分数不能直接等于相关性或答案正确。

10.2.1 点积、余弦与欧氏距离

点积是两个向量对应维度相乘后求和:a·b=Σaᵢbᵢ。它同时受到方向和长度影响。一个向量范数很大,即使方向一般,也可能获得较高点积;在某些双塔模型中,范数确实携带训练信号,但在另一些检索任务中,长度差异只是噪声。使用点积前要确认编码器和索引的约定。

余弦相似度把点积除以两个向量的范数:cos(a,b)=(a·b)/(||a||||b||),只比较方向。两个完全同向的向量得分接近 1,正交接近 0,反向接近 -1。文本向量经 L2 归一化后,点积和余弦排序等价,但原始范数信息也会被舍弃。

欧氏距离是坐标差的平方和开根号:||a-b||₂。对已归一化的向量,欧氏距离和余弦排序有固定关系;对未归一化向量,两者可能给出不同近邻。不要只看数据库的参数名叫“cosine”还是“L2”,实际检查它是否自动归一化、返回的是距离还是相似度,以及排序方向是否正确。

距离的数值范围决定阈值解释。余弦通常在 -1 到 1 之间,归一化后的点积也在这个范围;非归一化点积可能没有固定上限;L2 距离随着维度和范数变化。把一个模型的 0.8 阈值搬到另一个模型,往往会造成大量漏召回或误召回。阈值必须在自己的验证集上校准。

高维空间会出现距离集中:随机向量之间的距离差异变小,最近邻和普通邻居不再明显分开。向量维度、数据规模、归一化和分布都会影响这种现象。除了看 Top-1,还要看 Top-10 与随机候选的分数分布、最近邻间隔和不同查询的排名稳定性。

数值稳定性也不能忽视。零向量无法做余弦,极小范数会放大浮点误差,float16 可能在长向量点积时丢精度。编码器输出进入索引前先检查 NaN、Inf、范数和维度;查询时遇到异常要返回明确错误,不要让数据库悄悄把坏向量排在前面。

图 10.2 · 同一批向量的三种距离视角本站自制示意
向量 a、b方向 + 长度
→
点积方向与范数
→
归一化只看方向
→
余弦/L2排序信号

距离函数的选择会改变候选排序;先写清数学约定,再配置索引。

表 10.2A · 距离函数的选择
函数关注什么优点需要警惕
点积方向 + 范数计算简单、适合部分双塔模型长度大的向量可能占优
余弦方向尺度不敏感、易解释零向量、范数信息丢失
L2 距离坐标差几何直观、可用于聚类受维度和尺度影响
内积 + 归一化归一化后的方向可用高效内积索引必须确认双方一致归一化
学习排序分数任务相关性可融合多种特征需独立训练和校准
PYTHON · distance_probe.py
import numpy as np

a = np.array([3.0, 4.0], dtype=np.float32)
b = np.array([6.0, 8.0], dtype=np.float32)
c = np.array([4.0, -3.0], dtype=np.float32)

def cosine(x, y):
    nx, ny = np.linalg.norm(x), np.linalg.norm(y)
    if nx == 0 or ny == 0:
        raise ValueError("zero vector")
    return float(x @ y / (nx * ny))

def l2(x, y):
    return float(np.linalg.norm(x - y))

for name, item in [("b", b), ("c", c)]:
    print(name, "dot=", float(a @ item),
          "cos=", round(cosine(a, item), 3),
          "l2=", round(l2(a, item), 3))

代码中的 a 和 b 方向完全相同,点积很大但余弦为 1;这正好说明“长”与“同向”是两个不同信号。接入真实向量后,把未归一化和归一化结果都打印一次,确认数据库排序和本地计算一致。

SIMILARITY LAB 10.2.1证明你的距离约定

构造三对向量:同向但长度不同、长度相同但方向不同、包含零向量。分别计算点积、余弦和 L2,写出哪一种排序更符合你的检索任务及其理由。

必须说明是否归一化、排序方向和零向量处理;不能只报一个分数就说“相似”。

10.2.2 归一化、Top-k 与阈值

Top-k 表示返回分数最高的 k 个候选,但 k 不是质量保证。k 太小,正确证据可能排在后面;k 太大,上下文会被无关内容挤满,生成成本和冲突概率上升。k 应与后续重排能力、上下文预算、证据粒度和任务风险一起决定。

阈值与 Top-k 是两种不同控制。Top-k 保证“至少返回 k 个”(如果索引有结果),阈值保证“只有分数足够高才返回”。实践中常用两者组合:先取较大的候选集,再按校准阈值、元数据和去重过滤,最后保留不超过预算的证据。资料不足时允许返回少于 k 个,不能为了凑数塞入低相关文本。

归一化通常有两层:编码器输出的 L2 归一化,以及数据库存储或查询时的自动归一化。只做一边会让分数不可比,做两边通常结果相同但可能浪费计算。把归一化责任写进契约,并用一对已知向量验证索引返回的分数。

阈值需要按切片校准。短查询、长查询、中文、英文、代码、数字和低资源语言的分数分布可能不同;同一个全局阈值会偏爱某些切片。可以用验证集画 precision-recall 曲线,选择满足风险预算的点,或为不同查询类型设置不同门槛,并记录门槛版本。

稳定排序也很重要。分数相同或非常接近时,数据库返回顺序可能随分片、并发和索引重建变化。为每条记录加入稳定的 tie-breaker,例如 doc_id;日志保存原始分数和排序前后列表。否则一次升级后排名变化,无法判断是模型变了还是并列顺序变了。

检索结果还要做去重和多样性控制。同一文档的相邻 chunk 可能占据 Top-k,重复内容挤掉其他来源。可以按文档配额、标题路径、来源和版本去重,也可以使用 MMR 在相关性和新颖性之间折中。去重规则本身要评估,过度去重会删除互相补充的证据。

表 10.2B · Top-k 与阈值的联动
阶段建议动作记录字段常见错误
候选召回取 k_candidate 较大k、原始分数把候选当最终证据
阈值过滤按验证集校准threshold_version照搬别的模型阈值
元数据过滤权限/版本/语言过滤原因先向量后越权
去重与多样性按 doc_id/标题配额保留/丢弃理由相邻 chunk 占满
预算截断按 token 和风险排序被截断 ID静默丢关键证据
最终上下文保留可引用片段context_ids只留文本不留来源
PYTHON · topk_threshold.py
candidates = [
    {"id": "a", "score": 0.91, "doc": "policy", "tokens": 120},
    {"id": "b", "score": 0.89, "doc": "policy", "tokens": 130},
    {"id": "c", "score": 0.87, "doc": "faq", "tokens": 90},
    {"id": "d", "score": 0.62, "doc": "old", "tokens": 110},
]

threshold = 0.80
budget = 250
selected, used_docs, used_tokens = [], set(), 0
for item in candidates:
    if item["score"] < threshold:
        continue
    if item["doc"] in used_docs:
        continue
    if used_tokens + item["tokens"] > budget:
        continue
    selected.append(item)
    used_docs.add(item["doc"])
    used_tokens += item["tokens"]
print("selected:", [x["id"] for x in selected])

这个示例把阈值、文档去重和 Token 预算放到同一个选择器中。生产版本要先做权限过滤,再做分数排序;示例中的“每文档一个”只是教学规则,是否合理应由任务验证。任何被过滤或截断的候选都应该写入调试日志。

SIMILARITY LAB 10.2.2校准一个 Top-k 策略

准备 30 条带人工标签的查询,比较 k=3、5、10 与阈值 0.6、0.75、0.85 的 precision、recall、上下文 Token 和答案支持率。选择一组配置并说明它适合什么风险等级。

同时报告召回、上下文长度和最终答案;阈值必须来自验证集;资料不足时允许少返回。

10.2.3 语义召回的基线与陷阱

任何向量检索都应和简单基线比较。关键词、BM25、标题匹配或规则过滤在编号、专有名词、错误代码和精确版本上经常胜过语义检索。一个向量方案只有在相同测试集上超过或补足基线,才值得增加复杂度;如果基线已经能解决任务,就不要为“用了 Embedding”而强行加入向量。

语义相似容易被否定、数字和实体差异欺骗。“允许退款”和“不允许退款”词面几乎一样,向量可能把它们排在一起;“版本 2.1”和“版本 2.10”只差一个字符,却可能代表不同规则;人名、产品名和地区需要精确匹配。对这些场景加入关键词、字段过滤或重排器,而不是单纯提高向量维度。

长查询往往混合多个意图。把整句编码成一个向量,会让一个主题遮住另一个主题;可以先拆成子查询,分别召回,再合并证据并去重。拆分也可能放大错误,必须记录原查询、子查询和各自结果,确保能够解释最终上下文来自哪一步。

查询和文档的语言风格不对称时,Embedding 也可能退化。用户说“怎么退钱”,文档写“售后款项返还政策”;训练好的模型通常能连接两者,但公司内部缩写、产品代号和新词未必能。把真实查询、同义改写和内部术语加入验证集,必要时训练领域适配器或维护术语表。

检索错误要分成漏召回和误召回。漏召回是正确文档没进候选集,应该查编码、分块、查询和索引;误召回是无关文档排名靠前,应该查距离、关键词、过滤和重排。两者的修复方向不同,不能只看最终答案“错了”就调一个阈值。

建立难负例比随机负例更有价值。随机文档很容易被排除,难负例要与查询共享词面或主题,却缺少关键事实。每次发现错误近邻,把它加入回归集;但要确认它确实是负例,避免把两个都正确的资料错误标成互斥。

表 10.2C · 语义召回的陷阱清单
陷阱示例最小修复验证方式
否定词丢失允许/不允许关键词或重排否定对照集
数字混淆2.1/2.10数字字段过滤数值差异集
实体相近同名产品/人名实体与租户过滤实体对照集
整句多意图退款+发票子查询或多路召回意图拆分集
领域新词内部缩写术语表/适配数据真实查询集
版本冲突旧政策排名靠前生效时间过滤版本回归集
难负例不足相似但不支持持续收集误召回近邻审核
PYTHON · retrieval_baseline.py
def lexical_score(query, document):
    q = set(query.lower().split())
    d = set(document.lower().split())
    return len(q & d) / max(len(q), 1)

def recall_at_k(results, gold_ids, k):
    hit = sum(item["id"] in gold_ids for item in results[:k])
    return hit / max(len(gold_ids), 1)

query = "版本 2.1 的退款期限"
docs = [
    {"id": "semantic-1", "text": "售后款项返还政策"},
    {"id": "exact-2.1", "text": "版本 2.1 退款期限为七天"},
]
ranked = sorted(
    docs,
    key=lambda item: lexical_score(query, item["text"]),
    reverse=True,
)
print(ranked)

基线代码很简单,却能提醒你精确词面在数字和版本任务中的价值。实际系统可以把 lexical、dense、metadata 和业务规则的结果并列记录,再由重排器或融合算法排序。先让每条通道独立可测,调试时才知道某条证据是从哪里进入候选集的。

SIMILARITY LAB 10.2.3建立漏召回与误召回地图

从真实问题中收集 20 个失败案例,分别标记漏召回、误召回、版本冲突、否定错误、数字错误和权限错误;为每类指定一个修复通道并添加到回归集。

每个案例保存 query、gold 文档、Top-k、分数、版本和根因假设;不要把所有失败归因于 Embedding。

本节深挖:分数要经过验证才有意义

相似度的数学值只在固定模型、固定预处理、固定距离和固定数据分布下可比较。换一个模型或归一化方式,分数的绝对范围就可能改变;即使排序大致稳定,阈值也要重新校准。日志同时保存原始分数、归一化规则和索引版本。

评估召回时先定义“正确文档”。一条问题可能有多个可接受来源,不能只放一个 gold_id;可以记录 gold 集合、必需证据和可选证据。否则模型召回了另一份等价政策却被误判为失败,指标会错误地推动系统远离真实需求。

Top-k 的 k 还受 chunk 粒度影响。同一事实被切成三个 chunk 时,k=3 可能只返回一个文档的相邻片段;切得更细或更粗,最佳 k 都会变化。报告 k 时同时报告平均 chunk Token、每文档配额和最终上下文长度,避免脱离分块谈召回。

阈值的目标不是“分数看起来高”,而是控制错误代价。医疗或财务问题可能宁愿少返回并接管,内部搜索可能更看重召回;不同风险等级使用不同策略是合理的。把阈值与动作绑定,例如低分澄清、高风险拒答、版本不明阻塞,系统才真正可用。

相似度排序可以加入业务特征,但要防止不可解释。版本新鲜度、标题匹配、权限、来源可信度和文档类型都可能影响排序;把这些特征拆开记录,先做简单加权,再用离线集验证。一个黑盒重排分数如果无法解释,出错时很难修复。

检索服务的并发和缓存也会改变结果。缓存命中应返回相同的候选版本,索引重建期间要有 snapshot 或 alias;否则同一查询在几分钟内可能看到不同数据。事件日志保存 index_alias、snapshot_id 和查询时间,才能区分数据更新与模型变化。

近邻可视化适合发现簇和异常,不适合证明排序正确。PCA、t-SNE 或 UMAP 会改变几何关系,二维图上的近邻不一定是原空间近邻。用可视化提出假设,再回到原始向量和人工标签验证。

当你阅读向量数据库文档时,重点看距离、过滤、分片、删除、更新和一致性,而不只是“支持向量搜索”。数据库返回的 score 语义、过滤执行顺序和索引重建策略都会进入 RAG 质量。把这些参数纳入 manifest,后面才能做可复现压测。

表 10.2D · 本节实验验收
实验固定条件要比较结论边界
距离对照同一向量集dot/cosine/L2排序是否一致
阈值扫描模型/语料固定precision/recall只适用于该分布
Top-k 扫描chunk/预算固定召回/Token/支持率不等于越大越好
基线比较同一 gold 集BM25/dense/hybrid看任务切片
难负例回归固定错误集近邻与根因避免重复犯错
VECTOR SEARCH REVIEW 10.2写出你的召回协议

用一页文档规定距离、归一化、候选 k、阈值、去重、元数据过滤、低分动作和日志字段。附一张 20 条查询的回放表,标出漏召回、误召回和版本冲突。

协议要能让另一位同学复算排序;阈值有数据依据;资料不足和权限失败有明确动作。

章节任务卡:让分数、证据和动作连起来

先用三维向量手算一个查询和五个文档的点积、余弦与 L2。把每一步中间值写下来,再用代码复算。你会发现,数学公式很短,但归一化、零向量和排序方向任何一个约定不清,最后的检索结果都会改变。

准备一个带 gold 文档的验证集,至少包含一个问题对应多份正确资料的情况。分别计算 Recall@1、Recall@5、MRR 和 nDCG,说明每个指标回答的不同问题。不要把一个指标提高就称为系统整体变好。

做一个阈值扫描,从 0.4 到 0.95 每隔 0.05 运行一次。记录 precision、recall、低分拒答率、上下文 Token 和最终答案支持率。你可能会看到“召回率最高”的阈值让上下文充满噪声,这正是需要和生成阶段联动的原因。

将候选按文档去重,再做不去重的对照。记录相邻 chunk 占比、来源覆盖数和答案引用数。如果去重后事实支持率下降,说明相邻片段本来互相补充,需要改成按标题或事实单元配额,而不是粗暴地每文档只留一个。

加入一个 BM25 或简单词重叠基线,并把它与 dense 检索的候选并排保存。精确编号、产品代号和否定句往往需要词面信号;语义改写和同义表达更适合向量。混合并不是“两个分数相加”这么简单,先让两条通道各自可解释。

对失败结果做根因分类:编码异常、距离配置、查询歧义、分块粒度、版本冲突、权限过滤、索引延迟或人工标签问题。每类写一个最小修复和一个回归样本。这样下一轮实验可以验证假设,而不是继续盲调阈值。

把查询时间、索引 snapshot、模型 manifest 和候选列表写进 JSONL。重新运行同一查询,比较排序是否一致;若不一致,优先检查数据更新、随机 tie-breaker 和服务端归一化。可复现的排序是后续 RAG 事实核验的基础。

最后定义不同风险等级的动作:低风险允许较低阈值并显示候选,高风险要求更高支持率并在不足时接管,版本或权限不明直接阻塞。阈值不是孤立参数,它决定系统在不确定时是继续生成、澄清还是停止。

表 10.2E · 召回报告的最小指标
指标回答的问题配套字段不能单独说明
Recall@k正确资料是否进入前 k?gold 集合、k排名和答案是否使用
Precision@k前 k 中有多少相关?人工标签、k是否漏掉其他证据
MRR第一份正确资料排多前?首个 gold rank多个证据的整体质量
nDCG相关性等级排序如何?分级标签事实支持和安全
支持率答案声明是否被证据支持?claim/source检索本身是否召回
拒答率资料不足时是否停止?evidence threshold是否误拒合法问题
PYTHON · recall_report.py
def recall_at_k(ranked_ids, gold_ids, k):
    return int(bool(set(ranked_ids[:k]) & set(gold_ids)))

def reciprocal_rank(ranked_ids, gold_ids):
    gold = set(gold_ids)
    for rank, item_id in enumerate(ranked_ids, start=1):
        if item_id in gold:
            return 1.0 / rank
    return 0.0

cases = [
    (["d2", "d1", "d8"], {"d1"}),
    (["d4", "d5", "d6"], {"d9"}),
]
for ranked, gold in cases:
    print({
        "recall@2": recall_at_k(ranked, gold, 2),
        "rr": reciprocal_rank(ranked, gold),
    })

这个报告只计算最小指标,真正项目还要按查询类型和风险切片聚合。先确认 gold 标签质量,再解释数字;如果标签不完整,低 recall 可能是标注问题。指标、原始排名和人工判断必须同时保留。

VECTOR SEARCH LAB 10.2.4完成一次召回评审

交付距离说明、阈值曲线、Recall@1/5、MRR、候选去重对照、一个 lexical 基线、五个失败样本和风险等级动作表。

报告有 gold 集合和样本数;阈值来自验证集;每个失败样本能追溯到查询、索引和版本。

课后复习与易错:不要把 Top-k 当作答案

易错一:把高分当作相关性证明。高分只是模型空间中的排序信号,必须通过 gold 文档、人工标签和最终声明支持率核验。

易错二:混淆距离和相似度。数据库可能返回越小越近的距离,也可能返回越大越相似的分数;先用已知向量做方向测试。

易错三:忽略归一化责任。查询端和文档端的范数处理不一致,会让点积偏爱长向量;manifest 中写明谁负责归一化。

易错四:只调 k 不看上下文。k 变大可能提高召回,却把无关文本和冲突版本带进 Prompt;同时报告 Token、支持率和成本。

易错五:阈值脱离数据。不同语言、长度、任务和模型的分数分布不同;阈值用验证集校准,并为低分定义拒答或澄清动作。

易错六:没有 lexical 基线。数字、否定、代码和专有名词需要精确匹配;先比较关键词/BM25,再决定向量是否解决了增量问题。

易错七:把漏召回和误召回混在一起。正确文档没进候选集要查编码、分块和查询;无关文档靠前要查距离、过滤和重排。

易错八:忽视多个 gold。一个问题可能有多份等价资料;标注只留一份会把正确结果误判为错误,导致错误优化。

易错九:忽视排序稳定性。并列分数、索引分片和重建会改变顺序;使用稳定 tie-breaker,并保存 snapshot 与原始候选。

复盘时用一句话总结:在固定向量契约和数据版本下,哪一种距离、k、阈值和融合策略以什么成本找到了哪些证据。下一节将把这些候选交给可扩展的向量索引。

表 10.2F · 召回章节口试题
问题回答应包含最小证据
余弦和点积差别?是否考虑范数、归一化已知向量对照
为什么要阈值?控制误召回和资料不足PR 曲线
k 越大越好吗?召回、噪声、Token 取舍k 扫描
为什么保留 BM25?数字、实体、否定词基线比较
漏召回怎么修?查编码、分块、查询、索引错误地图
分数如何复现?模型、距离、版本、snapshot回放事件
SIMILARITY REVIEW 10.2写一份召回协议

用一页 Markdown 规定距离函数、归一化、Top-k、阈值、去重、tie-breaker、低分动作和评估指标,并附三条能证明协议有效的查询。

协议能让另一个人复算同一排序;至少包含一个精确数字问题、一个否定问题和一个资料不足问题。

做距离实验时,把同一批向量分别以 float32 和 float16 保存,比较排序是否改变。若只有边界样本改变,记录可接受波动;若大量近邻改变,先检查量化和归一化,再决定是否需要更高精度。

做阈值实验时,不要把测试集拿来反复调门槛。用训练/校准集选择阈值,用锁定的测试集报告最终结果。这样才能知道门槛是在适应真实分布,还是只记住了几条样本。

做基线实验时,保持同样的 gold 集、同样的权限过滤和同样的输出预算。否则 dense 方案使用更多上下文、lexical 方案使用更少上下文,比较出来的不是检索算法差异。

如果一个候选没有来源或版本信息,即使相似度很高,也不能进入最终上下文。来源、版本和权限是证据的组成部分,不是检索完成后的装饰字段。

记录查询失败而不是只记录成功。空查询、编码异常、索引超时、低于阈值和权限过滤都应该有状态码;失败分布会告诉你系统需要更好的用户提示、降级通道还是数据修复。

掌握这些约定后,你就能把“搜索感觉不准”拆成数学、数据、排序和产品动作四层问题,下一步才有必要引入 ANN 索引来提高规模和速度。

相似度报告还应保留排序间隔。Top-1 为 0.81、Top-2 为 0.80 与 Top-1 为 0.81、Top-2 为 0.42 的风险不同;前者需要重排或人工确认,后者通常有更清晰的候选。用 margin、分数分布和人工标签一起判断。

当多个来源都相关时,保留它们的出处和覆盖范围。不要为了提高 precision 只留一条最像的文本;问答可能需要政策条款、例外条款和定义条款共同支持。最终选择器应按事实单元而不是只按文档数量做配额。

查询中的时间词也需要显式处理。“现在”“去年”“最新”不能只交给 Embedding;先解析时间范围,再用 metadata 过滤和新鲜度排序。否则模型可能把旧版本的语义近邻送进上下文,生成一段过时但流畅的答案。

在多租户系统中,分数比较必须发生在权限过滤之后。不同租户的向量可以在数学上相似,但不代表可以互相暴露;先限定可见集合,再做 Top-k,日志只记录当前租户允许看到的 ID。

本节的结业标准是能用一页表格解释:距离如何计算、分数如何校准、候选如何去重、基线如何比较、资料不足如何处理,以及一次错误近邻怎样被加入回归集。

检查报告中是否明确写出了“相关性”的定义:是同一主题、同一答案、同一版本,还是能支持某一条声明。定义不清,模型分数再高也无法指导下一步。

把阈值、k 和排序函数记录成配置而不是散落在代码里;后续切换索引或模型时,配置和回放事件会告诉你哪些结果仍然可以比较。

一个好的召回协议还会规定失败如何对用户说:低分时说明资料不足,权限过滤时不泄露被过滤内容,索引故障时切换到关键词或人工路径。技术分数只有转化为清晰动作,才真正改善体验。

小型实验:用分数间隔决定动作

把 Top-1 与 Top-2 的分数间隔记为 margin。margin 大并不保证事实正确,但能帮助区分“有一个明显候选”和“多个候选难分高下”。把 margin 与人工标签、版本冲突和最终支持率放在同一张表里,观察它是否能成为澄清、重排或接管的触发条件。

对每个查询保存四类结果:原始候选、过滤后的候选、最终上下文和用户可见答案。很多问题不是距离计算错,而是正确候选在元数据过滤、文档去重或 Token 截断时被丢掉。分层保存结果,才能定位损失发生在哪一步。

当你改变阈值时,还要观察拒答质量。阈值升高可能减少误召回,却让本来可以回答的问题被拒绝;阈值降低可能提高 Recall,却把相似但冲突的政策送进上下文。将误答、误拒和正确澄清分开计数,比追求一个漂亮的平均分更可靠。

表 10.2G · 分数间隔与产品动作
观察可能含义建议动作需要的证据
高分且 margin 大候选较明确进入重排与引用核验gold、版本、权限
高分但 margin 小多个候选竞争保留多来源或澄清候选 diff、冲突标签
整体分数低可能无资料或查询歧义澄清、关键词回退或拒答阈值校准、失败状态
分数正常但支持率低排序信号与任务不一致检查分块、重排和 gold声明级标注
PYTHON · score_policy.py
def decide(scores, threshold=0.72, margin=0.06):
    if not scores or scores[0] < threshold:
        return "clarify"
    if len(scores) > 1 and scores[0] - scores[1] < margin:
        return "keep_multiple"
    return "continue"

for scores in ([0.88, 0.71], [0.79, 0.77], [0.54, 0.51]):
    print(scores, decide(scores))

这段策略不是通用真理,而是一个可被数据推翻的起点。把 threshold、margin、模型、距离和数据切片写入实验配置;当策略改变时,保留旧配置和回放结果,避免只凭一次线上样例就修改门槛。

SIMILARITY LAB 10.2.5建立“分数—动作”校准表

收集至少 40 条查询,记录 Top-1、Top-2、margin、gold 命中、答案支持、版本冲突和用户动作。把结果分成继续、保留多候选、澄清、拒答四类,给每类写出可回放的门槛和限制。

门槛来自验证集;分数不是置信度;每个动作都有安全理由、日志字段和回归样本。

完成这个实验后,距离函数就不再是孤立的数学选择,而是连接检索质量、上下文预算和用户体验的接口。下一章的 ANN 索引可以加速候选发现,但不能替代这里建立的校准与证据协议。

03 · VECTOR INDEX

向量索引:让相似搜索能够规模化

当文档从几百条增长到几百万条,逐条计算距离会变慢。索引通过近似最近邻、分片、量化和过滤把查询成本控制在预算内。本节关注 HNSW、IVF、PQ 的直觉,以及索引构建、更新、备份和一致性。

10.3.1 ANN、HNSW、IVF 与 PQ

精确最近邻的做法是把查询向量与所有文档逐一计算距离,再排序。这在小数据集上最容易理解,也应该作为质量基线;但候选数量增加后,计算、内存访问和网络返回都会成为瓶颈。ANN,也就是近似最近邻,用少量召回损失换取更低延迟,关键是要知道损失发生在哪里。

HNSW 把向量看成图上的节点,每个节点连接若干近邻,并在不同层构建稀疏的导航结构。查询从高层的少量节点开始,逐步下降到更密的底层,在局部扩展候选。M 控制连接数,ef_construction 影响建图质量和构建成本,ef_search 影响查询时探索范围;参数越大通常召回更好,但内存或延迟也会上升。

IVF 先用聚类把向量分到若干倒排桶,查询时只探查最接近的几个桶。桶数、训练样本、nprobe 决定搜索范围。桶太少,候选多、速度慢;桶太多,边界附近的正确向量可能落在未探查的桶里。IVF 适合数据量较大且可以接受训练索引的场景,更新策略要额外设计。

PQ 将向量分段并把每段量化为较短的码,用码表近似计算距离,从而节省内存和带宽。压缩会改变边界样本的排序,适合先粗召回、再用原始向量重排。不要把 PQ 的距离直接拿去和未压缩向量的阈值比较,先做一组压缩前后的近邻一致性实验。

ANN 的调参目标不是追求某个参数最大,而是在召回、p95 延迟、内存、构建时间和更新成本之间找到满足门槛的点。用固定查询集扫描 ef_search、nprobe 或压缩率,记录 Recall@k 和资源。平均延迟之外要看长尾,因为一次慢查询可能拖住整个 RAG 请求。

索引还会受到硬件和访问模式影响。随机内存访问使 CPU cache 不友好,GPU 索引可能提高吞吐却增加搬运和构建复杂度;批量查询能摊薄开销,但在线单查询仍要满足交互延迟。压测要区分冷启动、热缓存、单查询、批量和并发,不能用一个数字代表全部场景。

图 10.3 · 从全量扫描到近似导航本站自制示意
全量向量精确但昂贵
→
分桶/图缩小候选
→
候选集近似召回
→
精确重排恢复边界

ANN 不是把近似结果当真理,而是先缩小搜索,再用重排和评估控制损失。

表 10.3A · ANN 索引的主要旋钮
索引核心结构提高召回的旋钮主要代价
Flat全量精确扫描无近似参数查询成本随数据线性增长
HNSW分层近邻图M、ef_search内存和建图时间
IVF聚类倒排桶nprobe、桶训练边界漏召回、更新复杂
PQ分段量化码码长、重排数量压缩误差和额外码表
混合索引粗召回+精排候选数量、精排深度实现和监控更复杂
PYTHON · ann_sweep.py
settings = [
    {"name": "hnsw-fast", "ef_search": 32, "expected_recall": 0.90},
    {"name": "hnsw-balanced", "ef_search": 96, "expected_recall": 0.95},
    {"name": "hnsw-careful", "ef_search": 192, "expected_recall": 0.98},
]

budget_ms = 80
for item in settings:
    latency = 20 + item["ef_search"] * 0.25
    status = "ok" if latency <= budget_ms else "over_budget"
    print(item["name"], round(latency, 1), status)

代码只是假设延迟模型,目的是展示如何把索引参数和预算放在一张实验表里。真实压测要记录硬件、数据量、向量维度、并发、缓存状态和实际召回。用固定查询集与精确 Flat 结果做对照,才知道“expected_recall”是否真的成立。

INDEX LAB 10.3.1做一次 ANN 参数扫描

用精确扫描作为 gold,分别改变 HNSW 的 ef_search 或 IVF 的 nprobe,记录 Recall@10、p50/p95 延迟、内存和构建时间,并选择一个满足业务门槛的配置。

至少保留一个低延迟配置和一个高召回配置;说明取舍;不要只看平均延迟。

10.3.2 向量库 Schema、过滤与租户

一条向量记录不应只有一个浮点数组。最小 Schema 包括稳定的 chunk_id、父文档 doc_id、向量、原文或可回查位置、标题路径、语言、来源、版本、生效时间、权限标签和模型 manifest。把这些字段放入索引或可联查的存储,检索结果才能从坐标回到可引用的证据。

过滤条件与向量搜索的执行顺序会影响结果。如果先在全库找近邻,再过滤权限,可能得到不足 k 个候选,甚至把不该看到的 ID 暴露在调试日志;如果先过滤可见集合,搜索空间变小但索引可能需要支持高选择率过滤。向量库的文档要确认 pre-filter、post-filter 和过滤索引的实际语义。

多租户系统把 tenant_id 作为硬边界。最安全的做法是查询时由服务端从会话取得租户和用户权限,模型或客户端不能自行传入任意 tenant_id。大租户可以独立索引,小租户可以共享索引但必须有强制过滤;无论哪种方式,都要用交叉租户测试证明不会泄露。

权限不只是一列标签。文档可能对部门、角色、地区、项目或时间段开放,chunk 继承权限但也可能有更严格的局部规则。权限更新后,旧向量和缓存需要失效;查询结果要保存当时的授权决策版本,方便审计。宁可少召回,也不能把不可见资料拼进上下文。

元数据还能帮助新鲜度和多样性控制。查询“最新政策”时先按生效时间和状态过滤,再按向量排序;同一文档的相邻 chunk 可以按父文档配额;不同来源的证据可以要求至少覆盖两个独立文档。元数据不是对向量的替代,而是把业务约束放回正确的位置。

Schema 设计要考虑演进。向量维度或模型版本变化时,可以用 collection_v1、collection_v2 和 alias 做双读;字段新增时给默认值并更新解析器;删除时保留 tombstone 或操作日志,防止离线重建又把已删除文档带回来。所有 schema 变更都写迁移说明和回滚路径。

表 10.3B · 向量记录 Schema
字段类型用途安全/一致性要求
chunk_id稳定字符串引用和回放不可因重排随意变化
doc_id字符串父文档去重与原文目录一致
vectorfloat 数组近邻搜索维度、dtype、模型固定
text_ref路径/范围回查原文权限同源
tenant_id字符串租户隔离服务端注入
acl规则集合细粒度授权先过滤再返回
valid_from/to时间版本和新鲜度统一时区
manifest_id字符串编码契约升级触发迁移
PYTHON · record_schema.py
REQUIRED = {
    "chunk_id", "doc_id", "vector", "text_ref",
    "tenant_id", "acl", "manifest_id",
}

def validate_record(record, dimension):
    missing = sorted(REQUIRED - record.keys())
    if missing:
        return False, "missing:" + ",".join(missing)
    if len(record["vector"]) != dimension:
        return False, "dimension_mismatch"
    if not record["chunk_id"].startswith(record["doc_id"] + ":"):
        return False, "unstable_chunk_id"
    return True, "ok"

record = {
    "chunk_id": "policy-7:003",
    "doc_id": "policy-7",
    "vector": [0.0, 0.1, 0.2],
    "text_ref": "docs/policy-7#p3",
    "tenant_id": "team-a",
    "acl": ["finance"],
    "manifest_id": "embed-v2",
}
print(validate_record(record, 3))

记录校验器应该在入库、查询结果组装和离线重建时都复用。示例只检查最小字段,生产环境还要验证时间区间、ACL 语法、文本 hash、向量 NaN、manifest 是否存在以及父文档是否仍可访问。

INDEX LAB 10.3.2设计一张可审计 Schema

为一个多租户知识库写字段表,包含向量、原文回查、权限、版本、新鲜度和 manifest。再设计三条越权查询,说明过滤应在哪一层阻止。

客户端不能决定 tenant_id;结果日志不能暴露被过滤的文档 ID;schema 变化要有迁移和回滚。

10.3.3 索引构建、更新与备份

索引构建是一个数据发布流程:读取已审计的 chunk,按固定 manifest 编码,校验维度和 hash,写入新 collection,跑离线召回,再把 alias 切到新版本。不要边读原始文件边在线覆盖生产索引;这样一旦中途失败,用户会看到混合版本,问题也难以回滚。

增量更新要区分新增、修改和删除。新增可以追加向量,修改需要先标记旧 chunk 失效再写新版本,删除需要 tombstone 或可靠的 delete log。若索引是异步构建,查询要知道 snapshot 的时间和延迟;“数据库已返回”不等于所有副本都已可见。

别名或版本指针能把切换变成原子动作。离线生成 collection_v3,完成 smoke test 后把 alias 从 v2 切到 v3;保留 v2 直到新版本经过一段观察期。回滚只切回 alias 并恢复对应 manifest,不需要重新下载或重新编码。切换事件记录操作者、时间、原因和评估报告。

备份至少包含向量数据、原文映射、元数据、索引参数、模型文件或获取方式、Tokenizer、schema 版本和权限规则。只备份浮点数组不够,因为没有 chunk_id、版本和 ACL 就无法安全地还原。定期做恢复演练,确认备份真的能在干净环境中加载并通过召回回归。

索引重建与数据一致性需要监控。统计 source_count、chunk_count、active_count、deleted_count、dimension、范数范围、每个租户数量和最新更新时间;发现突然下降或增长要告警。对每个 snapshot 计算清单 hash,防止文件被悄悄替换却仍使用旧版本号。

更新频率取决于资料时效和业务风险。政策、价格和库存需要更短延迟,教材或历史资料可以批量重建;但“实时”不应成为没有证据的口号。定义 freshness SLA,例如变更在十五分钟内可检索,并用事件时间和索引可见时间测量。

表 10.3C · 索引发布状态
状态允许做什么必须检查失败动作
building写入临时 collection文件、维度、hash停止并保留日志
candidate离线查询Recall、schema、ACL不切 alias
shadow旁路对比排名、延迟、新鲜度延长观察
active线上服务p95、错误、租户隔离降级或回滚
retired备份和恢复保留期、hash到期再清理
PYTHON · index_manifest.py
from dataclasses import dataclass

@dataclass
class IndexManifest:
    collection: str
    embedding_manifest: str
    schema_version: str
    snapshot: str
    chunk_count: int
    source_hash: str

candidate = IndexManifest(
    collection="kb_v3",
    embedding_manifest="embed-v2",
    schema_version="schema-3",
    snapshot="2026-08-29T10:00Z",
    chunk_count=12840,
    source_hash="sha256:demo",
)

def publish(manifest, expected_min_chunks):
    if manifest.chunk_count < expected_min_chunks:
        return "BLOCK: chunk count dropped"
    return "READY: switch alias to " + manifest.collection

print(publish(candidate, 12000))

发布函数不应该只检查记录数。还要有固定查询的近邻 diff、权限回归、删除回归、新鲜度延迟、索引参数和恢复演练。记录数不变也可能因为 chunk 内容被替换;source_hash 和近邻样例能补上这部分证据。

INDEX LAB 10.3.3演练一次索引切换和回滚

创建 v1 与 v2 两个小索引,模拟新增、修改、删除和权限变更;先 shadow 对比,再切 alias,最后故意让一个安全用例失败并回滚。

旧版仍可加载;回滚后正常、越权和已删除文档三类查询都重放;事件记录 snapshot、alias 和操作者。

本节深挖:索引速度不能以召回为代价

调 ANN 参数前先建立 Flat gold。没有精确基线,就无法知道速度提升牺牲了多少正确候选;只看最终答案可能被生成模型“猜对”掩盖检索漏召回。gold 可以只在离线小语料上运行,但要覆盖真实查询类型和难负例。

HNSW 的图结构适合热数据和频繁查询,但删除、更新和内存占用需要谨慎;IVF 依赖聚类分布,语料变化大时可能需要重新训练;PQ 节省内存,却更依赖精排。选择索引时考虑数据更新频率,而不是只看单次 benchmark。

过滤会改变有效搜索空间。一个全局索引在过滤后只剩很少文档,ANN 探索参数可能不够;某些数据库会先过滤再搜索,某些会扩大候选再过滤。用高选择率、低选择率和空结果三组查询测试真实语义,并记录过滤后候选数。

索引与缓存要有清晰边界。缓存可以减少重复查询,但必须绑定 tenant、权限版本、query 规范化、index snapshot 和模型 manifest;否则旧答案或别的租户结果会被复用。缓存命中日志不能省略,因为它会解释为什么新索引切换后用户仍看到旧结果。

备份恢复的目标是可用而不是文件存在。恢复后跑固定查询、删除回归、ACL 回归和版本过滤;比较恢复索引的 Top-k 与原索引。若量化或重建使边界排序略有变化,写明容差和影响;若关键证据消失,恢复就不合格。

构建管道应可断点续跑。批量编码按 chunk_id 分片保存,失败记录包含输入 hash 和错误;重新运行只补失败项,不能重复写入同一 ID。重复写入会造成版本和计数混乱,后续去重也会掩盖真正的数据问题。

租户隔离需要主动攻击测试。创建两个租户的相似文档,分别使用不同用户、空 ACL、伪造 tenant_id、缓存命中和旧 token 查询;任何返回别的租户 ID、标题或分数都算失败。安全回归应和质量回归一样进入每次索引发布。

新鲜度不是更新时间字段本身。要记录资料变更时间、进入队列时间、编码完成时间、索引可见时间和首次被召回时间,计算端到端延迟。这样才能判断瓶颈在抓取、解析、编码、写入还是缓存。

表 10.3D · 索引验收矩阵
验收项最小实验通过标准阻塞信号
召回Flat 对照Recall 达门槛关键证据漏掉
延迟并发压测p95 在预算内长尾超时
过滤高/低选择率权限后结果正确越权 ID
更新增改删回归新鲜度 SLA已删内容仍可见
恢复干净环境加载查询和 ACL 通过只有文件没有可用服务
回滚alias 切换旧版可重放无法恢复
INDEX REVIEW 10.3写一份索引发布报告

交付索引 manifest、ANN 参数扫描、过滤与租户测试、增改删日志、freshness 统计、备份恢复记录和回滚命令;最后给出“可发布/限量/阻塞”结论。

结论同时引用召回、延迟、安全和新鲜度;旧版仍可回滚;所有查询结果带 snapshot 与 manifest。

课后复习与易错

易错一:直接上 ANN,没有 Flat gold。先用精确结果定义召回损失,再谈速度。

易错二:把 HNSW、IVF、PQ 当作互换的按钮。它们的结构、更新方式和资源瓶颈不同,参数不能跨索引照搬。

易错三:先搜索后做权限过滤。租户和 ACL 必须在服务端强制,不能相信模型或客户端传入的过滤字段。

易错四:只备份向量数组。没有原文映射、版本、schema、权限和 manifest,恢复后无法安全引用。

易错五:把记录数当作一致性证明。内容、hash、删除、近邻和新鲜度都要检查。

易错六:更新时覆盖生产索引。用 candidate、shadow、alias 和旧版保留构成可回滚发布。

章节任务卡:用一个小索引理解规模化

先建立十到五十条向量的 Flat 索引,保存每个查询的精确 Top-k。再用同一批数据建立 HNSW 或 IVF,逐步提高 ef_search 或 nprobe。每次运行都与 Flat gold 对照,记录漏掉的 ID,而不是只看“查询成功”。

把索引参数分成构建期和查询期。构建期改变图连接、聚类和量化,查询期改变探索范围、候选数和重排深度;两类参数的成本和回滚方式不同。报告中写清楚改的是哪一类,避免把一次重建成本误算成单次查询成本。

给记录加入 doc_id、chunk_id、tenant_id、ACL、valid_from、manifest_id 和 text_ref。用同一向量做三种查询:正常权限、无权限和过期版本,确认过滤在正确的位置发生。日志中不要记录被拒绝文档的标题或原文。

模拟四种更新:新增一条、修改一条、删除一条、撤销一个租户权限。先写入 candidate,再做 shadow 查询和统计,最后切换 alias。切换后重放更新前后的查询,确认已删除和已撤销内容不会通过缓存或旧 snapshot 返回。

做一次备份恢复。备份不仅包含索引文件,还包含 schema、manifest、原文映射、权限规则和 snapshot 清单;恢复到新目录后跑召回、权限和删除回归。若只能“看到文件”却不能服务查询,恢复不算通过。

索引监控至少有记录数、活跃数、删除数、维度、范数范围、最新可见时间、p95 查询延迟、过滤后候选数和异常率。把异常阈值写成告警规则,避免索引逐渐损坏却直到用户反馈才发现。

表 10.3E · 索引任务卡验收
任务交付物合格标准
Flat gold精确 Top-k 文件ANN 可计算召回损失
参数扫描Recall/延迟表有预算内配置
Schema字段和校验器tenant/ACL 由服务端控制
更新发布candidate/alias 日志增改删可回放
恢复回滚备份和演练记录新目录可查询、旧版可切回
INDEX LAB 10.3.4交付一份索引运行手册

写明构建命令、参数、监控、更新、备份、恢复、权限测试和回滚步骤;让另一位同学在空目录中按手册完成一次发布。

手册不依赖你的本机路径;所有版本和 snapshot 可追溯;失败时有明确停止和回滚动作。

排错路线:索引慢、漏或错时先问什么

如果延迟突然上升,先看查询输入长度、并发、缓存、过滤选择率和索引参数,再看硬件利用率。不要一开始就更换 Embedding 模型;模型可能完全没变,瓶颈只是候选数或网络返回变多。

如果 Recall 下降,先与 Flat gold 比较同一 snapshot。确认向量维度、归一化、距离和查询文本没有变化,再检查 HNSW/IVF 参数、分片覆盖和过滤顺序。把每一步的候选数保存下来,才能知道漏召回发生在哪一层。

如果查询结果重复,检查 chunk_id、doc_id 和批量重试的幂等性。重复写入不一定会报错,却会让同一事实占据多个排名位置;清理重复前保留原始写入事件,避免把数据问题误当索引问题。

如果删除后仍能检索到内容,依次检查主索引、缓存、异步队列、备份恢复和原文回查。删除是端到端动作,不只是对向量库发一个 delete 请求;需要定义完成时间和用户可见的状态。

如果权限测试失败,先阻止服务继续对外提供结果,保存请求、会话和策略版本,避免日志包含被保护的正文。修复后用两个租户的相似文档重放正常、越权、缓存命中和旧 snapshot 四类请求。

如果新旧索引的分数整体漂移,检查 Embedding manifest、量化、归一化和数据清洗 hash。分数漂移不一定代表质量下降,但所有阈值和排序回归都要重新校准。

如果索引构建中途失败,保留失败批次、输入 hash 和临时 collection,不要直接覆盖 active alias。修复后只重跑失败批次,再用记录数、hash 和固定查询确认没有重复或缺口。

运行手册要把“谁可以做什么”写出来。构建、切换、回滚、删除和权限变更都是高影响动作,至少有审批人、事件 ID 和恢复办法;工程细节清楚,RAG 的安全边界才不会只存在于口头约定。

表 10.3F · 索引故障定位
现象先查最小证据安全动作
延迟上升并发、过滤、候选数分段计时限流/降级
Recall 下降Flat、距离、参数漏掉的 gold ID回退配置
结果重复幂等和 chunk_id写入事件停止重建
删除仍可见缓存、队列、snapshot删除时间线阻止返回
越权结果tenant/ACL/日志交叉租户回放立即熔断
分数漂移manifest/量化/清洗向量统计重校准

索引设计的迁移思考

小规模实验常用 Flat,因为它最容易解释;数据变大后再引入 HNSW、IVF 或 PQ。先保持 Schema、距离和日志不变,只替换索引实现,才能把性能收益归因于索引而不是数据或模型变化。

分片策略要与租户、地区和语言一起考虑。按租户分片便于隔离,按时间分片便于新鲜度,按向量空间分片便于均衡负载;每种策略都可能增加跨分片合并。压测要包含单片和多片查询。

向量库的过滤字段应建立索引或等价的快速路径。若每次查询都从海量记录中逐条检查 ACL,p95 会随着租户规模恶化;安全过滤和性能设计不能互相甩锅。

备份文件也要做访问控制。向量、原文引用和元数据可以组合推断敏感主题,备份应加密、记录访问、设置保留期,并在删除请求后按政策清理副本。

新鲜度监控应区分“数据已经抓到”和“用户已经查到”。中间任一队列、编码批次、索引写入或缓存都可能延迟;把时间线画出来,才能找到真正的瓶颈。

索引切换前跑 smoke queries:一个普通问题、一个数字问题、一个版本问题、一个低分问题和两个租户隔离问题。少量固定查询能快速阻止明显回归,再进行完整离线评估。

不要过度优化边界分数。如果精排和答案核验会读取原文,ANN 的职责只是把候选缩小到可处理范围;把复杂逻辑塞进索引会让更新、备份和回滚变得脆弱。

完成本节后,你应能解释索引为什么近似、哪些参数影响召回、记录为什么需要元数据和权限、更新为什么需要 alias 与 snapshot,以及恢复为什么必须用查询和安全回归验收。

表 10.3G · 索引上线前的五个问题
问题应该有的答案证据
能否找回 gold?明确召回门槛Flat 对照表
能否只看可见资料?服务端过滤租户攻击回放
更新多久可见?freshness SLA时间线日志
坏了如何切回?旧 alias 可用回滚演练
恢复后是否相同?关键查询可接受快照 diff

把这五问放进发布审批单,索引就从“数据库配置”变成可检查的学习资产。任何一个问题回答不清,都应该先补实验或收紧上线范围。

索引层的可观测性与容量推算

索引设计先做容量账。向量数量、维度、数据类型、图连接数、分桶数、量化码长和副本数都会影响内存;原文、metadata、倒排字段和快照还会占用额外空间。用小数据实测每条记录的平均占用,再按增长率、重建临时空间和备份保留期预留容量,不要只用“向量维度 × 数量”估算。

压测不能只发完全随机的查询。准备短问题、长问题、数字和版本、空结果、权限过滤、热门缓存和并发突增等工作负载;每种负载分别记录 p50、p95、p99、Recall、错误率和成本。平均值掩盖的长尾,往往才是用户真正感受到的故障。

过滤执行顺序是索引契约的一部分。有的系统先做近邻再过滤,有的系统支持预过滤或分区;同一个 k 在不同顺序下可能返回不同数量的可见结果。用跨租户和窄时间范围样例验证过滤语义,禁止只凭厂商参数名称推断安全性。

更新需要区分 upsert、delete、tombstone 和 compaction。重复写入可能制造同一 chunk 的多个版本,延迟删除可能让旧内容继续被召回,压缩又可能改变 snapshot。为每个事件分配 idempotency key,并在回放中证明重复执行不会产生额外记录。

表 10.3G · 索引可观测性最小字段
阶段关键指标必须记录异常动作
构建吞吐、失败率、临时空间输入 manifest、批次、hash隔离失败批次
查询p95、p99、Recall、空结果率snapshot、过滤、k限流或降级
更新source 到 visible 延迟事件 ID、版本、时间线暂停 alias
删除tombstone 生效时间被删 ID、缓存键阻止返回并告警
恢复快照一致性、回放差异旧 manifest、查询集保持旧版本
PYTHON · index_smoke.py
REQUIRED = {"snapshot_id", "model_id", "distance", "acl_version"}

def smoke(manifest, visible_ids, forbidden_ids):
    missing = REQUIRED - manifest.keys()
    leaked = set(visible_ids) & set(forbidden_ids)
    return {
        "manifest_ok": not missing,
        "missing": sorted(missing),
        "no_leak": not leaked,
        "leaked": sorted(leaked),
    }

manifest = {
    "snapshot_id": "snap-2026-08-29",
    "model_id": "embed-v1",
    "distance": "cosine",
    "acl_version": "acl-7",
}
print(smoke(manifest, ["doc-1", "doc-2"], ["doc-secret"]))

冒烟检查只证明最小契约,没有证明 ANN 质量。它适合在 alias 切换前快速拦截缺字段、错误距离和越权结果;之后仍要运行 Flat 对照、固定查询回归、压力测试和恢复演练。

INDEX LAB 10.3.4完成一次索引发布演练

用同一批向量建立 Flat 和一个 ANN 索引,记录内存、构建时间、Recall@k、p95 与过滤后的结果数;随后执行增、改、删、重启和 alias 回滚,提交完整事件时间线。

ANN 的损失和收益有数字;过滤在服务端验证;重复事件幂等;旧 alias 可用且回滚后查询集通过。

索引章节的核心不是记住某个库的参数,而是能把“近似导航、数据契约、权限过滤、更新一致性和故障恢复”放在同一个系统模型里。这样换数据库、换部署方式或换 Embedding 模型时,仍然知道需要重新验证哪些边界。

04 · DOCUMENT PIPELINE

文档管道:解析、分块与元数据

RAG 的质量上限往往来自文档管道。PDF 的版面、表格、标题、脚注、权限和版本信息如果在入库时丢失,后面的模型再大也无法恢复。本节建立从原文到 chunk 的可追溯处理链。

10.4.1 PDF、HTML、表格与扫描件

文档入库的第一步不是立刻切字符串,而是确认原文结构。HTML 有标题、列表、链接和隐藏导航;PDF 可能是有文字层的电子文档,也可能只是图片;Word、Excel 和 Markdown 的段落、表格、代码块含义不同。解析器要输出统一的中间表示,例如块类型、文本、页码、标题路径、字符范围和来源 hash。

PDF 的视觉顺序不总是阅读顺序。双栏文章可能按坐标交错,页眉页脚会在每页重复,脚注可能被抽到正文前,表格列会被拆成一串难以理解的词。先做版面分析和抽样回看,再决定是否保留页码、表格标题和脚注。不要把一个“提取了多少字符”的统计当成解析正确。

扫描件需要 OCR,OCR 还会带来错字、数字混淆和行列顺序问题。对高风险资料,保留原始图片、OCR 文本、置信度和人工校对状态;对低风险资料,也至少检测不可见字符、异常重复和编码错误。OCR 结果进入 Embedding 前应经过同样的来源、许可和脱敏审查。

表格不能简单拼成“单元格单元格”。表头、行列关系、单位、合并单元格和脚注决定了数字的含义。可以同时保存结构化表格 JSON 和适合检索的文本表述,例如“字段:值;单位:元;适用版本:2.1”。回答数字问题时回查结构化原表,避免模型从被打散的文字中猜关系。

代码和配置文件也需要特殊处理。保留缩进、文件名、语言和行号,切分时不要把一个函数从中间截断;错误日志要保留时间、级别和请求 ID。Embedding 可以帮助找到相关代码,但真正的引用应回到文件路径和行号,不能只返回一段脱离上下文的文本。

解析质量用人工样例和规则共同验收。随机抽取页面,比较原文截图与中间表示;检查标题层级是否连续、表格列是否错位、链接是否丢失、特殊符号是否被替换、敏感字段是否脱敏。将坏文档列入 quarantine,等待人工处理,不要为了让批处理跑完而静默跳过。

图 10.4 · 原文到 chunk 的可追溯管道本站自制示意
原始文件PDF/HTML/表格
→
解析中间层块/标题/页码
→
清洗校验OCR/脱敏/去重
→
分块编号范围/父文档
→
向量索引manifest/权限

每一步保留输入、输出和版本,出现召回错误时才能回查原文。

表 10.4A · 不同文档的解析重点
类型容易丢失什么建议中间表示验收样例
HTML标题、链接、隐藏导航DOM 块+标题路径章节和链接回查
文字 PDF双栏、页眉、脚注坐标块+页码截图对照
扫描 PDF字符、数字、表格图片+OCR+置信度人工抽检
表格列、单位、合并单元格JSON+可检索文本数字问答
代码缩进、文件和行号文件块+语言+范围函数定位
日志时间、级别、请求链事件字段+原文故障回放
PYTHON · parse_blocks.py
from dataclasses import dataclass

@dataclass
class Block:
    kind: str
    text: str
    page: int | None
    heading_path: tuple[str, ...]
    start: int
    end: int

raw = [
    Block("heading", "退款政策", 1, ("退款政策",), 0, 4),
    Block("paragraph", "订单在七天内可以申请退款。", 1, ("退款政策",), 5, 20),
    Block("table", "期限 | 七天\\n条件 | 未使用", 1, ("退款政策",), 21, 40),
]
for item in raw:
    print(item.kind, item.heading_path, item.start, item.end)

统一的 Block 让后续分块不必知道文件格式。解析器可以替换,chunk 和索引的接口不变;同时保留 start/end 便于生成引用。遇到表格或代码时,分块器可以调用专门规则,而不是把所有内容都交给同一个按字符切分器。

DOCUMENT LAB 10.4.1做一次文档解析抽检

选一份网页、一份 PDF、一张扫描表和一个代码文件,生成中间 Block;找出三处顺序、数字、标题或权限错误,并写出隔离或修复动作。

每个 Block 有来源和范围;表格保留列语义;扫描件保存 OCR 证据;坏文档不能静默进入索引。

10.4.2 Chunk 大小、边界与重叠

Chunk 是检索和生成之间的接口。太小会丢失定义、条件和上下文,太大会把多个主题平均在一起并增加生成成本。切分单位可以是 Token、字符、句子、段落、标题树或语义边界;没有一个固定数字适合全部文档。先依据任务的证据粒度,再选择初始大小。

标题路径通常比盲切更可靠。把“第三章/退款/特殊情况”作为前缀加入 chunk,用户询问“特殊情况”时上下文仍保留主题。对 FAQ 可以一问一答,对法律条款可以一条定义加一条例外,对代码可以一个函数或类为单位。边界要与用户需要引用的最小事实单元一致。

重叠用于缓冲边界,但不是越多越好。相邻 chunk 共享几十个 Token 可以保留跨句联系;重叠过大则索引重复、Top-k 被同一内容占满、成本增加。用固定问题集比较不同 overlap,观察跨边界问题的 Recall、重复率、上下文 Token 和引用完整度。

父子 chunk 适合“精确定位加完整阅读”。先用小 child chunk 召回,再把同一父段落或标题下的更大范围补入上下文;父文档保存 child 的关系和字符范围。这样既能提高近邻定位,又不会让生成只看到半句话,但需要严格控制预算和去重。

语义切分可以根据主题变化或段落相似度决定边界,但它也会把表格、列表和短标题切得奇怪。任何自动切分都要有最大 Token、最小 Token、不可拆块和异常回退规则。切分失败时保留原文和错误原因,不能生成一个没有来源的“万能 chunk”。

Chunk 文本应适度规范化,不要把所有格式都抹掉。保留标题、列表编号、单位、否定词、代码缩进和表格字段;去掉重复导航、追踪参数和无意义空白。清洗前后都计算 hash,便于判断一次召回变化是分块原因还是 Embedding 原因。

表 10.4B · 分块策略取舍
策略适合优点风险
固定 Token格式稳定的长文简单、成本可控语义被截断
句/段落普通说明文边界自然长度差异大
标题树教材、政策、手册主题路径清晰层级异常
问答单元FAQ、客服证据完整跨问关联丢失
代码单元源代码、配置函数级定位跨文件依赖
父子层级长文档问答定位和上下文兼顾预算与去重复杂
PYTHON · chunk_by_tokens.py
def chunk_words(words, size=40, overlap=8):
    if size <= overlap:
        raise ValueError("size must be larger than overlap")
    chunks = []
    start = 0
    while start < len(words):
        end = min(start + size, len(words))
        chunks.append({
            "start": start,
            "end": end,
            "text": " ".join(words[start:end]),
        })
        if end == len(words):
            break
        start = end - overlap
    return chunks

words = "标题 定义 条件 例外 计算 方法 证据".split()
print(chunk_words(words, size=4, overlap=1))

示例用词数模拟 Token,只为展示边界和 overlap 的关系。真实分块要用与 Embedding 一致或兼容的 Tokenizer,并在 chunk 中写入标题路径和字符范围。分块器的测试应覆盖空文档、单段超长、表格、代码、Unicode 和最后一块不足 size 的情况。

DOCUMENT LAB 10.4.2做一次 chunk 矩阵

用同一语料比较固定 Token、标题树和父子 chunk 三种策略,记录平均长度、overlap、Recall@5、引用完整度、重复率和生成 Token。

一次只改分块策略;保留 chunk_id、父子关系和原文范围;结论要说明任务适配,而不是只选最长或最短。

10.4.3 Metadata、权限与版本

Metadata 把“这段文字是什么”告诉检索系统。至少包含 doc_id、chunk_id、标题路径、来源 URL 或文件路径、作者、语言、文档类型、创建和生效时间、版本、标签、权限、租户、文本 hash 和 embedding manifest。元数据不是装饰,它决定过滤、引用、新鲜度、去重和审计。

稳定 ID 要与内容和位置有关系,但不能只用行号。文档插入一段内容后,后面的行号会全部变化;可以用父文档版本、块路径和内容 hash 组合 ID,并另存 start/end 用于当前版本回查。ID 变化会影响缓存、反馈、引用和删除,因此要在设计阶段明确迁移策略。

权限字段应从权威系统同步。不要让模型从文本里猜“谁能看”,也不要只在最终答案阶段过滤。检索前拿到用户的租户、角色和资源范围,数据库只在可见集合内返回候选;文档更新或权限撤销后,旧缓存和索引 snapshot 都需要失效。

版本字段至少区分采集版本、内容版本、索引版本和 Embedding 版本。一个政策的文字没有变化,但权限或生效日期变化,也可能需要重新发布。回答引用应显示可理解的版本或日期,后台保存精确 manifest 和 snapshot,方便追溯。

元数据过滤与向量排序要有优先级。租户、权限、删除和生效状态是硬过滤;语言、文档类型和来源可以按任务选择;新鲜度、可信来源和多样性可以作为排序特征。把所有字段都塞进一个黑盒分数,会让错误无法解释。

数据治理还包括删除和导出。用户要求删除资料时,原文、清洗产物、向量、缓存、备份和评估样本的生命周期都要有记录;导出时只给授权范围。Embedding 可能保留语义线索,不能因为它不是原文就跳过删除政策。

表 10.4C · Metadata 的职责
字段组示例被谁使用缺失影响
身份doc_id/chunk_id引用、去重、删除无法回查
来源URL/path/page证据展示、审计答案不可核验
结构heading_path/type分块、排序主题混淆
时间valid_from/version新鲜度、版本过滤旧政策混入
权限tenant/acl安全过滤越权泄露
技术hash/manifest回归、迁移无法复现
PYTHON · metadata_filter.py
def visible(record, user):
    if record["tenant_id"] != user["tenant_id"]:
        return False
    if record["deleted"] or record["valid_from"] > user["at_time"]:
        return False
    if not set(record["acl"]) & set(user["roles"]):
        return False
    return True

records = [
    {"chunk_id": "a:1", "tenant_id": "team-a",
     "acl": ["finance"], "deleted": False, "valid_from": 1},
    {"chunk_id": "b:1", "tenant_id": "team-b",
     "acl": ["finance"], "deleted": False, "valid_from": 1},
]
user = {"tenant_id": "team-a", "roles": ["finance"], "at_time": 2}
print([r["chunk_id"] for r in records if visible(r, user)])

服务端过滤示例故意在向量排序之前执行。生产系统还要验证用户身份来自可信会话、ACL 规则来自权威服务、时间使用统一时区、删除状态不会被缓存覆盖,并为每次过滤记录策略版本。不要把这个函数放在客户端或 Prompt 里。

DOCUMENT LAB 10.4.3设计文档卡和权限回归集

为 10 个 chunk 填写完整 metadata,模拟版本更新、权限撤销和删除;用两个租户、两个角色重放查询,证明不可见资料不会进入候选。

测试包含缓存和旧 snapshot;日志不泄露被拒绝正文;每个 chunk 能回到来源、版本和原文范围。

本节深挖:文档管道决定证据能否被找到

解析器输出是 RAG 的“事实底稿”。如果标题层级错误,检索会把相邻章节混在一起;如果表格列错位,模型会把数值和字段配错;如果脚注被提前,回答可能引用例外却漏掉适用条件。入库前做少量人工高质量抽检,往往比上线后调十个检索参数更有效。

分块的目标不是让每个 chunk 长度相同,而是让一个 chunk 能独立支持某个最小问题。定义“证据单元”后,再决定是否带标题、定义、条件、例外和示例。对于跨段关系,用父子结构或邻接扩展补回上下文,不要无限增加 overlap。

一个可回查的引用至少需要来源、版本、标题路径和字符或页码范围。只保存一段清洗后的文本,未来无法证明它来自哪份原文;只保存 URL,也无法定位网页当时的内容。原文快照、hash 和范围应与 chunk 一起保存。

解析、清洗、分块、编码和索引都要有状态。processing、quarantine、ready、retired 和 deleted 这些状态能让增量管道安全地重试;否则一个 OCR 失败可能被当成“没有内容”,然后错误地发布到生产。

权限和版本最好在数据进入索引前就确定。生成阶段再过滤已经太晚,因为模型可能看到不该看的文字,日志也可能保存泄露内容。服务端过滤必须是强制边界,Prompt 里的“只使用可见资料”只能作为辅助说明。

新鲜度的计算从文档变更时间开始,到用户查询可见结束。每个阶段保存时间戳,发现 SLA 违约时才能定位抓取、解析、编码、写入、缓存或查询的瓶颈。不要把索引更新时间当作资料已经可用。

数据格式演进要有兼容策略。增加字段通常可以向后兼容,改变 chunk ID 或向量维度则需要新版本索引;删除字段前确认报告、缓存和回滚是否仍依赖它。迁移文档写清“旧数据能否读、新数据能否写、失败如何恢复”。

处理外部网页时,版权、许可和提示注入都属于管道问题。保存来源和许可状态,只抓取允许使用的内容;网页中的指令当作数据而不是系统规则,清洗和后续生成都要隔离。RAG 的“证据”首先要合法、可追溯、可控。

表 10.4D · 文档管道验收
阶段通过标准保存什么失败去向
解析结构和数字可回查原文、Block、抽检quarantine
清洗规则无关键丢失前后 hash、规则版本人工复核
分块证据单元完整范围、父子关系重分块
元数据来源/权限/版本齐全document card阻止入库
编码维度和 manifest 一致向量统计、错误批次断点重试
发布召回和 ACL 回归通过snapshot、alias保留旧版
DOCUMENT REVIEW 10.4交付一条可追溯数据链

从一份原始文件开始,提交原文 hash、解析 Block、清洗版本、chunk 表、metadata、Embedding manifest、索引 snapshot 和一个可点击的引用;附三条解析或权限失败记录。

任何 chunk 都能回到原文;失败记录不被覆盖;版本、权限和删除状态在索引与缓存中一致。

章节任务卡:把一份复杂文档做成可检索资料

先选择一份结构丰富的资料:最好同时有标题、列表、表格、脚注或代码。保存原文件和 hash,记录许可与用途;不要直接从搜索结果复制一段文字,因为缺少版本和来源无法做可靠引用。

写一个解析清单,逐页或逐块检查阅读顺序、标题层级、页眉页脚、表格单位、链接、代码缩进和特殊字符。发现错误时把原文截图、解析结果和修复规则放在同一条记录,后续可以重新抽检。

用三种 chunk 策略建立候选集:固定 Token、标题边界和父子 chunk。对每种策略记录 chunk 数、平均 Token、最长 Token、重叠比例、父子关系和原文覆盖率。不要只说“看起来切得不错”,用覆盖率和引用完整度验证。

给每个 chunk 加完整 metadata。模拟一个用户只能看到某一租户、某一版本和某一角色的资料;验证检索前过滤后只剩可见内容。再撤销权限、删除版本和命中缓存,确保结果会失效。

将失败文档放到 quarantine,不要混入 ready 集合。为 OCR 错字、表格错位、空文本、超长段落、重复页眉和缺少许可证各准备一个样例,写清人工处理和自动回退方式。

模拟一次索引更新:原文修改一段、增加一页、删除一个段落。保留旧 chunk 与新 chunk 的关系,比较引用和近邻变化;如果用户问的是旧版本,系统仍能找到旧证据或明确说明已过期。

最后生成一张数据卡:来源、许可、解析器版本、清洗规则、分块策略、Embedding manifest、索引 snapshot、权限范围、新鲜度 SLA 和删除方法。数据卡会成为第 10 章后面所有检索和生成实验的共同前提。

表 10.4E · 文档任务卡验收
任务交付物合格标准
原文审计hash、许可、抽检来源和版本可追溯
解析Block JSON顺序、标题、表格正确
分块chunk 表和父子关系边界可解释、范围可回查
元数据document card权限、新鲜度、版本齐全
异常quarantine 清单坏文档不会静默发布
更新增改删回放索引与缓存一致
PYTHON · chunk_audit.py
def audit_chunks(chunks):
    problems = []
    seen = set()
    for item in chunks:
        if item["chunk_id"] in seen:
            problems.append((item["chunk_id"], "duplicate_id"))
        seen.add(item["chunk_id"])
        if item["start"] >= item["end"]:
            problems.append((item["chunk_id"], "empty_range"))
        if not item.get("doc_id") or not item.get("manifest_id"):
            problems.append((item["chunk_id"], "missing_trace"))
        if item.get("tokens", 0) > 512:
            problems.append((item["chunk_id"], "too_long"))
    return problems

sample = [
    {"chunk_id": "doc:1", "doc_id": "doc",
     "manifest_id": "embed-v1", "start": 0, "end": 80, "tokens": 64},
    {"chunk_id": "doc:1", "doc_id": "doc",
     "manifest_id": "embed-v1", "start": 80, "end": 80, "tokens": 2},
]
print(audit_chunks(sample))

分块审计器把“内容是否可检索”变成可执行检查。真正的项目还要检查原文 hash、标题路径、ACL、时间区间、语言和编码错误;任何问题都应该能回到原文件或 quarantine 列表。

DOCUMENT LAB 10.4.4完成一次从原文到索引的演练

用一份含表格和标题的资料跑完整管道,提交原文、Block、chunk、metadata、异常清单和索引 snapshot;让同伴点击一个 chunk 回到原文位置。

引用能回到页码或字符范围;版本、权限和 hash 一致;至少保留一个被 quarantine 的坏块。

课后复习与易错:分块不是切字符串

易错一:按固定字符数切所有文档。标题、表格、代码和 FAQ 的最小证据单元不同,先识别结构再选边界。

易错二:把 overlap 当质量开关。重叠只能缓冲边界,过大会制造重复和成本;用跨边界问题集校准。

易错三:丢掉标题、页码和来源。没有这些字段,检索结果无法解释,答案引用也无法回查。

易错四:把 OCR 文本当作原文。数字和列关系可能被识别错误,保留图片、置信度和人工校对状态。

易错五:把权限放在 Prompt。模型看到不可见文本后再拒答已经太晚,服务端必须先过滤。

易错六:只保存当前版本。政策、价格和代码会更新,旧版本、hash 和删除记录决定能否复现历史回答。

易错七:解析异常静默跳过。坏文档应进入 quarantine 并告警,否则“没检索到”会被误解为资料不存在。

易错八:只统计 chunk 数量。覆盖率、长度分布、引用完整度、权限和新鲜度更能说明管道是否可用。

表 10.4F · 文档管道口试题
问题回答应包含最小证据
为什么保留 Block?格式无关、可回查解析抽检
chunk 怎样定边界?证据单元、Token、任务策略对照
overlap 有什么代价?重复、存储、上下文扫描实验
权限放在哪?服务端先过滤跨租户回放
版本怎样追踪?hash、manifest、snapshot引用回查
解析失败怎么办?quarantine、告警、人工坏文档清单
DOCUMENT REVIEW 10.4画出你的数据状态机

把文档从 received、parsed、cleaned、chunked、embedded、indexed、active 到 retired/deleted 画成状态机,为每次转移写触发条件、日志和回滚办法。

状态不能从 received 直接跳 active;失败有隔离状态;删除和权限撤销会影响索引、缓存与备份。

文档类型决定分块策略

同一套固定字符数不适合所有资料。政策文档需要保留章节、条款和例外关系;FAQ 更适合问题—答案成对;表格要保留表头、单位和行列语义;代码要保持函数、类和注释边界;幻灯片还要保存页码、标题和讲者备注。先把文件解析成结构化 Block,再根据任务选择 chunk 边界。

表格是最容易被破坏的结构之一。把每一行单独切开会丢失列名,把整张大表塞进一个 chunk 又可能超出上下文。可保存“表名、表头、行范围、单位、原文位置”的 metadata,并生成一份适合检索的行级文本;答案引用时仍回到原始表格。

代码资料要同时服务精确搜索和语义搜索。函数名、错误码和参数名需要 lexical 通道,解释文字和使用场景可以用向量通道。分块时保留文件路径、符号名、行号和语言;不要为了短小而截掉 import、类型定义或异常处理上下文。

OCR 结果必须带置信度和页面坐标。数字、小数点、负号和表格列错位都会改变事实;低置信度块进入 quarantine 或人工校对,不应该悄悄进入正式索引。解析器升级时用同一批扫描件做 diff,检查文本、页码和字段是否稳定。

分块实验要包含跨边界问题。例如问题需要标题中的适用范围和下一段的例外条款,固定窗口可能把二者分开;overlap、父子 chunk 或标题路径可以缓冲边界,但应通过支持率和引用完整度验证,而不是凭经验设置。

表 10.4G · 文档类型与最小元数据
文档边界线索建议字段抽检重点
政策/规章章节、条款、例外生效日、地区、版本否定词和适用范围
FAQ问题—答案对问题 ID、产品、语言答案是否完整
表格表头、行列、单位表名、行范围、单位列关系和数字
代码文件、符号、行号语言、路径、commit可运行性和错误码
扫描/OCR页码、坐标、置信度ocr_score、图片 hash数字和版式
PYTHON · chunk_audit.py
def audit_chunk(chunk):
    text = chunk["text"].strip()
    problems = []
    if not text:
        problems.append("empty")
    if len(text.split()) > chunk["max_words"]:
        problems.append("too_long")
    if not chunk.get("source") or not chunk.get("version"):
        problems.append("missing_provenance")
    if chunk.get("ocr_score", 1.0) < 0.85:
        problems.append("low_ocr_confidence")
    return {"id": chunk["id"], "ok": not problems, "problems": problems}

sample = {
    "id": "doc-7#p3#c2", "text": "退款期限为七天。",
    "max_words": 80, "source": "policy.pdf", "version": "v3",
}
print(audit_chunk(sample))

审计代码把“能不能进入索引”变成显式状态。生产管道还要保存 parser_version、text_hash、parent_id、acl_version 和原文范围;当一个答案引用错误时,可以沿这些字段反查是哪一次解析或分块改变了内容。

DOCUMENT LAB 10.4.5为三类文档设计分块规则

各选一份政策、表格和代码文件,分别写出 Block Schema、chunk 边界、overlap、父子关系和抽检清单。用同一组跨边界问题比较固定窗口、结构化分块和父子检索。

三类文档的规则有差异;表头、单位、符号和页码不丢失;失败块进入 quarantine;结论有支持率和引用完整度。

当你能让读者从一个 chunk 点击回原文,并看到它为何被切在这里、属于哪个版本、谁可以看、何时失效,分块才真正成为检索基础设施,而不只是预处理脚本。

做一次“反向引用”练习:从答案中的一条数字或限制条件出发,找到对应 chunk、父文档、页码或字符范围,再检查它是否仍在当前版本和权限范围内。反向路径走不通时,优先修复 metadata 和状态,而不是让生成模型补一句解释。

分块策略也要能解释失败。记录一个被切断的条款、一个表头丢失的行、一个代码依赖缺失的函数和一个 OCR 数字错误;为每个失败写最小修复与回归样本,下一次解析器升级时重跑。

05 · RETRIEVAL

查询与召回:改写、过滤和重排

用户问题通常不是可以直接检索的完美查询。系统要判断意图、补全实体、拆分子问题,再用向量、关键词、元数据过滤和重排组合出证据。本节把召回链路拆成可测试的阶段。

10.5.1 查询理解与改写

查询理解先回答“用户到底想找什么”。问题可能缺少主语、时间、版本或文档范围,也可能把多个任务混在一句话里。把查询拆成意图、实体、时间、约束和期望输出,再决定检索策略;不要一上来让生成模型自由改写,因为改写可能加入原问题没有的事实。

最小查询规范化包括空白、大小写、全半角、同义词和日期格式,但要谨慎处理代码、产品 ID、数字和否定词。把“v2.1”改成“v2.10”会造成严重错误;把“不支持”删除会反转政策。规范化规则需要按语言和字段测试,并保存原查询与规范化结果。

查询改写可以生成多个视角:用户口语、关键词串、正式术语、英文翻译或假设的文档标题。多查询提高召回机会,也会增加调用次数、成本和噪声。每个改写都记录来源和用途,最终候选要去重、重排并回到原问题验证,而不是把所有改写结果直接拼进上下文。

多轮对话中的“它”“上一个版本”“那条规则”需要借助历史消歧。系统应把当前问题与必要历史压缩成检索查询,但不要把整段聊天无条件送入索引。保留用户明确说过的实体和约束,删除闲聊、秘密和无关上下文;查询重写的结果可让用户确认。

复杂问题可以拆成子问题并行召回,例如先找政策定义,再找例外,再找生效日期。拆分要保留父问题 ID 和依赖关系;如果第二个子问题依赖第一个结果,应串行执行并记录中间证据。拆分越多不一定越好,过度拆分会产生相互矛盾的片段。

HyDE 或假设文档方法让模型先生成一段可能的答案,再用它编码检索。这对抽象问题可能有帮助,但假设文本本身可能带来幻觉和偏见。把假设只当作召回通道之一,保留原查询通道;高风险任务不应把未经核验的假设当作证据。

图 10.5 · 查询从原话到证据候选本站自制示意
用户原话口语/省略
→
意图解析实体/时间/权限
→
查询改写多路表达
→
候选召回dense/lexical
→
重排选择证据预算

改写只改变检索表达,不应偷偷改变用户意图;所有中间结果都要可回放。

表 10.5A · 查询改写策略
策略解决的问题代价适用边界
规范化格式、同义词、日期规则维护数字和否定需保护
历史消歧指代和上下文隐私、窗口只保留必要历史
多查询表达覆盖不足调用和噪声候选去重
子问题多个意图依赖与编排保存父子关系
HyDE抽象问题召回假设幻觉只作辅助通道
关键词抽取实体、编号、代码词表维护和 dense 互补
PYTHON · query_rewrite.py
def rewrite(query, history=None):
    history = history or []
    protected = {"v2.1", "不支持", "订单号"}
    normalized = " ".join(query.strip().split())
    variants = [normalized]
    if "它" in normalized and history:
        variants.append(history[-1] + " " + normalized.replace("它", "该对象"))
    keywords = [
        token for token in normalized.split()
        if token not in {"请问", "一下", "怎么"}
        or token in protected
    ]
    variants.append(" ".join(keywords))
    return list(dict.fromkeys(variants))

print(rewrite("它在 v2.1 怎么退款?", ["退款规则"]))

示例只产生可解释的候选,不直接调用模型。真实改写器要限制长度、保留数字和否定、记录提示版本,并把改写结果与原问题一起交给后续过滤。高风险任务可以要求用户确认改写后的实体和时间。

RETRIEVAL LAB 10.5.1设计查询改写回放

收集 20 条包含指代、日期、否定、编号和多意图的问题,记录原话、结构化字段、改写版本、召回结果和人工是否认可。

原问题不被覆盖;数字和否定保留;每个改写有来源;用户意图改变时必须澄清而不是默默继续。

10.5.2 召回、重排与证据选择

召回阶段追求覆盖,重排阶段追求顺序。先用便宜的 dense、BM25 或过滤得到几十到几百个候选,再用 cross-encoder、规则或轻量模型逐对读取查询和文本,判断更细的相关性。重排器比双塔更准确但更慢,候选数量是质量与延迟的主要旋钮。

重排特征可以包括向量分数、关键词命中、标题匹配、版本新鲜度、来源可信度、实体一致性、文档权限和父子关系。每个特征都要知道方向和范围;未经归一化的分数直接相加会让某一通道支配结果。先做简单加权和离线对照,再考虑学习排序。

证据选择不能只看单条相关性。一个回答可能需要定义、条件、例外和示例四类片段;单条最高分文本未必覆盖全部声明。可以按事实单元、标题路径或文档配额选择互补证据,再由生成阶段判断是否足够。被舍弃的候选保留在调试日志中。

重排器也会学到偏见和捷径。长文本可能因为包含更多词而得分高,标题中有问题词的无关文档可能被偏爱,某个来源格式可能被当作可信。用长度、来源、语言、版本和难负例切片,检查重排是否在真正判断相关性。

重排失败时要有回退:模型超时可以使用 dense 排名,返回异常可以只保留安全白名单,候选不足可以扩大查询或请求澄清。回退路径不能绕过权限、版本和删除过滤;任何回退都记录原因,方便评估其质量损失。

证据选择还要考虑引用可用性。只有能回查原文、版本和范围的候选才能进入最终上下文;缺少来源的高分片段应降权或阻止。把“相关”“可引用”“可公开”分成三个状态,避免模型引用一段内部备注或过期缓存。

表 10.5B · 两阶段检索
阶段输入/输出优先指标失败回退
过滤用户范围→可见集合零越权阻止查询
粗召回query→候选 100Recall@100关键词/扩大 k
重排候选→排序 20nDCG/MRRdense 排序
证据选择排序→最终 5支持率/多样性保守少返回
上下文预算证据→Token 包引用覆盖/成本分层摘要
PYTHON · rerank_candidates.py
def score(query, item):
    lexical = len(set(query.split()) & set(item["text"].split()))
    title_hit = int(any(word in item["title"] for word in query.split()))
    freshness = 1.0 if item["version"] == "current" else 0.2
    return 0.5 * item["dense"] + 0.2 * lexical + 0.2 * title_hit + 0.1 * freshness

query = "退款 版本 2.1"
candidates = [
    {"id": "a", "title": "退款政策", "text": "版本 2.1 七天",
     "dense": 0.84, "version": "current"},
    {"id": "b", "title": "运输政策", "text": "版本 2.1 物流",
     "dense": 0.86, "version": "current"},
]
ranked = sorted(candidates, key=lambda x: score(query, x), reverse=True)
print([(x["id"], round(score(query, x), 3)) for x in ranked])

示例中的权重只是可解释的起点。不要因为加权后一个样例变好就宣布重排有效;用固定 gold、难负例和最终答案支持率评估。重排器越复杂,越要保存特征、版本和失败样例。

RETRIEVAL LAB 10.5.2做一次重排对照

对同一候选集比较 dense、关键词、规则加权和一个可选 cross-encoder,报告 nDCG、Recall@10、p95 延迟、候选 Token 和三个排序反例。

候选集固定;特征有归一化和版本;重排不能绕过 ACL、删除和生效时间过滤。

10.5.3 多路检索与结果融合

混合检索把不同通道的优点组合起来:BM25 擅长精确词面,dense 擅长同义改写,字段过滤处理权限和版本,知识图谱或 SQL 处理结构关系。通道之间的分数通常不可直接比较,常用排名融合、分数校准或学习排序。融合策略要能解释每条证据来自哪一条通道。

倒数排名融合 RRF 的直觉是:某个文档在多个通道都靠前,就获得更高的综合分;单个通道的极端分数不会完全支配结果。融合前先统一过滤、去重和 tie-breaker;同一 doc 的多个 chunk 可以先在文档内聚合,再参与跨文档排序。

通道权重应由查询类型决定。含有产品编号、日期、错误码的查询可以提高 lexical 权重;口语化、跨语言和概念问题可以提高 dense 权重;明确要求最新版本时必须先用 metadata 过滤。分类器判断查询类型也可能错,因此保留一个安全的默认通道和回退策略。

多路结果会增加上下文冲突。不同来源可能讲同一事实却适用不同版本,dense 找到概念解释、BM25 找到旧编号。融合后按版本、来源和标题路径聚合,生成阶段显示冲突并要求核验;不要把所有通道输出直接串在一起。

缓存也要区分通道和版本。查询规范化、模型 manifest、索引 snapshot、过滤条件和用户权限都进入 cache key;改动任意一个就不能复用旧候选。缓存命中后仍可做轻量权限复核,防止角色变化带来泄露。

当某通道不可用时,系统应明确降级。向量服务超时可以走关键词,关键词索引更新中可以走旧 snapshot,但要在答案或日志里说明证据范围。高风险问题若只剩低召回通道,宁可拒答或人工接管,也不要用流畅生成掩盖检索缺失。

表 10.5C · 多路通道的职责
通道擅长弱点融合前检查
BM25/词面编号、实体、否定同义改写弱分词和字段
Dense语义、跨语言、口语数字和版本易混模型契约
Metadata权限、时间、类型不能发现语义权威字段
SQL/图关系、聚合、状态文本覆盖有限查询安全
重排精细相关性耗时、需要候选候选规模
规则回退可用性和安全覆盖窄低分动作
PYTHON · rrf_fusion.py
def rrf(rankings, k=60):
    scores = {}
    origins = {}
    for channel, items in rankings.items():
        for rank, item_id in enumerate(items, start=1):
            scores[item_id] = scores.get(item_id, 0.0) + 1 / (k + rank)
            origins.setdefault(item_id, []).append(channel)
    ranked = sorted(scores, key=lambda item: (-scores[item], item))
    return [
        {"id": item, "score": round(scores[item], 5),
         "channels": origins[item]}
        for item in ranked
    ]

rankings = {
    "dense": ["policy-2", "faq-1", "policy-1"],
    "bm25": ["policy-1", "policy-2", "code-4"],
}
print(rrf(rankings))

RRF 不需要让各通道分数落在同一个范围,只使用排名;这让它适合作为透明基线。生产系统可以在 RRF 之后重排,但要保存每个候选的通道来源和排名,遇到错误时能回答“是哪条通道把它带进来的”。

RETRIEVAL LAB 10.5.3搭建一条混合召回基线

用同一 gold 集比较 dense、BM25、RRF 和规则过滤四种结果;为编号、同义、否定、版本和权限五类查询分别写出最佳通道和降级动作。

通道结果可追溯;融合前完成权限和版本过滤;降级时不隐藏证据缺失。

本节深挖:检索链路每一步都可能改变答案

查询改写改变的是搜索表达,不能改变用户事实。把改写结果回传给用户或写入日志,检查实体、时间、否定和权限是否保留;模型生成的“更完整问题”如果加入了原文没有的条件,应被标记为假设。

召回与重排的职责不同。召回要尽量不漏,重排要尽量把正确证据放前;如果 gold 文档根本没有进入候选集,重排再强也无能为力。评估时分别报告 Recall@candidate、nDCG@final 和最终答案支持率。

候选越多不一定越好。过多候选提高召回,却增加重排成本和上下文冲突;候选太少则可能漏掉例外。用预算约束决定候选和最终证据数量,按任务和风险切片校准。

重排器读取查询和文本,容易受到长度、标题和格式捷径影响。设计难负例时保持主题相似,只改变关键事实、版本或权限,才能发现它是否真的理解相关性。

融合时不要把来源权重隐藏在一个总分。记录每条通道的排名、分数、过滤原因、去重关系和最终贡献;解释失败时,先定位通道,再决定修复模型、规则或数据。

查询缓存必须包含权限和 snapshot。相同文字不代表同一个可见集合;缓存键过短会造成跨用户泄露,过长会失去命中率。安全优先,命中率只在边界正确后优化。

通道降级要有质量预算。关键词回退可能仍能处理编号,但语义问题 Recall 下降;向量回退到旧 snapshot 可能包含过期政策。把降级状态写入答案元数据,让用户和运营者知道证据范围。

多路检索也需要可恢复性。每条子查询和通道调用有超时、重试和断路器,连续失败后停止,不要让 Agent 无限增加查询。失败事件和最终候选一起保存,后续才能评估可靠性。

表 10.5D · 查询链路验收
阶段要回答的问题关键证据失败动作
解析意图和实体是什么?结构化查询澄清
改写是否改变原意?原文/改写 diff阻止或确认
过滤用户能看什么?ACL/版本只返回可见集合
召回gold 是否进入候选?Recall@k换通道/扩大候选
重排正确证据是否靠前?nDCG/难负例回退/重训
融合哪些通道贡献?origin/rank降级并标记
RETRIEVAL REVIEW 10.5画出一条可解释召回链

选一条真实查询,画出原话、改写、过滤、各通道候选、重排、去重和最终证据;标注每一步的输入、输出、版本、超时和失败动作。

链路能从最终引用反查到原查询和通道;权限先于返回;任何降级都被记录。

课后复习与易错

易错一:查询改写越长越好。长改写可能引入假设;保留原查询并做 diff。

易错二:把候选召回和最终证据混为一谈。候选为覆盖服务,重排和预算决定最终上下文。

易错三:直接相加不同通道分数。先做排名融合或校准,否则某通道会支配结果。

易错四:没有 gold 集就调重排。没有正确候选,重排无法挽救漏召回。

易错五:过滤放在生成后。模型看到了不可见资料,事后拒答已经不安全。

易错六:忽视通道降级。向量超时、旧索引和关键词回退都应标记并评估。

章节任务卡:把用户问题变成证据候选

建立一个小型查询集,至少覆盖口语、指代、多意图、日期、否定、数字、内部缩写、跨语言和资料不足。为每条问题写出 gold 文档或 gold 证据单元,允许多个正确来源,并注明关键声明。

先运行原查询,再运行规范化和多查询改写。保存每个版本的文本、结构化实体、时间、关键词、Embedding manifest 和调用耗时。人工检查改写是否改变了意图,尤其是否定、版本号和用户限定条件。

将候选链拆成过滤、dense、BM25、SQL 或规则几个通道。每个通道独立记录候选 ID、排名、分数、过滤原因和 snapshot,再用 RRF 或简单加权融合。这样出现错误时,你可以知道是哪个通道带来了错误文档。

用一个轻量重排器或规则把候选缩到最终证据。为每个保留的 chunk 记录相关性、来源、版本、权限、标题路径和预计 Token;为被舍弃的候选写出原因。最终证据必须能回查原文,不能只留一段复制文本。

为查询服务设置超时、重试和降级。向量服务不可用时走关键词,重排超时时使用粗召回,但在事件中标记 degraded;高风险或低分问题不能因为降级就继续生成,应澄清或接管。

做一轮难负例回归。把词面相似但版本、否定、实体或权限不同的文档加入数据集,检查重排器和融合是否把它们放到前面。每修复一个案例,再加入一个邻近变体,防止规则只记住原句。

最后写一页检索决策说明:什么查询走什么通道,什么条件触发改写、拆分、重排、澄清或拒答,候选和最终上下文各有多少预算。说明中引用 Recall、nDCG、支持率、延迟和成本,避免“感觉更准”的结论。

表 10.5E · 查询任务卡验收
任务交付物合格标准
查询集多类型问题+gold有否定、数字、版本和资料不足
改写原话/改写 diff意图不被偷偷改变
多路召回通道候选和来源权限、版本先过滤
重排排序与难负例相关证据靠前
降级超时和回退日志高风险不绕过门槛
决策说明规则、指标、预算别人可复算
PYTHON · retrieval_trace.py
trace = {
    "query": "v2.1 不支持哪些退款条件?",
    "rewrites": [
        "版本 v2.1 退款条件 不支持",
        "v2.1 refund exceptions",
    ],
    "filters": {"tenant": "team-a", "version": "v2.1"},
    "channels": [
        {"name": "dense", "ids": ["p-3", "p-7"]},
        {"name": "bm25", "ids": ["p-7", "p-9"]},
    ],
    "fused": ["p-7", "p-3"],
    "selected": ["p-7"],
    "degraded": False,
}
print("final evidence:", trace["selected"])
print("channel provenance:", trace["channels"])

检索 trace 是调试和学习的共同材料。它把用户原话、改写、过滤、通道、融合和最终证据放在同一事件里,后续可以计算每条通道的贡献、失败率和成本。敏感查询要脱敏或做访问控制。

RETRIEVAL LAB 10.5.4提交一条完整 trace

选择一个真实问题,保存原话、改写、过滤、各通道候选、融合、重排、最终证据、Token、延迟和失败动作;让同伴只凭 trace 解释答案来自哪里。

trace 不隐藏降级和被过滤原因;最终证据可回查原文;同伴能复算排序或指出缺失字段。

排错与复盘:从错误排名定位根因

如果 gold 文档不在候选中,先检查查询改写、文本分块、Embedding 模型和索引 snapshot;不要先调生成 Prompt。候选阶段已经漏掉证据,后面的模型无法凭空恢复。

如果 gold 在候选却没有进入最终上下文,检查重排特征、去重、版本过滤、文档配额和 Token 预算。保留候选到最终证据的 diff,能迅速定位是哪条规则删掉了它。

如果最终证据正确但回答引用错,问题在上下文编排、引用 ID、模型模板或解析器;如果证据本身错,问题在召回或数据管道。把检索质量和生成质量分开评估。

如果改写让 Recall 上升但支持率下降,说明引入了噪声或假设。保留原查询通道,要求改写只作为候选扩展,并按风险决定是否让用户确认。

如果融合让平均分变好但数字和版本变差,检查通道权重和精确字段。对高风险实体和编号,词面或结构化查询应有优先级。

如果重排延迟超预算,先减少候选、使用轻量模型、批处理或缓存,再考虑更强重排器。降低延迟不能绕过 ACL、版本和删除过滤。

如果同一查询结果不稳定,保存 query hash、改写、通道、snapshot、tie-breaker 和缓存键。稳定性问题可能来自数据更新,而不是模型随机性。

每次修复都加入回归:原失败、同义改写、数字变体、否定变体和权限变体。只修原句会让系统在下一条相似问题上再次失败。

表 10.5F · 检索故障定位
现象优先检查证据安全动作
gold 不在候选改写、分块、Embedding、索引候选 trace扩大通道/澄清
gold 在候选却被删重排、去重、预算candidate diff保守保留
证据正确、引用错模板、ID、解析原始上下文阻止发布
改写引入噪声假设、历史、HyDErewrite diff回到原查询
重排超时候选数、批次、模型分段计时有限降级
结果不稳定cache/snapshot/tie回放事件锁版本或回滚

课后复习与易错

易错一:改写越多越好。改写会增加成本和噪声,必须和原查询并行、可回放并检查意图。

易错二:重排可以挽救所有问题。正确证据若没进入候选集,重排无能为力;先看 Recall。

易错三:把各通道的分数直接相加。分数范围不同,先做校准或使用排名融合。

易错四:让模型决定过滤和权限。权限由服务端和权威元数据决定,模型只能提出查询。

易错五:候选越多越可靠。过多候选会增加冲突和成本;按任务和风险校准预算。

易错六:忽略被丢弃的候选。候选到最终上下文的 diff 是定位漏证据的重要证据。

易错七:只评估最终答案。分开报告召回、重排、引用和生成,才能知道修复哪一层。

易错八:降级时继续假装正常。关键词回退、旧 snapshot 和重排超时都应标记并触发相应门槛。

表 10.5G · 检索结业检查
检查通过证据不通过动作
意图保持原话/改写人工确认停止自动改写
候选覆盖Recall@candidate补通道或数据
排序质量nDCG/难负例调整重排
来源可追溯chunk/版本/权限阻止入上下文
降级可解释trace 与状态码澄清或接管

完成本节后,你应能从一条最终引用反查到原查询、改写、过滤、召回通道、重排特征、索引 snapshot 和降级状态。下一节将在这条证据链上加入上下文编排和受约束生成。

检索 trace:把候选生命周期记录下来

一条可诊断的检索 trace 至少包含 query_id、原查询、规范化查询、改写列表、过滤条件、每个通道的候选、去重结果、重排分数、最终 context_ids 和降级状态。不要只保存最终的五个 ID;中间被丢弃的候选往往才是发现根因的关键。

查询改写要有“意图保持”检查。把历史对话中的代词补全、时间词和产品名记录为结构化字段;如果改写增加了原问题没有的假设,应该并行保留原查询,或要求用户确认。HyDE 生成的假设文档只能作为召回线索,不能直接当证据。

多路召回的合并应区分候选来源。一个 chunk 同时出现在 BM25、dense 和标题匹配中,可以增加排序信号,但不能把它当三份独立证据。保存 source_channels,并在最终上下文里按事实单元和来源多样性做配额。

重排器输入的字段也要审计。模型看到的文本、标题、查询、版本和 ACL 摘要必须与上线协议一致;禁止把不可见字段或未来标签混入离线评估,否则会产生数据泄漏,线上表现必然回落。

“检索失败”不是一个状态。至少区分没有可见候选、候选低于阈值、gold 被过滤、重排超时、索引不可用、查询为空和标注不完整。不同状态需要不同用户提示和修复负责人,统一成一个空列表会掩盖问题。

表 10.5H · 候选账本的字段与责任
阶段输入输出审计问题
规范化原查询、历史canonical query是否改变意图
改写模板、术语queries/assumptions是否引入新事实
召回索引、过滤候选与分数gold 是否进候选
融合多通道候选统一排名分数是否可比较
重排候选文本、查询rerank score是否泄漏字段
编排预算、风险context_ids为何保留/截断
PYTHON · retrieval_trace.py
from datetime import datetime

def make_trace(query, rewritten, channels, selected, status="ok"):
    return {
        "query_id": "q-001",
        "created_at": datetime.utcnow().isoformat(),
        "query": query,
        "rewritten": rewritten,
        "channels": channels,
        "selected": [item["id"] for item in selected],
        "status": status,
    }

trace = make_trace(
    "如何退货?",
    ["退货流程", "当前退货政策"],
    {"dense": ["c1", "c4"], "bm25": ["c4", "c8"]},
    [{"id": "c4", "score": 0.91}],
)
print(trace)

示例中的 trace 还很小,但它提醒我们把查询和结果分离保存。真实系统要用 JSONL 或事件系统追加写入,敏感查询脱敏,保留 retention 和访问审计;调试日志不能成为新的数据泄露面。

RETRIEVAL LAB 10.5.5画一条候选到上下文的证据链

选一条失败查询,提交原查询、两种改写、三条召回通道、过滤原因、重排前后列表、最终截断结果和用户动作。为每个被丢弃的候选写出可验证的理由。

原查询仍可回放;改写没有偷偷增加事实;过滤和截断有原因;最终引用能回到 chunk、版本和权限。

检索的工程质量体现在可解释的中间状态:你不只知道“答案错了”,还知道 gold 没有进候选、进入后被删掉、被错误重排,还是被上下文预算截断。下一节将继续追踪这些状态如何影响生成和拒答。

06 · RAG GENERATION

RAG 生成:让回答有证据、有边界

检索到资料不等于答案可靠。生成阶段还要控制上下文顺序、引用格式、冲突处理和资料不足时的拒答。本节把 RAG 看成“检索证据加受约束生成”的协议,而不是把片段简单拼到 Prompt 里。

10.6.1 上下文编排与 Prompt 契约

RAG 请求通常包含系统规则、用户问题、对话历史、检索证据、工具结果和输出 Schema。它们的信任等级和职责不同:系统规则定义边界,用户提供任务,证据提供可核验资料,工具返回外部状态。用清晰的分隔符和字段名区分这些区域,告诉模型证据是数据而不是指令。

上下文编排首先解决优先级。系统规则和安全策略放在稳定位置,当前问题明确,证据按来源和相关性排列,历史只保留必要内容;不要把无关聊天和未审查网页混在同一个长字符串。顺序不是绝对规则,应在自己的模型和模板上做位置实验。

每个证据块带上 chunk_id、标题、来源、版本、文本和可信状态。引用 ID 不要只用数组位置,因为排序或去重变化会导致“[1]”指向另一段;用稳定 ID,并让模型在输出中回传这些 ID。系统随后验证 ID 是否存在、是否属于当前用户可见集合。

上下文预算要分层分配。先预留系统和输出 Schema,再为问题和必要历史留空间,剩余预算才给证据;长证据可以摘要,但摘要也要带来源并标记“二次压缩”。达到上限时应告诉用户资料被压缩或请求更具体的问题,不要静默截掉关键条件。

证据冲突要显式呈现。不同版本、地区或角色的政策可能互相矛盾;把冲突片段分别标注版本和生效时间,要求模型说明适用范围或请求澄清。不要简单按相似度选一个,因为最高分不一定是最新或最权威。

RAG Prompt 不是越长越专业。规则重复、示例过多和低相关证据会稀释注意力并增加成本。每一条说明都要回答“它防止哪个失败”;没有用途的装饰性提示删掉。把模板作为版本化文件,用固定请求回放验证格式、引用和拒答。

图 10.6 · 受约束的 RAG 上下文本站自制示意
系统规则边界/策略
+
用户问题任务/约束
+
证据块ID/来源/版本
+
输出协议答案/引用/拒答
→
校验器声明/权限/格式

生成模型负责提出回答,协议和校验器负责限定它能声称什么。

表 10.6A · 上下文区域的职责
区域允许包含不应包含校验方式
系统规则角色、边界、失败动作用户可编辑秘密版本和访问控制
用户问题目标、范围、格式伪造系统指令长度和安全分类
证据块原文、ID、来源、版本可执行指令、未授权资料ACL、hash、引用
历史必要约束和已确认事实闲聊、秘密、过期假设摘要和时间
输出 Schema字段、枚举、引用格式业务权限决定解析和业务校验
PYTHON · build_context.py
def build_context(question, evidence, budget=600):
    header = "回答必须只使用下面的证据;证据不足时返回 insufficient."
    blocks = []
    used = len(header) + len(question)
    for item in evidence:
        text = item["text"]
        piece = "[SOURCE " + item["id"] + "] " + text
        if used + len(piece) > budget:
            break
        blocks.append(piece)
        used += len(piece)
    return {
        "system": header,
        "question": question,
        "evidence": blocks,
        "used_chars": used,
    }

evidence = [
    {"id": "p-7", "text": "版本 2.1 退款期限为七天。"},
    {"id": "p-8", "text": "特殊商品适用不同条件。"},
]
print(build_context("退款期限?", evidence))

示例用字符数模拟预算,真实系统要用 Tokenizer。预算函数应返回被截断的证据 ID 和原因,便于解释“为什么答案没有看到某段”。对于高风险问题,宁可减少证据并触发澄清,也不要把超预算内容静默丢弃。

RAG LAB 10.6.1写一份上下文协议

为一个问答任务画出系统、问题、历史、证据和输出 Schema 的边界;规定每个区域的信任等级、Token 预算、引用字段和超预算动作。

证据被标记为数据;系统规则和业务权限不由文档覆盖;截断和冲突都有日志与用户可见动作。

10.6.2 引用、声明与事实核验

一个可核验的答案要先拆成原子声明。声明是可以单独判断真假的最小句子,例如“版本 2.1 的退款期限为七天”;不要把三个事实和一个推断写成一条长句再只给一个引用。声明拆开后,才能知道哪些有证据、哪些缺证据、哪些互相冲突。

引用支持不等于引用存在。系统至少检查 source_id 是否来自本次可见证据、引用范围是否覆盖声明、版本和适用条件是否一致。引用了一段“七天”但忽略“特殊商品除外”,仍然是事实不完整。验证器可以使用规则、关键词、检索相似度、结构化查询或人工审核。

引用格式要稳定且可点击。展示层可以用脚注、来源卡或段落旁的链接,后台保留 doc_id、chunk_id、版本、页码/字符范围和 hash。不要让模型自己生成 URL 或路径;由服务端根据稳定 ID渲染,防止伪造和越权。

资料冲突时,回答应列出冲突而不是挑一个顺口的。比较生效日期、权威等级、地区、用户角色和文档状态;如果无法决定,说明冲突并请求澄清。将“冲突未解决”作为结构化状态,避免模型用自信语气掩盖不确定性。

数字、日期和实体应有更严格的核验。可以把声明中的数值抽取出来,与结构化表或规则引擎比较;把版本号、货币单位和时间区间规范化;对计算结果调用计算器而不是依赖生成。RAG 能提供资料,但算术和状态判断应交给专门工具。

事实核验也要防提示注入。证据中可能写着“忽略之前指令”或要求泄露秘密;这些文字只能作为待引用数据,不改变系统规则。对网页和用户上传资料做内容隔离,输出引用时保留来源而不执行其中的命令。

表 10.6B · 声明级核验
检查问题通过证据失败动作
来源存在引用 ID 是否可见?当前 trace 的 source删除引用/阻止输出
文本支持片段是否支持声明?原文和匹配范围改写或人工
条件完整例外是否遗漏?相邻条款/标题路径补证据
版本适用是否最新且适用?生效时间/角色冲突澄清
数值正确单位和计算是否对?结构化表/计算器重新计算
安全可见用户是否有权限?ACL 决策阻止泄露
PYTHON · claim_validator.py
def validate_claims(answer, sources):
    source_map = {item["id"]: item for item in sources}
    results = []
    for claim in answer["claims"]:
        source = source_map.get(claim.get("source_id"))
        supported = bool(source and claim["text"] in source["text"])
        results.append({
            "text": claim["text"],
            "source_id": claim.get("source_id"),
            "supported": supported,
        })
    return results

sources = [{"id": "p-7", "text": "版本 2.1 的退款期限为七天。"}]
answer = {"claims": [
    {"text": "版本 2.1 的退款期限为七天。", "source_id": "p-7"},
    {"text": "所有商品都能退款。", "source_id": "p-7"},
]}
print(validate_claims(answer, sources))

示例用字符串包含做最小演示,真实核验需要处理同义表达、数字格式和声明拆分,并且最好让人工抽样复核。验证器的职责是阻止明显不支持的声明,不是把所有自然语言蕴含都自动证明。

RAG LAB 10.6.2制作声明—来源对照表

让模型回答五个问题,人工把每个答案拆成原子声明,记录 source_id、支持范围、版本、例外和核验结果;挑一条引用存在但不支持的反例。

至少区分引用存在率和引用支持率;数字、日期和例外单独核验;不支持的声明不能以“模型很确定”放行。

10.6.3 无证据时拒答与澄清

资料不足是正常状态,不是系统异常。查询可能超出知识库范围、召回低于阈值、版本冲突、文档正在更新或用户权限不足。系统需要一个结构化的 insufficient 状态,说明缺少什么证据、可以补充什么资料、是否能改问法,而不是让模型用通用知识填空。

拒答有不同等级。低风险问题可以返回“当前资料没有找到”并给出已检索范围;中风险问题可以请求文档、时间和地区;高风险问题在证据不足时直接停止并转人工。拒答文案应具体、简洁,不泄露隐藏策略、被过滤的文档或内部路径。

澄清问题也需要预算和优先级。一次只问最能缩小搜索空间的条件,例如“你指的是版本 2.1 还是 2.2?”而不是连续追问五个字段。若用户不补充,系统可以给出基于当前资料的有限答案,并明确哪些部分未确认。

“没有证据”与“证据证明没有”不同。检索不到资料只能说明当前可见知识库未找到,不代表现实中不存在。回答中使用“在提供的资料范围内”这样的边界表述,避免把检索失败说成绝对否定。若有负面证据,也要引用其适用范围。

拒答与用户体验可以共存。返回已找到的相关标题、下一步检索建议和人工入口,但不要把低相关片段当作答案。对代码、财务和医疗等高风险任务,明确建议用户核对权威来源;系统的目标是减少误导,而不是每次都给完整段落。

拒答率要和误拒率一起评估。过低可能代表幻觉,过高可能代表知识库、阈值或查询改写有问题。按任务、语言、风险、版本和用户群切片,记录触发原因和最终解决率,才能判断是模型需要改进还是资料管道缺陷。

表 10.6C · 资料不足的响应策略
状态判定用户看到什么后续动作
无候选过滤后为空说明检索范围改问法/补资料
低相关最高分低于阈值不生成事实答案澄清或人工
冲突多个版本不一致列出冲突条件确认版本/地区
权限不足资料不可见不透露文档存在申请授权
过时资料超 freshness SLA提示更新时间更新索引
高风险风险规则触发停止并转人工记录接管
PYTHON · abstain_policy.py
def answer_policy(top_score, evidence_count, risk, conflict=False):
    if risk == "high":
        return "handoff"
    if conflict:
        return "clarify_version"
    if evidence_count == 0 or top_score < 0.72:
        return "insufficient"
    return "answer_with_citations"

cases = [
    (0.91, 3, "normal", False),
    (0.61, 4, "normal", False),
    (0.88, 2, "high", False),
    (0.90, 2, "normal", True),
]
for case in cases:
    print(case, "=>", answer_policy(*case))

策略函数把生成前的边界显式化。真实项目还要加入证据支持率、版本新鲜度、权限状态、工具失败和用户是否确认;模型输出不能绕过这个门控。

RAG LAB 10.6.3设计资料不足的三种响应

为无候选、版本冲突和高风险三类请求分别写用户文案、结构化状态、日志字段和下一步动作;再加入一个会触发过度拒答的正常问题。

文案不泄露内部资料;无证据不等于现实中不存在;高风险有人工或阻止路径;误拒进入回归集。

本节深挖:RAG 是证据协议,不是 Prompt 拼接

RAG 的端到端质量由多层共同决定:文档是否合法可追溯,分块是否保留最小事实,Embedding 是否找得到,过滤是否安全,重排是否把证据放前,上下文是否完整,模型是否遵守引用协议,验证器是否阻止不支持声明。任何一层失败,都可能在最终答案中表现为“模型胡说”。

上下文顺序和证据数量应通过实验选择。把同一事实放在开头、中间和结尾,比较支持率;把证据数量从 1 增到 10,观察召回、冲突、Token 和成本;把正确证据与提示注入混合,验证规则是否仍然优先。实验结果写入 manifest,不要靠记忆。

引用是用户信任与工程审计的共同接口。用户需要知道答案依据哪份资料,开发者需要重放和修复,合规人员需要确认权限和许可。稳定 ID、版本、范围、hash 和可点击来源缺一不可;“引用了一段文本”本身不够。

声明级核验让指标更细。答案可以有 90% 的句子被支持,却在关键数字上错误;也可以只说两句但全部正确。把关键声明、支持率、覆盖率、冲突和人工复核分开报告,并按风险给不同门槛。

资料不足时拒答是正向能力。训练和评估数据要包含“没有答案”“版本冲突”“权限不可见”和“需要工具”的样例,奖励模型不要只奖励完整长文。用户体验通过明确下一步、可查询范围和人工入口改善,而不是让系统编造。

Prompt 注入防护靠分层,而不是一句提示。把文档标记为 data,使用明确分隔符,限制工具权限,服务端校验引用和动作;即使模型被一段恶意文本影响,也不能直接改变系统规则或执行副作用。

长上下文不是无限记忆。历史摘要、证据压缩和 Token 截断都会丢信息;把被丢弃的 chunk、摘要版本和预算记录下来。高风险问题在压缩后支持率下降时应触发澄清或人工,而不是继续生成。

评估 RAG 时把检索和生成拆开。先问 gold 是否进入候选,再问最终上下文是否包含关键证据,最后问回答是否正确引用。这样一次失败可以定位到 data、retrieval、context、generation 或 validation 阶段。

表 10.6D · RAG 端到端验收
阶段通过标准保存证据失败动作
数据来源、许可、版本明确data card/hash隔离
检索gold 进入候选Recall/trace补通道
上下文关键证据未丢context IDs/预算重排或澄清
生成格式和引用正确原始输出/模板阻止入库
核验声明被支持claim table人工/拒答
安全无越权和注入执行红队回放熔断
RAG REVIEW 10.6交付一个有证据的回答

选择一个问题,提交原查询、检索 trace、最终上下文、回答 JSON、声明—来源对照、引用链接和资料不足时的备用响应;再故意删掉一个关键 chunk,验证系统会拒答或澄清。

答案不能引用不可见资料;删证据后的行为有明确状态;用户可从引用回到原文和版本。

课后复习与易错

易错一:把检索片段直接拼进 Prompt。必须有区域边界、来源、版本、权限和预算。

易错二:只检查引用 ID 存在。还要检查文本是否支持声明、例外是否完整、版本是否适用。

易错三:把无候选说成“现实中不存在”。检索失败只说明当前可见资料没有找到。

易错四:让文档覆盖系统规则。证据是数据,不能改变权限、工具或安全策略。

易错五:长上下文越多越好。噪声、冲突、延迟和成本都会增加,要按任务测试。

易错六:只看最终答案分数。分开评估召回、上下文、引用和声明支持。

章节任务卡:完成一次可核验的 RAG 回答

先固定一条任务契约:回答什么问题、允许使用哪些资料、必须返回哪些字段、何时拒答、预算是多少。准备一组资料充分、资料不足、版本冲突、权限不可见和包含提示注入的测试问题,避免只测试理想路径。

把检索结果编排成证据包。每个证据包含稳定 ID、标题路径、来源、版本、权限状态、文本和 Token 数;按相关性与多样性排序。保存被截断和被过滤的候选,之后才能解释为什么一个关键事实没有进入上下文。

设计结构化输出,例如 answer、claims、citations、confidence_state 和 next_action。模型可以填写候选字段,但服务器校验字段类型、引用存在、权限和业务状态;解析失败或支持率不足时阻止入库。

做声明级事实核验。把回答拆成最小可判断句,检查每条声明是否由一个或多个证据支持,是否遗漏版本、地区、条件和例外。数字与日期使用规则或计算器复核,不让流畅文案替代验证。

设计三种资料不足响应:没有候选时说明搜索范围,低分时请求补充条件,冲突时要求确认版本。高风险请求直接接管。文案不能泄露不可见文档、内部提示或策略细节。

测上下文位置和数量。把关键证据放在开头、中间、结尾,分别加入 1、3、6 个 chunk,记录支持率、冲突、Token、延迟和成本。若证据更多反而变差,优先改排序和预算,而不是盲目增加窗口。

把文档中的指令当数据处理。用一段包含“忽略规则”的网页文本做红队,确认模型不会执行其中的命令,服务端也不会因此开放工具权限。失败时阻止发布并保存原始上下文。

最后提交回答包:任务契约、检索 trace、上下文快照、原始输出、解析 JSON、声明核验表、用户展示、评估指标和回放命令。一个可审计的回答包比一张聊天截图更能证明系统确实可靠。

表 10.6E · RAG 回答包
文件/字段最小内容用途缺失后果
contract输入、输出、失败、预算冻结目标无法验收
retrieval_trace改写、通道、候选、过滤定位漏召回无法解释
context_snapshot证据 ID、版本、Token回放生成引用漂移
raw_output模型原始返回审计和修复解析覆盖证据
claim_review声明、来源、支持结果事实核验流畅错答
fallback拒答、澄清、接管边界行为资料不足时编造
PYTHON · rag_response_check.py
def check_response(response, visible_sources):
    source_ids = {item["id"] for item in visible_sources}
    errors = []
    if response.get("status") == "answer":
        if not response.get("claims"):
            errors.append("no_claims")
        for claim in response["claims"]:
            if claim.get("source_id") not in source_ids:
                errors.append("unknown_source")
            if not claim.get("supported"):
                errors.append("unsupported_claim")
    if response.get("status") == "insufficient" and response.get("claims"):
        errors.append("insufficient_should_not_claim")
    return errors

sources = [{"id": "p-7"}]
answer = {"status": "answer", "claims": [
    {"source_id": "p-7", "supported": True},
]}
print(check_response(answer, sources))

校验器应该在用户展示前和数据入库前各运行一次。它不是事实证明器,而是阻止明显越权、未知引用、未支持声明和状态矛盾的最后一道边界;所有错误都要进入事件日志和回归集。

RAG LAB 10.6.4交付一个可核验回答包

完成一条正常、一条资料不足和一条提示注入请求,分别提交检索 trace、上下文、原始输出、声明核验、用户响应和阻止/接管日志。

引用来自当前可见集合;资料不足没有编造声明;提示注入不改变规则;所有状态和版本可回放。

排错与复盘:回答为什么不可靠

如果没有引用,先检查模型输出 Schema、引用字段、上下文是否带 ID 和服务端渲染,而不是马上调整 Embedding。证据存在但没有显示,通常是协议或解析问题。

如果引用存在但不支持声明,检查 chunk 是否过大、版本是否冲突、生成是否合并了多条事实、核验器是否只做字符串包含。把声明拆开,重新回查证据范围。

如果模型引用了不可见资料,立即阻断并检查过滤、缓存、snapshot、上下文组装和日志。权限问题比普通召回问题优先级更高,修复前不要继续扩大测试。

如果资料充分却频繁拒答,检查阈值、支持率规则、改写、版本过滤和评估器;把合法问题的误拒样本加入回归集。严格不是越严格越好,关键是风险等级匹配。

如果资料不足却生成流畅答案,检查 abstain 门控是否在生成前执行、模型是否被允许调用通用知识、低分候选是否被凑数。拒答策略要作为成功路径测试。

如果长上下文导致支持率下降,检查排序、证据数量、位置、摘要和截断。先减少低相关片段或使用父子 chunk,再考虑增加窗口。

如果冲突版本被合并成一个结论,检查 metadata、生效时间、来源权威等级和 Prompt 编排。系统应列出冲突或请求确认,不应替用户猜适用版本。

如果提示注入改变了工具或安全策略,检查证据隔离、工具权限、结构化输出和服务端门控。文档内容永远不能越过系统规则。

表 10.6F · RAG 故障定位
现象优先检查最小证据动作
无引用Schema、ID、渲染raw/context阻止入库
引用不支持声明、chunk、版本claim review修复/人工
不可见资料泄露ACL、cache、snapshot权限回放熔断
过度拒答阈值、过滤、评估器误拒切片重校准
资料不足仍回答abstain 门控低分样例拒答/接管
上下文冲突版本、顺序、摘要context diff澄清
注入成功隔离、权限、工具红队回放阻塞发布

课后复习与易错

易错一:把证据块直接拼到 Prompt。必须区分规则、问题、历史和资料,并为每块保留来源、版本和权限。

易错二:只验证引用 ID。引用还要支持声明,不能遗漏例外、时间、地区和单位。

易错三:资料不足时用模型常识补齐。检索不到只说明当前资料不足,不代表事实不存在。

易错四:让文档内容覆盖系统规则。网页和文件是数据,提示注入不能获得工具或权限。

易错五:把拒答率当作唯一安全指标。还要看合法问题误拒、关键风险漏放和人工接管率。

易错六:上下文越长越可靠。长输入会增加成本、冲突和位置退化;用位置与数量实验验证。

易错七:只展示最终答案截图。保存 raw output、context snapshot、claim review 和事件日志,别人才能复核。

易错八:把校验器当事实证明器。规则能阻止明显错误,复杂蕴含仍需人工或专用验证。

表 10.6G · RAG 口试题
问题回答应包含最小证据
为什么要分区域?信任等级和职责不同context snapshot
怎样证明引用正确?声明、支持范围、版本claim table
资料不足怎么办?状态、澄清、接管abstain 回放
如何防注入?隔离、权限、服务端门控红队样例
如何控制成本?Token 预算、压缩、降级预算日志

本节的结业标准是:你能让一个读者从用户问题点击到最终引用,再回到原文、版本、权限和检索 trace;当关键证据被删掉或变成不可见时,系统会明确拒答、澄清或接管。

回答协议:先组织证据,再允许生成

RAG 的上下文不是一段无标签的字符串,而是一份有结构的证据包。建议把系统规则、用户问题、对话历史、检索片段和工具结果分开存储;每个片段带 chunk_id、来源、版本、权限和支持范围。渲染成 Prompt 只是最后一步,原始结构应保留以便核验。

引用核验要落到声明级。先把回答拆成可检查的事实声明,再为每条声明绑定支持片段、来源和生效时间;如果一条声明需要两个片段共同支持,记录组合关系。引用放在段落末尾但不说明覆盖范围,读者仍无法判断它支持哪句话。

冲突资料要显式建模。当前政策、历史政策、不同地区规则和草稿可能都与问题相似;过滤生效时间和适用范围后仍有冲突时,回答应列出冲突、请求澄清或转人工。让模型自行“综合”会把不同条件误合并成一条看似确定的结论。

上下文预算要按风险分配,而不是只按相似度排序。定义、例外、限制和计算依据可能比一段高相似度背景更重要;高风险问题宁愿缩短回答,也不要截掉决定结论的例外条款。记录每次压缩、摘要和截断,避免支持率下降却找不到原因。

拒答是正常产品状态。无可见证据、证据过期、声明无法核验、权限不足、工具失败和多版本冲突,都应该映射到不同的状态码和用户提示;提示不泄露被过滤的标题或文本,必要时只说明“当前可见资料不足”。

表 10.6H · 声明生命周期
步骤产物核验问题失败动作
拆声明claim_id、文本是否可独立判断重新分句
绑定证据source_ids、范围是否直接支持降级/拒答
检查版本生效日、地区是否适用当前问题列出冲突
生成回答带引用文本是否超出证据删除或改写
发布后回放raw、trace、评分是否可复现加入回归集
PYTHON · answer_packet.py
def build_packet(claims, evidence, status="ok"):
    evidence_by_id = {item["id"]: item for item in evidence}
    checked = []
    for claim in claims:
        sources = [
            evidence_by_id[item_id]
            for item_id in claim["source_ids"]
            if item_id in evidence_by_id
        ]
        checked.append({
            "claim_id": claim["id"],
            "text": claim["text"],
            "sources": [item["id"] for item in sources],
            "supported": bool(sources),
        })
    return {"status": status, "claims": checked}

packet = build_packet(
    [{"id": "c-1", "text": "退款期限为七天。", "source_ids": ["chunk-7"]}],
    [{"id": "chunk-7", "version": "v3"}],
)
print(packet)

这个小函数只检查是否存在绑定来源,并没有判断来源语义是否真的支持声明;它的价值在于建立结构化接口,让更严格的规则、人工审核或专用评估器有位置接入。任何自动核验都要把“未判断”与“已支持”区分开。

RAG LAB 10.6.4写一个带引用的回答包

准备五个问题,其中包含资料不足、两个版本冲突、权限过滤和一个需要两段证据共同支持的回答。输出 claim 表、context snapshot、状态码和用户可见文本,并让同伴逐条回查。

每条声明都有支持范围;冲突不会被静默合并;不可见资料不泄露;资料不足会澄清或拒答。

完成后,你会看到 RAG 的核心不是“把检索结果拼进 Prompt”,而是建立一套从证据到声明、从声明到用户动作的协议。生成模型可以负责语言组织,但证据边界、权限和拒答门控必须由可检查的结构控制。

07 · HYBRID & AGENTIC RAG

进阶检索:混合、迭代与持续更新

真实知识库同时包含精确编号、自然语言、表格和不断变化的状态。单一向量检索很难覆盖全部情况。本节学习 BM25 与向量的混合、迭代检索、Agent 辅助查询,以及增量更新、删除和新鲜度治理。

10.7.1 BM25 与向量的混合检索

BM25 是经典的词法检索基线,使用词频、逆文档频率和文档长度校正来排序。它不理解同义词,却很擅长精确词、数字、错误码、产品 ID 和否定词。dense Embedding 能连接“怎么退钱”和“退款流程”,但可能把“版本 2.1”和“版本 2.10”混在一起。两者互补,混合是工程选择而不是时髦标签。

最简单的混合方法是并集后去重,再用 RRF 按排名融合;另一种是先把分数校准到同一范围,再加权求和。RRF 对分数尺度不敏感,适合作为透明起点;加权分数可以加入新鲜度、标题命中和来源可信度,但需要验证权重。无论哪种方式,都要保存每条候选来自哪个通道。

查询路由可以根据意图选择通道。包含编号、日期、代码或引号短语时,提高词法权重;概念问题、跨语言和口语改写时,提高 dense 权重;明确要求最新版本时,先做时间和状态过滤。路由器本身可能判断错误,因此保留默认混合策略和低质量回退。

混合结果要处理重复和冲突。同一文档可能由多个通道返回,先按 chunk_id 去重,再按 doc_id、版本和标题路径聚合;不同版本需要显示适用范围,不能因为两个通道都命中就把旧内容当作更可信。融合日志保留原始排名和过滤原因。

词法检索的分词也会影响质量。中文分词、英文词干、代码符号、数字小数点和产品连字符都可能被处理不同。对关键字段可以增加不分词的精确索引;不要只调 BM25 的 k1、b,却忽略分析器和字段权重。

混合通道的成本和稳定性不同。BM25 通常便宜且可解释,dense 需要 Embedding 和向量索引,重排又会增加模型调用。先用离线集证明增量收益,再决定是否在线执行所有通道;通道超时要有有限回退并标记 degraded。

图 10.7 · 混合检索的候选汇合本站自制示意
原始查询意图/实体
→
BM25精确词面
+
Dense语义近邻
+
Metadata版本/权限
→
融合重排可引用候选

通道可以互补,但每条结果都要保留来源和过滤依据。

表 10.7A · 混合检索通道
通道最擅长常见失败最小回归
BM25精确词、编号、错误码同义表达、跨语言术语改写集
Dense语义、口语、跨语言数字、否定、版本数字否定集
字段索引标题、代码、实体字段缺失字段覆盖率
MetadataACL、时间、状态数据过期或错标权限版本集
重排候选精细顺序延迟和捷径难负例/压测
PYTHON · hybrid_rrf.py
def rrf(channels, constant=60):
    score = {}
    provenance = {}
    for name, items in channels.items():
        for rank, item in enumerate(items, start=1):
            score[item] = score.get(item, 0) + 1 / (constant + rank)
            provenance.setdefault(item, []).append(name)
    result = sorted(score, key=lambda x: (-score[x], x))
    return [
        {"id": item, "score": round(score[item], 5),
         "from": provenance[item]}
        for item in result
    ]

channels = {
    "bm25": ["v2.1-policy", "refund-faq", "old-policy"],
    "dense": ["refund-faq", "v2.1-policy", "returns-guide"],
}
print(rrf(channels))

RRF 输出的 from 字段是排错重点:如果旧政策只由 dense 通道带入,可能需要改版本过滤;如果精确编号只由 BM25 找到,就不要为了追求“纯语义”删除它。

HYBRID LAB 10.7.1证明混合检索的增量价值

为编号、同义、否定、跨语言和版本五类查询,比较 BM25、dense、RRF 与重排;报告 Recall、nDCG、支持率、p95 和每次查询成本。

每类至少一个反例;通道来源可追溯;若混合没有增益,说明复杂度不值得。

10.7.2 迭代检索与 Agentic RAG

单次检索适合问题和证据关系直接的场景;多跳问题需要先找定义,再找关联实体、时间或例外。迭代 RAG 可以让系统根据当前证据提出下一条查询,但每一步都要有状态、预算和停止条件。Agent 不是“让模型无限搜索”,而是把查询、工具和验证编排成可控状态机。

一个安全的循环通常包含 plan、retrieve、inspect、refine、answer 五类状态。模型可以提出下一步查询和理由,控制器决定是否允许执行;检索结果被当作 data,不能覆盖系统规则。每一步保存 query、候选、证据、Token、耗时和下一步决定,发生错误时能回放。

多跳检索要管理依赖。如果第二个问题依赖第一个实体,先验证实体再继续;如果出现多个候选,要求模型或用户选择,不能随意挑一个。把中间结论标记为 hypothesis,只有被来源支持后才能进入最终回答。未经核验的中间文本不应成为新的事实。

循环预算包括最大步数、最大查询数、最大 Token、最大费用和总超时。重复查询、相同候选和无进展状态要被检测;连续两次没有新证据时停止。高风险动作或外部写工具要单独审批,检索 Agent 不能自动扩大权限。

Agentic RAG 的收益要和单次基线比较。它可能提高复杂问题 Recall,却增加延迟、调用次数和错误面;简单问题不应强制走多跳。路由器可以先判断问题复杂度,只有多实体、多约束或首轮证据不足时才启动迭代。

循环失败时的用户体验要明确。返回已确认的部分、缺失条件和下一步建议,或转人工;不要让用户看到一串内部思考,也不要把未验证的假设包装成结论。日志可以保存结构化的理由,但外部文案只呈现必要信息。

表 10.7B · Agentic RAG 状态
状态允许动作停止条件记录字段
plan拆分查询、估计预算不安全或范围过大plan_id、理由
retrieve调用只读检索超时、权限失败query、snapshot
inspect读候选、提取实体无新证据evidence_id、hypothesis
refine提出下一查询达到步数/Tokenparent_query
answer生成引用或拒答支持率不足claims、sources
handoff转人工或请求确认人工完成reason、actor
PYTHON · bounded_retrieval_loop.py
MAX_STEPS = 3

def run_loop(initial_query, retrieve, refine):
    state = {"query": initial_query, "steps": [], "seen": set()}
    for step in range(MAX_STEPS):
        query = state["query"]
        if query in state["seen"]:
            return {"status": "stop", "reason": "repeat", "steps": state["steps"]}
        state["seen"].add(query)
        evidence = retrieve(query)
        state["steps"].append({"query": query, "evidence": evidence})
        if evidence.get("complete"):
            return {"status": "answer", "steps": state["steps"]}
        next_query = refine(query, evidence)
        if not next_query:
            break
        state["query"] = next_query
    return {"status": "insufficient", "steps": state["steps"]}

print(run_loop("退款例外和生效时间", lambda q: {"complete": False},
               lambda q, e: None))

示例把“重复”和“最大步数”放在控制器而不是 Prompt。生产版本还要检查租户、ACL、查询长度、每步预算、工具 allowlist、人工审批和日志脱敏。

AGENTIC RAG LAB 10.7.2设计一条有上限的多跳流程

选择一个需要两跳检索的问题,画出 plan、retrieve、inspect、refine、answer、handoff 状态;为无新证据、重复查询、超时和权限失败各写停止动作。

最大步数和费用可测试;中间结论标记为假设;模型只能提出查询,控制器决定是否执行。

10.7.3 新鲜度、更新与删除

知识库不是一次性导入。资料会新增、修改、撤回、过期和变更权限;索引、缓存、备份和评估集都要反映这些状态。把原文变更看成事件,经过解析、清洗、编码、写入、发布和缓存失效,最终才算用户可见。

更新策略有全量重建、定时增量和事件驱动。全量重建简单但成本高,适合模型或 schema 变化;增量快但容易遗漏依赖,适合日常修改;事件驱动延迟低但需要可靠队列、幂等和失败补偿。根据资料风险和 freshness SLA 选择,不要为了“实时”引入无法审计的复杂度。

修改和删除要保留历史。修改写新版本,旧版本进入 retired;删除写 tombstone,并让查询、缓存和备份都遵守删除状态。若法规要求彻底删除,还要清理原文、清洗产物、向量、日志和快照中的复制品;删除完成时间必须可证明。

缓存是新鲜度最常见的隐形问题。缓存键包含 query、用户权限、版本、snapshot 和模型 manifest;资料更新或 ACL 撤销时主动失效。只刷新向量索引而不清缓存,用户仍可能看到旧答案;只清缓存而索引没更新,又会返回过期证据。

事件处理需要幂等。使用 doc_version、event_id 或 content hash 防止同一修改重复写入;失败批次保存输入、错误和重试次数。部分成功时查询真实状态再补偿,不能让模型猜“应该已经更新”。

新鲜度评估要同时测时间和正确性。记录 source_changed_at、parsed_at、embedded_at、indexed_at、cache_invalidated_at 和 first_visible_at;做一条更新前后查询,确认新版本出现、旧版本按规则处理、已删除内容不再可见。

表 10.7C · 更新与删除流程
事件索引动作缓存动作验收
新增写新 chunk必要时预热/失效新问题可召回
修改新版本+旧版 retired失效相关 query版本过滤正确
删除tombstone/移除强制清理原文和向量不可见
权限撤销更新 ACL按租户失效交叉租户回放
模型升级新 manifest/新索引切换 key近邻回归
队列失败保留失败批次不发布半成品断点重跑
PYTHON · freshness_timeline.py
from datetime import datetime

events = {
    "source_changed": "2026-08-29T09:00:00+00:00",
    "parsed": "2026-08-29T09:02:00+00:00",
    "embedded": "2026-08-29T09:04:00+00:00",
    "indexed": "2026-08-29T09:06:00+00:00",
    "first_visible": "2026-08-29T09:07:30+00:00",
}

def parse(value):
    return datetime.fromisoformat(value)

lag = parse(events["first_visible"]) - parse(events["source_changed"])
print("freshness seconds:", lag.total_seconds())
print("within_10m:", lag.total_seconds() <= 600)

时间线示例把“实时”变成可测 SLA。生产监控还要按租户、文档类型和失败状态分层,发现某类资料长期延迟时,优先修复队列或解析器,而不是让模型承担过期风险。

FRESHNESS LAB 10.7.3演练一次修改与删除

修改一个政策段落、撤销一个租户权限、删除一个文档;记录全链路时间、旧新版本、缓存和索引状态,验证三类查询不会返回错误内容。

删除有 tombstone 或等价记录;缓存和备份遵守政策;更新时间线能定位瓶颈。

本节深挖:复杂度必须有可证明的收益

混合检索不是默认答案。先看错误分布:如果 80% 的问题是精确编号,BM25 已经能解决大部分;如果用户常用口语和同义表达,dense 才有明显增量。用任务切片和边际收益决定通道,而不是因为工具支持就全部打开。

RRF 是透明的排名基线,适合解释通道贡献;学习排序可以更强,但需要标注、特征版本和防泄漏。无论选择哪种,保留每个通道的候选和排名,避免最后只剩一个黑盒分数。

多跳 Agent 适合关系链和证据缺口明显的问题。简单问题走单次检索,复杂问题才启动循环;状态机的预算、权限和停止条件比模型“思考得多不多”更重要。

中间假设不能直接变成事实。每跳都要回到可引用来源,记录父查询和证据;当候选冲突或没有新证据时停止、澄清或接管。这样多跳不会把早期猜测无限放大。

新鲜度是数据、索引、缓存和回答共同的属性。一个索引已更新但缓存未失效的系统仍然过期;一个缓存已清除但文档未入索引的系统仍然缺资料。把时间线和版本写进事件。

删除和权限撤销比新增更敏感。新内容漏掉是质量问题,已删除内容仍能看到可能是合规和安全事故;测试顺序应先做删除、越权和旧版本回放。

复杂系统要准备降级。向量服务、重排器、队列或 Agent 调度不可用时,选择安全的词法或旧 snapshot,并明确标记能力下降;高风险问题在降级下应拒答或人工。

每次引入复杂通道都写出“为什么值得”。指标提升、延迟变化、成本变化、维护职责和回滚办法都在同一张决策表里;没有证据的复杂度不是能力,而是风险。

表 10.7D · 进阶检索验收
能力最小实验上线门槛
混合检索BM25/dense/RRF 对照有明确增量
多跳流程两跳+失败分支步数、Token、费用受限
版本更新增改删回放旧版和删除规则正确
新鲜度时间线压测SLA 可监控
降级服务故障注入不绕过权限和风险门槛
HYBRID REVIEW 10.7写一份进阶检索决策

为你的知识库选择是否加入混合、迭代和事件更新;每个选择写出问题、指标、资源、风险、回退和负责人,最后给出可发布范围。

复杂度有任务增量;多跳有硬预算;更新、删除、缓存和权限在同一条时间线上;结论可回滚。

课后复习与易错

易错一:觉得 dense 会替代 BM25。数字、版本、否定和代码仍需要精确通道。

易错二:把 RRF 分数当概率。它只是排名融合信号,不能直接当置信度。

易错三:让 Agent 无限搜索。最大步数、Token、费用、超时和重复检测必须由控制器执行。

易错四:把中间假设当事实。每一跳都需要来源支持,冲突时应停下。

易错五:新增有事件,删除没有 tombstone。删除、权限撤销和缓存失效要同样可审计。

易错六:索引更新了就认为用户看到新内容。测从 source_changed 到 first_visible 的完整延迟。

进阶决策:复杂度必须有增量

混合检索、重排、Agent 循环和实时更新都能解决真实问题,但每个能力都会增加延迟、成本、状态和故障面。先写出任务的最小失败案例,再决定是否需要复杂组件:如果 BM25 加字段过滤已经解决版本号问题,就没有必要立刻引入一个更大的生成式改写器。

混合检索的实验应保持候选预算和评估集不变。比较 lexical、dense、RRF 和学习式融合在同一 gold 集上的 Recall、nDCG、支持率、p95 与成本;把收益按数字、实体、同义表达、长查询和多语言切片报告。一个总分的提升可能只来自某一类简单问题。

RRF 只使用排名,不要求不同通道的分数同尺度,因此适合作为稳健基线;但它也会隐藏分数质量和通道相关性。记录每个候选来自哪些通道、排名是多少、最终融合值如何计算,遇到重复证据时仍要按事实和来源去重。

Agentic RAG 的“迭代”必须是受控状态机,而不是让模型自由调用搜索。控制器规定最大步数、每步 Token、总费用、工具白名单、超时、重复查询检测和停止条件;每一跳只能提出检索计划,不能绕过 ACL 或直接修改索引。

多跳任务要区分新证据和重复证据。如果第二跳只是把第一跳的猜测换一种说法,继续搜索只会扩大幻觉;当没有新来源、候选冲突或预算达到上限时,应停止并说明限制。把每跳的父查询、假设、证据和结论写进 trace,才能审计推理链。

实时更新需要事件驱动的时间线:source_changed、parsed、embedded、indexed、cache_invalidated、first_visible 和 deleted_visible。监控端到端延迟的分位数,而不是只看队列长度;用户真正关心的是何时可以查到新内容、何时旧内容彻底不可见。

删除与撤权必须覆盖所有副本。索引 tombstone、缓存、异步队列、备份、分析日志和训练样本的处理策略可能不同;在高风险系统中,宁可暂时降低召回,也不能继续返回已撤销资料。把删除事件与权限版本绑定,回放时验证旧 snapshot 也不会绕过门控。

复杂度评审还要包含运维职责。谁维护词法索引、Embedding 服务、重排模型、Agent 控制器和事件队列?谁在 p99 超时或权限回放失败时有权熔断?没有负责人和 runbook 的高级能力,只是把风险转移给上线后的值班同学。

表 10.7E · 进阶能力的增量账
能力解决的问题新增成本最小证据
Hybrid/RRF词面与语义互补多通道、融合、去重切片 Recall/nDCG
Rerank候选边界更精确模型延迟、批处理候选到支持率
多跳 Agent跨文档组合证据步数、Token、工具风险每跳 trace 与停止率
实时更新缩短新鲜度延迟事件一致性、删除复杂度端到端时间线
自动摘要压缩上下文事实损失、额外调用摘要支持率
自适应路由按任务选择通道分类错误、调试困难路由混淆矩阵
PYTHON · bounded_agent.py
MAX_STEPS = 3
MAX_TOKENS = 900

def run_agent(question, search, enough):
    trace = []
    used_tokens = 0
    for step in range(1, MAX_STEPS + 1):
        query = question if step == 1 else f"{question} 补充证据 {step}"
        result = search(query)
        used_tokens += result["tokens"]
        trace.append({"step": step, "query": query, "ids": result["ids"]})
        if enough(result) or used_tokens >= MAX_TOKENS:
            break
    status = "ok" if trace and trace[-1]["ids"] else "abstain"
    return {"status": status, "tokens": used_tokens, "trace": trace}

print(run_agent("退款期限", lambda q: {"ids": ["c1"], "tokens": 180}, lambda r: bool(r["ids"])))

示例中的搜索函数被刻意简化,但控制器已经把最大步数和 Token 预算放在模型之外。生产版本还要加入超时、费用、重复查询、工具权限、来源核验和人工接管;不要把这些限制写成 Prompt 中的一句请求。

ADVANCED LAB 10.7.4做一次复杂度评审

为一个多条件问题分别运行 lexical、dense、RRF 和两跳检索。比较每种方案的候选覆盖、声明支持、阶段延迟、Token、费用和失败动作;如果增加的能力没有明确增量,就把它从发布范围移除。

每项复杂能力都对应失败案例、指标和负责人;Agent 有硬预算和停止条件;更新与删除可回放;降级不绕过权限。

进阶复盘问题

问自己:当数字和同义表达冲突时,哪个通道优先?当两个版本都相关时,谁决定适用范围?当第二跳没有新证据时,谁让 Agent 停止?当删除事件晚到时,哪个层阻止旧内容返回?当重排器超时时,用户看到的是降级、澄清还是拒答?

把答案写成状态图和 runbook,而不是写成模型提示词。状态图说明系统允许什么,runbook 说明人在异常时做什么;两者都应带事件 ID、日志位置、回滚命令和验证查询。

进阶能力的学习目标不是堆叠名词,而是能够证明每一个额外组件改善了某类任务,并且在故障、越权、过期和成本超限时仍然可控。达到这个标准,才值得进入下一章的系统评估。

把进阶能力放进同一条时间线

先画一个事件时间线,再讨论“实时”。一份政策修改可能经历抓取、解析、清洗、Embedding、索引写入、alias 切换和缓存失效;任何一步延迟都会影响用户看到的答案。把每个事件的开始、结束、版本和失败状态写入同一条 trace,才能区分数据源慢、队列堵塞和索引写入慢。

混合路由也需要可解释。可以先用查询分类器识别是否含数字、错误码、专有名词、时间词或多条件意图,再选择 lexical、dense、RRF 或多跳路径;分类器不确定时保留安全的并行基线。路由决策、置信度和实际通道都要记录,避免线上突然换路由却无法回放。

对 Agent 的工具调用设定最小权限。检索工具只读当前用户可见的索引,计算工具只接收经过校验的数字,写入工具必须经过人工或服务端审批。工具返回的数据再次作为不可信证据处理,不能因为“来自内部工具”就跳过声明核验。

自动摘要和上下文压缩也要经过回归。比较原始 chunk、摘要 chunk 和最终答案的声明支持率,特别关注数字、否定、例外和引用范围。摘要若删掉限制条件,答案会更流畅却更危险;压缩策略应允许回到原文,而不是只保留一段不可解释的摘要。

进阶实验要记录反事实:如果关闭某个通道,哪些查询退化?如果把最大步数减半,哪些任务仍然通过?如果更新延迟增加十分钟,哪些答案变旧?反事实对照能说明组件的真实贡献,也能帮助你选择降级优先级。

表 10.7F · 进阶系统的事件与权限
对象允许动作禁止动作回放证据
查询路由选择已批准通道绕过 ACL路由 trace
Agent 检索有限只读查询无限循环/任意工具step、预算、来源
计算工具校验后的输入执行未审计代码输入输出 hash
实时更新追加、修改、撤权跳过版本和删除事件时间线
摘要压缩保留可回查来源删除例外和限制原文/摘要 diff
PYTHON · freshness_event.py
EVENTS = (
    "source_changed", "parsed", "embedded", "indexed",
    "cache_invalidated", "first_visible", "deleted_visible",
)

def missing_events(log):
    seen = {item["event"] for item in log}
    return [event for event in EVENTS if event not in seen]

log = [
    {"event": "source_changed", "version": "v4"},
    {"event": "parsed", "version": "v4"},
    {"event": "embedded", "version": "v4"},
    {"event": "indexed", "version": "v4"},
]
print(missing_events(log))

时间线检查器只检查事件是否存在,还应验证事件顺序、版本一致性和耗时阈值。比如 deleted_visible 不能早于删除审批,first_visible 不能引用旧版本;一旦发现顺序异常,宁可把资料标为不可用,也不要静默返回不确定结果。

ADVANCED LAB 10.7.5做一次反事实与权限回放

选择十条包含数字、版本、时间和多跳条件的查询,分别关闭 lexical、dense、RRF、重排和第二跳;再用普通用户、管理员和已撤权用户回放。记录哪一层改变了结果、是否越权、如何降级。

反事实保持相同数据和预算;每个差异能定位到通道或状态;工具权限由服务端执行;撤权后旧缓存和旧 snapshot 也不能返回。

当复杂系统可以用事件、权限、预算、反事实和回放解释时,进阶能力才从“听说过”变成可操作知识。把这些证据带入评估章节,你就能判断一项新技术究竟提高了质量,还是只是增加了不可见的复杂度。

为每个进阶开关准备一个安全默认值:混合通道关闭时仍有可用的精确基线,多跳达到预算时明确停止,实时更新失败时保留已知有效 snapshot,摘要失败时回到原文。默认值不是保守的借口,而是让系统在依赖异常时仍保持可解释。

复盘一次复杂度决策时,分别回答“解决了哪个失败”“增加了什么状态”“谁维护它”“怎样测到回归”“怎样在五分钟内撤回”。五个答案缺一个,就把能力留在实验环境,先补齐证据。

08 · RAG EVALUATION

评估与运营:用证据判断 RAG 是否可靠

RAG 的质量必须拆成检索和生成两层:证据有没有召回,答案有没有正确使用证据,服务是否在延迟、成本和安全预算内。本节建立从 Recall@k 到声明支持率、延迟、成本和上线门槛的完整评估。

10.8.1 检索指标、数据集与切片

评估先定义决策:你要判断一个 Embedding、分块、索引、查询改写还是重排是否值得上线。不同决策需要不同数据。测试集至少保存 query、可见范围、gold 文档或证据集合、相关性等级、版本、语言、长度和风险标签;没有这些字段,指标无法解释。

Recall@k 回答“正确证据是否进入前 k”,Precision@k 回答“前 k 中有多少相关”,MRR 关注第一份正确结果的位置,nDCG 支持分级相关性。它们都依赖高质量 gold 标签;一个问题可能有多个正确来源,不能只留一个 ID。对多跳问题还要标记每一跳需要的证据。

切片比总体平均更重要。按查询意图、语言、长度、数字、否定、版本、资料新旧、租户和风险分层,查看最弱切片和样本数。一个模型平均 Recall 上升但中文编号问题下降,发布结论应针对切片,而不是被总分掩盖。

数据集要防污染。调参时使用开发集,选择阈值和权重;锁定后的测试集只在里程碑评估;新增回归集记录加入原因和日期。公开资料可能出现在模型训练中,测试集也可能被提示模板间接泄漏,关键任务最好有私有或合成的对照。

离线指标不等于线上体验。离线 Recall 可能很高,但上下文太长、引用丢失或延迟超预算;线上点击和人工反馈又可能受界面影响。把检索指标、答案支持率、延迟、成本和用户成功率放到同一份报告,明确它们之间的取舍。

标注协议要让多人达成一致。定义相关、部分相关、错误、过期、不可见和危险的标准,给出正反例,记录分歧并复核。若标注者不一致,先改善 gold 规则;不要把分歧当作模型性能差异。

图 10.8 · 从测试样本到发布门槛本站自制示意
查询集gold/切片
→
检索评估Recall/MRR
→
回答评估支持/安全
→
资源评估p95/成本
→
门槛决策发布/阻塞

每个指标都应对应一个决策和失败动作,不能只收集漂亮数字。

表 10.8A · 检索指标
指标定义直觉适合回答注意事项
Recall@kgold 是否进前 k粗召回是否漏证据gold 集要完整
Precision@k前 k 中相关比例噪声是否过多相关性需分级
MRR首个 gold 的倒数排名第一个正确结果多快出现忽略后续 gold
nDCG分级相关性排序不同证据价值如何排序标注协议要稳定
coverage有足够证据的查询比例知识库覆盖范围不能等同答案正确
latency检索到结果的耗时交互是否可用看 p95/p99
PYTHON · retrieval_metrics.py
def recall_at_k(ranked, gold, k):
    return int(bool(set(ranked[:k]) & set(gold)))

def precision_at_k(ranked, relevant, k):
    relevant = set(relevant)
    return sum(item in relevant for item in ranked[:k]) / max(k, 1)

def mrr(ranked, gold):
    gold = set(gold)
    for index, item in enumerate(ranked, 1):
        if item in gold:
            return 1 / index
    return 0.0

cases = [
    {"ranked": ["a", "c", "b"], "gold": ["b", "c"]},
    {"ranked": ["x", "y", "z"], "gold": ["b"]},
]
for case in cases:
    print({
        "recall@3": recall_at_k(case["ranked"], case["gold"], 3),
        "precision@2": precision_at_k(case["ranked"], case["gold"], 2),
        "mrr": mrr(case["ranked"], case["gold"]),
    })

代码是教学版,生产报告还要按切片聚合并给出样本数。对于每个查询保存完整排名,不能只保存最终指标;遇到回归时需要看到 gold 排在第几、哪些错误文档抢了位置。

EVAL LAB 10.8.1建立一个带切片的检索集

准备 50 条查询,覆盖五种意图和三种风险;为每条写 gold 集与相关性等级,计算 Recall@5、MRR、nDCG,并找出最弱切片。

gold 可以有多个;每个切片报告样本数;弱切片要有原始排名和下一步修复假设。

10.8.2 答案质量、事实性与安全

答案评估不能只问“像不像参考答案”。RAG 的回答应检查任务完成、引用存在、引用支持、事实准确、条件完整、无越权、格式可解析和资料不足时的行为。可以用规则、自动评估器和人工复核组合,但要知道自动评分器也会被流畅文案和提示注入影响。

声明级评估把回答拆成最小事实单元。对每条声明记录是否有来源、来源是否支持、是否遗漏例外、版本是否适用、数字是否正确和是否属于用户可见集合。关键声明可以有更高权重;一个错误金额不应被十句正确背景平均掉。

事实性与相关性不同。答案可能引用了相关资料,却把条件理解错;也可能事实正确,但没有回答用户问题。评估表分开记录 answer relevance、claim support、citation correctness 和 completeness,修复时才知道要改检索、Prompt、模型还是验证器。

安全评估要测漏放和误拒。漏放包括越权资料、敏感信息、恶意工具参数和提示注入执行;误拒包括合法资料被挡住、正常问题被判高风险和过度要求人工。按租户、角色、风险和资料状态切片,保留可重放输入。

参考答案不是唯一答案。开放式问答可以用 rubric 评估关键事实、证据、结构和语气;对数字、代码和结构化字段则使用精确校验。人工标注要抽样复核自动分数,记录分歧和评审版本。

评估器本身也要防污染和泄漏。不要把答案或 gold 直接放进提示而又声称评估了事实;不要让评估器看到不可见资料;不要用同一个模型生成答案、评估答案并决定发布而没有独立抽样。重要门槛需要人工或独立程序确认。

表 10.8B · RAG 答案指标
指标检查对象最小方法失败动作
任务完成回答是否解决问题rubric/人工改 Prompt 或数据
citation presence是否给来源ID/链接检查阻止入库
citation support来源是否支持声明声明对照补证据/人工
completeness条件和例外是否齐关键字段清单补上下文
factuality数字和实体是否正确规则/计算器重算/拒答
safety越权、注入、敏感泄露红队回放熔断/接管
abstention资料不足是否停止低分与冲突集重校准
PYTHON · claim_scorecard.py
weights = {
    "supported": 0.35,
    "complete": 0.20,
    "correct": 0.25,
    "safe": 0.20,
}

def score_claim(claim):
    return sum(weights[name] for name, ok in claim.items() if ok)

claims = [
    {"supported": True, "complete": True, "correct": True, "safe": True},
    {"supported": True, "complete": False, "correct": False, "safe": True},
]
for claim in claims:
    score = score_claim(claim)
    print(round(score, 2), "pass" if score >= 0.9 else "review")

这个分数只能作为筛选信号。真实发布不能用加权平均抵消安全硬失败;只要 safe 为 False 就应阻塞。把硬门槛和软评分分开,在报告里同时展示原始字段和聚合值。

EVAL LAB 10.8.2做一轮声明级安全评估

抽取 30 个回答的声明,标记支持、完整、事实、引用、权限和安全;故意加入一个过期版本和一个提示注入,验证评估器不会只奖励流畅。

安全失败不可被平均分抵消;评估器看不到不可见资料;每条声明有原始回答和来源。

10.8.3 延迟、吞吐、成本与门槛

RAG 的一次请求可能经过查询改写、Embedding、向量检索、关键词检索、重排、原文读取、模型生成和引用校验。每一段都有延迟和失败率,端到端 p95 往往由最慢或最不稳定的环节决定。日志要记录各阶段开始结束、缓存命中、候选数、Token 和重试。

首 Token、每 Token、总延迟和 p95/p99 反映不同体验。检索慢会让用户等待上下文,生成慢会让长回答变慢,工具或重排超时会造成长尾。压测使用真实输入长度分布和并发,不要只跑一个短问题。

成本由 Embedding、存储、检索、重排、生成、网络和人工接管共同构成。输入 Token、输出 Token、重复重试和多跳查询都要计入;缓存节省了模型调用,却可能增加失效复杂度。单请求预算、日预算和异常熔断分别定义,不能只看月度平均。

吞吐与质量有取舍。批量 Embedding 提高吞吐但增加单批等待,批量生成提高 GPU 利用率但可能让短请求排队;并发越高,KV cache 和内存压力越大。把队列时间和处理时间分开,按 p95 和错误率设扩容、限流或降级门槛。

上线门槛要写成硬规则和软目标。零越权、零敏感泄露、Schema 可解析、回滚可用属于硬门槛;Recall、支持率、p95、成本和用户成功率可以设目标与灰度范围。门槛在看结果之前冻结,不能为了发布临时改数字。

运营监控还需要采样真实失败。保存脱敏的 query hash、版本、检索候选、引用、状态码和成本;对高风险事件保留更完整证据并限制访问。定期抽样用户反馈,区分资料缺失、检索错、答案错和界面误解,回归集持续增长。

图 10.9 · RAG 请求的资源账本本站自制示意
改写/编码调用+Token
→
多路检索候选+延迟
→
重排/读取CPU/网络
→
生成/核验输出+校验
→
门槛与监控发布/降级

成本和延迟要沿请求链分摊,才能找到真正的优化点。

表 10.8C · 性能与成本门槛
指标示例门槛监控字段不达标动作
Recall@20≥ 0.95query slice/gold补通道或数据
claim support≥ 0.90claim/source拒答/人工
schema success≥ 0.99parse errors阻止入库
p95 latency≤ 3sstage timings限流/降级
cost/request≤ 0.02Token/重试压缩/路由
unsafe leak0red-team cases立即熔断
rollback演练通过旧 manifest禁止发布
PYTHON · rag_release_gate.py
def decision(m):
    hard = [
        m["unsafe_leak"] == 0,
        m["schema_success"] >= 0.99,
        m["rollback_passed"],
    ]
    soft = [
        m["recall20"] >= 0.95,
        m["claim_support"] >= 0.90,
        m["p95_ms"] <= 3000,
        m["cost"] <= 0.02,
    ]
    if not all(hard):
        return "BLOCK"
    return "READY" if all(soft) else "LIMITED"

metrics = {
    "unsafe_leak": 0, "schema_success": 0.996,
    "rollback_passed": True, "recall20": 0.96,
    "claim_support": 0.88, "p95_ms": 2400, "cost": 0.015,
}
print(decision(metrics))

示例会返回 LIMITED,因为支持率这个软目标未达标;如果 unsafe_leak 大于 0,则无论其他指标多好都 BLOCK。生产门槛还要写灰度比例、负责人、观察期和回滚触发条件。

EVAL LAB 10.8.3做一次发布或阻塞演练

为两个 RAG 版本跑同一评估集,报告检索、答案、安全、p95、成本和回滚结果;设计一组指标让系统进入 LIMITED,再设计一组触发 BLOCK。

硬门槛不能被平均分抵消;LIMITED 有灰度和期限;所有指标有样本数、版本和原始失败。

本节深挖:指标必须服务决策

先写决策问题再选指标。要换 Embedding,重点看召回和资源;要换分块,重点看证据覆盖和引用;要开放工具,重点看权限、越权和恢复;要扩大窗口,重点看位置、延迟和成本。指标多不代表评估完整,和决策无关的数字只会增加噪声。

检索与生成的错误要串起来看。gold 不在候选是召回问题,gold 在候选却被截断是上下文问题,证据正确但声明错是生成或核验问题,引用正确但用户看不到是界面问题。错误地图应保留每一层的输入输出。

样本数量决定结论可信度。十条样本的 0.9 不能和一千条样本的 0.9 等价;人工评分要记录评审者和一致性;自动评估要用独立样例抽检。报告至少写样本数、切片、时间和评分器版本。

最坏案例比平均值更接近风险。展示一条越权、过期、数字错误、无证据编造和长尾超时样例,说明系统如何阻止或恢复。把修复后的原失败加入回归集,避免同类问题再次出现。

成本与质量一起报告。一个版本支持率高 2 个百分点但成本翻倍,是否值得取决于风险和预算;一个版本平均质量略低但零越权、延迟稳定,也可能更适合灰度。不要把“效果最好”与“可以上线”混为一谈。

评估数据本身要版本化。新增样本、修改 gold、改变风险标签都会影响指标;报告引用 data_manifest 和标注协议,避免团队在不同数据上争论数字。

线上监控要有动作。阈值告警后谁负责、多久响应、切哪个旧版本、如何通知用户,都写在 runbook;没有责任人和回滚命令的监控只是图表。

最后把评估结果写成决策摘要:结论、依据、限制、灰度范围和下一步实验。摘要链接到完整报告和原始 trace,既方便管理者阅读,也方便工程师复现。

表 10.8D · 评估报告的证据链
层级要回答的问题必须保存发布动作
检索正确证据是否被找回?gold、排名、Recall补数据/通道
上下文关键证据是否进入 Prompt?snapshot、Token改预算/编排
答案声明是否完成任务?raw、claims、rubric改生成/校验
安全是否越权或漏放?红队、ACL、状态阻塞/熔断
资源是否满足 p95 和成本?阶段计时、Token限流/扩容
恢复坏了能否回滚?旧 manifest、演练允许/禁止发布
RAG EVALUATION REVIEW 10.8交付一张可审计报告

用一个基线和一个候选版本完成完整报告:数据 manifest、切片、检索指标、声明核验、安全样例、阶段延迟、成本账本、门槛结论和回滚命令。

读者能从结论追到原始样本;硬门槛和软目标分开;最坏案例与平均指标同时出现。

课后复习与易错

易错一:只看最终答案。先看 gold 是否召回,再看上下文、声明和引用。

易错二:用一个总分代表所有质量。按任务、语言、长度、版本和风险切片。

易错三:用自动评估器替代人工。关键安全和事实门槛需要独立复核。

易错四:把硬门槛平均进软分数。越权、泄露、无法回滚必须直接阻塞。

易错五:只测平均延迟。记录首 Token、阶段耗时、p95/p99 和重试。

易错六:上线后才定义回滚。发布前演练旧索引、旧模板和旧模型。

评估设计:先写问题,再选指标

评估不是把所有数字放进一个仪表盘。先写决策问题:正确资料是否被找回?回答是否完成任务?引用是否支持声明?系统是否越权?延迟和成本是否在预算内?每个问题对应数据、指标、切片、门槛和动作,缺少其中一项就无法形成可靠结论。

检索评估要定义 gold 的粒度。一个问题可能有多个等价文档,也可能只需要某个条款而不是整篇文件;记录 gold 集合、必需证据、可选证据和不可接受证据。标注协议写清“相关”“支持”“同版本”和“可见”的区别,并抽样复核标注一致性。

答案评估要从整体分数下沉到声明。把回答拆成原子声明,分别标注任务完成、事实正确、证据支持、引用位置、时间和权限。自动评估器可以筛选大批样本,但高风险结论、越权和版本冲突仍应由独立人工复核。

切片是发现不均衡的工具。按语言、查询长度、文档类型、行业术语、数字/否定、风险等级、租户、版本新旧和是否需要多跳分组;同时报告样本量,避免某个切片只有两条样本却得出确定结论。

置信区间和重复回放能防止过度解读。对小样本用 bootstrap 或二项区间估计波动,对线上指标保留时间窗口和流量分母;一个版本只提高 1 条样本并不等于真实改善。报告差异、区间和实际动作,而不是只报百分比。

安全门槛与质量目标分开。越权、敏感泄露、无法回滚和删除后仍可见属于硬阻塞;Recall、支持率、p95 和成本可以作为软目标或灰度门槛。硬门槛失败时,即使平均质量更高,也不能用总分抵消风险。

延迟需要阶段化测量:排队、查询改写、向量召回、关键词召回、重排、上下文压缩、首 Token、完整生成和重试。成本账本记录 Embedding、索引、存储、检索、重排、生成和人工审核,按每次请求和每千条资料归一化。

线上评估要准备影子流量、灰度和停止条件。候选版本先在不影响用户的流量上记录结果,再按租户或百分比逐步开放;监控越权、支持率、拒答率、p95、成本和错误日志,一旦硬门槛触发立即切回旧 alias 和旧模板。

表 10.8E · 评估计划模板
决策问题指标切片/样本门槛与动作
gold 是否召回Recall@k、MRR任务、语言、长度低于门槛补数据/通道
答案是否支持claim support rate声明、版本、风险下降则阻止生成
是否越权leak rate、ACL pass租户、角色、删除任何泄露都阻塞
是否新鲜source-to-visible p95新增、修改、删除超时则降级/告警
是否可用p95、错误率、成本并发、缓存、长查询限流或扩容
能否恢复rollback time、diff故障注入演练失败不发布
PYTHON · evaluation_gate.py
HARD = {
    "leak_rate": 0.0,
    "delete_visible_rate": 0.0,
    "rollback_ok": True,
}
SOFT = {
    "recall_at_5": 0.82,
    "support_rate": 0.85,
    "p95_ms": 1800,
}

def gate(metrics):
    hard_fail = [
        key for key, limit in HARD.items()
        if metrics.get(key) != limit
    ]
    soft_fail = [
        key for key, limit in SOFT.items()
        if (metrics.get(key, 0) < limit if key != "p95_ms"
            else metrics.get(key, float("inf")) > limit)
    ]
    return {
        "release": not hard_fail and len(soft_fail) <= 1,
        "hard_fail": hard_fail,
        "soft_fail": soft_fail,
    }

print(gate({
    "leak_rate": 0.0, "delete_visible_rate": 0.0,
    "rollback_ok": True, "recall_at_5": 0.86,
    "support_rate": 0.88, "p95_ms": 1750,
}))

门控代码把硬阻塞和软目标分开,便于把发布决策写成可审计规则。真实系统应保存指标计算版本、样本 manifest、切片定义和原始结果;如果阈值改变,要说明是谁在什么证据上做了决定。

EVALUATION LAB 10.8.5完成一次版本评估与灰度计划

为基线和候选版本准备至少 60 条分层查询,报告召回、声明支持、安全、延迟、Token、费用和置信区间;写出影子流量、灰度比例、停止条件和回滚命令。

样本和 gold 有版本;硬门槛单独判断;关键切片不被平均值掩盖;结论能追到原始 trace 和负责人。

评估复盘的四个追问

第一,失败发生在哪一层:解析、分块、Embedding、索引、召回、上下文、生成还是引用?第二,失败是随机波动、数据分布变化还是版本回归?第三,修复会不会损害另一个切片或安全边界?第四,修复后是否加入了可长期运行的回归集和监控?

评估报告最后写限制:样本是否偏向短问题,标注是否只覆盖单一语言,线上流量是否与离线数据不同,成本是否包含人工审核,安全攻击是否覆盖真实权限模型。承认边界不是削弱结论,而是告诉读者结论可以外推到哪里。

完成本节后,你应能把一次实验交付为可复查的发布决定,而不是一句“这个版本感觉更好”。这也是下一节综合项目验收的评分基础。

统计、标注与线上监控的细节

先固定数据切分,再运行版本对照。训练或调参集用于选择模型、chunk、k 和阈值;验证集用于校准;锁定的测试集只在阶段性报告中使用。线上影子流量不应反向泄漏到测试集,否则每次调参都在悄悄适应用户问题。

标注指南要给出边界案例。什么算“部分支持”?引用只覆盖句子的一半怎么办?两份政策互相冲突时是否都算相关?一个问题没有唯一答案时怎样记录多个 gold?让两名标注者独立判断一小批样本,讨论分歧并更新指南,指标才有可解释的基础。

报告百分比时带上分母和区间。支持率 90% 可能来自 10 条样本,也可能来自 10,000 条;两者的可信度完全不同。按切片报告样本数、成功数、失败数和区间,必要时合并相近切片或延长采样,而不是把小样本结果画成醒目的排行榜。

在线监控要避免把用户隐私写进日志。query_id、模型版本、状态码、耗时和脱敏后的 chunk_id 通常足够诊断;原始问题和回答按最小权限、最短保留期存储。安全评估也要记录攻击类型和结果,但不应在报告里复制敏感文本。

告警阈值需要考虑基线波动。把过去一段稳定流量的 p95、拒答率和支持率作为参考,采用连续窗口或分位数规则减少单点噪声;硬安全事件则不等待统计稳定,立即触发阻断。告警信息包含负责人、影响范围、当前 alias 和回滚命令。

灰度不是只看“总体是否变好”。按租户、语言、风险等级和新旧版本分桶,确认候选版本没有把收益集中在低风险短问题上,却让高风险或长尾查询退化。灰度结束后保留完整的分桶结果,便于未来解释为什么扩大或收回流量。

表 10.8F · 评估数据的可信度检查
检查项合格标准风险信号补救
数据切分调参与测试隔离反复看测试集重建 manifest
gold 标注指南、双人抽检、分歧记录只有一个人的直觉复标和修订指南
样本量有分母和区间小样本排行榜扩充或合并切片
隐私脱敏和最小保留日志含原文/密钥立即清理并轮换
灰度分桶、停止条件、回滚只看总体平均暂停扩大流量
告警负责人和动作明确只发一张图补 runbook
PYTHON · slice_metrics.py
from collections import defaultdict

def summarize(rows, key):
    groups = defaultdict(list)
    for row in rows:
        groups[row[key]].append(row)
    report = {}
    for name, items in groups.items():
        hits = sum(item["supported"] for item in items)
        report[name] = {
            "n": len(items),
            "support_rate": hits / len(items),
            "leak_rate": sum(item["leak"] for item in items) / len(items),
        }
    return report

rows = [
    {"language": "zh", "supported": 1, "leak": 0},
    {"language": "zh", "supported": 0, "leak": 0},
    {"language": "en", "supported": 1, "leak": 0},
]
print(summarize(rows, "language"))

示例按语言切片统计支持率和泄露率,真实报告还要计算区间、延迟和成本,并保留每条原始样本的 manifest。先看 n,再看 rate;如果一个切片只有一条样本,结论应标为探索性而不是发布门槛。

EVALUATION LAB 10.8.6写一份可复核的评估备忘录

把一次版本比较写成两页备忘录:决策问题、数据切分、标注指南、指标与区间、切片结果、安全门槛、成本、灰度和回滚。让没有参与实验的人只读备忘录就能复算结论。

结论有分母、区间和样本 manifest;失败案例可回查;硬安全门槛独立判断;备忘录说明不能外推的范围。

评估的价值在于把不确定性说清楚并连接到行动。你不需要假装一次实验回答了所有问题,但必须知道哪些问题已经有证据、哪些问题仍需采样,以及在证据不足时系统应该停止在哪里。

发布前的最后核对

逐项确认评估脚本读取的是锁定的 manifest,指标计算没有把失败样本静默丢掉,切片分母与总样本一致,硬门槛与软目标没有混在一个平均分里。把报告、原始 trace、配置和回滚版本放在同一份发布记录中,评审者才能在有限时间内复核结论。

如果证据不足,最专业的结论是“暂不扩大范围”,并写清需要补采的样本、负责人和截止时间。评估不是为上线寻找理由,而是帮助团队知道何时可以放心、何时应该停止,以及下一次实验如何减少不确定性。

发布会议中把“未测量”与“测量后不通过”分开:前者需要补实验,后者需要修复或收紧范围。两种状态都不能被写成绿色通过。报告还应标明指标适用的时间窗口和数据版本,防止旧结论被误用于新资料。

当版本最终通过时,保留一份不可变的评估快照;当版本被阻塞时,也保留快照和决策理由。未来重新评估可以比较同一门槛下的变化,避免团队只记住最后一次成功而忘记曾经暴露的边界。

09 · CAPSTONE

综合项目:交付一个可评估的知识库问答

最后把向量模型、文档管道、检索、RAG、评估和回滚连接起来。项目不追求“聊天界面看起来聪明”,而是交付一条别人可以运行、核验、复现和修复的知识库问答链路。

10.9.1 项目设计:从资料到回答

项目先冻结问题边界:谁使用、查询什么资料、输出什么格式、允许哪些外部信息、资料不足时怎么办、什么动作需要人工。建议从只读的课程资料问答或团队手册助手开始,不要一开始就加入写数据库、自动发消息和无限 Agent。范围越小,越容易把每一层证据做完整。

目录结构体现职责。原始资料只读保存,解析和清洗产物独立,chunk 表和 metadata 可查询,Embedding 与索引有 manifest,检索、上下文编排、模型调用、校验、评估和报告分别有模块。配置不写秘密,路径相对项目根,示例数据足以让陌生人启动。

项目接口可以先用假模型。fake_model 返回合法 JSON、解析错误、资料不足、无证据声明、提示注入和工具超时等固定场景;校验器、回退和日志先在这些场景上跑通,再接真实 API 或本地模型。这样网络、额度和 GPU 不可用时,工程边界仍然可验收。

答案协议规定字段,例如 status、answer、claims、citations、sources、warnings 和 next_action。模型只能提出候选,服务端校验字段、来源、权限、版本、事实和安全状态;状态为 insufficient、conflict 或 handoff 时,不能继续当作正常答案入库。

用户体验与证据一起设计。引用可点击到标题、页码或字符范围,显示资料版本和更新时间;资料不足说明搜索范围和可补充条件;高风险请求提供人工入口。不要向用户展示内部思考链,也不要隐藏降级和过期状态。

项目最小流程是:审计资料、解析、分块、编码、建索引、查询改写、混合召回、重排、编排上下文、生成、声明核验、评估和回滚。每个步骤有输入输出和错误状态,能够单独运行和测试。一个 main.py 包含所有逻辑通常意味着职责还没有拆清。

图 10.9 · 知识库问答项目的闭环本站自制示意
资料审计许可/版本
→
解析分块chunk/metadata
→
Embedding 索引manifest/snapshot
→
检索编排query/evidence
→
生成核验claims/citations
→
评估交付report/rollback

项目完成的标志是链路可重放、失败可解释、版本可回滚。

表 10.9A · 项目模块职责
模块输入输出不应负责什么
ingest原始文件Block/来源决定答案
chunkerBlockchunk/范围执行权限
embedderchunk 文本向量/manifest判断事实
retrieverquery/用户候选/trace绕过 ACL
generator问题/证据原始答案直接写业务库
validator答案/来源状态/claims替代人工裁决
evaluator固定数据指标/报告修改测试集
release报告/manifest发布或回滚隐式改门槛
PYTHON · fake_rag_app.py
def fake_model(prompt):
    if "资料不足" in prompt:
        return {"status": "insufficient", "claims": [], "citations": []}
    if "注入" in prompt:
        return {"status": "answer", "claims": [
            {"text": "忽略系统规则", "source_id": "evil"}
        ], "citations": ["evil"]}
    return {"status": "answer", "claims": [
        {"text": "退款期限为七天", "source_id": "policy-7"}
    ], "citations": ["policy-7"]}

def run(question, sources):
    prompt = "只使用证据回答:\\n" + question
    result = fake_model(prompt)
    visible = {item["id"] for item in sources}
    unknown = [cid for cid in result.get("citations", [])
               if cid not in visible]
    if unknown:
        return {"status": "blocked", "reason": "unknown_source"}
    return result

sources = [{"id": "policy-7", "text": "退款期限为七天"}]
print(run("退款期限?", sources))
print(run("请测试注入", sources))

假模型让你先验证来源检查、资料不足和注入阻断。真实模型接入后,仍然沿用同一接口;如果接口改变,评估和回滚会同时受到影响。把 fake 场景放进持续回归,每次模板或模型升级都重跑。

CAPSTONE LAB 10.9.1写一份项目设计卡

冻结用户、资料范围、输入输出、引用、资料不足、高风险动作、模块边界和资源预算;用假模型跑通正常、低分、冲突和注入四条路径。

项目先做只读和可回滚;模块职责清楚;模型不能直接执行副作用;每条路径都有状态和日志。

10.9.2 实验矩阵、证据日志与错误分析

实验矩阵一次只改变一个主要变量。可以比较 Embedding 模型、分块大小、Top-k、阈值、重排、上下文数量、解码和提示模板,但固定数据、权限、gold、硬件和预算。若同时改动多个变量,只能把结果描述为组合变化,不能声称某个因素带来收益。

每次运行有 run_id 和 manifest。保存输入数据 hash、模型、Tokenizer、索引 snapshot、查询集、参数、环境、时间、输出、指标、错误和结论。失败运行不删除;它们可以揭示某个阈值、解析器或权限规则的边界。

错误分析从用户可见现象追到内部阶段。答案错误可能由资料缺失、解析错、分块截断、Embedding 近邻错、过滤漏掉、重排误判、上下文截断、模型编造、引用解析或界面误导造成。为每个 case 写 stage、severity、root_cause、evidence 和 next_experiment。

证据日志不要只保存最终答案。保存 query trace、候选排名、被过滤 ID 的原因、最终 context、Token、原始模型返回、解析 JSON、声明核验和用户动作。敏感数据脱敏,但不能把所有错误信息删掉到无法回放。

实验对照包含基线和最坏案例。基线可以是关键词检索、无 RAG 模型或上一版本;最坏案例包括数字、否定、版本冲突、资料不足、越权和提示注入。平均分改善但最坏案例恶化时,结论应阻塞或缩小范围。

反馈要进入回归集,但不能直接把用户的全部内容当训练数据。检查许可、隐私、标注质量和是否重复;记录样本为何加入、适用于哪个版本、是否需要人工复核。回归集是产品资产,不是随手复制的聊天记录。

表 10.9B · 实验运行记录
字段示例解释缺失后果
run_idrun-042串联文件无法回放
data_manifestcorpus-v3数据版本集合漂移
embed_manifestembed-v2向量契约分数不可比
index_snapshotkb-20260829索引状态更新原因不明
retrieval_tracecandidate IDs/rank解释召回无法定位漏证据
context_snapshotsource IDs/Token重放生成引用漂移
raw_output原始返回解析审计修复覆盖证据
decisionship/hold结论数字被孤立
PYTHON · experiment_log.py
from dataclasses import asdict, dataclass
import json

@dataclass
class Run:
    run_id: str
    variable: str
    value: str
    fixed: tuple
    recall: float
    support: float
    p95_ms: int
    cost: float
    failures: list

run = Run(
    run_id="run-042",
    variable="chunk_size",
    value="384 tokens",
    fixed=("model=embed-v2", "top_k=20", "gold=v1"),
    recall=0.94,
    support=0.89,
    p95_ms=2100,
    cost=0.014,
    failures=["old-version-conflict"],
)
print(json.dumps(asdict(run), ensure_ascii=False, indent=2))

运行记录把实验结果变成机器可读资产。后续可以按变量、切片和失败类型生成表格,也能在发布时自动检查门槛。固定条件写成元组只是示意,实际保存完整配置和环境锁定文件。

CAPSTONE LAB 10.9.2完成三轮单变量实验

分别只改变分块大小、Top-k 和是否加入 BM25,提交三份 manifest、指标表、失败样例和错误地图;写出每轮实验排除了什么解释。

每轮固定条件明确;失败运行保留;结论不把组合变化冒充单变量因果。

10.9.3 交付、回滚与下一步

交付包至少包含 README、数据卡、模型和 Embedding manifest、schema、示例资料、索引构建脚本、查询脚本、校验器、评估报告、事件样例、监控说明和 rollback。README 从干净环境开始写,包含安装、配置、命令、预期输出、常见错误和限制。

发布前做三种验收:正常问题能返回正确引用,资料不足会拒答或澄清,高风险和越权请求会阻止并记录。再做一条更新和一条删除,确认新鲜度、缓存、索引、权限和备份一致。所有结果带模型、数据、索引和规则版本。

回滚不是“重新部署旧代码”一句话。保留旧 collection、旧 alias、旧 manifest、旧 Prompt、旧 Schema 和旧评估报告;写出触发阈值、操作者、命令和通知。回滚后重放正常、资料不足、越权、删除和版本冲突请求,确认系统恢复。

交付前清理隐私和许可问题。删除密钥、个人路径、未授权原文、临时日志和无法分享的大文件;对外只给必要的 hash、下载方式和许可证。Embedding、缓存和备份也要遵守删除和访问政策。

监控指标要有负责人和动作。Recall 或支持率下降触发数据/模型排查,越权或注入触发熔断,p95 或成本超限触发限流或路由,freshness SLA 违约触发队列排查。没有 runbook 的图表不能算运营能力。

下一步可以进入视觉、多模态或部署章节,但保留本章方法:目标契约、数据卡、向量或表示、检索/调用边界、声明级评估、成本和回滚。新名词变化很快,证据链和职责划分仍然适用。

表 10.9C · 交付清单
交付物最低内容验收问题缺失动作
README安装、命令、限制陌生人能启动?补干净环境
data_card来源、许可、清洗能追溯和删除?隔离资料
manifests模型、向量、索引、Prompt能复现?锁版本
validator格式、引用、ACL、业务失败会阻止?补测试
report切片、基线、最坏案例结论有证据?重跑评估
eventstrace、raw、状态、成本能回放?补日志字段
rollback旧版、命令、门槛坏了能切回?演练
PYTHON · verify_project.py
from pathlib import Path
import json

REQUIRED = [
    "README.md", "data_card.md", "embedding_manifest.json",
    "index_manifest.json", "report.md", "rollback.md",
]

def verify(root):
    root = Path(root)
    missing = [name for name in REQUIRED if not (root / name).exists()]
    if missing:
        return {"status": "HOLD", "missing": missing}
    report = (root / "report.md").read_text(encoding="utf-8")
    rollback = (root / "rollback.md").read_text(encoding="utf-8")
    return {
        "status": "READY" if "LIMITATION" in report and "COMMAND" in rollback else "HOLD",
        "missing": [],
    }

print(verify("artifacts/rag-demo"))

验收脚本只检查交付物是否存在和包含关键部分,不能替代真正运行。把它作为第一道门,再在新环境运行完整命令;最终报告要写限制和未完成项,诚实边界比截图更有价值。

CAPSTONE LAB 10.9.3做一次从零交付和回滚

将项目复制到新目录,按 README 安装并运行;故意切换到旧索引、删除一份资料、触发越权和资料不足,记录结果和恢复时间。

新目录无需你的本机缓存;回滚后安全用例仍通过;数据卡、manifest、报告和日志可以互相引用。

官方与原始参考

建议对照阅读 Word2Vec,理解分布式词表示;阅读 Sentence-BERT,理解句向量和孪生网络;阅读 Retrieval-Augmented Generation,理解检索与生成的组合;实现细节可查 Sentence Transformers 文档、Faiss 文档 和 Transformers 文档。阅读时记录版本、数据、评价方法和自己的复现实验,不要把论文摘要当成生产方案。

课后复习与易错

易错一:只选 Embedding 模型,不做领域验证。真实术语、数字、版本和跨语言样本必须进入测试集。

易错二:向量检索替代一切。BM25、字段过滤、SQL 和规则仍然是精确和安全边界。

易错三:把 chunk 越大越好。证据粒度、引用完整度和上下文预算共同决定边界。

易错四:把 RAG 当作降低幻觉的保证。无证据拒答、声明核验和版本冲突处理仍然必须实现。

易错五:只看 Recall 或最终答案。检索、上下文、引用、事实、安全、延迟和成本要分层报告。

易错六:让 Agent 无限查询。步数、Token、费用、超时、权限和重复检测由控制器执行。

易错七:更新只写新增。修改、删除、权限撤销、缓存失效和备份都要有时间线。

易错八:交付只给一个 Notebook。README、数据卡、manifest、事件、评估和回滚才让别人能复用。

表 10.9D · 本章口试题
问题回答应包含最小证据
向量表示什么?训练目标塑造的坐标近邻回放
距离如何选择?归一化、排序、阈值距离实验
为什么要分块?证据粒度、预算、引用chunk 对照
检索如何安全?ACL、版本、租户先过滤越权回放
RAG 如何拒答?低分、无候选、冲突状态abstain 测试
如何评估?Recall、支持率、成本、p95发布报告
更新如何可靠?事件、snapshot、缓存、删除新鲜度时间线
坏了如何恢复?旧版、alias、回放rollback 日志
CHAPTER 10 REVIEW提交你的 RAG 学习包

提交设计卡、数据卡、向量契约、索引 manifest、检索 trace、RAG 回答包、评估报告、错误地图、README 和回滚演练;写下进入下一章前最想验证的一个问题。

学习包可在干净目录运行;所有引用可回查;资料不足与越权有安全响应;结论包含限制和下一步实验。

综合项目的交付结构

把本章知识组织成一个最小但完整的 RAG 学习项目:输入资料进入可追溯管道,向量和关键词索引提供候选,检索 trace 记录每一步,回答包保存声明与引用,评估报告决定能否灰度。项目不追求接入最多组件,而追求每个接口都能被替换、测试和回滚。

建议先做离线版本。准备一组小型公开或自建资料,手工标注查询、gold chunk、必需事实和风险标签;用本地 Embedding 或可替换的假向量跑通流程。离线流程稳定后再接入真实服务,避免把网络、权限和模型问题混在第一轮调试里。

数据卡说明资料来源、授权、抓取时间、语言、版本、敏感字段、删除策略和已知缺陷。模型卡说明模型 ID、维度、最大输入、归一化、语言范围、资源需求和评估边界。两张卡共同决定索引和检索结果能否被解释。

代码按职责拆分:loader 读取资料,parser 产生 Block,chunker 生成 chunk,embedder 负责批量编码,indexer 管理 snapshot,retriever 产生 trace,context_builder 组织证据,answer_checker 核验声明,evaluator 汇总指标。每个模块都有输入输出契约,避免所有逻辑挤在一个 Notebook 单元里。

README 应包含冷启动命令、目录结构、环境变量说明、最小样例、测试、数据许可、常见错误、指标解释和回滚步骤。别人能在干净目录运行,是综合项目是否完成的重要标准;“在我电脑上可以”不算验收证据。

把运行产物分成可提交和不可提交两类。提交 schema、manifest、脱敏 trace、评估汇总和示例资料;密钥、原始敏感文本、临时缓存和大模型权重放在明确的忽略规则中。即使项目只是学习练习,也要养成不把数据和凭据混进仓库的习惯。

表 10.9E · 综合项目目录建议
路径职责最小产物验收方式
data/输入与标注脱敏样例、gold、manifest许可和 hash 可查
src/parse.py解析与 Block结构化中间层原文位置抽检
src/chunk.py分块与元数据chunk、parent、ACL跨边界问题集
src/index.py建库与 snapshot索引 manifestFlat/ANN 对照
src/retrieve.py召回与 trace候选账本Recall、权限回放
src/answer.py上下文与引用回答包、claim支持率、拒答
reports/评估与决策指标、切片、限制硬门槛和回滚
README.md冷启动与运维命令、故障地图新目录复跑
PYTHON · project_checks.py
from pathlib import Path
import json

REQUIRED = ("data_manifest.json", "index_manifest.json", "README.md")

def verify_project(root):
    root = Path(root)
    missing = [name for name in REQUIRED if not (root / name).exists()]
    reports = list((root / "reports").glob("*.json")) if (root / "reports").exists() else []
    valid_reports = []
    for path in reports:
        try:
            json.loads(path.read_text(encoding="utf-8"))
            valid_reports.append(path.name)
        except json.JSONDecodeError:
            pass
    return {
        "missing": missing,
        "valid_reports": valid_reports,
        "ready": not missing and bool(valid_reports),
    }

print(verify_project("rag-project"))

项目检查器不会验证内容质量,但可以防止交付缺少关键文件或损坏 JSON。把它接入测试命令,再补上查询回放、越权、删除、新鲜度和回滚检查,综合项目就有了可重复的起点。

CAPSTONE LAB 10.9.4让同伴完成冷启动验收

把项目复制到一个全新目录,请同伴只阅读 README 完成安装、建索引、运行五条查询、检查引用和执行回滚。你只能根据日志修复问题,不能口头补充隐藏步骤。

新目录无需个人缓存和隐含环境;失败有明确日志;同伴能解释每个模块职责并复现评估结果。

从综合项目迁移到下一章

进入视觉或多模态大模型前,保留本章的接口思想:图像也需要数据卡、版本、权限、分块或区域、Embedding 契约、检索 trace、声明核验和评估门槛。模态改变了输入和编码器,不会取消可追溯、可复现和可回滚的要求。

整理一张错误地图,按“数据质量、表示、距离、索引、分块、检索、上下文、生成、安全、资源”分类。每个错误写现象、最小复现、根因假设、修复、回归和负责人。未来遇到新模态或新模型时,可以沿用这张地图快速定位。

最后写下三个仍未解决的问题,例如跨语言召回是否稳定、删除事件多久完全生效、怎样让声明核验覆盖计算结果。问题要能转化为下一次实验,而不是泛泛的“继续学习”。带着可验证的问题进入下一章,学习会更有方向。

本章真正的完成标志不是记住 RAG 的缩写,而是能够交付一条有边界的证据链:资料从哪里来、如何变成向量、怎样被找回、为何允许生成、引用是否支持、失败如何拒答、上线如何监控、坏了如何回滚。做到这一点,你已经具备继续学习视觉、多模态和行业大模型的工程底座。

综合项目评分与自评

综合项目可以用“可运行、可解释、可验证、可恢复”四个维度自评。可运行要求新目录能冷启动;可解释要求每条结果能追到数据、模型、索引和事件;可验证要求有 gold、声明、权限、延迟和成本证据;可恢复要求故障、删除、撤权和版本回滚都有演练记录。

项目评分不看代码行数,而看职责是否清楚。至少拆出读取资料、解析与分块、批量编码、索引管理、候选检索、上下文编排、声明核验和评估报告;每个职责都应有输入、输出、异常和测试。一个很短但接口清晰的项目,比一个很长却无法回放的脚本更值得保留。

为每个模块写一个最小失败测试。解析器面对空文件和乱码,分块器面对超长表格,Embedding 面对空文本,索引面对重复事件,检索面对无结果,回答器面对冲突证据,评估器面对缺失标签。失败测试让“应该怎么处理”变成可以运行的约定。

交付时把结果分层:用户可见回答、调试 trace、离线评估、运维事件和敏感数据分别由不同权限访问。这样既能让同伴复查,也不会因为为了排查问题而暴露所有原文。README 中说明哪些文件可以公开,哪些只能在受控环境查看。

最终演示不要只展示成功案例。准备一条正常查询、一条数字/版本查询、一条资料不足、一条跨文档组合、一条权限拒绝、一条删除后查询和一次服务降级;为每条展示系统状态、引用、日志和预期动作。失败路径才真正检验你是否理解了边界。

表 10.9F · 综合项目自评表
维度达到标准证据文件未达标时
可运行干净目录可启动README、安装日志补环境与命令
可解释结果能回查来源trace、claim、版本补字段和链接
可验证指标与门槛有数据gold、报告、切片补标注和回归
可恢复旧版、删除、降级可演练事件、runbook、截图收紧发布范围
职责清楚模块契约和测试分开src、tests、schema拆分并补测试
安全合规ACL、脱敏、保留期明确数据卡、攻击回放阻断公开和上线
PYTHON · acceptance_check.py
CHECKS = (
    "cold_start", "citation_backtrace", "low_evidence_abstain",
    "acl_isolation", "delete_visibility", "rollback",
)

def acceptance(results):
    missing = [name for name in CHECKS if not results.get(name)]
    return {"ready": not missing, "missing": missing}

demo = {
    "cold_start": True,
    "citation_backtrace": True,
    "low_evidence_abstain": True,
    "acl_isolation": True,
    "delete_visibility": True,
    "rollback": True,
}
print(acceptance(demo))

验收函数只表达“是否完成”,不替代每项测试的原始证据。把每个布尔值链接到日志、查询集或演练记录,并在版本升级后重新运行;如果只修改 README 或 Prompt,也要确认引用、拒答和安全回归没有退化。

CAPSTONE LAB 10.9.5完成最终答辩

用七条演示查询和一次故障注入完成五分钟答辩:说明数据从哪里来、候选如何产生、回答为何可信、资料不足如何处理、权限如何保证、指标如何判断、坏了怎样回滚。答辩后根据同伴问题更新错误地图。

演示包含成功与失败;每项结论都有证据链接;回答不超出资料;故障后能恢复;改动进入 README 和回归集。

将这份自评表保存为项目的 release note。下一章开始接触视觉大模型时,不必重新发明流程,只需替换输入模态和编码器,再沿用数据卡、索引契约、检索 trace、声明核验、评估门槛和回滚习惯。