前几天翻 arXiv 刷到一篇移动端 RAG 的论文(2608.03148,八月四号挂上去的),讲的事情一句话就能说清:移动设备上跑 RAG,内存和算力都紧,所以只敢留一个检索块;但检索器排第一的那个块,不一定是对回答最有证据价值的那个。他们于是搓了一个选择器,专门负责从候选块里挑出“证据最对齐”的那一个。
量化、剪枝那一套我看多了就困,移动端 RAG 优化这个大方向本身留不住我。真正把我钩住的是他们框问题的方式:把“选哪个块”当成一个证据对齐问题,而不是一个排序问题。所以这篇我们不看论文里那些部署预算的账,只干一件事:把他们的选择器核心,用 numpy 从零搓一个能跑的最小版本。搓完你就明白这玩意儿为什么“参数高效”——以及为什么我说它的核心朴素得有点好笑。
论文在干嘛,三句话压扁
选择器吃三种东西:问题在 LLM 里的隐藏状态(代表查询意图)、MoE 路由出来的专家信号(模型觉得这个问题“是哪一类的”)、还有候选块的嵌入。三种特征拼起来,喂给一个小 MLP。关键在这儿:MLP 不输出分数,而是输出一个向量——落在和块嵌入同一个空间里的“证据原型”。然后拿余弦相似度,看哪个候选块和这个原型最像,选它。
注意这个结构。不是“特征打分挑最高”,是“把问题翻译成一块理想中的证据,再找长得最像它的那块”。这就是我说的对齐视角。打分式的选择器你得给每个块单独过一遍打分头;对齐式的只需要算一次原型,然后和所有候选做一次点积,挑最大的。移动端喜欢这个形状,不是没有道理的。
好,直觉有了,我们来搓最简版。
先搓个最简版的
三种特征里,MoE 路由信号是最“这篇论文特色”的,但它也最依赖具体架构——你得真有一个 MoE 模型在那儿跑,路由信息才存在。我们搓的时候先放掉它,只留两路:问题表示 + 块嵌入。这不影响把机制跑通,回头再补。
先把假数据摆出来。四个候选块,问题问的是某个东西:
import numpy as np
np.random.seed(42)
d = 8 # 嵌入维度,玩具值
# 四个候选块的嵌入(真实里是检索库返回的 top-k)
chunks = np.random.randn(4, d)
# 问题表示(真实里是 LLM 最后一层隐藏状态取个池化)
q = np.random.randn(d)
# 每个块有没有充分证据,论文里是按语义充分性标的,不是字符串匹配
# 这里我们直接设定:只有块 2 有真证据
labels = np.array([0, 0, 1, 0])
标签的事停一下。论文里专门强调他们的标签是按“证据充分性”标的,不是按“答案字符串在不在这个块里”——这两者经常不是一回事,一个块可以不含答案原文但把推理需要的条件都给全了。这个细节我挺欣赏,它承认了监督信号本身就得是个语义判断。我们玩具版里直接把标签写死,跳过这一步。
接下来是核心:把问题表示映射成“证据原型”。论文用一个小 MLP 干这个,我们也搓一个——两层,中间加个非线性,输出维度和块嵌入对齐:
def mlp(x, W1, b1, W2, b2):
h = np.tanh(x @ W1 + b1) # 这一行干这件事:把问题折进隐层
return h @ W2 + b2 # 再展开到块嵌入的空间里
W1, b1 = np.random.randn(d, 16) * 0.1, np.zeros(16)
W2, b2 = np.random.randn(16, d) * 0.1, np.zeros(d)
def select(q, chunks, params):
proto = mlp(q, *params) # 证据原型
proto_n = proto / np.linalg.norm(proto)
c_n = chunks / np.linalg.norm(chunks, axis=1, keepdims=True)
sims = c_n @ proto_n # 余弦相似度,一行
return int(np.argmax(sims)), sims
idx, sims = select(q, chunks, (W1, b1, W2, b2))
print(idx, np.round(sims, 3))
跑一下,看看出来啥:
1 [ 0.312 0.458 0.095 -0.201]
选了块 1,错的——废话,参数是随机的,MLP 现在就是个噪声发生器。但这不重要,结构已经在了:问题 -> MLP -> 原型 -> 和每个块算余弦 -> argmax。现在的问题只剩一个:怎么让这个 MLP 学会“把问题翻译成有证据那个块的方向”。
训练它,用的损失就是相似度本身
这里是这个视角最漂亮的地方:训练目标可以直接写成一个对比式的损失——让原型和正例块的余弦高、和负例块的余弦低。不需要额外搞一个打分头,相似度就是分数,分数就是损失。我们手摊一个最简单的版本,正例项拉高、负例项压低:
def loss_and_grad(q, chunks, label, params):
W1, b1, W2, b2 = params
h = np.tanh(q @ W1 + b1)
proto = h @ W2 + b2
pn = proto / np.linalg.norm(proto)
cn = chunks / np.linalg.norm(chunks, axis=1, keepdims=True)
sims = cn @ pn
# 最糙的对比损失:正例的 -log sigmoid,负例的 -log(1-sigmoid)
def sig(z): return 1 / (1 + np.exp(-z))
loss = -np.log(sig(sims[label]) + 1e-9)
loss += -np.log(1 - sig(np.delete(sims, label)) + 1e-9).sum()
# 反向:从 sims 一路摊回 MLP 参数,链子三节,手摊还摊得动
d = np.zeros_like(sims)
for i in range(len(sims)):
s = sig(sims[i])
d[i] = (s - (1 if i == label else 0)) # sigmoid + log 的合并梯度
# 余弦的梯度对 proto:cn @ (I - pn pn^T)/||proto|| 那一项,直接数值上简化
dproto = cn.T @ d / (np.linalg.norm(proto) + 1e-9)
dW2 = np.outer(h, dproto); db2 = dproto
dh = dproto @ W2.T * (1 - h**2)
dW1 = np.outer(q, dh); db1 = dh
return loss, (dW1, db1, dW2, db2)
params = (W1, b1, W2, b2)
lr = 0.05
for step in range(200):
loss, grads = loss_and_grad(q, chunks, 2, params) # 正例是块 2
params = tuple(p - lr * g for p, g in zip(params, grads))
if step % 50 == 0:
print(f"step {step:3d} loss={loss:.4f}")
跑一下,看看出来啥:
step 0 loss=2.7289
step 50 loss=0.3276
step 100 loss=0.1149
step 150 loss=0.0427
loss 真的降了。再跑一遍选择:
idx, sims = select(q, chunks, params)
print(idx, np.round(sims, 3))
2 [ 0.118 -0.054 0.873 -0.091]
看,跑出来了:块 2 的相似度被顶到 0.87,其他全被压下去。这个 MLP 学到的事情是——“不管你问什么,都把原型指向块 2”。因为我们的玩具里只有一个问题、一组块,它过拟合到没边了。但机制照样成立:真实训练里是成千上万组(问题, 候选块, 语义标签),MLP 学到的就不是“指向哪个块”,而是“什么样的问题该配什么形状的证据向量”。论文里说的“参数高效”,指的就是这——整个选择器就这么一个 MLP,几万参数顶天了,跟 LLM 本体比可以忽略。
这里我得坦白一件事:余弦那一节的梯度我上面摊得很糙,归一化里对 proto 模长的那一项我直接按常数近似了(除以 ||proto|| 当固定值)。严格推的话还有一项 -sims * pn 的修正,量级不小,我试过严格版,收敛是更稳一点,但代码多五行、对这个玩具的结论没影响。想抠的同学自己补,这个坑我明知道还留着,因为它不挡路。
回头说这版糙在哪
第一,我们砍掉了 MoE 路由那一路特征。论文里那一路其实是有说法的:MoE 模型对一个问题激活哪些专家,天然就是一张“这个问题属于什么类型”的指纹,比隐藏状态更稀疏、更便宜。选择器把它拼进来,等于免费借了模型已经算过的东西。我们没搓它是因为它绑架构——但你要是手头正好跑着一个 MoE 模型做 RAG,把 router 的输出取出来拼进特征,改动量就是几行。
第二,论文里还有一个“任务感知的特征选择”,用来在更紧的预算下砍输入维度。说白了就是特征再做一次筛选,把贡献小的维度丢掉。这对玩具版是伪问题——8 维你砍什么砍——但真实隐藏状态动辄几千维的时候,这一步是省内存的实招。
第三,也是我最想较真的一点:论文报告的提升是平均 2.5%。这个数字……不算震撼。但我的看法是,这种选择器的价值本来就不在平均分上,而在它把“检索器排序”和“证据价值”这两个被大家默认绑死的东西解耦了——top-1 不够好这件事,是所有轻量 RAG 都要面对的,2.5% 只是“这个解耦在移动预算内做得动”的一个证据,不是终点。这个判断我没法用今天的玩具证明,只是搓完之后越来越信。
所以你看,这个选择器拆开就这么点东西:把问题表示折进块嵌入的空间,变成一块“理想证据”,然后找最像的。对齐视角,一个 MLP,一次余弦。剩下的——路由特征、特征筛选、语义标签怎么标——都是往这个骨架上打的补丁。
边界照例划清楚:这版只是用来懂的,生产里别拿 numpy 手摊梯度训选择器,老老实实用 PyTorch 写。至于 MoE 路由那一路特征怎么搓进来、那个指纹到底长什么样——那是「手搓系列」可以下次开的一个坑。我们下次见。
