本章可运行实验 · 真实 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、归一化方式、最大输入长度和每千条文本的计算时间,才能讨论“质量提高是否值得”。
模型输出的不是“意义标签”,而是服务于某种训练目标的坐标;是否适合检索要用任务数据验证。
| 表示 | 优点 | 限制 | 常见用途 |
|---|---|---|---|
| Token/词向量 | 细粒度、可组成 | 难表达整句意图 | 词义分析、特征初始化 |
| 句向量 | 查询和短段落方便比较 | 长文档细节被平均 | 语义搜索、聚类 |
| chunk 向量 | 可返回具体证据 | 依赖切分边界 | RAG 召回 |
| 文档向量 | 索引少、粗筛快 | 定位不到原句 | 分类、路由、去重 |
| 多向量表示 | 保留多个局部语义 | 存储和查询复杂 | 长文档、高召回场景 |
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())))示例故意不用真实模型,先让你检查最基本的契约:有多少条文本、每条多少维、数据类型是什么、文本和向量是否按同一顺序对应。接入真实编码器后,再补充最大输入长度、批次、设备、归一化和模型版本;这些字段缺失时,向量库里的分数很难复现。
任选三段短文本,写出输入 ID、向量维度、dtype、归一化和所属模型。说明为什么“距离近”只能说明训练目标下相似,不能直接说明事实相同。
10.1.2 词向量、句向量与多语言表示
词向量通常把一个词或 Token 作为单位,但现代文本编码器往往先把词拆成多个子词,再通过上下文得到 hidden state。同一个词在不同句子里可以有不同表示,这叫上下文表示;句向量则需要把多个 Token 的信息聚合成一个固定长度结果。聚合方式可能是特殊 Token、平均池化、加权池化或模型专门训练的 pooling head。
句向量模型常见的训练信号是对比学习:给定一个查询和正确文本作为正对,再给出其他文本作为负例,让正对的相似度更高。负例怎么选非常关键。随机负例容易区分,难负例更接近真实检索边界;如果负例本身也正确,模型会被迫把两个正确答案推远,造成空间扭曲。
多语言模型希望不同语言中表达相同意图的句子靠近,但对齐不是自动完美的。语言覆盖、脚本、形态变化、翻译质量和训练数据比例都会影响结果。中文查询配英文文档、简体配繁体、代码注释配自然语言时,都要用自己的跨语言样本测试,不要只看模型卡上的语言列表。
Embedding 的各向异性是一个常见现象:大量向量集中在空间的一小块区域,导致任意两段文本都显得相似。中心化、白化、归一化或模型专门的训练目标可能改善分布,但处理方式要在验证集上比较。盲目做后处理可能提高平均分,却伤害某些长尾类别。
句向量还会受到长度和格式影响。把标题、正文、表格和页脚直接拼在一起,模型可能把页码、导航词当作语义;过长输入被截断后,关键结论可能完全没有进入向量。入库前需要保留标题路径、文档类型和字符范围,必要时分别编码标题与正文,再在检索阶段融合。
选择 pooling 时不要凭接口名称判断质量。平均池化可能对短句稳定,特殊 Token 可能依赖模型训练方式,最后一个 Token 对因果模型有时更自然,但每个模型的预训练和后训练不同。最小做法是取一个小验证集,比较不同 pooling、归一化和长度策略的 Recall@k 与延迟。
| 维度 | 要问的问题 | 最小测试 | 失败表现 |
|---|---|---|---|
| 语言 | 跨语言意图是否对齐? | 中英同义句 | 按语言分簇 |
| 粒度 | 句子还是长段落? | 标题/正文切片 | 关键细节被平均 |
| 长度 | 超长如何处理? | 逐档长度扫描 | 截断后召回掉落 |
| 负例 | 难负例是否合理? | 近邻错配集 | 相关文档被推远 |
| 分布 | 向量是否塌缩? | 均值/方差/余弦分布 | 所有分数相近 |
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 版本,才能解释两个实现为什么分数不同。
准备同一意图的中文、英文、繁体和带标题版本,再准备一条超长版本。比较向量距离、有效 Token 数和检索排名,指出哪些差异来自语言,哪些可能来自截断或格式。
10.1.3 选择模型与建立向量契约
选 Embedding 模型先从任务反推。查询是短问题还是完整段落?文档是中文政策还是代码?需要跨语言吗?索引规模和更新频率如何?是否允许把数据发送到外部服务?这些问题比“排行榜第几名”更能缩小选择范围。一个在通用语义相似度上高分的模型,可能不理解你的产品术语。
向量契约至少写出 model_id、版本或 hash、维度、dtype、归一化、最大输入 Token、pooling、批次、语言、相似度函数和更新时间。查询端与文档端必须使用兼容契约;只要更换模型、维度或归一化,就不能把新向量直接写进旧索引。迁移时保留双写或双索引,比较一段时间后再切换。
外部 API 的向量还要记录供应商、区域、请求版本和失败重试。服务返回的浮点精度、截断策略和模型别名可能变化,调用方如果只保存一个友好的模型名称,就无法重现旧结果。对敏感资料要考虑数据出境、保留策略、加密和删除接口,Embedding 并非自动匿名化。
本地模型则要关注权重许可、Tokenizer 文件、硬件、量化和并行。量化可能改变向量方向和近邻顺序,不能只比较生成质量。首次部署时跑一个固定向量基准,保存若干文本的向量摘要、范数、近邻 ID 和耗时,后续升级才能做回归。
契约还应规定异常输入。空字符串、只有标点、超长文本、未知语言、包含秘密的文本和编码错误应该分别处理。是返回零向量、报错、跳过入库还是切成多个 chunk,必须由上层流程决定;悄悄把异常输入送进模型会让索引混入无法解释的点。
最后建立“质量—资源—风险”三维选择表。质量包括召回和切片表现,资源包括单条编码时间、显存、存储和查询成本,风险包括许可、隐私和供应商依赖。只有当三维条件都满足,模型才适合进入生产,而不是因为一个分数漂亮就上线。
| 字段 | 示例 | 为什么重要 | 改变后的动作 |
|---|---|---|---|
| model_id/version | embed-v2.1 | 确定训练和服务版本 | 建立新索引 |
| dimension | 768 | 决定存储和索引结构 | 禁止混写 |
| dtype | float32 | 影响精度和内存 | 重测距离 |
| normalize | L2=true | 决定距离解释 | 重算分数 |
| max_tokens | 512 | 决定截断边界 | 改分块策略 |
| pooling | mean-mask | 决定句向量来源 | 重新编码 |
| license/region | internal / CN | 决定可否使用 | 审查或迁移 |
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;查询日志也保存它,出现排名变化时能回答“是不是模型或归一化变了”。当你准备切换模型时,先用离线集比较新旧近邻和下游答案,再决定是否迁移。
为两个候选模型填写任务适配、语言、维度、长度、速度、许可、隐私、成本和离线 Recall@k。最后写出一个“不能上线”的阻塞条件。
本节深挖:向量不是事实标签
看到两个向量接近时,正确表述是“在这个模型、这个输入处理和这个距离函数下,它们被映射得接近”。不要把它改写成“模型判断两段话是真的”或“它们一定回答同一个问题”。相似度是排序信号,事实判断仍需要原文、规则或外部验证。
向量空间的几何性质依赖训练数据。语料中常见的词会形成稳定邻域,缺少样本的领域术语可能被拆成奇怪的子词,拼写错误可能落在无关区域。遇到新业务词,先做近邻可视化和人工抽样,必要时用领域数据微调或增加关键词通道。
维度、范数和角度要分开理解。两个向量长度不同,点积会同时反映方向和大小;L2 归一化后,点积等价于余弦相似度。归一化不是免费的“质量增强”,它会抹去范数携带的某些信息,因此必须和索引距离、训练目标一起验证。
输入清洗也会改变向量。去掉标题可能丢失主题,去掉标点可能改变代码和数字,统一大小写可能损害专有名词,删除页眉页脚可能需要版面识别。清洗规则要带版本,原文与清洗后文本都保留 hash 和字符范围,便于追溯。
Embedding 模型通常把长度压到固定维度,因此信息必然有损。越长的输入越容易把多个主题平均到一个点上;越短的 chunk 越可能丢失限定条件。分块与模型选择必须一起评估,不能先固定一个 chunk_size 再要求模型解决所有文档。
判断模型是否适合自己的数据,优先做小而难的验证集。包含同义改写、词面相似但意图不同、否定句、数字差异、版本差异、跨语言和资料不足。若只用随机句子,模型很容易得到漂亮但没有产品价值的分数。
近邻样例是最有效的解释材料之一。对每条查询展示 Top-k 文本、分数、来源、版本和人工标签,按“正确、部分相关、错误、危险”分类。错误近邻能告诉你是词面、领域、长度、语言还是权限元数据出了问题。
当你阅读新模型论文时,记录训练目标、正负样本、池化、硬负例、评估集和推理限制。不要只记一个维度和榜单分数;这些细节决定模型的向量空间是否能迁移到你的 RAG 任务。
| 证据 | 保存内容 | 它能排除什么 |
|---|---|---|
| 输入快照 | 原文、清洗版、hash | 文本悄悄变化 |
| 模型契约 | 版本、维度、pooling | 编码器漂移 |
| 向量统计 | 范数、均值、方差 | 归一化/塌缩 |
| 近邻回放 | Top-k、分数、标签 | 排序退化 |
| 资源记录 | 批次、设备、耗时 | 成本异常 |
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”前面,说明模型或查询需要更多上下文、关键词过滤或重排,而不是简单把阈值调高。每一次修复都保留前后的近邻,避免只报告改进后的样例。
为一个错误近邻写出:原始查询、Top-k、模型契约、距离、人工判定、可能根因和下一步实验。卡片中不能出现“语义不够好”这种没有证据的结论。
本章任务卡:建立你的第一份向量实验报告
先固定一个小语料集,包含同义问题、词面相似但意图不同的问题、数字不同的问题、中文与英文改写、一个超长段落和一个完全无关段落。为每条文本分配稳定的 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 更强”。
| 问题 | 需要的证据 | 可接受的结论 | 不能这样写 |
|---|---|---|---|
| 近邻是否相关? | Top-k + 人工标签 | 按切片报告相关率 | 距离小所以一定正确 |
| 模型是否稳定? | 版本、范数、回放 | 固定契约下可复现 | 跑一次看起来一样 |
| 长文如何表示? | 整篇/chunk 对照 | 任务决定粒度 | 整篇向量永远更省 |
| 清洗是否有害? | 前后近邻和 hash | 规则带来可解释变化 | 清洗越多越干净 |
| 是否合规? | 来源、权限、删除记录 | 有治理边界 | 向量不可读就安全 |
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))这个审计器只检查几何和形状,不判断文本是否真的相关。它适合放在入库前,及早发现空向量、维度错误、未归一化或向量塌缩。语义质量仍要通过带人工标签的检索集验证。
为 20 条文本建立固定语料,比较两个编码方案;交付 manifest、向量统计、Top-k 回放、三个错误近邻、Recall@5、编码耗时和你的模型选择结论。
易错与复盘:把向量当作工具而不是答案
易错一:认为 Embedding 直接保存了完整语义。固定维度必然有信息损失,长文本中的限定条件尤其容易被平均掉;要用 chunk、元数据和原文回查补足。
易错二:查询端和文档端用了不同模型,却仍然把分数放在一起排序。即使维度相同,空间也可能完全不同;契约不一致时应拒绝查询,而不是默默返回低质量结果。
易错三:只凭余弦分数设置阈值。不同任务、语言和长度的分数分布不同,阈值要在带标签的验证集上校准,并报告误召回和漏召回。
易错四:把清洗当成越多越好。删除标题、数字、否定词和代码标点都可能损害检索;每条规则都做前后对照,保留原文和 hash。
易错五:用公开榜单替代自己的数据。业务术语、法规版本、跨语言和权限边界常常不在榜单里;最小的领域验证集比盲信一个总分更有价值。
复盘时问五个问题:向量代表的粒度是什么?训练目标和检索目标是否一致?距离函数与归一化是否匹配?错误近邻能否定位到清洗或模型?如果更换模型,索引和日志如何迁移?这五问会贯穿后面所有 RAG 实验。
| 问题 | 合格回答要点 | 最小证据 |
|---|---|---|
| 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、范数和维度;查询时遇到异常要返回明确错误,不要让数据库悄悄把坏向量排在前面。
距离函数的选择会改变候选排序;先写清数学约定,再配置索引。
| 函数 | 关注什么 | 优点 | 需要警惕 |
|---|---|---|---|
| 点积 | 方向 + 范数 | 计算简单、适合部分双塔模型 | 长度大的向量可能占优 |
| 余弦 | 方向 | 尺度不敏感、易解释 | 零向量、范数信息丢失 |
| L2 距离 | 坐标差 | 几何直观、可用于聚类 | 受维度和尺度影响 |
| 内积 + 归一化 | 归一化后的方向 | 可用高效内积索引 | 必须确认双方一致归一化 |
| 学习排序分数 | 任务相关性 | 可融合多种特征 | 需独立训练和校准 |
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;这正好说明“长”与“同向”是两个不同信号。接入真实向量后,把未归一化和归一化结果都打印一次,确认数据库排序和本地计算一致。
构造三对向量:同向但长度不同、长度相同但方向不同、包含零向量。分别计算点积、余弦和 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 在相关性和新颖性之间折中。去重规则本身要评估,过度去重会删除互相补充的证据。
| 阶段 | 建议动作 | 记录字段 | 常见错误 |
|---|---|---|---|
| 候选召回 | 取 k_candidate 较大 | k、原始分数 | 把候选当最终证据 |
| 阈值过滤 | 按验证集校准 | threshold_version | 照搬别的模型阈值 |
| 元数据过滤 | 权限/版本/语言 | 过滤原因 | 先向量后越权 |
| 去重与多样性 | 按 doc_id/标题配额 | 保留/丢弃理由 | 相邻 chunk 占满 |
| 预算截断 | 按 token 和风险排序 | 被截断 ID | 静默丢关键证据 |
| 最终上下文 | 保留可引用片段 | context_ids | 只留文本不留来源 |
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 预算放到同一个选择器中。生产版本要先做权限过滤,再做分数排序;示例中的“每文档一个”只是教学规则,是否合理应由任务验证。任何被过滤或截断的候选都应该写入调试日志。
准备 30 条带人工标签的查询,比较 k=3、5、10 与阈值 0.6、0.75、0.85 的 precision、recall、上下文 Token 和答案支持率。选择一组配置并说明它适合什么风险等级。
10.2.3 语义召回的基线与陷阱
任何向量检索都应和简单基线比较。关键词、BM25、标题匹配或规则过滤在编号、专有名词、错误代码和精确版本上经常胜过语义检索。一个向量方案只有在相同测试集上超过或补足基线,才值得增加复杂度;如果基线已经能解决任务,就不要为“用了 Embedding”而强行加入向量。
语义相似容易被否定、数字和实体差异欺骗。“允许退款”和“不允许退款”词面几乎一样,向量可能把它们排在一起;“版本 2.1”和“版本 2.10”只差一个字符,却可能代表不同规则;人名、产品名和地区需要精确匹配。对这些场景加入关键词、字段过滤或重排器,而不是单纯提高向量维度。
长查询往往混合多个意图。把整句编码成一个向量,会让一个主题遮住另一个主题;可以先拆成子查询,分别召回,再合并证据并去重。拆分也可能放大错误,必须记录原查询、子查询和各自结果,确保能够解释最终上下文来自哪一步。
查询和文档的语言风格不对称时,Embedding 也可能退化。用户说“怎么退钱”,文档写“售后款项返还政策”;训练好的模型通常能连接两者,但公司内部缩写、产品代号和新词未必能。把真实查询、同义改写和内部术语加入验证集,必要时训练领域适配器或维护术语表。
检索错误要分成漏召回和误召回。漏召回是正确文档没进候选集,应该查编码、分块、查询和索引;误召回是无关文档排名靠前,应该查距离、关键词、过滤和重排。两者的修复方向不同,不能只看最终答案“错了”就调一个阈值。
建立难负例比随机负例更有价值。随机文档很容易被排除,难负例要与查询共享词面或主题,却缺少关键事实。每次发现错误近邻,把它加入回归集;但要确认它确实是负例,避免把两个都正确的资料错误标成互斥。
| 陷阱 | 示例 | 最小修复 | 验证方式 |
|---|---|---|---|
| 否定词丢失 | 允许/不允许 | 关键词或重排 | 否定对照集 |
| 数字混淆 | 2.1/2.10 | 数字字段过滤 | 数值差异集 |
| 实体相近 | 同名产品/人名 | 实体与租户过滤 | 实体对照集 |
| 整句多意图 | 退款+发票 | 子查询或多路召回 | 意图拆分集 |
| 领域新词 | 内部缩写 | 术语表/适配数据 | 真实查询集 |
| 版本冲突 | 旧政策排名靠前 | 生效时间过滤 | 版本回归集 |
| 难负例不足 | 相似但不支持 | 持续收集误召回 | 近邻审核 |
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 和业务规则的结果并列记录,再由重排器或融合算法排序。先让每条通道独立可测,调试时才知道某条证据是从哪里进入候选集的。
从真实问题中收集 20 个失败案例,分别标记漏召回、误召回、版本冲突、否定错误、数字错误和权限错误;为每类指定一个修复通道并添加到回归集。
本节深挖:分数要经过验证才有意义
相似度的数学值只在固定模型、固定预处理、固定距离和固定数据分布下可比较。换一个模型或归一化方式,分数的绝对范围就可能改变;即使排序大致稳定,阈值也要重新校准。日志同时保存原始分数、归一化规则和索引版本。
评估召回时先定义“正确文档”。一条问题可能有多个可接受来源,不能只放一个 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,后面才能做可复现压测。
| 实验 | 固定条件 | 要比较 | 结论边界 |
|---|---|---|---|
| 距离对照 | 同一向量集 | dot/cosine/L2 | 排序是否一致 |
| 阈值扫描 | 模型/语料固定 | precision/recall | 只适用于该分布 |
| Top-k 扫描 | chunk/预算固定 | 召回/Token/支持率 | 不等于越大越好 |
| 基线比较 | 同一 gold 集 | BM25/dense/hybrid | 看任务切片 |
| 难负例回归 | 固定错误集 | 近邻与根因 | 避免重复犯错 |
用一页文档规定距离、归一化、候选 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 事实核验的基础。
最后定义不同风险等级的动作:低风险允许较低阈值并显示候选,高风险要求更高支持率并在不足时接管,版本或权限不明直接阻塞。阈值不是孤立参数,它决定系统在不确定时是继续生成、澄清还是停止。
| 指标 | 回答的问题 | 配套字段 | 不能单独说明 |
|---|---|---|---|
| Recall@k | 正确资料是否进入前 k? | gold 集合、k | 排名和答案是否使用 |
| Precision@k | 前 k 中有多少相关? | 人工标签、k | 是否漏掉其他证据 |
| MRR | 第一份正确资料排多前? | 首个 gold rank | 多个证据的整体质量 |
| nDCG | 相关性等级排序如何? | 分级标签 | 事实支持和安全 |
| 支持率 | 答案声明是否被证据支持? | claim/source | 检索本身是否召回 |
| 拒答率 | 资料不足时是否停止? | evidence threshold | 是否误拒合法问题 |
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 可能是标注问题。指标、原始排名和人工判断必须同时保留。
交付距离说明、阈值曲线、Recall@1/5、MRR、候选去重对照、一个 lexical 基线、五个失败样本和风险等级动作表。
课后复习与易错:不要把 Top-k 当作答案
易错一:把高分当作相关性证明。高分只是模型空间中的排序信号,必须通过 gold 文档、人工标签和最终声明支持率核验。
易错二:混淆距离和相似度。数据库可能返回越小越近的距离,也可能返回越大越相似的分数;先用已知向量做方向测试。
易错三:忽略归一化责任。查询端和文档端的范数处理不一致,会让点积偏爱长向量;manifest 中写明谁负责归一化。
易错四:只调 k 不看上下文。k 变大可能提高召回,却把无关文本和冲突版本带进 Prompt;同时报告 Token、支持率和成本。
易错五:阈值脱离数据。不同语言、长度、任务和模型的分数分布不同;阈值用验证集校准,并为低分定义拒答或澄清动作。
易错六:没有 lexical 基线。数字、否定、代码和专有名词需要精确匹配;先比较关键词/BM25,再决定向量是否解决了增量问题。
易错七:把漏召回和误召回混在一起。正确文档没进候选集要查编码、分块和查询;无关文档靠前要查距离、过滤和重排。
易错八:忽视多个 gold。一个问题可能有多份等价资料;标注只留一份会把正确结果误判为错误,导致错误优化。
易错九:忽视排序稳定性。并列分数、索引分片和重建会改变顺序;使用稳定 tie-breaker,并保存 snapshot 与原始候选。
复盘时用一句话总结:在固定向量契约和数据版本下,哪一种距离、k、阈值和融合策略以什么成本找到了哪些证据。下一节将把这些候选交给可扩展的向量索引。
| 问题 | 回答应包含 | 最小证据 |
|---|---|---|
| 余弦和点积差别? | 是否考虑范数、归一化 | 已知向量对照 |
| 为什么要阈值? | 控制误召回和资料不足 | PR 曲线 |
| k 越大越好吗? | 召回、噪声、Token 取舍 | k 扫描 |
| 为什么保留 BM25? | 数字、实体、否定词 | 基线比较 |
| 漏召回怎么修? | 查编码、分块、查询、索引 | 错误地图 |
| 分数如何复现? | 模型、距离、版本、snapshot | 回放事件 |
用一页 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,却把相似但冲突的政策送进上下文。将误答、误拒和正确澄清分开计数,比追求一个漂亮的平均分更可靠。
| 观察 | 可能含义 | 建议动作 | 需要的证据 |
|---|---|---|---|
| 高分且 margin 大 | 候选较明确 | 进入重排与引用核验 | gold、版本、权限 |
| 高分但 margin 小 | 多个候选竞争 | 保留多来源或澄清 | 候选 diff、冲突标签 |
| 整体分数低 | 可能无资料或查询歧义 | 澄清、关键词回退或拒答 | 阈值校准、失败状态 |
| 分数正常但支持率低 | 排序信号与任务不一致 | 检查分块、重排和 gold | 声明级标注 |
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、模型、距离和数据切片写入实验配置;当策略改变时,保留旧配置和回放结果,避免只凭一次线上样例就修改门槛。
收集至少 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 索引可能提高吞吐却增加搬运和构建复杂度;批量查询能摊薄开销,但在线单查询仍要满足交互延迟。压测要区分冷启动、热缓存、单查询、批量和并发,不能用一个数字代表全部场景。
ANN 不是把近似结果当真理,而是先缩小搜索,再用重排和评估控制损失。
| 索引 | 核心结构 | 提高召回的旋钮 | 主要代价 |
|---|---|---|---|
| Flat | 全量精确扫描 | 无近似参数 | 查询成本随数据线性增长 |
| HNSW | 分层近邻图 | M、ef_search | 内存和建图时间 |
| IVF | 聚类倒排桶 | nprobe、桶训练 | 边界漏召回、更新复杂 |
| PQ | 分段量化码 | 码长、重排数量 | 压缩误差和额外码表 |
| 混合索引 | 粗召回+精排 | 候选数量、精排深度 | 实现和监控更复杂 |
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”是否真的成立。
用精确扫描作为 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 变更都写迁移说明和回滚路径。
| 字段 | 类型 | 用途 | 安全/一致性要求 |
|---|---|---|---|
| chunk_id | 稳定字符串 | 引用和回放 | 不可因重排随意变化 |
| doc_id | 字符串 | 父文档去重 | 与原文目录一致 |
| vector | float 数组 | 近邻搜索 | 维度、dtype、模型固定 |
| text_ref | 路径/范围 | 回查原文 | 权限同源 |
| tenant_id | 字符串 | 租户隔离 | 服务端注入 |
| acl | 规则集合 | 细粒度授权 | 先过滤再返回 |
| valid_from/to | 时间 | 版本和新鲜度 | 统一时区 |
| manifest_id | 字符串 | 编码契约 | 升级触发迁移 |
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 是否存在以及父文档是否仍可访问。
为一个多租户知识库写字段表,包含向量、原文回查、权限、版本、新鲜度和 manifest。再设计三条越权查询,说明过滤应在哪一层阻止。
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,例如变更在十五分钟内可检索,并用事件时间和索引可见时间测量。
| 状态 | 允许做什么 | 必须检查 | 失败动作 |
|---|---|---|---|
| building | 写入临时 collection | 文件、维度、hash | 停止并保留日志 |
| candidate | 离线查询 | Recall、schema、ACL | 不切 alias |
| shadow | 旁路对比 | 排名、延迟、新鲜度 | 延长观察 |
| active | 线上服务 | p95、错误、租户隔离 | 降级或回滚 |
| retired | 备份和恢复 | 保留期、hash | 到期再清理 |
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 和近邻样例能补上这部分证据。
创建 v1 与 v2 两个小索引,模拟新增、修改、删除和权限变更;先 shadow 对比,再切 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、标题或分数都算失败。安全回归应和质量回归一样进入每次索引发布。
新鲜度不是更新时间字段本身。要记录资料变更时间、进入队列时间、编码完成时间、索引可见时间和首次被召回时间,计算端到端延迟。这样才能判断瓶颈在抓取、解析、编码、写入还是缓存。
| 验收项 | 最小实验 | 通过标准 | 阻塞信号 |
|---|---|---|---|
| 召回 | Flat 对照 | Recall 达门槛 | 关键证据漏掉 |
| 延迟 | 并发压测 | p95 在预算内 | 长尾超时 |
| 过滤 | 高/低选择率 | 权限后结果正确 | 越权 ID |
| 更新 | 增改删回归 | 新鲜度 SLA | 已删内容仍可见 |
| 恢复 | 干净环境加载 | 查询和 ACL 通过 | 只有文件没有可用服务 |
| 回滚 | alias 切换 | 旧版可重放 | 无法恢复 |
交付索引 manifest、ANN 参数扫描、过滤与租户测试、增改删日志、freshness 统计、备份恢复记录和回滚命令;最后给出“可发布/限量/阻塞”结论。
课后复习与易错
易错一:直接上 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 查询延迟、过滤后候选数和异常率。把异常阈值写成告警规则,避免索引逐渐损坏却直到用户反馈才发现。
| 任务 | 交付物 | 合格标准 |
|---|---|---|
| Flat gold | 精确 Top-k 文件 | ANN 可计算召回损失 |
| 参数扫描 | Recall/延迟表 | 有预算内配置 |
| Schema | 字段和校验器 | tenant/ACL 由服务端控制 |
| 更新发布 | candidate/alias 日志 | 增改删可回放 |
| 恢复回滚 | 备份和演练记录 | 新目录可查询、旧版可切回 |
写明构建命令、参数、监控、更新、备份、恢复、权限测试和回滚步骤;让另一位同学在空目录中按手册完成一次发布。
排错路线:索引慢、漏或错时先问什么
如果延迟突然上升,先看查询输入长度、并发、缓存、过滤选择率和索引参数,再看硬件利用率。不要一开始就更换 Embedding 模型;模型可能完全没变,瓶颈只是候选数或网络返回变多。
如果 Recall 下降,先与 Flat gold 比较同一 snapshot。确认向量维度、归一化、距离和查询文本没有变化,再检查 HNSW/IVF 参数、分片覆盖和过滤顺序。把每一步的候选数保存下来,才能知道漏召回发生在哪一层。
如果查询结果重复,检查 chunk_id、doc_id 和批量重试的幂等性。重复写入不一定会报错,却会让同一事实占据多个排名位置;清理重复前保留原始写入事件,避免把数据问题误当索引问题。
如果删除后仍能检索到内容,依次检查主索引、缓存、异步队列、备份恢复和原文回查。删除是端到端动作,不只是对向量库发一个 delete 请求;需要定义完成时间和用户可见的状态。
如果权限测试失败,先阻止服务继续对外提供结果,保存请求、会话和策略版本,避免日志包含被保护的正文。修复后用两个租户的相似文档重放正常、越权、缓存命中和旧 snapshot 四类请求。
如果新旧索引的分数整体漂移,检查 Embedding manifest、量化、归一化和数据清洗 hash。分数漂移不一定代表质量下降,但所有阈值和排序回归都要重新校准。
如果索引构建中途失败,保留失败批次、输入 hash 和临时 collection,不要直接覆盖 active alias。修复后只重跑失败批次,再用记录数、hash 和固定查询确认没有重复或缺口。
运行手册要把“谁可以做什么”写出来。构建、切换、回滚、删除和权限变更都是高影响动作,至少有审批人、事件 ID 和恢复办法;工程细节清楚,RAG 的安全边界才不会只存在于口头约定。
| 现象 | 先查 | 最小证据 | 安全动作 |
|---|---|---|---|
| 延迟上升 | 并发、过滤、候选数 | 分段计时 | 限流/降级 |
| Recall 下降 | Flat、距离、参数 | 漏掉的 gold ID | 回退配置 |
| 结果重复 | 幂等和 chunk_id | 写入事件 | 停止重建 |
| 删除仍可见 | 缓存、队列、snapshot | 删除时间线 | 阻止返回 |
| 越权结果 | tenant/ACL/日志 | 交叉租户回放 | 立即熔断 |
| 分数漂移 | manifest/量化/清洗 | 向量统计 | 重校准 |
索引设计的迁移思考
小规模实验常用 Flat,因为它最容易解释;数据变大后再引入 HNSW、IVF 或 PQ。先保持 Schema、距离和日志不变,只替换索引实现,才能把性能收益归因于索引而不是数据或模型变化。
分片策略要与租户、地区和语言一起考虑。按租户分片便于隔离,按时间分片便于新鲜度,按向量空间分片便于均衡负载;每种策略都可能增加跨分片合并。压测要包含单片和多片查询。
向量库的过滤字段应建立索引或等价的快速路径。若每次查询都从海量记录中逐条检查 ACL,p95 会随着租户规模恶化;安全过滤和性能设计不能互相甩锅。
备份文件也要做访问控制。向量、原文引用和元数据可以组合推断敏感主题,备份应加密、记录访问、设置保留期,并在删除请求后按政策清理副本。
新鲜度监控应区分“数据已经抓到”和“用户已经查到”。中间任一队列、编码批次、索引写入或缓存都可能延迟;把时间线画出来,才能找到真正的瓶颈。
索引切换前跑 smoke queries:一个普通问题、一个数字问题、一个版本问题、一个低分问题和两个租户隔离问题。少量固定查询能快速阻止明显回归,再进行完整离线评估。
不要过度优化边界分数。如果精排和答案核验会读取原文,ANN 的职责只是把候选缩小到可处理范围;把复杂逻辑塞进索引会让更新、备份和回滚变得脆弱。
完成本节后,你应能解释索引为什么近似、哪些参数影响召回、记录为什么需要元数据和权限、更新为什么需要 alias 与 snapshot,以及恢复为什么必须用查询和安全回归验收。
| 问题 | 应该有的答案 | 证据 |
|---|---|---|
| 能否找回 gold? | 明确召回门槛 | Flat 对照表 |
| 能否只看可见资料? | 服务端过滤 | 租户攻击回放 |
| 更新多久可见? | freshness SLA | 时间线日志 |
| 坏了如何切回? | 旧 alias 可用 | 回滚演练 |
| 恢复后是否相同? | 关键查询可接受 | 快照 diff |
把这五问放进发布审批单,索引就从“数据库配置”变成可检查的学习资产。任何一个问题回答不清,都应该先补实验或收紧上线范围。
索引层的可观测性与容量推算
索引设计先做容量账。向量数量、维度、数据类型、图连接数、分桶数、量化码长和副本数都会影响内存;原文、metadata、倒排字段和快照还会占用额外空间。用小数据实测每条记录的平均占用,再按增长率、重建临时空间和备份保留期预留容量,不要只用“向量维度 × 数量”估算。
压测不能只发完全随机的查询。准备短问题、长问题、数字和版本、空结果、权限过滤、热门缓存和并发突增等工作负载;每种负载分别记录 p50、p95、p99、Recall、错误率和成本。平均值掩盖的长尾,往往才是用户真正感受到的故障。
过滤执行顺序是索引契约的一部分。有的系统先做近邻再过滤,有的系统支持预过滤或分区;同一个 k 在不同顺序下可能返回不同数量的可见结果。用跨租户和窄时间范围样例验证过滤语义,禁止只凭厂商参数名称推断安全性。
更新需要区分 upsert、delete、tombstone 和 compaction。重复写入可能制造同一 chunk 的多个版本,延迟删除可能让旧内容继续被召回,压缩又可能改变 snapshot。为每个事件分配 idempotency key,并在回放中证明重复执行不会产生额外记录。
| 阶段 | 关键指标 | 必须记录 | 异常动作 |
|---|---|---|---|
| 构建 | 吞吐、失败率、临时空间 | 输入 manifest、批次、hash | 隔离失败批次 |
| 查询 | p95、p99、Recall、空结果率 | snapshot、过滤、k | 限流或降级 |
| 更新 | source 到 visible 延迟 | 事件 ID、版本、时间线 | 暂停 alias |
| 删除 | tombstone 生效时间 | 被删 ID、缓存键 | 阻止返回并告警 |
| 恢复 | 快照一致性、回放差异 | 旧 manifest、查询集 | 保持旧版本 |
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 对照、固定查询回归、压力测试和恢复演练。
用同一批向量建立 Flat 和一个 ANN 索引,记录内存、构建时间、Recall@k、p95 与过滤后的结果数;随后执行增、改、删、重启和 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,等待人工处理,不要为了让批处理跑完而静默跳过。
每一步保留输入、输出和版本,出现召回错误时才能回查原文。
| 类型 | 容易丢失什么 | 建议中间表示 | 验收样例 |
|---|---|---|---|
| HTML | 标题、链接、隐藏导航 | DOM 块+标题路径 | 章节和链接回查 |
| 文字 PDF | 双栏、页眉、脚注 | 坐标块+页码 | 截图对照 |
| 扫描 PDF | 字符、数字、表格 | 图片+OCR+置信度 | 人工抽检 |
| 表格 | 列、单位、合并单元格 | JSON+可检索文本 | 数字问答 |
| 代码 | 缩进、文件和行号 | 文件块+语言+范围 | 函数定位 |
| 日志 | 时间、级别、请求链 | 事件字段+原文 | 故障回放 |
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 便于生成引用。遇到表格或代码时,分块器可以调用专门规则,而不是把所有内容都交给同一个按字符切分器。
选一份网页、一份 PDF、一张扫描表和一个代码文件,生成中间 Block;找出三处顺序、数字、标题或权限错误,并写出隔离或修复动作。
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 原因。
| 策略 | 适合 | 优点 | 风险 |
|---|---|---|---|
| 固定 Token | 格式稳定的长文 | 简单、成本可控 | 语义被截断 |
| 句/段落 | 普通说明文 | 边界自然 | 长度差异大 |
| 标题树 | 教材、政策、手册 | 主题路径清晰 | 层级异常 |
| 问答单元 | FAQ、客服 | 证据完整 | 跨问关联丢失 |
| 代码单元 | 源代码、配置 | 函数级定位 | 跨文件依赖 |
| 父子层级 | 长文档问答 | 定位和上下文兼顾 | 预算与去重复杂 |
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 的情况。
用同一语料比较固定 Token、标题树和父子 chunk 三种策略,记录平均长度、overlap、Recall@5、引用完整度、重复率和生成 Token。
10.4.3 Metadata、权限与版本
Metadata 把“这段文字是什么”告诉检索系统。至少包含 doc_id、chunk_id、标题路径、来源 URL 或文件路径、作者、语言、文档类型、创建和生效时间、版本、标签、权限、租户、文本 hash 和 embedding manifest。元数据不是装饰,它决定过滤、引用、新鲜度、去重和审计。
稳定 ID 要与内容和位置有关系,但不能只用行号。文档插入一段内容后,后面的行号会全部变化;可以用父文档版本、块路径和内容 hash 组合 ID,并另存 start/end 用于当前版本回查。ID 变化会影响缓存、反馈、引用和删除,因此要在设计阶段明确迁移策略。
权限字段应从权威系统同步。不要让模型从文本里猜“谁能看”,也不要只在最终答案阶段过滤。检索前拿到用户的租户、角色和资源范围,数据库只在可见集合内返回候选;文档更新或权限撤销后,旧缓存和索引 snapshot 都需要失效。
版本字段至少区分采集版本、内容版本、索引版本和 Embedding 版本。一个政策的文字没有变化,但权限或生效日期变化,也可能需要重新发布。回答引用应显示可理解的版本或日期,后台保存精确 manifest 和 snapshot,方便追溯。
元数据过滤与向量排序要有优先级。租户、权限、删除和生效状态是硬过滤;语言、文档类型和来源可以按任务选择;新鲜度、可信来源和多样性可以作为排序特征。把所有字段都塞进一个黑盒分数,会让错误无法解释。
数据治理还包括删除和导出。用户要求删除资料时,原文、清洗产物、向量、缓存、备份和评估样本的生命周期都要有记录;导出时只给授权范围。Embedding 可能保留语义线索,不能因为它不是原文就跳过删除政策。
| 字段组 | 示例 | 被谁使用 | 缺失影响 |
|---|---|---|---|
| 身份 | doc_id/chunk_id | 引用、去重、删除 | 无法回查 |
| 来源 | URL/path/page | 证据展示、审计 | 答案不可核验 |
| 结构 | heading_path/type | 分块、排序 | 主题混淆 |
| 时间 | valid_from/version | 新鲜度、版本过滤 | 旧政策混入 |
| 权限 | tenant/acl | 安全过滤 | 越权泄露 |
| 技术 | hash/manifest | 回归、迁移 | 无法复现 |
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 里。
为 10 个 chunk 填写完整 metadata,模拟版本更新、权限撤销和删除;用两个租户、两个角色重放查询,证明不可见资料不会进入候选。
本节深挖:文档管道决定证据能否被找到
解析器输出是 RAG 的“事实底稿”。如果标题层级错误,检索会把相邻章节混在一起;如果表格列错位,模型会把数值和字段配错;如果脚注被提前,回答可能引用例外却漏掉适用条件。入库前做少量人工高质量抽检,往往比上线后调十个检索参数更有效。
分块的目标不是让每个 chunk 长度相同,而是让一个 chunk 能独立支持某个最小问题。定义“证据单元”后,再决定是否带标题、定义、条件、例外和示例。对于跨段关系,用父子结构或邻接扩展补回上下文,不要无限增加 overlap。
一个可回查的引用至少需要来源、版本、标题路径和字符或页码范围。只保存一段清洗后的文本,未来无法证明它来自哪份原文;只保存 URL,也无法定位网页当时的内容。原文快照、hash 和范围应与 chunk 一起保存。
解析、清洗、分块、编码和索引都要有状态。processing、quarantine、ready、retired 和 deleted 这些状态能让增量管道安全地重试;否则一个 OCR 失败可能被当成“没有内容”,然后错误地发布到生产。
权限和版本最好在数据进入索引前就确定。生成阶段再过滤已经太晚,因为模型可能看到不该看的文字,日志也可能保存泄露内容。服务端过滤必须是强制边界,Prompt 里的“只使用可见资料”只能作为辅助说明。
新鲜度的计算从文档变更时间开始,到用户查询可见结束。每个阶段保存时间戳,发现 SLA 违约时才能定位抓取、解析、编码、写入、缓存或查询的瓶颈。不要把索引更新时间当作资料已经可用。
数据格式演进要有兼容策略。增加字段通常可以向后兼容,改变 chunk ID 或向量维度则需要新版本索引;删除字段前确认报告、缓存和回滚是否仍依赖它。迁移文档写清“旧数据能否读、新数据能否写、失败如何恢复”。
处理外部网页时,版权、许可和提示注入都属于管道问题。保存来源和许可状态,只抓取允许使用的内容;网页中的指令当作数据而不是系统规则,清洗和后续生成都要隔离。RAG 的“证据”首先要合法、可追溯、可控。
| 阶段 | 通过标准 | 保存什么 | 失败去向 |
|---|---|---|---|
| 解析 | 结构和数字可回查 | 原文、Block、抽检 | quarantine |
| 清洗 | 规则无关键丢失 | 前后 hash、规则版本 | 人工复核 |
| 分块 | 证据单元完整 | 范围、父子关系 | 重分块 |
| 元数据 | 来源/权限/版本齐全 | document card | 阻止入库 |
| 编码 | 维度和 manifest 一致 | 向量统计、错误批次 | 断点重试 |
| 发布 | 召回和 ACL 回归通过 | snapshot、alias | 保留旧版 |
从一份原始文件开始,提交原文 hash、解析 Block、清洗版本、chunk 表、metadata、Embedding manifest、索引 snapshot 和一个可点击的引用;附三条解析或权限失败记录。
章节任务卡:把一份复杂文档做成可检索资料
先选择一份结构丰富的资料:最好同时有标题、列表、表格、脚注或代码。保存原文件和 hash,记录许可与用途;不要直接从搜索结果复制一段文字,因为缺少版本和来源无法做可靠引用。
写一个解析清单,逐页或逐块检查阅读顺序、标题层级、页眉页脚、表格单位、链接、代码缩进和特殊字符。发现错误时把原文截图、解析结果和修复规则放在同一条记录,后续可以重新抽检。
用三种 chunk 策略建立候选集:固定 Token、标题边界和父子 chunk。对每种策略记录 chunk 数、平均 Token、最长 Token、重叠比例、父子关系和原文覆盖率。不要只说“看起来切得不错”,用覆盖率和引用完整度验证。
给每个 chunk 加完整 metadata。模拟一个用户只能看到某一租户、某一版本和某一角色的资料;验证检索前过滤后只剩可见内容。再撤销权限、删除版本和命中缓存,确保结果会失效。
将失败文档放到 quarantine,不要混入 ready 集合。为 OCR 错字、表格错位、空文本、超长段落、重复页眉和缺少许可证各准备一个样例,写清人工处理和自动回退方式。
模拟一次索引更新:原文修改一段、增加一页、删除一个段落。保留旧 chunk 与新 chunk 的关系,比较引用和近邻变化;如果用户问的是旧版本,系统仍能找到旧证据或明确说明已过期。
最后生成一张数据卡:来源、许可、解析器版本、清洗规则、分块策略、Embedding manifest、索引 snapshot、权限范围、新鲜度 SLA 和删除方法。数据卡会成为第 10 章后面所有检索和生成实验的共同前提。
| 任务 | 交付物 | 合格标准 |
|---|---|---|
| 原文审计 | hash、许可、抽检 | 来源和版本可追溯 |
| 解析 | Block JSON | 顺序、标题、表格正确 |
| 分块 | chunk 表和父子关系 | 边界可解释、范围可回查 |
| 元数据 | document card | 权限、新鲜度、版本齐全 |
| 异常 | quarantine 清单 | 坏文档不会静默发布 |
| 更新 | 增改删回放 | 索引与缓存一致 |
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 列表。
用一份含表格和标题的资料跑完整管道,提交原文、Block、chunk、metadata、异常清单和索引 snapshot;让同伴点击一个 chunk 回到原文位置。
课后复习与易错:分块不是切字符串
易错一:按固定字符数切所有文档。标题、表格、代码和 FAQ 的最小证据单元不同,先识别结构再选边界。
易错二:把 overlap 当质量开关。重叠只能缓冲边界,过大会制造重复和成本;用跨边界问题集校准。
易错三:丢掉标题、页码和来源。没有这些字段,检索结果无法解释,答案引用也无法回查。
易错四:把 OCR 文本当作原文。数字和列关系可能被识别错误,保留图片、置信度和人工校对状态。
易错五:把权限放在 Prompt。模型看到不可见文本后再拒答已经太晚,服务端必须先过滤。
易错六:只保存当前版本。政策、价格和代码会更新,旧版本、hash 和删除记录决定能否复现历史回答。
易错七:解析异常静默跳过。坏文档应进入 quarantine 并告警,否则“没检索到”会被误解为资料不存在。
易错八:只统计 chunk 数量。覆盖率、长度分布、引用完整度、权限和新鲜度更能说明管道是否可用。
| 问题 | 回答应包含 | 最小证据 |
|---|---|---|
| 为什么保留 Block? | 格式无关、可回查 | 解析抽检 |
| chunk 怎样定边界? | 证据单元、Token、任务 | 策略对照 |
| overlap 有什么代价? | 重复、存储、上下文 | 扫描实验 |
| 权限放在哪? | 服务端先过滤 | 跨租户回放 |
| 版本怎样追踪? | hash、manifest、snapshot | 引用回查 |
| 解析失败怎么办? | quarantine、告警、人工 | 坏文档清单 |
把文档从 received、parsed、cleaned、chunked、embedded、indexed、active 到 retired/deleted 画成状态机,为每次转移写触发条件、日志和回滚办法。
文档类型决定分块策略
同一套固定字符数不适合所有资料。政策文档需要保留章节、条款和例外关系;FAQ 更适合问题—答案成对;表格要保留表头、单位和行列语义;代码要保持函数、类和注释边界;幻灯片还要保存页码、标题和讲者备注。先把文件解析成结构化 Block,再根据任务选择 chunk 边界。
表格是最容易被破坏的结构之一。把每一行单独切开会丢失列名,把整张大表塞进一个 chunk 又可能超出上下文。可保存“表名、表头、行范围、单位、原文位置”的 metadata,并生成一份适合检索的行级文本;答案引用时仍回到原始表格。
代码资料要同时服务精确搜索和语义搜索。函数名、错误码和参数名需要 lexical 通道,解释文字和使用场景可以用向量通道。分块时保留文件路径、符号名、行号和语言;不要为了短小而截掉 import、类型定义或异常处理上下文。
OCR 结果必须带置信度和页面坐标。数字、小数点、负号和表格列错位都会改变事实;低置信度块进入 quarantine 或人工校对,不应该悄悄进入正式索引。解析器升级时用同一批扫描件做 diff,检查文本、页码和字段是否稳定。
分块实验要包含跨边界问题。例如问题需要标题中的适用范围和下一段的例外条款,固定窗口可能把二者分开;overlap、父子 chunk 或标题路径可以缓冲边界,但应通过支持率和引用完整度验证,而不是凭经验设置。
| 文档 | 边界线索 | 建议字段 | 抽检重点 |
|---|---|---|---|
| 政策/规章 | 章节、条款、例外 | 生效日、地区、版本 | 否定词和适用范围 |
| FAQ | 问题—答案对 | 问题 ID、产品、语言 | 答案是否完整 |
| 表格 | 表头、行列、单位 | 表名、行范围、单位 | 列关系和数字 |
| 代码 | 文件、符号、行号 | 语言、路径、commit | 可运行性和错误码 |
| 扫描/OCR | 页码、坐标、置信度 | ocr_score、图片 hash | 数字和版式 |
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 和原文范围;当一个答案引用错误时,可以沿这些字段反查是哪一次解析或分块改变了内容。
各选一份政策、表格和代码文件,分别写出 Block Schema、chunk 边界、overlap、父子关系和抽检清单。用同一组跨边界问题比较固定窗口、结构化分块和父子检索。
当你能让读者从一个 chunk 点击回原文,并看到它为何被切在这里、属于哪个版本、谁可以看、何时失效,分块才真正成为检索基础设施,而不只是预处理脚本。
做一次“反向引用”练习:从答案中的一条数字或限制条件出发,找到对应 chunk、父文档、页码或字符范围,再检查它是否仍在当前版本和权限范围内。反向路径走不通时,优先修复 metadata 和状态,而不是让生成模型补一句解释。
分块策略也要能解释失败。记录一个被切断的条款、一个表头丢失的行、一个代码依赖缺失的函数和一个 OCR 数字错误;为每个失败写最小修复与回归样本,下一次解析器升级时重跑。
05 · RETRIEVAL
查询与召回:改写、过滤和重排
用户问题通常不是可以直接检索的完美查询。系统要判断意图、补全实体、拆分子问题,再用向量、关键词、元数据过滤和重排组合出证据。本节把召回链路拆成可测试的阶段。
10.5.1 查询理解与改写
查询理解先回答“用户到底想找什么”。问题可能缺少主语、时间、版本或文档范围,也可能把多个任务混在一句话里。把查询拆成意图、实体、时间、约束和期望输出,再决定检索策略;不要一上来让生成模型自由改写,因为改写可能加入原问题没有的事实。
最小查询规范化包括空白、大小写、全半角、同义词和日期格式,但要谨慎处理代码、产品 ID、数字和否定词。把“v2.1”改成“v2.10”会造成严重错误;把“不支持”删除会反转政策。规范化规则需要按语言和字段测试,并保存原查询与规范化结果。
查询改写可以生成多个视角:用户口语、关键词串、正式术语、英文翻译或假设的文档标题。多查询提高召回机会,也会增加调用次数、成本和噪声。每个改写都记录来源和用途,最终候选要去重、重排并回到原问题验证,而不是把所有改写结果直接拼进上下文。
多轮对话中的“它”“上一个版本”“那条规则”需要借助历史消歧。系统应把当前问题与必要历史压缩成检索查询,但不要把整段聊天无条件送入索引。保留用户明确说过的实体和约束,删除闲聊、秘密和无关上下文;查询重写的结果可让用户确认。
复杂问题可以拆成子问题并行召回,例如先找政策定义,再找例外,再找生效日期。拆分要保留父问题 ID 和依赖关系;如果第二个子问题依赖第一个结果,应串行执行并记录中间证据。拆分越多不一定越好,过度拆分会产生相互矛盾的片段。
HyDE 或假设文档方法让模型先生成一段可能的答案,再用它编码检索。这对抽象问题可能有帮助,但假设文本本身可能带来幻觉和偏见。把假设只当作召回通道之一,保留原查询通道;高风险任务不应把未经核验的假设当作证据。
改写只改变检索表达,不应偷偷改变用户意图;所有中间结果都要可回放。
| 策略 | 解决的问题 | 代价 | 适用边界 |
|---|---|---|---|
| 规范化 | 格式、同义词、日期 | 规则维护 | 数字和否定需保护 |
| 历史消歧 | 指代和上下文 | 隐私、窗口 | 只保留必要历史 |
| 多查询 | 表达覆盖不足 | 调用和噪声 | 候选去重 |
| 子问题 | 多个意图 | 依赖与编排 | 保存父子关系 |
| HyDE | 抽象问题召回 | 假设幻觉 | 只作辅助通道 |
| 关键词抽取 | 实体、编号、代码 | 词表维护 | 和 dense 互补 |
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 怎么退款?", ["退款规则"]))示例只产生可解释的候选,不直接调用模型。真实改写器要限制长度、保留数字和否定、记录提示版本,并把改写结果与原问题一起交给后续过滤。高风险任务可以要求用户确认改写后的实体和时间。
收集 20 条包含指代、日期、否定、编号和多意图的问题,记录原话、结构化字段、改写版本、召回结果和人工是否认可。
10.5.2 召回、重排与证据选择
召回阶段追求覆盖,重排阶段追求顺序。先用便宜的 dense、BM25 或过滤得到几十到几百个候选,再用 cross-encoder、规则或轻量模型逐对读取查询和文本,判断更细的相关性。重排器比双塔更准确但更慢,候选数量是质量与延迟的主要旋钮。
重排特征可以包括向量分数、关键词命中、标题匹配、版本新鲜度、来源可信度、实体一致性、文档权限和父子关系。每个特征都要知道方向和范围;未经归一化的分数直接相加会让某一通道支配结果。先做简单加权和离线对照,再考虑学习排序。
证据选择不能只看单条相关性。一个回答可能需要定义、条件、例外和示例四类片段;单条最高分文本未必覆盖全部声明。可以按事实单元、标题路径或文档配额选择互补证据,再由生成阶段判断是否足够。被舍弃的候选保留在调试日志中。
重排器也会学到偏见和捷径。长文本可能因为包含更多词而得分高,标题中有问题词的无关文档可能被偏爱,某个来源格式可能被当作可信。用长度、来源、语言、版本和难负例切片,检查重排是否在真正判断相关性。
重排失败时要有回退:模型超时可以使用 dense 排名,返回异常可以只保留安全白名单,候选不足可以扩大查询或请求澄清。回退路径不能绕过权限、版本和删除过滤;任何回退都记录原因,方便评估其质量损失。
证据选择还要考虑引用可用性。只有能回查原文、版本和范围的候选才能进入最终上下文;缺少来源的高分片段应降权或阻止。把“相关”“可引用”“可公开”分成三个状态,避免模型引用一段内部备注或过期缓存。
| 阶段 | 输入/输出 | 优先指标 | 失败回退 |
|---|---|---|---|
| 过滤 | 用户范围→可见集合 | 零越权 | 阻止查询 |
| 粗召回 | query→候选 100 | Recall@100 | 关键词/扩大 k |
| 重排 | 候选→排序 20 | nDCG/MRR | dense 排序 |
| 证据选择 | 排序→最终 5 | 支持率/多样性 | 保守少返回 |
| 上下文预算 | 证据→Token 包 | 引用覆盖/成本 | 分层摘要 |
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、难负例和最终答案支持率评估。重排器越复杂,越要保存特征、版本和失败样例。
对同一候选集比较 dense、关键词、规则加权和一个可选 cross-encoder,报告 nDCG、Recall@10、p95 延迟、候选 Token 和三个排序反例。
10.5.3 多路检索与结果融合
混合检索把不同通道的优点组合起来:BM25 擅长精确词面,dense 擅长同义改写,字段过滤处理权限和版本,知识图谱或 SQL 处理结构关系。通道之间的分数通常不可直接比较,常用排名融合、分数校准或学习排序。融合策略要能解释每条证据来自哪一条通道。
倒数排名融合 RRF 的直觉是:某个文档在多个通道都靠前,就获得更高的综合分;单个通道的极端分数不会完全支配结果。融合前先统一过滤、去重和 tie-breaker;同一 doc 的多个 chunk 可以先在文档内聚合,再参与跨文档排序。
通道权重应由查询类型决定。含有产品编号、日期、错误码的查询可以提高 lexical 权重;口语化、跨语言和概念问题可以提高 dense 权重;明确要求最新版本时必须先用 metadata 过滤。分类器判断查询类型也可能错,因此保留一个安全的默认通道和回退策略。
多路结果会增加上下文冲突。不同来源可能讲同一事实却适用不同版本,dense 找到概念解释、BM25 找到旧编号。融合后按版本、来源和标题路径聚合,生成阶段显示冲突并要求核验;不要把所有通道输出直接串在一起。
缓存也要区分通道和版本。查询规范化、模型 manifest、索引 snapshot、过滤条件和用户权限都进入 cache key;改动任意一个就不能复用旧候选。缓存命中后仍可做轻量权限复核,防止角色变化带来泄露。
当某通道不可用时,系统应明确降级。向量服务超时可以走关键词,关键词索引更新中可以走旧 snapshot,但要在答案或日志里说明证据范围。高风险问题若只剩低召回通道,宁可拒答或人工接管,也不要用流畅生成掩盖检索缺失。
| 通道 | 擅长 | 弱点 | 融合前检查 |
|---|---|---|---|
| BM25/词面 | 编号、实体、否定 | 同义改写弱 | 分词和字段 |
| Dense | 语义、跨语言、口语 | 数字和版本易混 | 模型契约 |
| Metadata | 权限、时间、类型 | 不能发现语义 | 权威字段 |
| SQL/图 | 关系、聚合、状态 | 文本覆盖有限 | 查询安全 |
| 重排 | 精细相关性 | 耗时、需要候选 | 候选规模 |
| 规则回退 | 可用性和安全 | 覆盖窄 | 低分动作 |
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 之后重排,但要保存每个候选的通道来源和排名,遇到错误时能回答“是哪条通道把它带进来的”。
用同一 gold 集比较 dense、BM25、RRF 和规则过滤四种结果;为编号、同义、否定、版本和权限五类查询分别写出最佳通道和降级动作。
本节深挖:检索链路每一步都可能改变答案
查询改写改变的是搜索表达,不能改变用户事实。把改写结果回传给用户或写入日志,检查实体、时间、否定和权限是否保留;模型生成的“更完整问题”如果加入了原文没有的条件,应被标记为假设。
召回与重排的职责不同。召回要尽量不漏,重排要尽量把正确证据放前;如果 gold 文档根本没有进入候选集,重排再强也无能为力。评估时分别报告 Recall@candidate、nDCG@final 和最终答案支持率。
候选越多不一定越好。过多候选提高召回,却增加重排成本和上下文冲突;候选太少则可能漏掉例外。用预算约束决定候选和最终证据数量,按任务和风险切片校准。
重排器读取查询和文本,容易受到长度、标题和格式捷径影响。设计难负例时保持主题相似,只改变关键事实、版本或权限,才能发现它是否真的理解相关性。
融合时不要把来源权重隐藏在一个总分。记录每条通道的排名、分数、过滤原因、去重关系和最终贡献;解释失败时,先定位通道,再决定修复模型、规则或数据。
查询缓存必须包含权限和 snapshot。相同文字不代表同一个可见集合;缓存键过短会造成跨用户泄露,过长会失去命中率。安全优先,命中率只在边界正确后优化。
通道降级要有质量预算。关键词回退可能仍能处理编号,但语义问题 Recall 下降;向量回退到旧 snapshot 可能包含过期政策。把降级状态写入答案元数据,让用户和运营者知道证据范围。
多路检索也需要可恢复性。每条子查询和通道调用有超时、重试和断路器,连续失败后停止,不要让 Agent 无限增加查询。失败事件和最终候选一起保存,后续才能评估可靠性。
| 阶段 | 要回答的问题 | 关键证据 | 失败动作 |
|---|---|---|---|
| 解析 | 意图和实体是什么? | 结构化查询 | 澄清 |
| 改写 | 是否改变原意? | 原文/改写 diff | 阻止或确认 |
| 过滤 | 用户能看什么? | ACL/版本 | 只返回可见集合 |
| 召回 | gold 是否进入候选? | Recall@k | 换通道/扩大候选 |
| 重排 | 正确证据是否靠前? | nDCG/难负例 | 回退/重训 |
| 融合 | 哪些通道贡献? | origin/rank | 降级并标记 |
选一条真实查询,画出原话、改写、过滤、各通道候选、重排、去重和最终证据;标注每一步的输入、输出、版本、超时和失败动作。
课后复习与易错
易错一:查询改写越长越好。长改写可能引入假设;保留原查询并做 diff。
易错二:把候选召回和最终证据混为一谈。候选为覆盖服务,重排和预算决定最终上下文。
易错三:直接相加不同通道分数。先做排名融合或校准,否则某通道会支配结果。
易错四:没有 gold 集就调重排。没有正确候选,重排无法挽救漏召回。
易错五:过滤放在生成后。模型看到了不可见资料,事后拒答已经不安全。
易错六:忽视通道降级。向量超时、旧索引和关键词回退都应标记并评估。
章节任务卡:把用户问题变成证据候选
建立一个小型查询集,至少覆盖口语、指代、多意图、日期、否定、数字、内部缩写、跨语言和资料不足。为每条问题写出 gold 文档或 gold 证据单元,允许多个正确来源,并注明关键声明。
先运行原查询,再运行规范化和多查询改写。保存每个版本的文本、结构化实体、时间、关键词、Embedding manifest 和调用耗时。人工检查改写是否改变了意图,尤其是否定、版本号和用户限定条件。
将候选链拆成过滤、dense、BM25、SQL 或规则几个通道。每个通道独立记录候选 ID、排名、分数、过滤原因和 snapshot,再用 RRF 或简单加权融合。这样出现错误时,你可以知道是哪个通道带来了错误文档。
用一个轻量重排器或规则把候选缩到最终证据。为每个保留的 chunk 记录相关性、来源、版本、权限、标题路径和预计 Token;为被舍弃的候选写出原因。最终证据必须能回查原文,不能只留一段复制文本。
为查询服务设置超时、重试和降级。向量服务不可用时走关键词,重排超时时使用粗召回,但在事件中标记 degraded;高风险或低分问题不能因为降级就继续生成,应澄清或接管。
做一轮难负例回归。把词面相似但版本、否定、实体或权限不同的文档加入数据集,检查重排器和融合是否把它们放到前面。每修复一个案例,再加入一个邻近变体,防止规则只记住原句。
最后写一页检索决策说明:什么查询走什么通道,什么条件触发改写、拆分、重排、澄清或拒答,候选和最终上下文各有多少预算。说明中引用 Recall、nDCG、支持率、延迟和成本,避免“感觉更准”的结论。
| 任务 | 交付物 | 合格标准 |
|---|---|---|
| 查询集 | 多类型问题+gold | 有否定、数字、版本和资料不足 |
| 改写 | 原话/改写 diff | 意图不被偷偷改变 |
| 多路召回 | 通道候选和来源 | 权限、版本先过滤 |
| 重排 | 排序与难负例 | 相关证据靠前 |
| 降级 | 超时和回退日志 | 高风险不绕过门槛 |
| 决策说明 | 规则、指标、预算 | 别人可复算 |
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 是调试和学习的共同材料。它把用户原话、改写、过滤、通道、融合和最终证据放在同一事件里,后续可以计算每条通道的贡献、失败率和成本。敏感查询要脱敏或做访问控制。
选择一个真实问题,保存原话、改写、过滤、各通道候选、融合、重排、最终证据、Token、延迟和失败动作;让同伴只凭 trace 解释答案来自哪里。
排错与复盘:从错误排名定位根因
如果 gold 文档不在候选中,先检查查询改写、文本分块、Embedding 模型和索引 snapshot;不要先调生成 Prompt。候选阶段已经漏掉证据,后面的模型无法凭空恢复。
如果 gold 在候选却没有进入最终上下文,检查重排特征、去重、版本过滤、文档配额和 Token 预算。保留候选到最终证据的 diff,能迅速定位是哪条规则删掉了它。
如果最终证据正确但回答引用错,问题在上下文编排、引用 ID、模型模板或解析器;如果证据本身错,问题在召回或数据管道。把检索质量和生成质量分开评估。
如果改写让 Recall 上升但支持率下降,说明引入了噪声或假设。保留原查询通道,要求改写只作为候选扩展,并按风险决定是否让用户确认。
如果融合让平均分变好但数字和版本变差,检查通道权重和精确字段。对高风险实体和编号,词面或结构化查询应有优先级。
如果重排延迟超预算,先减少候选、使用轻量模型、批处理或缓存,再考虑更强重排器。降低延迟不能绕过 ACL、版本和删除过滤。
如果同一查询结果不稳定,保存 query hash、改写、通道、snapshot、tie-breaker 和缓存键。稳定性问题可能来自数据更新,而不是模型随机性。
每次修复都加入回归:原失败、同义改写、数字变体、否定变体和权限变体。只修原句会让系统在下一条相似问题上再次失败。
| 现象 | 优先检查 | 证据 | 安全动作 |
|---|---|---|---|
| gold 不在候选 | 改写、分块、Embedding、索引 | 候选 trace | 扩大通道/澄清 |
| gold 在候选却被删 | 重排、去重、预算 | candidate diff | 保守保留 |
| 证据正确、引用错 | 模板、ID、解析 | 原始上下文 | 阻止发布 |
| 改写引入噪声 | 假设、历史、HyDE | rewrite diff | 回到原查询 |
| 重排超时 | 候选数、批次、模型 | 分段计时 | 有限降级 |
| 结果不稳定 | cache/snapshot/tie | 回放事件 | 锁版本或回滚 |
课后复习与易错
易错一:改写越多越好。改写会增加成本和噪声,必须和原查询并行、可回放并检查意图。
易错二:重排可以挽救所有问题。正确证据若没进入候选集,重排无能为力;先看 Recall。
易错三:把各通道的分数直接相加。分数范围不同,先做校准或使用排名融合。
易错四:让模型决定过滤和权限。权限由服务端和权威元数据决定,模型只能提出查询。
易错五:候选越多越可靠。过多候选会增加冲突和成本;按任务和风险校准预算。
易错六:忽略被丢弃的候选。候选到最终上下文的 diff 是定位漏证据的重要证据。
易错七:只评估最终答案。分开报告召回、重排、引用和生成,才能知道修复哪一层。
易错八:降级时继续假装正常。关键词回退、旧 snapshot 和重排超时都应标记并触发相应门槛。
| 检查 | 通过证据 | 不通过动作 |
|---|---|---|
| 意图保持 | 原话/改写人工确认 | 停止自动改写 |
| 候选覆盖 | Recall@candidate | 补通道或数据 |
| 排序质量 | nDCG/难负例 | 调整重排 |
| 来源可追溯 | chunk/版本/权限 | 阻止入上下文 |
| 降级可解释 | trace 与状态码 | 澄清或接管 |
完成本节后,你应能从一条最终引用反查到原查询、改写、过滤、召回通道、重排特征、索引 snapshot 和降级状态。下一节将在这条证据链上加入上下文编排和受约束生成。
检索 trace:把候选生命周期记录下来
一条可诊断的检索 trace 至少包含 query_id、原查询、规范化查询、改写列表、过滤条件、每个通道的候选、去重结果、重排分数、最终 context_ids 和降级状态。不要只保存最终的五个 ID;中间被丢弃的候选往往才是发现根因的关键。
查询改写要有“意图保持”检查。把历史对话中的代词补全、时间词和产品名记录为结构化字段;如果改写增加了原问题没有的假设,应该并行保留原查询,或要求用户确认。HyDE 生成的假设文档只能作为召回线索,不能直接当证据。
多路召回的合并应区分候选来源。一个 chunk 同时出现在 BM25、dense 和标题匹配中,可以增加排序信号,但不能把它当三份独立证据。保存 source_channels,并在最终上下文里按事实单元和来源多样性做配额。
重排器输入的字段也要审计。模型看到的文本、标题、查询、版本和 ACL 摘要必须与上线协议一致;禁止把不可见字段或未来标签混入离线评估,否则会产生数据泄漏,线上表现必然回落。
“检索失败”不是一个状态。至少区分没有可见候选、候选低于阈值、gold 被过滤、重排超时、索引不可用、查询为空和标注不完整。不同状态需要不同用户提示和修复负责人,统一成一个空列表会掩盖问题。
| 阶段 | 输入 | 输出 | 审计问题 |
|---|---|---|---|
| 规范化 | 原查询、历史 | canonical query | 是否改变意图 |
| 改写 | 模板、术语 | queries/assumptions | 是否引入新事实 |
| 召回 | 索引、过滤 | 候选与分数 | gold 是否进候选 |
| 融合 | 多通道候选 | 统一排名 | 分数是否可比较 |
| 重排 | 候选文本、查询 | rerank score | 是否泄漏字段 |
| 编排 | 预算、风险 | context_ids | 为何保留/截断 |
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 和访问审计;调试日志不能成为新的数据泄露面。
选一条失败查询,提交原查询、两种改写、三条召回通道、过滤原因、重排前后列表、最终截断结果和用户动作。为每个被丢弃的候选写出可验证的理由。
检索的工程质量体现在可解释的中间状态:你不只知道“答案错了”,还知道 gold 没有进候选、进入后被删掉、被错误重排,还是被上下文预算截断。下一节将继续追踪这些状态如何影响生成和拒答。
06 · RAG GENERATION
RAG 生成:让回答有证据、有边界
检索到资料不等于答案可靠。生成阶段还要控制上下文顺序、引用格式、冲突处理和资料不足时的拒答。本节把 RAG 看成“检索证据加受约束生成”的协议,而不是把片段简单拼到 Prompt 里。
10.6.1 上下文编排与 Prompt 契约
RAG 请求通常包含系统规则、用户问题、对话历史、检索证据、工具结果和输出 Schema。它们的信任等级和职责不同:系统规则定义边界,用户提供任务,证据提供可核验资料,工具返回外部状态。用清晰的分隔符和字段名区分这些区域,告诉模型证据是数据而不是指令。
上下文编排首先解决优先级。系统规则和安全策略放在稳定位置,当前问题明确,证据按来源和相关性排列,历史只保留必要内容;不要把无关聊天和未审查网页混在同一个长字符串。顺序不是绝对规则,应在自己的模型和模板上做位置实验。
每个证据块带上 chunk_id、标题、来源、版本、文本和可信状态。引用 ID 不要只用数组位置,因为排序或去重变化会导致“[1]”指向另一段;用稳定 ID,并让模型在输出中回传这些 ID。系统随后验证 ID 是否存在、是否属于当前用户可见集合。
上下文预算要分层分配。先预留系统和输出 Schema,再为问题和必要历史留空间,剩余预算才给证据;长证据可以摘要,但摘要也要带来源并标记“二次压缩”。达到上限时应告诉用户资料被压缩或请求更具体的问题,不要静默截掉关键条件。
证据冲突要显式呈现。不同版本、地区或角色的政策可能互相矛盾;把冲突片段分别标注版本和生效时间,要求模型说明适用范围或请求澄清。不要简单按相似度选一个,因为最高分不一定是最新或最权威。
RAG Prompt 不是越长越专业。规则重复、示例过多和低相关证据会稀释注意力并增加成本。每一条说明都要回答“它防止哪个失败”;没有用途的装饰性提示删掉。把模板作为版本化文件,用固定请求回放验证格式、引用和拒答。
生成模型负责提出回答,协议和校验器负责限定它能声称什么。
| 区域 | 允许包含 | 不应包含 | 校验方式 |
|---|---|---|---|
| 系统规则 | 角色、边界、失败动作 | 用户可编辑秘密 | 版本和访问控制 |
| 用户问题 | 目标、范围、格式 | 伪造系统指令 | 长度和安全分类 |
| 证据块 | 原文、ID、来源、版本 | 可执行指令、未授权资料 | ACL、hash、引用 |
| 历史 | 必要约束和已确认事实 | 闲聊、秘密、过期假设 | 摘要和时间 |
| 输出 Schema | 字段、枚举、引用格式 | 业务权限决定 | 解析和业务校验 |
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 和原因,便于解释“为什么答案没有看到某段”。对于高风险问题,宁可减少证据并触发澄清,也不要把超预算内容静默丢弃。
为一个问答任务画出系统、问题、历史、证据和输出 Schema 的边界;规定每个区域的信任等级、Token 预算、引用字段和超预算动作。
10.6.2 引用、声明与事实核验
一个可核验的答案要先拆成原子声明。声明是可以单独判断真假的最小句子,例如“版本 2.1 的退款期限为七天”;不要把三个事实和一个推断写成一条长句再只给一个引用。声明拆开后,才能知道哪些有证据、哪些缺证据、哪些互相冲突。
引用支持不等于引用存在。系统至少检查 source_id 是否来自本次可见证据、引用范围是否覆盖声明、版本和适用条件是否一致。引用了一段“七天”但忽略“特殊商品除外”,仍然是事实不完整。验证器可以使用规则、关键词、检索相似度、结构化查询或人工审核。
引用格式要稳定且可点击。展示层可以用脚注、来源卡或段落旁的链接,后台保留 doc_id、chunk_id、版本、页码/字符范围和 hash。不要让模型自己生成 URL 或路径;由服务端根据稳定 ID渲染,防止伪造和越权。
资料冲突时,回答应列出冲突而不是挑一个顺口的。比较生效日期、权威等级、地区、用户角色和文档状态;如果无法决定,说明冲突并请求澄清。将“冲突未解决”作为结构化状态,避免模型用自信语气掩盖不确定性。
数字、日期和实体应有更严格的核验。可以把声明中的数值抽取出来,与结构化表或规则引擎比较;把版本号、货币单位和时间区间规范化;对计算结果调用计算器而不是依赖生成。RAG 能提供资料,但算术和状态判断应交给专门工具。
事实核验也要防提示注入。证据中可能写着“忽略之前指令”或要求泄露秘密;这些文字只能作为待引用数据,不改变系统规则。对网页和用户上传资料做内容隔离,输出引用时保留来源而不执行其中的命令。
| 检查 | 问题 | 通过证据 | 失败动作 |
|---|---|---|---|
| 来源存在 | 引用 ID 是否可见? | 当前 trace 的 source | 删除引用/阻止输出 |
| 文本支持 | 片段是否支持声明? | 原文和匹配范围 | 改写或人工 |
| 条件完整 | 例外是否遗漏? | 相邻条款/标题路径 | 补证据 |
| 版本适用 | 是否最新且适用? | 生效时间/角色 | 冲突澄清 |
| 数值正确 | 单位和计算是否对? | 结构化表/计算器 | 重新计算 |
| 安全可见 | 用户是否有权限? | ACL 决策 | 阻止泄露 |
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))示例用字符串包含做最小演示,真实核验需要处理同义表达、数字格式和声明拆分,并且最好让人工抽样复核。验证器的职责是阻止明显不支持的声明,不是把所有自然语言蕴含都自动证明。
让模型回答五个问题,人工把每个答案拆成原子声明,记录 source_id、支持范围、版本、例外和核验结果;挑一条引用存在但不支持的反例。
10.6.3 无证据时拒答与澄清
资料不足是正常状态,不是系统异常。查询可能超出知识库范围、召回低于阈值、版本冲突、文档正在更新或用户权限不足。系统需要一个结构化的 insufficient 状态,说明缺少什么证据、可以补充什么资料、是否能改问法,而不是让模型用通用知识填空。
拒答有不同等级。低风险问题可以返回“当前资料没有找到”并给出已检索范围;中风险问题可以请求文档、时间和地区;高风险问题在证据不足时直接停止并转人工。拒答文案应具体、简洁,不泄露隐藏策略、被过滤的文档或内部路径。
澄清问题也需要预算和优先级。一次只问最能缩小搜索空间的条件,例如“你指的是版本 2.1 还是 2.2?”而不是连续追问五个字段。若用户不补充,系统可以给出基于当前资料的有限答案,并明确哪些部分未确认。
“没有证据”与“证据证明没有”不同。检索不到资料只能说明当前可见知识库未找到,不代表现实中不存在。回答中使用“在提供的资料范围内”这样的边界表述,避免把检索失败说成绝对否定。若有负面证据,也要引用其适用范围。
拒答与用户体验可以共存。返回已找到的相关标题、下一步检索建议和人工入口,但不要把低相关片段当作答案。对代码、财务和医疗等高风险任务,明确建议用户核对权威来源;系统的目标是减少误导,而不是每次都给完整段落。
拒答率要和误拒率一起评估。过低可能代表幻觉,过高可能代表知识库、阈值或查询改写有问题。按任务、语言、风险、版本和用户群切片,记录触发原因和最终解决率,才能判断是模型需要改进还是资料管道缺陷。
| 状态 | 判定 | 用户看到什么 | 后续动作 |
|---|---|---|---|
| 无候选 | 过滤后为空 | 说明检索范围 | 改问法/补资料 |
| 低相关 | 最高分低于阈值 | 不生成事实答案 | 澄清或人工 |
| 冲突 | 多个版本不一致 | 列出冲突条件 | 确认版本/地区 |
| 权限不足 | 资料不可见 | 不透露文档存在 | 申请授权 |
| 过时 | 资料超 freshness SLA | 提示更新时间 | 更新索引 |
| 高风险 | 风险规则触发 | 停止并转人工 | 记录接管 |
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 是证据协议,不是 Prompt 拼接
RAG 的端到端质量由多层共同决定:文档是否合法可追溯,分块是否保留最小事实,Embedding 是否找得到,过滤是否安全,重排是否把证据放前,上下文是否完整,模型是否遵守引用协议,验证器是否阻止不支持声明。任何一层失败,都可能在最终答案中表现为“模型胡说”。
上下文顺序和证据数量应通过实验选择。把同一事实放在开头、中间和结尾,比较支持率;把证据数量从 1 增到 10,观察召回、冲突、Token 和成本;把正确证据与提示注入混合,验证规则是否仍然优先。实验结果写入 manifest,不要靠记忆。
引用是用户信任与工程审计的共同接口。用户需要知道答案依据哪份资料,开发者需要重放和修复,合规人员需要确认权限和许可。稳定 ID、版本、范围、hash 和可点击来源缺一不可;“引用了一段文本”本身不够。
声明级核验让指标更细。答案可以有 90% 的句子被支持,却在关键数字上错误;也可以只说两句但全部正确。把关键声明、支持率、覆盖率、冲突和人工复核分开报告,并按风险给不同门槛。
资料不足时拒答是正向能力。训练和评估数据要包含“没有答案”“版本冲突”“权限不可见”和“需要工具”的样例,奖励模型不要只奖励完整长文。用户体验通过明确下一步、可查询范围和人工入口改善,而不是让系统编造。
Prompt 注入防护靠分层,而不是一句提示。把文档标记为 data,使用明确分隔符,限制工具权限,服务端校验引用和动作;即使模型被一段恶意文本影响,也不能直接改变系统规则或执行副作用。
长上下文不是无限记忆。历史摘要、证据压缩和 Token 截断都会丢信息;把被丢弃的 chunk、摘要版本和预算记录下来。高风险问题在压缩后支持率下降时应触发澄清或人工,而不是继续生成。
评估 RAG 时把检索和生成拆开。先问 gold 是否进入候选,再问最终上下文是否包含关键证据,最后问回答是否正确引用。这样一次失败可以定位到 data、retrieval、context、generation 或 validation 阶段。
| 阶段 | 通过标准 | 保存证据 | 失败动作 |
|---|---|---|---|
| 数据 | 来源、许可、版本明确 | data card/hash | 隔离 |
| 检索 | gold 进入候选 | Recall/trace | 补通道 |
| 上下文 | 关键证据未丢 | context IDs/预算 | 重排或澄清 |
| 生成 | 格式和引用正确 | 原始输出/模板 | 阻止入库 |
| 核验 | 声明被支持 | claim table | 人工/拒答 |
| 安全 | 无越权和注入执行 | 红队回放 | 熔断 |
选择一个问题,提交原查询、检索 trace、最终上下文、回答 JSON、声明—来源对照、引用链接和资料不足时的备用响应;再故意删掉一个关键 chunk,验证系统会拒答或澄清。
课后复习与易错
易错一:把检索片段直接拼进 Prompt。必须有区域边界、来源、版本、权限和预算。
易错二:只检查引用 ID 存在。还要检查文本是否支持声明、例外是否完整、版本是否适用。
易错三:把无候选说成“现实中不存在”。检索失败只说明当前可见资料没有找到。
易错四:让文档覆盖系统规则。证据是数据,不能改变权限、工具或安全策略。
易错五:长上下文越多越好。噪声、冲突、延迟和成本都会增加,要按任务测试。
易错六:只看最终答案分数。分开评估召回、上下文、引用和声明支持。
章节任务卡:完成一次可核验的 RAG 回答
先固定一条任务契约:回答什么问题、允许使用哪些资料、必须返回哪些字段、何时拒答、预算是多少。准备一组资料充分、资料不足、版本冲突、权限不可见和包含提示注入的测试问题,避免只测试理想路径。
把检索结果编排成证据包。每个证据包含稳定 ID、标题路径、来源、版本、权限状态、文本和 Token 数;按相关性与多样性排序。保存被截断和被过滤的候选,之后才能解释为什么一个关键事实没有进入上下文。
设计结构化输出,例如 answer、claims、citations、confidence_state 和 next_action。模型可以填写候选字段,但服务器校验字段类型、引用存在、权限和业务状态;解析失败或支持率不足时阻止入库。
做声明级事实核验。把回答拆成最小可判断句,检查每条声明是否由一个或多个证据支持,是否遗漏版本、地区、条件和例外。数字与日期使用规则或计算器复核,不让流畅文案替代验证。
设计三种资料不足响应:没有候选时说明搜索范围,低分时请求补充条件,冲突时要求确认版本。高风险请求直接接管。文案不能泄露不可见文档、内部提示或策略细节。
测上下文位置和数量。把关键证据放在开头、中间、结尾,分别加入 1、3、6 个 chunk,记录支持率、冲突、Token、延迟和成本。若证据更多反而变差,优先改排序和预算,而不是盲目增加窗口。
把文档中的指令当数据处理。用一段包含“忽略规则”的网页文本做红队,确认模型不会执行其中的命令,服务端也不会因此开放工具权限。失败时阻止发布并保存原始上下文。
最后提交回答包:任务契约、检索 trace、上下文快照、原始输出、解析 JSON、声明核验表、用户展示、评估指标和回放命令。一个可审计的回答包比一张聊天截图更能证明系统确实可靠。
| 文件/字段 | 最小内容 | 用途 | 缺失后果 |
|---|---|---|---|
| contract | 输入、输出、失败、预算 | 冻结目标 | 无法验收 |
| retrieval_trace | 改写、通道、候选、过滤 | 定位漏召回 | 无法解释 |
| context_snapshot | 证据 ID、版本、Token | 回放生成 | 引用漂移 |
| raw_output | 模型原始返回 | 审计和修复 | 解析覆盖证据 |
| claim_review | 声明、来源、支持结果 | 事实核验 | 流畅错答 |
| fallback | 拒答、澄清、接管 | 边界行为 | 资料不足时编造 |
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))校验器应该在用户展示前和数据入库前各运行一次。它不是事实证明器,而是阻止明显越权、未知引用、未支持声明和状态矛盾的最后一道边界;所有错误都要进入事件日志和回归集。
完成一条正常、一条资料不足和一条提示注入请求,分别提交检索 trace、上下文、原始输出、声明核验、用户响应和阻止/接管日志。
排错与复盘:回答为什么不可靠
如果没有引用,先检查模型输出 Schema、引用字段、上下文是否带 ID 和服务端渲染,而不是马上调整 Embedding。证据存在但没有显示,通常是协议或解析问题。
如果引用存在但不支持声明,检查 chunk 是否过大、版本是否冲突、生成是否合并了多条事实、核验器是否只做字符串包含。把声明拆开,重新回查证据范围。
如果模型引用了不可见资料,立即阻断并检查过滤、缓存、snapshot、上下文组装和日志。权限问题比普通召回问题优先级更高,修复前不要继续扩大测试。
如果资料充分却频繁拒答,检查阈值、支持率规则、改写、版本过滤和评估器;把合法问题的误拒样本加入回归集。严格不是越严格越好,关键是风险等级匹配。
如果资料不足却生成流畅答案,检查 abstain 门控是否在生成前执行、模型是否被允许调用通用知识、低分候选是否被凑数。拒答策略要作为成功路径测试。
如果长上下文导致支持率下降,检查排序、证据数量、位置、摘要和截断。先减少低相关片段或使用父子 chunk,再考虑增加窗口。
如果冲突版本被合并成一个结论,检查 metadata、生效时间、来源权威等级和 Prompt 编排。系统应列出冲突或请求确认,不应替用户猜适用版本。
如果提示注入改变了工具或安全策略,检查证据隔离、工具权限、结构化输出和服务端门控。文档内容永远不能越过系统规则。
| 现象 | 优先检查 | 最小证据 | 动作 |
|---|---|---|---|
| 无引用 | Schema、ID、渲染 | raw/context | 阻止入库 |
| 引用不支持 | 声明、chunk、版本 | claim review | 修复/人工 |
| 不可见资料泄露 | ACL、cache、snapshot | 权限回放 | 熔断 |
| 过度拒答 | 阈值、过滤、评估器 | 误拒切片 | 重校准 |
| 资料不足仍回答 | abstain 门控 | 低分样例 | 拒答/接管 |
| 上下文冲突 | 版本、顺序、摘要 | context diff | 澄清 |
| 注入成功 | 隔离、权限、工具 | 红队回放 | 阻塞发布 |
课后复习与易错
易错一:把证据块直接拼到 Prompt。必须区分规则、问题、历史和资料,并为每块保留来源、版本和权限。
易错二:只验证引用 ID。引用还要支持声明,不能遗漏例外、时间、地区和单位。
易错三:资料不足时用模型常识补齐。检索不到只说明当前资料不足,不代表事实不存在。
易错四:让文档内容覆盖系统规则。网页和文件是数据,提示注入不能获得工具或权限。
易错五:把拒答率当作唯一安全指标。还要看合法问题误拒、关键风险漏放和人工接管率。
易错六:上下文越长越可靠。长输入会增加成本、冲突和位置退化;用位置与数量实验验证。
易错七:只展示最终答案截图。保存 raw output、context snapshot、claim review 和事件日志,别人才能复核。
易错八:把校验器当事实证明器。规则能阻止明显错误,复杂蕴含仍需人工或专用验证。
| 问题 | 回答应包含 | 最小证据 |
|---|---|---|
| 为什么要分区域? | 信任等级和职责不同 | context snapshot |
| 怎样证明引用正确? | 声明、支持范围、版本 | claim table |
| 资料不足怎么办? | 状态、澄清、接管 | abstain 回放 |
| 如何防注入? | 隔离、权限、服务端门控 | 红队样例 |
| 如何控制成本? | Token 预算、压缩、降级 | 预算日志 |
本节的结业标准是:你能让一个读者从用户问题点击到最终引用,再回到原文、版本、权限和检索 trace;当关键证据被删掉或变成不可见时,系统会明确拒答、澄清或接管。
回答协议:先组织证据,再允许生成
RAG 的上下文不是一段无标签的字符串,而是一份有结构的证据包。建议把系统规则、用户问题、对话历史、检索片段和工具结果分开存储;每个片段带 chunk_id、来源、版本、权限和支持范围。渲染成 Prompt 只是最后一步,原始结构应保留以便核验。
引用核验要落到声明级。先把回答拆成可检查的事实声明,再为每条声明绑定支持片段、来源和生效时间;如果一条声明需要两个片段共同支持,记录组合关系。引用放在段落末尾但不说明覆盖范围,读者仍无法判断它支持哪句话。
冲突资料要显式建模。当前政策、历史政策、不同地区规则和草稿可能都与问题相似;过滤生效时间和适用范围后仍有冲突时,回答应列出冲突、请求澄清或转人工。让模型自行“综合”会把不同条件误合并成一条看似确定的结论。
上下文预算要按风险分配,而不是只按相似度排序。定义、例外、限制和计算依据可能比一段高相似度背景更重要;高风险问题宁愿缩短回答,也不要截掉决定结论的例外条款。记录每次压缩、摘要和截断,避免支持率下降却找不到原因。
拒答是正常产品状态。无可见证据、证据过期、声明无法核验、权限不足、工具失败和多版本冲突,都应该映射到不同的状态码和用户提示;提示不泄露被过滤的标题或文本,必要时只说明“当前可见资料不足”。
| 步骤 | 产物 | 核验问题 | 失败动作 |
|---|---|---|---|
| 拆声明 | claim_id、文本 | 是否可独立判断 | 重新分句 |
| 绑定证据 | source_ids、范围 | 是否直接支持 | 降级/拒答 |
| 检查版本 | 生效日、地区 | 是否适用当前问题 | 列出冲突 |
| 生成回答 | 带引用文本 | 是否超出证据 | 删除或改写 |
| 发布后回放 | raw、trace、评分 | 是否可复现 | 加入回归集 |
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)这个小函数只检查是否存在绑定来源,并没有判断来源语义是否真的支持声明;它的价值在于建立结构化接口,让更严格的规则、人工审核或专用评估器有位置接入。任何自动核验都要把“未判断”与“已支持”区分开。
准备五个问题,其中包含资料不足、两个版本冲突、权限过滤和一个需要两段证据共同支持的回答。输出 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。
通道可以互补,但每条结果都要保留来源和过滤依据。
| 通道 | 最擅长 | 常见失败 | 最小回归 |
|---|---|---|---|
| BM25 | 精确词、编号、错误码 | 同义表达、跨语言 | 术语改写集 |
| Dense | 语义、口语、跨语言 | 数字、否定、版本 | 数字否定集 |
| 字段索引 | 标题、代码、实体 | 字段缺失 | 字段覆盖率 |
| Metadata | ACL、时间、状态 | 数据过期或错标 | 权限版本集 |
| 重排 | 候选精细顺序 | 延迟和捷径 | 难负例/压测 |
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 找到,就不要为了追求“纯语义”删除它。
为编号、同义、否定、跨语言和版本五类查询,比较 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,却增加延迟、调用次数和错误面;简单问题不应强制走多跳。路由器可以先判断问题复杂度,只有多实体、多约束或首轮证据不足时才启动迭代。
循环失败时的用户体验要明确。返回已确认的部分、缺失条件和下一步建议,或转人工;不要让用户看到一串内部思考,也不要把未验证的假设包装成结论。日志可以保存结构化的理由,但外部文案只呈现必要信息。
| 状态 | 允许动作 | 停止条件 | 记录字段 |
|---|---|---|---|
| plan | 拆分查询、估计预算 | 不安全或范围过大 | plan_id、理由 |
| retrieve | 调用只读检索 | 超时、权限失败 | query、snapshot |
| inspect | 读候选、提取实体 | 无新证据 | evidence_id、hypothesis |
| refine | 提出下一查询 | 达到步数/Token | parent_query |
| answer | 生成引用或拒答 | 支持率不足 | claims、sources |
| handoff | 转人工或请求确认 | 人工完成 | reason、actor |
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、人工审批和日志脱敏。
选择一个需要两跳检索的问题,画出 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;做一条更新前后查询,确认新版本出现、旧版本按规则处理、已删除内容不再可见。
| 事件 | 索引动作 | 缓存动作 | 验收 |
|---|---|---|---|
| 新增 | 写新 chunk | 必要时预热/失效 | 新问题可召回 |
| 修改 | 新版本+旧版 retired | 失效相关 query | 版本过滤正确 |
| 删除 | tombstone/移除 | 强制清理 | 原文和向量不可见 |
| 权限撤销 | 更新 ACL | 按租户失效 | 交叉租户回放 |
| 模型升级 | 新 manifest/新索引 | 切换 key | 近邻回归 |
| 队列失败 | 保留失败批次 | 不发布半成品 | 断点重跑 |
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。生产监控还要按租户、文档类型和失败状态分层,发现某类资料长期延迟时,优先修复队列或解析器,而不是让模型承担过期风险。
修改一个政策段落、撤销一个租户权限、删除一个文档;记录全链路时间、旧新版本、缓存和索引状态,验证三类查询不会返回错误内容。
本节深挖:复杂度必须有可证明的收益
混合检索不是默认答案。先看错误分布:如果 80% 的问题是精确编号,BM25 已经能解决大部分;如果用户常用口语和同义表达,dense 才有明显增量。用任务切片和边际收益决定通道,而不是因为工具支持就全部打开。
RRF 是透明的排名基线,适合解释通道贡献;学习排序可以更强,但需要标注、特征版本和防泄漏。无论选择哪种,保留每个通道的候选和排名,避免最后只剩一个黑盒分数。
多跳 Agent 适合关系链和证据缺口明显的问题。简单问题走单次检索,复杂问题才启动循环;状态机的预算、权限和停止条件比模型“思考得多不多”更重要。
中间假设不能直接变成事实。每跳都要回到可引用来源,记录父查询和证据;当候选冲突或没有新证据时停止、澄清或接管。这样多跳不会把早期猜测无限放大。
新鲜度是数据、索引、缓存和回答共同的属性。一个索引已更新但缓存未失效的系统仍然过期;一个缓存已清除但文档未入索引的系统仍然缺资料。把时间线和版本写进事件。
删除和权限撤销比新增更敏感。新内容漏掉是质量问题,已删除内容仍能看到可能是合规和安全事故;测试顺序应先做删除、越权和旧版本回放。
复杂系统要准备降级。向量服务、重排器、队列或 Agent 调度不可用时,选择安全的词法或旧 snapshot,并明确标记能力下降;高风险问题在降级下应拒答或人工。
每次引入复杂通道都写出“为什么值得”。指标提升、延迟变化、成本变化、维护职责和回滚办法都在同一张决策表里;没有证据的复杂度不是能力,而是风险。
| 能力 | 最小实验 | 上线门槛 |
|---|---|---|
| 混合检索 | BM25/dense/RRF 对照 | 有明确增量 |
| 多跳流程 | 两跳+失败分支 | 步数、Token、费用受限 |
| 版本更新 | 增改删回放 | 旧版和删除规则正确 |
| 新鲜度 | 时间线压测 | SLA 可监控 |
| 降级 | 服务故障注入 | 不绕过权限和风险门槛 |
为你的知识库选择是否加入混合、迭代和事件更新;每个选择写出问题、指标、资源、风险、回退和负责人,最后给出可发布范围。
课后复习与易错
易错一:觉得 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 的高级能力,只是把风险转移给上线后的值班同学。
| 能力 | 解决的问题 | 新增成本 | 最小证据 |
|---|---|---|---|
| Hybrid/RRF | 词面与语义互补 | 多通道、融合、去重 | 切片 Recall/nDCG |
| Rerank | 候选边界更精确 | 模型延迟、批处理 | 候选到支持率 |
| 多跳 Agent | 跨文档组合证据 | 步数、Token、工具风险 | 每跳 trace 与停止率 |
| 实时更新 | 缩短新鲜度延迟 | 事件一致性、删除复杂度 | 端到端时间线 |
| 自动摘要 | 压缩上下文 | 事实损失、额外调用 | 摘要支持率 |
| 自适应路由 | 按任务选择通道 | 分类错误、调试困难 | 路由混淆矩阵 |
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 中的一句请求。
为一个多条件问题分别运行 lexical、dense、RRF 和两跳检索。比较每种方案的候选覆盖、声明支持、阶段延迟、Token、费用和失败动作;如果增加的能力没有明确增量,就把它从发布范围移除。
进阶复盘问题
问自己:当数字和同义表达冲突时,哪个通道优先?当两个版本都相关时,谁决定适用范围?当第二跳没有新证据时,谁让 Agent 停止?当删除事件晚到时,哪个层阻止旧内容返回?当重排器超时时,用户看到的是降级、澄清还是拒答?
把答案写成状态图和 runbook,而不是写成模型提示词。状态图说明系统允许什么,runbook 说明人在异常时做什么;两者都应带事件 ID、日志位置、回滚命令和验证查询。
进阶能力的学习目标不是堆叠名词,而是能够证明每一个额外组件改善了某类任务,并且在故障、越权、过期和成本超限时仍然可控。达到这个标准,才值得进入下一章的系统评估。
把进阶能力放进同一条时间线
先画一个事件时间线,再讨论“实时”。一份政策修改可能经历抓取、解析、清洗、Embedding、索引写入、alias 切换和缓存失效;任何一步延迟都会影响用户看到的答案。把每个事件的开始、结束、版本和失败状态写入同一条 trace,才能区分数据源慢、队列堵塞和索引写入慢。
混合路由也需要可解释。可以先用查询分类器识别是否含数字、错误码、专有名词、时间词或多条件意图,再选择 lexical、dense、RRF 或多跳路径;分类器不确定时保留安全的并行基线。路由决策、置信度和实际通道都要记录,避免线上突然换路由却无法回放。
对 Agent 的工具调用设定最小权限。检索工具只读当前用户可见的索引,计算工具只接收经过校验的数字,写入工具必须经过人工或服务端审批。工具返回的数据再次作为不可信证据处理,不能因为“来自内部工具”就跳过声明核验。
自动摘要和上下文压缩也要经过回归。比较原始 chunk、摘要 chunk 和最终答案的声明支持率,特别关注数字、否定、例外和引用范围。摘要若删掉限制条件,答案会更流畅却更危险;压缩策略应允许回到原文,而不是只保留一段不可解释的摘要。
进阶实验要记录反事实:如果关闭某个通道,哪些查询退化?如果把最大步数减半,哪些任务仍然通过?如果更新延迟增加十分钟,哪些答案变旧?反事实对照能说明组件的真实贡献,也能帮助你选择降级优先级。
| 对象 | 允许动作 | 禁止动作 | 回放证据 |
|---|---|---|---|
| 查询路由 | 选择已批准通道 | 绕过 ACL | 路由 trace |
| Agent 检索 | 有限只读查询 | 无限循环/任意工具 | step、预算、来源 |
| 计算工具 | 校验后的输入 | 执行未审计代码 | 输入输出 hash |
| 实时更新 | 追加、修改、撤权 | 跳过版本和删除 | 事件时间线 |
| 摘要压缩 | 保留可回查来源 | 删除例外和限制 | 原文/摘要 diff |
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 不能引用旧版本;一旦发现顺序异常,宁可把资料标为不可用,也不要静默返回不确定结果。
选择十条包含数字、版本、时间和多跳条件的查询,分别关闭 lexical、dense、RRF、重排和第二跳;再用普通用户、管理员和已撤权用户回放。记录哪一层改变了结果、是否越权、如何降级。
当复杂系统可以用事件、权限、预算、反事实和回放解释时,进阶能力才从“听说过”变成可操作知识。把这些证据带入评估章节,你就能判断一项新技术究竟提高了质量,还是只是增加了不可见的复杂度。
为每个进阶开关准备一个安全默认值:混合通道关闭时仍有可用的精确基线,多跳达到预算时明确停止,实时更新失败时保留已知有效 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 规则;不要把分歧当作模型性能差异。
每个指标都应对应一个决策和失败动作,不能只收集漂亮数字。
| 指标 | 定义直觉 | 适合回答 | 注意事项 |
|---|---|---|---|
| Recall@k | gold 是否进前 k | 粗召回是否漏证据 | gold 集要完整 |
| Precision@k | 前 k 中相关比例 | 噪声是否过多 | 相关性需分级 |
| MRR | 首个 gold 的倒数排名 | 第一个正确结果多快出现 | 忽略后续 gold |
| nDCG | 分级相关性排序 | 不同证据价值如何排序 | 标注协议要稳定 |
| coverage | 有足够证据的查询比例 | 知识库覆盖范围 | 不能等同答案正确 |
| latency | 检索到结果的耗时 | 交互是否可用 | 看 p95/p99 |
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 排在第几、哪些错误文档抢了位置。
准备 50 条查询,覆盖五种意图和三种风险;为每条写 gold 集与相关性等级,计算 Recall@5、MRR、nDCG,并找出最弱切片。
10.8.2 答案质量、事实性与安全
答案评估不能只问“像不像参考答案”。RAG 的回答应检查任务完成、引用存在、引用支持、事实准确、条件完整、无越权、格式可解析和资料不足时的行为。可以用规则、自动评估器和人工复核组合,但要知道自动评分器也会被流畅文案和提示注入影响。
声明级评估把回答拆成最小事实单元。对每条声明记录是否有来源、来源是否支持、是否遗漏例外、版本是否适用、数字是否正确和是否属于用户可见集合。关键声明可以有更高权重;一个错误金额不应被十句正确背景平均掉。
事实性与相关性不同。答案可能引用了相关资料,却把条件理解错;也可能事实正确,但没有回答用户问题。评估表分开记录 answer relevance、claim support、citation correctness 和 completeness,修复时才知道要改检索、Prompt、模型还是验证器。
安全评估要测漏放和误拒。漏放包括越权资料、敏感信息、恶意工具参数和提示注入执行;误拒包括合法资料被挡住、正常问题被判高风险和过度要求人工。按租户、角色、风险和资料状态切片,保留可重放输入。
参考答案不是唯一答案。开放式问答可以用 rubric 评估关键事实、证据、结构和语气;对数字、代码和结构化字段则使用精确校验。人工标注要抽样复核自动分数,记录分歧和评审版本。
评估器本身也要防污染和泄漏。不要把答案或 gold 直接放进提示而又声称评估了事实;不要让评估器看到不可见资料;不要用同一个模型生成答案、评估答案并决定发布而没有独立抽样。重要门槛需要人工或独立程序确认。
| 指标 | 检查对象 | 最小方法 | 失败动作 |
|---|---|---|---|
| 任务完成 | 回答是否解决问题 | rubric/人工 | 改 Prompt 或数据 |
| citation presence | 是否给来源 | ID/链接检查 | 阻止入库 |
| citation support | 来源是否支持声明 | 声明对照 | 补证据/人工 |
| completeness | 条件和例外是否齐 | 关键字段清单 | 补上下文 |
| factuality | 数字和实体是否正确 | 规则/计算器 | 重算/拒答 |
| safety | 越权、注入、敏感泄露 | 红队回放 | 熔断/接管 |
| abstention | 资料不足是否停止 | 低分与冲突集 | 重校准 |
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 就应阻塞。把硬门槛和软评分分开,在报告里同时展示原始字段和聚合值。
抽取 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、版本、检索候选、引用、状态码和成本;对高风险事件保留更完整证据并限制访问。定期抽样用户反馈,区分资料缺失、检索错、答案错和界面误解,回归集持续增长。
成本和延迟要沿请求链分摊,才能找到真正的优化点。
| 指标 | 示例门槛 | 监控字段 | 不达标动作 |
|---|---|---|---|
| Recall@20 | ≥ 0.95 | query slice/gold | 补通道或数据 |
| claim support | ≥ 0.90 | claim/source | 拒答/人工 |
| schema success | ≥ 0.99 | parse errors | 阻止入库 |
| p95 latency | ≤ 3s | stage timings | 限流/降级 |
| cost/request | ≤ 0.02 | Token/重试 | 压缩/路由 |
| unsafe leak | 0 | red-team cases | 立即熔断 |
| rollback | 演练通过 | 旧 manifest | 禁止发布 |
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。生产门槛还要写灰度比例、负责人、观察期和回滚触发条件。
为两个 RAG 版本跑同一评估集,报告检索、答案、安全、p95、成本和回滚结果;设计一组指标让系统进入 LIMITED,再设计一组触发 BLOCK。
本节深挖:指标必须服务决策
先写决策问题再选指标。要换 Embedding,重点看召回和资源;要换分块,重点看证据覆盖和引用;要开放工具,重点看权限、越权和恢复;要扩大窗口,重点看位置、延迟和成本。指标多不代表评估完整,和决策无关的数字只会增加噪声。
检索与生成的错误要串起来看。gold 不在候选是召回问题,gold 在候选却被截断是上下文问题,证据正确但声明错是生成或核验问题,引用正确但用户看不到是界面问题。错误地图应保留每一层的输入输出。
样本数量决定结论可信度。十条样本的 0.9 不能和一千条样本的 0.9 等价;人工评分要记录评审者和一致性;自动评估要用独立样例抽检。报告至少写样本数、切片、时间和评分器版本。
最坏案例比平均值更接近风险。展示一条越权、过期、数字错误、无证据编造和长尾超时样例,说明系统如何阻止或恢复。把修复后的原失败加入回归集,避免同类问题再次出现。
成本与质量一起报告。一个版本支持率高 2 个百分点但成本翻倍,是否值得取决于风险和预算;一个版本平均质量略低但零越权、延迟稳定,也可能更适合灰度。不要把“效果最好”与“可以上线”混为一谈。
评估数据本身要版本化。新增样本、修改 gold、改变风险标签都会影响指标;报告引用 data_manifest 和标注协议,避免团队在不同数据上争论数字。
线上监控要有动作。阈值告警后谁负责、多久响应、切哪个旧版本、如何通知用户,都写在 runbook;没有责任人和回滚命令的监控只是图表。
最后把评估结果写成决策摘要:结论、依据、限制、灰度范围和下一步实验。摘要链接到完整报告和原始 trace,既方便管理者阅读,也方便工程师复现。
| 层级 | 要回答的问题 | 必须保存 | 发布动作 |
|---|---|---|---|
| 检索 | 正确证据是否被找回? | gold、排名、Recall | 补数据/通道 |
| 上下文 | 关键证据是否进入 Prompt? | snapshot、Token | 改预算/编排 |
| 答案 | 声明是否完成任务? | raw、claims、rubric | 改生成/校验 |
| 安全 | 是否越权或漏放? | 红队、ACL、状态 | 阻塞/熔断 |
| 资源 | 是否满足 p95 和成本? | 阶段计时、Token | 限流/扩容 |
| 恢复 | 坏了能否回滚? | 旧 manifest、演练 | 允许/禁止发布 |
用一个基线和一个候选版本完成完整报告:数据 manifest、切片、检索指标、声明核验、安全样例、阶段延迟、成本账本、门槛结论和回滚命令。
课后复习与易错
易错一:只看最终答案。先看 gold 是否召回,再看上下文、声明和引用。
易错二:用一个总分代表所有质量。按任务、语言、长度、版本和风险切片。
易错三:用自动评估器替代人工。关键安全和事实门槛需要独立复核。
易错四:把硬门槛平均进软分数。越权、泄露、无法回滚必须直接阻塞。
易错五:只测平均延迟。记录首 Token、阶段耗时、p95/p99 和重试。
易错六:上线后才定义回滚。发布前演练旧索引、旧模板和旧模型。
评估设计:先写问题,再选指标
评估不是把所有数字放进一个仪表盘。先写决策问题:正确资料是否被找回?回答是否完成任务?引用是否支持声明?系统是否越权?延迟和成本是否在预算内?每个问题对应数据、指标、切片、门槛和动作,缺少其中一项就无法形成可靠结论。
检索评估要定义 gold 的粒度。一个问题可能有多个等价文档,也可能只需要某个条款而不是整篇文件;记录 gold 集合、必需证据、可选证据和不可接受证据。标注协议写清“相关”“支持”“同版本”和“可见”的区别,并抽样复核标注一致性。
答案评估要从整体分数下沉到声明。把回答拆成原子声明,分别标注任务完成、事实正确、证据支持、引用位置、时间和权限。自动评估器可以筛选大批样本,但高风险结论、越权和版本冲突仍应由独立人工复核。
切片是发现不均衡的工具。按语言、查询长度、文档类型、行业术语、数字/否定、风险等级、租户、版本新旧和是否需要多跳分组;同时报告样本量,避免某个切片只有两条样本却得出确定结论。
置信区间和重复回放能防止过度解读。对小样本用 bootstrap 或二项区间估计波动,对线上指标保留时间窗口和流量分母;一个版本只提高 1 条样本并不等于真实改善。报告差异、区间和实际动作,而不是只报百分比。
安全门槛与质量目标分开。越权、敏感泄露、无法回滚和删除后仍可见属于硬阻塞;Recall、支持率、p95 和成本可以作为软目标或灰度门槛。硬门槛失败时,即使平均质量更高,也不能用总分抵消风险。
延迟需要阶段化测量:排队、查询改写、向量召回、关键词召回、重排、上下文压缩、首 Token、完整生成和重试。成本账本记录 Embedding、索引、存储、检索、重排、生成和人工审核,按每次请求和每千条资料归一化。
线上评估要准备影子流量、灰度和停止条件。候选版本先在不影响用户的流量上记录结果,再按租户或百分比逐步开放;监控越权、支持率、拒答率、p95、成本和错误日志,一旦硬门槛触发立即切回旧 alias 和旧模板。
| 决策问题 | 指标 | 切片/样本 | 门槛与动作 |
|---|---|---|---|
| gold 是否召回 | Recall@k、MRR | 任务、语言、长度 | 低于门槛补数据/通道 |
| 答案是否支持 | claim support rate | 声明、版本、风险 | 下降则阻止生成 |
| 是否越权 | leak rate、ACL pass | 租户、角色、删除 | 任何泄露都阻塞 |
| 是否新鲜 | source-to-visible p95 | 新增、修改、删除 | 超时则降级/告警 |
| 是否可用 | p95、错误率、成本 | 并发、缓存、长查询 | 限流或扩容 |
| 能否恢复 | rollback time、diff | 故障注入 | 演练失败不发布 |
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、切片定义和原始结果;如果阈值改变,要说明是谁在什么证据上做了决定。
为基线和候选版本准备至少 60 条分层查询,报告召回、声明支持、安全、延迟、Token、费用和置信区间;写出影子流量、灰度比例、停止条件和回滚命令。
评估复盘的四个追问
第一,失败发生在哪一层:解析、分块、Embedding、索引、召回、上下文、生成还是引用?第二,失败是随机波动、数据分布变化还是版本回归?第三,修复会不会损害另一个切片或安全边界?第四,修复后是否加入了可长期运行的回归集和监控?
评估报告最后写限制:样本是否偏向短问题,标注是否只覆盖单一语言,线上流量是否与离线数据不同,成本是否包含人工审核,安全攻击是否覆盖真实权限模型。承认边界不是削弱结论,而是告诉读者结论可以外推到哪里。
完成本节后,你应能把一次实验交付为可复查的发布决定,而不是一句“这个版本感觉更好”。这也是下一节综合项目验收的评分基础。
统计、标注与线上监控的细节
先固定数据切分,再运行版本对照。训练或调参集用于选择模型、chunk、k 和阈值;验证集用于校准;锁定的测试集只在阶段性报告中使用。线上影子流量不应反向泄漏到测试集,否则每次调参都在悄悄适应用户问题。
标注指南要给出边界案例。什么算“部分支持”?引用只覆盖句子的一半怎么办?两份政策互相冲突时是否都算相关?一个问题没有唯一答案时怎样记录多个 gold?让两名标注者独立判断一小批样本,讨论分歧并更新指南,指标才有可解释的基础。
报告百分比时带上分母和区间。支持率 90% 可能来自 10 条样本,也可能来自 10,000 条;两者的可信度完全不同。按切片报告样本数、成功数、失败数和区间,必要时合并相近切片或延长采样,而不是把小样本结果画成醒目的排行榜。
在线监控要避免把用户隐私写进日志。query_id、模型版本、状态码、耗时和脱敏后的 chunk_id 通常足够诊断;原始问题和回答按最小权限、最短保留期存储。安全评估也要记录攻击类型和结果,但不应在报告里复制敏感文本。
告警阈值需要考虑基线波动。把过去一段稳定流量的 p95、拒答率和支持率作为参考,采用连续窗口或分位数规则减少单点噪声;硬安全事件则不等待统计稳定,立即触发阻断。告警信息包含负责人、影响范围、当前 alias 和回滚命令。
灰度不是只看“总体是否变好”。按租户、语言、风险等级和新旧版本分桶,确认候选版本没有把收益集中在低风险短问题上,却让高风险或长尾查询退化。灰度结束后保留完整的分桶结果,便于未来解释为什么扩大或收回流量。
| 检查项 | 合格标准 | 风险信号 | 补救 |
|---|---|---|---|
| 数据切分 | 调参与测试隔离 | 反复看测试集 | 重建 manifest |
| gold 标注 | 指南、双人抽检、分歧记录 | 只有一个人的直觉 | 复标和修订指南 |
| 样本量 | 有分母和区间 | 小样本排行榜 | 扩充或合并切片 |
| 隐私 | 脱敏和最小保留 | 日志含原文/密钥 | 立即清理并轮换 |
| 灰度 | 分桶、停止条件、回滚 | 只看总体平均 | 暂停扩大流量 |
| 告警 | 负责人和动作明确 | 只发一张图 | 补 runbook |
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;如果一个切片只有一条样本,结论应标为探索性而不是发布门槛。
把一次版本比较写成两页备忘录:决策问题、数据切分、标注指南、指标与区间、切片结果、安全门槛、成本、灰度和回滚。让没有参与实验的人只读备忘录就能复算结论。
评估的价值在于把不确定性说清楚并连接到行动。你不需要假装一次实验回答了所有问题,但必须知道哪些问题已经有证据、哪些问题仍需采样,以及在证据不足时系统应该停止在哪里。
发布前的最后核对
逐项确认评估脚本读取的是锁定的 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 包含所有逻辑通常意味着职责还没有拆清。
项目完成的标志是链路可重放、失败可解释、版本可回滚。
| 模块 | 输入 | 输出 | 不应负责什么 |
|---|---|---|---|
| ingest | 原始文件 | Block/来源 | 决定答案 |
| chunker | Block | chunk/范围 | 执行权限 |
| embedder | chunk 文本 | 向量/manifest | 判断事实 |
| retriever | query/用户 | 候选/trace | 绕过 ACL |
| generator | 问题/证据 | 原始答案 | 直接写业务库 |
| validator | 答案/来源 | 状态/claims | 替代人工裁决 |
| evaluator | 固定数据 | 指标/报告 | 修改测试集 |
| release | 报告/manifest | 发布或回滚 | 隐式改门槛 |
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 场景放进持续回归,每次模板或模型升级都重跑。
冻结用户、资料范围、输入输出、引用、资料不足、高风险动作、模块边界和资源预算;用假模型跑通正常、低分、冲突和注入四条路径。
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 模型或上一版本;最坏案例包括数字、否定、版本冲突、资料不足、越权和提示注入。平均分改善但最坏案例恶化时,结论应阻塞或缩小范围。
反馈要进入回归集,但不能直接把用户的全部内容当训练数据。检查许可、隐私、标注质量和是否重复;记录样本为何加入、适用于哪个版本、是否需要人工复核。回归集是产品资产,不是随手复制的聊天记录。
| 字段 | 示例 | 解释 | 缺失后果 |
|---|---|---|---|
| run_id | run-042 | 串联文件 | 无法回放 |
| data_manifest | corpus-v3 | 数据版本 | 集合漂移 |
| embed_manifest | embed-v2 | 向量契约 | 分数不可比 |
| index_snapshot | kb-20260829 | 索引状态 | 更新原因不明 |
| retrieval_trace | candidate IDs/rank | 解释召回 | 无法定位漏证据 |
| context_snapshot | source IDs/Token | 重放生成 | 引用漂移 |
| raw_output | 原始返回 | 解析审计 | 修复覆盖证据 |
| decision | ship/hold | 结论 | 数字被孤立 |
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))运行记录把实验结果变成机器可读资产。后续可以按变量、切片和失败类型生成表格,也能在发布时自动检查门槛。固定条件写成元组只是示意,实际保存完整配置和环境锁定文件。
分别只改变分块大小、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 的图表不能算运营能力。
下一步可以进入视觉、多模态或部署章节,但保留本章方法:目标契约、数据卡、向量或表示、检索/调用边界、声明级评估、成本和回滚。新名词变化很快,证据链和职责划分仍然适用。
| 交付物 | 最低内容 | 验收问题 | 缺失动作 |
|---|---|---|---|
| README | 安装、命令、限制 | 陌生人能启动? | 补干净环境 |
| data_card | 来源、许可、清洗 | 能追溯和删除? | 隔离资料 |
| manifests | 模型、向量、索引、Prompt | 能复现? | 锁版本 |
| validator | 格式、引用、ACL、业务 | 失败会阻止? | 补测试 |
| report | 切片、基线、最坏案例 | 结论有证据? | 重跑评估 |
| events | trace、raw、状态、成本 | 能回放? | 补日志字段 |
| rollback | 旧版、命令、门槛 | 坏了能切回? | 演练 |
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"))验收脚本只检查交付物是否存在和包含关键部分,不能替代真正运行。把它作为第一道门,再在新环境运行完整命令;最终报告要写限制和未完成项,诚实边界比截图更有价值。
将项目复制到新目录,按 README 安装并运行;故意切换到旧索引、删除一份资料、触发越权和资料不足,记录结果和恢复时间。
官方与原始参考
建议对照阅读 Word2Vec,理解分布式词表示;阅读 Sentence-BERT,理解句向量和孪生网络;阅读 Retrieval-Augmented Generation,理解检索与生成的组合;实现细节可查 Sentence Transformers 文档、Faiss 文档 和 Transformers 文档。阅读时记录版本、数据、评价方法和自己的复现实验,不要把论文摘要当成生产方案。
课后复习与易错
易错一:只选 Embedding 模型,不做领域验证。真实术语、数字、版本和跨语言样本必须进入测试集。
易错二:向量检索替代一切。BM25、字段过滤、SQL 和规则仍然是精确和安全边界。
易错三:把 chunk 越大越好。证据粒度、引用完整度和上下文预算共同决定边界。
易错四:把 RAG 当作降低幻觉的保证。无证据拒答、声明核验和版本冲突处理仍然必须实现。
易错五:只看 Recall 或最终答案。检索、上下文、引用、事实、安全、延迟和成本要分层报告。
易错六:让 Agent 无限查询。步数、Token、费用、超时、权限和重复检测由控制器执行。
易错七:更新只写新增。修改、删除、权限撤销、缓存失效和备份都要有时间线。
易错八:交付只给一个 Notebook。README、数据卡、manifest、事件、评估和回滚才让别人能复用。
| 问题 | 回答应包含 | 最小证据 |
|---|---|---|
| 向量表示什么? | 训练目标塑造的坐标 | 近邻回放 |
| 距离如何选择? | 归一化、排序、阈值 | 距离实验 |
| 为什么要分块? | 证据粒度、预算、引用 | chunk 对照 |
| 检索如何安全? | ACL、版本、租户先过滤 | 越权回放 |
| RAG 如何拒答? | 低分、无候选、冲突状态 | abstain 测试 |
| 如何评估? | Recall、支持率、成本、p95 | 发布报告 |
| 更新如何可靠? | 事件、snapshot、缓存、删除 | 新鲜度时间线 |
| 坏了如何恢复? | 旧版、alias、回放 | rollback 日志 |
提交设计卡、数据卡、向量契约、索引 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、评估汇总和示例资料;密钥、原始敏感文本、临时缓存和大模型权重放在明确的忽略规则中。即使项目只是学习练习,也要养成不把数据和凭据混进仓库的习惯。
| 路径 | 职责 | 最小产物 | 验收方式 |
|---|---|---|---|
| data/ | 输入与标注 | 脱敏样例、gold、manifest | 许可和 hash 可查 |
| src/parse.py | 解析与 Block | 结构化中间层 | 原文位置抽检 |
| src/chunk.py | 分块与元数据 | chunk、parent、ACL | 跨边界问题集 |
| src/index.py | 建库与 snapshot | 索引 manifest | Flat/ANN 对照 |
| src/retrieve.py | 召回与 trace | 候选账本 | Recall、权限回放 |
| src/answer.py | 上下文与引用 | 回答包、claim | 支持率、拒答 |
| reports/ | 评估与决策 | 指标、切片、限制 | 硬门槛和回滚 |
| README.md | 冷启动与运维 | 命令、故障地图 | 新目录复跑 |
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。把它接入测试命令,再补上查询回放、越权、删除、新鲜度和回滚检查,综合项目就有了可重复的起点。
把项目复制到一个全新目录,请同伴只阅读 README 完成安装、建索引、运行五条查询、检查引用和执行回滚。你只能根据日志修复问题,不能口头补充隐藏步骤。
从综合项目迁移到下一章
进入视觉或多模态大模型前,保留本章的接口思想:图像也需要数据卡、版本、权限、分块或区域、Embedding 契约、检索 trace、声明核验和评估门槛。模态改变了输入和编码器,不会取消可追溯、可复现和可回滚的要求。
整理一张错误地图,按“数据质量、表示、距离、索引、分块、检索、上下文、生成、安全、资源”分类。每个错误写现象、最小复现、根因假设、修复、回归和负责人。未来遇到新模态或新模型时,可以沿用这张地图快速定位。
最后写下三个仍未解决的问题,例如跨语言召回是否稳定、删除事件多久完全生效、怎样让声明核验覆盖计算结果。问题要能转化为下一次实验,而不是泛泛的“继续学习”。带着可验证的问题进入下一章,学习会更有方向。
本章真正的完成标志不是记住 RAG 的缩写,而是能够交付一条有边界的证据链:资料从哪里来、如何变成向量、怎样被找回、为何允许生成、引用是否支持、失败如何拒答、上线如何监控、坏了如何回滚。做到这一点,你已经具备继续学习视觉、多模态和行业大模型的工程底座。
综合项目评分与自评
综合项目可以用“可运行、可解释、可验证、可恢复”四个维度自评。可运行要求新目录能冷启动;可解释要求每条结果能追到数据、模型、索引和事件;可验证要求有 gold、声明、权限、延迟和成本证据;可恢复要求故障、删除、撤权和版本回滚都有演练记录。
项目评分不看代码行数,而看职责是否清楚。至少拆出读取资料、解析与分块、批量编码、索引管理、候选检索、上下文编排、声明核验和评估报告;每个职责都应有输入、输出、异常和测试。一个很短但接口清晰的项目,比一个很长却无法回放的脚本更值得保留。
为每个模块写一个最小失败测试。解析器面对空文件和乱码,分块器面对超长表格,Embedding 面对空文本,索引面对重复事件,检索面对无结果,回答器面对冲突证据,评估器面对缺失标签。失败测试让“应该怎么处理”变成可以运行的约定。
交付时把结果分层:用户可见回答、调试 trace、离线评估、运维事件和敏感数据分别由不同权限访问。这样既能让同伴复查,也不会因为为了排查问题而暴露所有原文。README 中说明哪些文件可以公开,哪些只能在受控环境查看。
最终演示不要只展示成功案例。准备一条正常查询、一条数字/版本查询、一条资料不足、一条跨文档组合、一条权限拒绝、一条删除后查询和一次服务降级;为每条展示系统状态、引用、日志和预期动作。失败路径才真正检验你是否理解了边界。
| 维度 | 达到标准 | 证据文件 | 未达标时 |
|---|---|---|---|
| 可运行 | 干净目录可启动 | README、安装日志 | 补环境与命令 |
| 可解释 | 结果能回查来源 | trace、claim、版本 | 补字段和链接 |
| 可验证 | 指标与门槛有数据 | gold、报告、切片 | 补标注和回归 |
| 可恢复 | 旧版、删除、降级可演练 | 事件、runbook、截图 | 收紧发布范围 |
| 职责清楚 | 模块契约和测试分开 | src、tests、schema | 拆分并补测试 |
| 安全合规 | ACL、脱敏、保留期明确 | 数据卡、攻击回放 | 阻断公开和上线 |
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,也要确认引用、拒答和安全回归没有退化。
用七条演示查询和一次故障注入完成五分钟答辩:说明数据从哪里来、候选如何产生、回答为何可信、资料不足如何处理、权限如何保证、指标如何判断、坏了怎样回滚。答辩后根据同伴问题更新错误地图。
将这份自评表保存为项目的 release note。下一章开始接触视觉大模型时,不必重新发明流程,只需替换输入模态和编码器,再沿用数据卡、索引契约、检索 trace、声明核验、评估门槛和回滚习惯。