这篇重点学什么
本篇重点:分别为查询和文档生成向量(双编码器),用负样本和对比损失学习区分相关资料,再检查内容选择策略及对新数据的效果。
开始读正文 ↓学过相近内容?查看哪些可以跳过
两条路线有些基础相同。已经学会的部分可以跳过,另一条路线只用来补你当前需要的内容。
检索与记忆
共同基础检索、证据和上下文选择相通;使用检索器与训练检索器有不同验收目标。
另一条路线补充什么?
把记忆和有出处的资料交给模型,检查读取权限和上下文长度,再用 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选择。无语料下载或外部向量服务。
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按顺序看,每一步做了什么
- 明确记忆写入读取与失效规则
- 构造查询正例和可信负例
- 训练表示并固定索引版本
- 授权过滤后选择互补证据
- 分别评估召回引用和答案
03对照实现代码
DPR论文 arXiv:2004.04906v3 · Section 3.2,Equation 2与in-batch negatives
原创DPR损失模型;Python实验使用ID嵌入,未加载BERT或真实文档语料。
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训练实现的一部分。将两个方法放在一起是为了区分“学出分数”和“按预算选择证据”:前者改善表示,后者控制重复,二者的实验指标、参数和失败模式都需要分别记录。
代码先调用谁,再调用谁
- 编码查询与文档
- 构造点积分数矩阵
- 按正例索引计算负对数概率
- 更新编码器并重建相容索引
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²),k轮每轮选择与最大值更新O(N);给定相似度矩阵不算额外空间。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 选择相关且较少重复的候选,并说明训练样本上检索命中不能证明新数据上也有效。
本篇重点加练
批内负例矩阵的标签结构决定梯度是否互相冲突,重点考查重复文档与不完整正例。
- 批内负例矩阵
- 假负例与多正例
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
三个查询对应四篇文档,其中一个查询有两个正确文档。写出相似度矩阵形状、正例集合和两种训练目标的样本约定。
- 提交内容
- 矩阵布局与标签契约
- 怎样算完成
- 多正例不被误标负例
- 行列身份一致
练到的知识点双编码器与点积 · 批内负例矩阵 · 假负例与多正例
- 作业 02原理问答
区分编码器参数、向量索引、记忆写入决策及读取权限四类状态,说明训练中改变任一项需要重新验证哪些条件。
- 提交内容
- 状态依赖图
- 怎样算完成
- 版本兼容明确
- 权限不是相似度阈值
练到的知识点文档向量版本 · 记忆写入和读取策略 · 权限与时间过滤
- 作业 03原理问答
同一文档因两个增强视图在批中出现两次,其中只有一列被标为正例。画出一个四查询批的身份和标签矩阵,指出需要人工核实的监督冲突。
- 提交内容
- 文档身份与标签审计表
- 怎样算完成
- 视图和文档身份分开
- 疑似负例不直接判真负例
练到的知识点批内负例矩阵 · 假负例与多正例
综合应用
- 作业 04编程实践
用 CPU 训练微型双编码器,使用固定的 20 组问答与保留查询,记录损失及检索指标;加入至少一个多正例样本。
- 提交内容
- 训练脚本与逐查询结果
- 怎样算完成
- 切分与标签可审计
- 训练拟合不冒充泛化
练到的知识点双编码器与点积 · 批内负例矩阵 · 假负例与多正例
- 作业 05排错实验
故意让部分文档向量来自旧编码器,并加入过期或无权限文档;分别核验版本、过滤和检索质量的失效位置。
- 提交内容
- 故障集及分层诊断
- 怎样算完成
- 版本与权限故障可定位
- 回答评价与检索评价分开
练到的知识点文档向量版本 · 权限与时间过滤 · 检索和回答分层评估
- 作业 06面试场景
候选文档高度重复但部分互补。实现小规模 MMR 选择实验,比较两种参数下的相关性、冗余和下游回答证据覆盖。
- 提交内容
- 选择器与三类指标表
- 怎样算完成
- 选择与训练机制区分
- 文档身份及预算一致
练到的知识点记忆写入和读取策略 · MMR贪心选择 · 检索和回答分层评估
- 作业 07编程实践
CPU 双编码器固定参数,比较批内加入一个确认为额外正例和一个真负例时查询梯度的变化;分别实现明确声明的多正例目标。
- 提交内容
- 梯度实验与目标定义
- 怎样算完成
- 增加样本的身份可信
- 归约及正例集合显式
练到的知识点批内负例矩阵 · 假负例与多正例
- 作业 08排错实验
数据加载器重排文档列但未同步标签。构造语义标签不沿对角线排列的批次,设计能检出错位的身份检查。
- 提交内容
- 故障输入与身份校验器
- 怎样算完成
- 检查依据不是默认对角线
- 每个正例来源可追踪
练到的知识点批内负例矩阵 · 假负例与多正例
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
研究批内负例与来源相近的困难负例,固定正例和预算,预先定义假负例审计;报告记忆读取策略的保留集收益。
- 提交内容
- 研究方案与受控实验
- 怎样算完成
- 负例标签质量可检查
- 不只报告训练损失
练到的知识点批内负例矩阵 · 假负例与多正例 · 记忆写入和读取策略
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 开放探究
增加批大小一定改善检索学习吗?自建重复主题比例随批大小变化的数据,研究更多负例与更多假负例之间的竞争,限定 CPU 小样本。
- 提交内容
- 生成机制与受控实验
- 怎样算完成
- 批大小外的变化单独记录
- 泛化通过保留查询评价
练到的知识点批内负例矩阵 · 假负例与多正例
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
DPR论文v3 (新标签页)↗
阅读第三节点积、批内负例及索引结构。
- 实践参考文档英文
DPR作者损失实现 (新标签页)↗
对照
BiEncoderNllLoss,注意main为可变阅读入口。 - 深入参考文档英文
MMR原论文 (新标签页)↗
理解相关性和新颖性的选择权衡。