qinghuan
← Agent 算法与学习 / 全部笔记

单元 41 / Agent 算法与学习

P1 · 重点提升L3 · 深入

记忆与检索学习:从固定规则到可训练选择

用双编码器小实验学习让相关内容更接近的对比损失,检查向量与索引是否匹配,再用 MMR 兼顾记忆候选的相关性和多样性。

本篇目录 · 按需要跳转

这篇重点学什么

本篇重点:分别为查询和文档生成向量(双编码器),用负样本和对比损失学习区分相关资料,再检查内容选择策略及对新数据的效果。

开始读正文 ↓
学过相近内容?查看哪些可以跳过

两条路线有些基础相同。已经学会的部分可以跳过,另一条路线只用来补你当前需要的内容。

检索与记忆

共同基础检索、证据和上下文选择相通;使用检索器与训练检索器有不同验收目标。

另一条路线补充什么?

把记忆和有出处的资料交给模型,检查读取权限和上下文长度,再用 Recall / MRR / NDCG 检查资料是否找全、排得是否合适。

读完这篇,你能做什么

训练四对 ID 嵌入并核验分数矩阵;用 Java 选择相关且较少重复的候选,并说明训练样本上检索命中不能证明新数据上也有效。

学习理由、准备要求与知识点

为什么学它

没找到证据,可能是向量表示不合适、候选没有覆盖,或权限过滤造成的。先定位原因,才知道训练能否帮上忙。

先读这些内容

知识点清单

  • 双编码器与点积
  • 批内负例矩阵
  • 假负例与多正例
  • 文档向量版本
  • 记忆写入和读取策略
  • MMR贪心选择
  • 权限与时间过滤
  • 检索和回答分层评估

运行前先确认论文机制按文中版本说明;Java 17 完整算法,Python 3.14 与 PyTorch 2.9.1+cpu 的小型 CPU 实验。未执行 GPU、多机或大规模模型训练。

01先弄懂它是什么

从资料里找内容,可以先固定切分方式、索引和相似度,再取排名靠前的结果;也可以用任务数据训练查询表示、文档表示或重排器,让检索更适合这类问题,这叫可学习检索。如果检索的是记忆,还要决定哪些内容值得写入、何时过期。记住一个事实,不代表下次一定要用;过时或没有权限读取的内容,即使很相关,也不能放进当前上下文。

双编码器分别把问题和文档变成 d 维向量,再用点积打分。若一批有 B 个问题、B 篇文档,Q 和 D 的形状都是 B×d,Q 乘 D 转置就得到 B×B 分数表:每一行是一个问题对全部文档的评分。训练时在已知正例的那一列计算交叉熵,同批其他文档可以当作对照候选。不过这是训练数据的组织方法,不表示现实里一道题只能有一篇正确来源。

训练读取策略和训练生成器是两件事。提高检索命中率不保证回答会正确引用;生成器可能忽略证据或引用错误位置。应先测授权范围内证据是否被召回,再测排序、上下文选择和最终答案。分层指标能把“找不到”和“找到了但不会用”分开,而端到端一个总分不能定位这些问题。

文中这几个词,先认一认

批内负例
训练一个查询时,把同一批中其他文档拿来当作对照候选,省去另外收集一组候选的工作。
假负例
被当作负例的文档其实也相关,甚至同样能回答问题;标签与真实关系不一致。
MMR
逐个选择内容时,同时考虑它与问题有多相关、与已选内容有多重复的方法,避免结果全在说同一件事。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

DPR的基本损失让正例点积分数相对于候选负例更高,并可离线预计算文档向量。若更新了文档编码器而仍使用旧索引,线上点积可能比较不一致的表示空间;因此模型版本、切分方式和索引构建版本应一起发布。只更新查询编码器也需在固定文档向量下专门训练,不能默认任意两版天然兼容。

Java使用MMR式选择:首项按相关性最高选择,以后按lambda×相关性-(1-lambda)×与已选项的最大相似度评分。lambda介于零和一,分数尺度需可比较;它不是可训练损失,也不保证所有问题都有一个全局最优多样集合。相似度为负时最大值也可能为负,程序保留这一数学语义,不偷偷改成零。

Python实验用两个4×4的可训练嵌入表代替真正文本编码器,四个查询ID分别对应四个文档ID,对角线为正例。每步得到4×4分数矩阵后反向传播,训练内命中率可达到一。这个小实验检验损失方向和维度,模型只是记住有限ID,没有分词、语义理解或测试集,因此不能把结果写成自然语言检索能力。

先跑通一个最小例子

PyTorch CPU运行experiment.py,训练四对ID嵌入100步;Java另测MMR选择。无语料下载或外部向量服务。

experiment.py · python
import torch
torch.set_num_threads(1)
torch.manual_seed(41)
# 四个查询和四篇文档仅以ID表示,不具有自然语言泛化能力。
q=torch.nn.Embedding(4,4,dtype=torch.float64)
d=torch.nn.Embedding(4,4,dtype=torch.float64)
ids=torch.arange(4)
opt=torch.optim.Adam(list(q.parameters())+list(d.parameters()),lr=.08)
def scores(): return q(ids) @ d(ids).T
initial=torch.nn.functional.cross_entropy(scores(),ids).item()
for _ in range(100):
    opt.zero_grad();loss=torch.nn.functional.cross_entropy(scores(),ids)
    loss.backward();opt.step()  # 对角线为正例,其余人为规定为负例。
final=torch.nn.functional.cross_entropy(scores(),ids).item()
assert scores().shape==(4,4)
assert final<initial and final<.01
assert torch.equal(scores().argmax(-1),ids)
assert torch.isfinite(scores()).all()
# 已授权过滤先于最终选择;不能让高相似度绕过权限。
allowed=torch.tensor([True,False,True,True])
filtered=scores()[1].detach().masked_fill(~allowed,float('-inf'))
assert filtered.argmax().item()!=1
assert torch.nn.functional.cross_entropy(torch.zeros((4,4)),ids).item()>1.
print(f"initial={initial:.6f} final={final:.6f} train_recall_at_1=1.0")
print("PASS 6 checks; trained ID embeddings, not a text retrieval benchmark")

核对运行结果final小于.01,训练内recall_at_1为1,PASS 6 checks;不表示真实检索泛化。

正在加载流程图…

可学习检索只排序已授权候选,评估时固定索引与资料版本以隔离训练变化。
查看流程图源码(Mermaid)
flowchart TD
  N0["明确记忆写入读取与失效规则"]
  N1["构造查询正例和可信负例"]
  N2["训练表示并固定索引版本"]
  N3["授权过滤后选择互补证据"]
  N4["分别评估召回引用和答案"]
  N0 --> N1
  N1 --> N2
  N2 --> N3
  N3 --> N4

按顺序看,每一步做了什么

  1. 明确记忆写入读取与失效规则
  2. 构造查询正例和可信负例
  3. 训练表示并固定索引版本
  4. 授权过滤后选择互补证据
  5. 分别评估召回引用和答案

03对照实现代码

DPR双编码器的批内对比目标 (新标签页)↗

DPR论文 arXiv:2004.04906v3 · Section 3.2,Equation 2与in-batch negatives

原创DPR损失模型;Python实验使用ID嵌入,未加载BERT或真实文档语料。

核心机制代码 · python
import torch
def retrieval_loss(query_vectors, document_vectors, positive_index):
    scores = query_vectors @ document_vectors.T  # B乘d与d乘M得到B乘M分数。
    log_prob = torch.log_softmax(scores, dim=1)  # 对每个查询的候选文档归一化。
    return torch.nn.functional.nll_loss(log_prob, positive_index)  # 正例列必须与实际数据排列一致。

固定论文第三节给出点积评分和正例负对数概率,批内方案复用其他查询的文档。作者仓库BiEncoderNllLoss.calc可对应阅读分数矩阵、log_softmax和正例索引处理;仓库main只作为延伸阅读入口,本文机制基线固定到论文v3,不假定可变分支永远相同。

短代码是公式的原创PyTorch表达,positive_index形状为B且每项指向合法候选列。若文档排列改变而标签仍按对角线取值,代码能正常运行但学到错误匹配,所以应在数据流水线中测试查询、文档ID与正例索引的一致性,不能只检查损失是否下降。

批内所有非正例不一定真的不相关。重复文档、同义问题和多篇互补证据会制造假负例,惩罚本来正确的召回。解决方向包括去重、多正例目标或过滤可信负例,但每种处理改变了样本分布,应在固定评估集下比较;“负例越难越好”不是无条件规律。

MMR来源于相关性与新颖性的候选选择思想,属于本文独立Java桥梁,不是DPR训练实现的一部分。将两个方法放在一起是为了区分“学出分数”和“按预算选择证据”:前者改善表示,后者控制重复,二者的实验指标、参数和失败模式都需要分别记录。

代码先调用谁,再调用谁

  1. 编码查询与文档
  2. 构造点积分数矩阵
  3. 按正例索引计算负对数概率
  4. 更新编码器并重建相容索引

04动手写一个例子

增量维护冗余分数的MMR选择

给定[-1,1]相关性向量和候选两两相似度矩阵,选择k个不同候选。首项按相关性,其后按约定MMR分数,同分取小编号。

输入 1
相关性[.9,.85,.6],前两项相似.99,lambda=.5
输出 1
选择[0,2]
输入 2
同样输入,lambda=1
输出 2
选择[0,1]

每个未选候选维护maximum,表示它与当前已选集合的最大相似度。每选择一个新候选,只需拿相似度矩阵的新列更新这个最大值,下一轮就能在常数时间计算每个候选的MMR分数。相比每轮遍历所有已选项,减少重复比较。

maximum在集合非空后恰是已选项对应相似度的最大值,初始负无穷表示尚无比较对象;首轮明确使用相关性绕开空集合歧义。used数组保证不重复选取,按编号顺序只在严格更优时替换,使平局结果确定。这个不变量保证执行既定贪心规则,不提供全局最优证明。

校验整个N×N矩阵需要O(N²),之后k轮选择和更新各扫描N项,需O(Nk)。当k不超过N,总时间可简写O(N²),但保留两项有助理解预验证与选择成本。额外数组为O(N+k),输入矩阵本身占O(N²),没有把输入存储错误地计成常数。

为什么这样写是对的已选集合非空时maximum[i]是候选i与全部已选项的最大相似度,used标记准确且结果不重复。

时间 O(N²+Nk)额外空间 O(N+k)额外空间

矩阵校验O(N²),k轮每轮选择与最大值更新O(N);给定相似度矩阵不算额外空间。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 45 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    static void near(double expected,double actual) {
        if (!Double.isFinite(actual) || Math.abs(expected-actual)>1e-9*Math.max(1,Math.abs(expected)))
            throw new AssertionError(expected+" != "+actual);
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    static void bad(Runnable r) {
        try { r.run(); } catch(IllegalArgumentException e) { return; }
        throw new AssertionError("invalid input accepted");
    }
static int[] select(double[] relevance,double[][] similarity,int k,double lambda) {
        if(relevance==null||similarity==null||similarity.length!=relevance.length||k<0||k>relevance.length||!Double.isFinite(lambda)||lambda<0||lambda>1)
            throw new IllegalArgumentException("configuration");
        int n=relevance.length;
        for(int i=0;i<n;i++) {
            if(!Double.isFinite(relevance[i])||Math.abs(relevance[i])>1||similarity[i]==null||similarity[i].length!=n) throw new IllegalArgumentException("row");
            for(double x:similarity[i]) if(!Double.isFinite(x)||Math.abs(x)>1) throw new IllegalArgumentException("score");
        }
        boolean[] used=new boolean[n]; double[] maximum=new double[n]; int[] result=new int[k];
        Arrays.fill(maximum,Double.NEGATIVE_INFINITY); // 非空已选集合的最大相似度可为负,不能擅自截断为零。
        for(int t=0;t<k;t++) {
            int best=-1;double score=Double.NEGATIVE_INFINITY;
            for(int i=0;i<n;i++) if(!used[i]) {
                double value=t==0?relevance[i]:lambda*relevance[i]-(1-lambda)*maximum[i]; // 首项按相关性选取,明确空集合约定。
                if(value>score) {score=value;best=i;} // 顺序扫描使同分时选择较小编号。
            }
            used[best]=true;result[t]=best;
            for(int i=0;i<n;i++) maximum[i]=Math.max(maximum[i],similarity[i][best]); // 只追加新列,避免每轮重扫已选项。
        }
        return result;
    }
    public static void main(String[] args) {
double[] r={.9,.85,.6};double[][] s={{1,.99,.1},{.99,1,.2},{.1,.2,1}};
        check(Arrays.equals(select(r,s,2,.5),new int[]{0,2}));
        check(Arrays.equals(select(r,s,2,1),new int[]{0,1}));
        check(Arrays.equals(select(r,s,3,0),new int[]{0,2,1}));
        check(select(new double[]{},new double[][]{},0,.5).length==0);
        check(Arrays.equals(select(new double[]{.5,.5},new double[][]{{1,0},{0,1}},1,.5),new int[]{0}));
        check(Arrays.equals(select(new double[]{.9,.1,.1},new double[][]{{1,-.2,-.8},{-.2,1,0},{-.8,0,1}},2,0),new int[]{0,2}));
        bad(()->select(r,s,4,.5));
        bad(()->select(new double[]{Double.NaN},new double[][]{{1}},1,.5));
        System.out.println("PASS 8 cases");
    }
}

这些特殊情况也要试一试

  • k零可返回空结果
  • 负相似度保留真实最大值
  • lambda一退化为相关性排序
  • 非法分数和越界k拒绝
进阶选读 · 可训练检索与记忆生命周期联动

场景长期助手的资料不断更新,权限也随用户变化。

做法将写入、失效、召回、重排分别记录版本与指标;对更新后的编码器重建或验证索引,训练负例仅来自合法候选;用时间切分任务检查旧记忆污染。

复杂度精确点积检索O(Nd),近似索引复杂度依赖结构与召回参数;重排成本还乘候选数与模型推理成本。

好处与代价可学习表示提高任务适配,也增加索引迁移和数据维护成本,多样性可能减少重复却牺牲单项相关性。

05项目里怎么用

权限过滤必须作为不可学习的访问边界:模型不能因为预测收益高而读取不授权资料。时间范围、删除标记和租户身份也要进入检索条件。排序日志可记录被过滤数量,但不应把被拒绝的内容泄漏到训练提示或可见解释中,后续模型更新同样遵循数据使用范围。

建立检索实验时保存语料快照、查询划分和标注依据,避免测试问题的答案被写入训练记忆后又宣称系统学会泛化。负例挖掘应记录所用模型版本,因为训练改变检索结果后难负例分布也会变化。可以先固定候选集比较重排,再单独比较召回层,减少同时变更的混淆。

生产评估还需观察上下文长度、重复证据比例、索引更新时间与拒答表现。没有授权证据时保留空结果通常比强行填满k条更合理;本文MMR接口按要求返回恰好k条,真实服务应在外层设置最低相关性与不足候选策略。教学算法正确不等于已经验证线上文档质量与权限实现。

容易踩的坑

  • 训练ID记忆被当语义泛化
  • 编码器更新却沿用不兼容索引
  • 默认批内文档全是负例
  • 高相关性绕过权限
  • 将贪心多样性称集合最优

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
固定关键词检索无需训练、解释直接同义表述覆盖有限冷启动与精确标识查询
可训练双编码器离线文档向量可复用需要可信对照数据和索引管理领域语义召回
候选重排加MMR控制精度与重复增加延迟且不能补回缺失候选上下文预算有限的回答

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01分数矩阵为何是B×B?

展开参考答案与推导

B个查询分别与B篇文档点积,每行列出一个查询的全部候选。

02其他文档一定是负例吗?

展开参考答案与推导

不一定,相关重复文档或多正例会形成假负例。

03MMR会训练编码器吗?

展开参考答案与推导

不会,它用给定分数执行选择,训练需另有可优化目标。

换个情况,还会处理吗

01召回变好但答案变差如何查?

展开参考答案与推导

固定候选检查重排、上下文重复和生成引用,再分层比较。

02更新编码器后线上掉分?

展开参考答案与推导

检查文档索引向量与查询编码器版本、切分和归一化是否相容。

03最相关文档没有权限?

展开参考答案与推导

先排除,再排序合法候选;没有证据时按产品规则拒答或追问。

写代码试一试

01维护最大冗余。

输入 / 条件
已选0后再选2
输出 / 预期
maximum取两列逐项最大

提示 增量更新

展开参考答案与推导

每轮只比较新加入列,保持最大值不变量。

02检查正例排列。

输入 / 条件
文档顺序置换
输出 / 预期
标签列同步置换

提示 追踪文档ID

展开参考答案与推导

对齐正例索引后再计算交叉熵。

03证明训练结果的范围。

输入 / 条件
四对ID全部命中
输出 / 预期
只能说明训练内匹配

提示 检查测试数据是否独立

展开参考答案与推导

另建未见文本和任务评估,不能由记忆ID推出语义能力。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

训练四对 ID 嵌入并核验分数矩阵;用 Java 选择相关且较少重复的候选,并说明训练样本上检索命中不能证明新数据上也有效。

本篇重点加练

批内负例矩阵的标签结构决定梯度是否互相冲突,重点考查重复文档与不完整正例。

  • 批内负例矩阵
  • 假负例与多正例

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    三个查询对应四篇文档,其中一个查询有两个正确文档。写出相似度矩阵形状、正例集合和两种训练目标的样本约定。

    提交内容
    矩阵布局与标签契约
    怎样算完成
    • 多正例不被误标负例
    • 行列身份一致

    练到的知识点双编码器与点积 · 批内负例矩阵 · 假负例与多正例

  2. 作业 02原理问答

    区分编码器参数、向量索引、记忆写入决策及读取权限四类状态,说明训练中改变任一项需要重新验证哪些条件。

    提交内容
    状态依赖图
    怎样算完成
    • 版本兼容明确
    • 权限不是相似度阈值

    练到的知识点文档向量版本 · 记忆写入和读取策略 · 权限与时间过滤

  3. 作业 03原理问答

    同一文档因两个增强视图在批中出现两次,其中只有一列被标为正例。画出一个四查询批的身份和标签矩阵,指出需要人工核实的监督冲突。

    提交内容
    文档身份与标签审计表
    怎样算完成
    • 视图和文档身份分开
    • 疑似负例不直接判真负例

    练到的知识点批内负例矩阵 · 假负例与多正例

综合应用

  1. 作业 04编程实践

    用 CPU 训练微型双编码器,使用固定的 20 组问答与保留查询,记录损失及检索指标;加入至少一个多正例样本。

    提交内容
    训练脚本与逐查询结果
    怎样算完成
    • 切分与标签可审计
    • 训练拟合不冒充泛化

    练到的知识点双编码器与点积 · 批内负例矩阵 · 假负例与多正例

  2. 作业 05排错实验

    故意让部分文档向量来自旧编码器,并加入过期或无权限文档;分别核验版本、过滤和检索质量的失效位置。

    提交内容
    故障集及分层诊断
    怎样算完成
    • 版本与权限故障可定位
    • 回答评价与检索评价分开

    练到的知识点文档向量版本 · 权限与时间过滤 · 检索和回答分层评估

  3. 作业 06面试场景

    候选文档高度重复但部分互补。实现小规模 MMR 选择实验,比较两种参数下的相关性、冗余和下游回答证据覆盖。

    提交内容
    选择器与三类指标表
    怎样算完成
    • 选择与训练机制区分
    • 文档身份及预算一致

    练到的知识点记忆写入和读取策略 · MMR贪心选择 · 检索和回答分层评估

  4. 作业 07编程实践

    CPU 双编码器固定参数,比较批内加入一个确认为额外正例和一个真负例时查询梯度的变化;分别实现明确声明的多正例目标。

    提交内容
    梯度实验与目标定义
    怎样算完成
    • 增加样本的身份可信
    • 归约及正例集合显式

    练到的知识点批内负例矩阵 · 假负例与多正例

  5. 作业 08排错实验

    数据加载器重排文档列但未同步标签。构造语义标签不沿对角线排列的批次,设计能检出错位的身份检查。

    提交内容
    故障输入与身份校验器
    怎样算完成
    • 检查依据不是默认对角线
    • 每个正例来源可追踪

    练到的知识点批内负例矩阵 · 假负例与多正例

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    研究批内负例与来源相近的困难负例,固定正例和预算,预先定义假负例审计;报告记忆读取策略的保留集收益。

    提交内容
    研究方案与受控实验
    怎样算完成
    • 负例标签质量可检查
    • 不只报告训练损失

    练到的知识点批内负例矩阵 · 假负例与多正例 · 记忆写入和读取策略

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 开放探究

    增加批大小一定改善检索学习吗?自建重复主题比例随批大小变化的数据,研究更多负例与更多假负例之间的竞争,限定 CPU 小样本。

    提交内容
    生成机制与受控实验
    怎样算完成
    • 批大小外的变化单独记录
    • 泛化通过保留查询评价

    练到的知识点批内负例矩阵 · 假负例与多正例

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗