引言:一个传统 Java 开发者的困惑

最近在维护 EduMind 这个 AI 教学平台时,我盯着 EmbeddingService.javaOnnxEmbeddingTranslator.java 看了很久——里面没有一个方法是我在业务代码里显式调用的,但它们确实在跑。更让我好奇的是:为什么需要两个模型(Embedding + Reranker)先后处理同一批数据?”加几句话”为什么就能让排序变准?

这篇文章从我最困惑的几个问题出发,用尽可能通俗的方式讲清楚 RAG 检索管线里那几段”看不懂但又确实在跑”的代码。

如果你是一个传统后端开发者,对深度学习不太熟,这篇文章是写给你的。

一、EmbeddingService:看起来像下载器,实际上是个翻译官

EmbeddingService.java 有 230 行代码,其中约 120 行在下载模型文件(model.onnx, tokenizer.json 等),看起来像个下载工具。但它的核心功能只有三个方法:

1
2
3
float[] embedQuery(String query);       // 把用户问题转成向量
float[] embedDocument(String text); // 把知识库文档转成向量
List<float[]> embedBatch(List<String>); // 批量转换

输入中文,输出 512 个浮点数。 这 512 个数就是这段文字的”语义指纹”——两段意思相近的文字,它们的向量在数学空间里也挨得近。

1
2
3
输入: "C语言中指针和数组的区别"
↓ ONNX 模型推理(本地 CPU,不联网)
输出: [0.032, -0.145, 0.678, ..., 0.201] ← 512 个 float

但这个转换不是一步完成的。中间有一个关键角色:OnnxEmbeddingTranslator——它是 DJL 框架和 ONNX 模型之间的”翻译官”。

二、Tokenizer:计算机是怎么”读”中文的?

模型不懂中文,只认识数字。Tokenizer 的任务就是把一句话翻译成一串数字。

1
Encoding encoding = tk.encode("C语言中指针和数组的区别");

这一个调用,内部做了四件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
① 分词(Tokenization)
"C语言中指针和数组的区别"
↓ 切词
["C", "语言", "中", "指针", "和", "数组", "的", "区别"]

② 查词表(Token → ID)
["C", "语言", "中", "指针", ...]
↓ 词表映射(每个词在词表里有唯一编号)
[3322, 7535, 704, 4689, ...]

③ 加特殊标记 + 对齐长度
[3322, 7535, 704, 4689, ...]
↓ 前面加 [CLS](句首标记,编号 101),后面加 [SEP](句尾标记,编号 102)
[101, 3322, 7535, 704, ..., 102]
↓ 不足 512 个位置?后面补 0。超过 512?截断。
[101, 3322, 7535, ..., 102, 0, 0, 0, ..., 0] ← 固定 512 长度

④ 生成 attention_mask
[101, 3322, 7535, ..., 102, 0, 0, 0]
[ 1, 1, 1, ..., 1, 0, 0, 0] ← 有字的位置=1,补的零=0

为什么不是简单按字切? 因为 String.split("") 会把”指针”切成”指”和”针”——丢失了它是一个语义单元的信息。Tokenizer 知道”指针”是一个词、”数组”是一个词,这个能力来自预训练词表。

三、processInput:打包快递,喂给模型

processInput 不做推理,只做打包——把 Tokenizer 的输出转换成 ONNX 模型吃得进去的张量格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ① long[] → NDArray,加 batch 维度
NDArray ids = manager.create(inputIds).reshape(1, inputIds.length);
ids.setName("input_ids");

// ② attention_mask
NDArray mask = manager.create(attentionMask).reshape(1, attentionMask.length);
mask.setName("attention_mask");

// ③ token_type_ids:全零矩阵(单句话,没有"句子A vs 句子B"的区分)
NDArray typeIds = manager.zeros(ids.getShape(), ids.getDataType());
typeIds.setName("token_type_ids");

// ④ 把 mask 暂存到 context,留给 processOutput 用
ctx.setAttachment("attention_mask", mask);

// ⑤ 打包返回
return new NDList(ids, mask, typeIds);

逐行解释:

操作 干了什么 类比
reshape(1, inputIds.length) 加一个 batch 维度,[15][1, 15] 模型设计一次能处理 32 句话,我们只喂 1 句,所以 batch=1
setName("input_ids") 告诉 ONNX Runtime”这个张量喂到 input_ids 输入口” 快递单上的收件人名字,名字必须跟模型文件里定义的一致
manager.zeros(...) 生成全零的 token_type_ids BERT 祖传字段,单句模型不需要但模型编译时写死了三个输入口,少一个就报错
ctx.setAttachment(...) 把 mask 暂存到跨方法的”快递袋”里 processInputprocessOutput 是两个独立方法,没有共享变量,只能靠 context 传递

四、processOutput:洗数据,把模型输出变成可用向量

ONNX 推理完后输出的是 [1, 15, 512] ——1 个 batch × 15 个 token × 每个 512 维。但我们要的是整句话的 512 维向量,不是 15 个。需要两步加工:

第一步:Mean Pooling(平均池化)

15 个 token 各有一个 512 维向量,怎么合成一个?最简单的想法是取平均——但 padding 位置(全是 0 的 token)不能参与平均。

1
2
3
4
5
6
7
8
9
hidden = [
[v₀₀, v₀₁, ..., v₀₅₁₁] ← token 0 ([CLS])
[v₁₀, v₁₁, ..., v₁₅₁₁] ← token 1 ("C")
...
[0, 0, ..., 0 ] ← token 14 (PAD,因为 mask=0,被清零)
]
mask = [1, 1, 1, ..., 1, 0]

加权平均 = (token₀ + token₁ + ... + token₁₃) / 14 ← 只算 mask=1 的位置

代码里就是用 hidden * mask 把 padding 位置清零,然后求和除以有效 token 数。

为什么不能用 [CLS] token 直接当句子向量? BERT 原版设计的 [CLS] 是给”判断两句话是否相似”这种分类任务用的。直接拿它做句子嵌入效果很差——这是业界反复验证过的结论。BGE 模型的标准做法是 Mean Pooling。

第二步:L2 归一化

把向量的长度缩放到 1。归一化后的向量,两个向量的点积 = 余弦相似度——pgvector 用点积做检索,省了每次算余弦。

1
2
归一化前:[3.0, 4.0, 0.0]  长度 = √(9+16) = 5
归一化后:[0.6, 0.8, 0.0] 长度 = √(0.36+0.64) = 1

五、粗筛 vs 精排:为什么需要两个模型?

这是我最困惑的问题。有了 Embedding 做向量相似度检索,为什么还要一个 Reranker?

因为它们是两种完全不同的模型架构:

Bi-Encoder(Embedding):各自编码,事后比较

1
2
3
4
5
6
喂模型时:                     比较时:
┌─────────┐ ┌─────────┐
│ Query │ │ Doc │ cos(A, B) = 0.87
└────┬────┘ └────┬────┘ ↑
▼ ▼ 唯一的一次交互,发生在模型外面
向量 A 向量 B

Query 编码时,模型完全不知道 Doc 长什么样。Doc 编码时也不知道 Query 是什么。两个向量各自独立生成,最后才做一次点积比较。

优点:Doc 向量可以离线算好存进 pgvector,搜索时只算 Query 向量(毫秒级),能在一万篇里快速召回。
缺点:压缩成 512 个数时丢失了细节。比如”Python 中遍历列表”和”Java 中遍历列表”在向量空间里可能非常接近(都在说”遍历列表”),但它们的编程语言不同——这个关键差异可能被压缩过程模糊掉了。

Cross-Encoder(Reranker):拼成一句话,让注意力逐词交互

1
2
3
4
5
6
7
8
9
10
11
喂模型时:
┌──────────────────────────────────────┐
│ [CLS] Query [SEP] Document [SEP] │ 一次喂进去,模型同时看到两者
└──────────────────┬───────────────────┘

Self-Attention × 12 层
每个 Query 词 ← 能看到 → 每个 Doc 词
"Java" ← 高注意力 → "ArrayList"
"Java" ← 低注意力 → "Python"

[CLS] → sigmoid → 0.87

Query 和 Document 被拼成一句话喂给模型。Transformer 的 Self-Attention 机制让每个词都能跟其他所有词”对话”。Query 里的”Java”能看到 Doc 里写的是”ArrayList”还是”Python”——这个交互给了模型足够的信息做出精准判断。

所以 Reranker 不是”加了几句话”,而是换了一个完全不同的交互方式:

Bi-Encoder Cross-Encoder
Query 和 Doc 的交互 0 次(编码阶段互不知晓) N×M 次 × 12 层(每层 self-attention)
比较发生在 模型外面(我们写代码算余弦) 模型里面(Transformer 自带的 Attention)
Doc 的编码 可以离线预先算好存起来 不能存——每次换 Query 都得重算
速度 毫秒级 百毫秒级(单篇)
场景 粗筛:10000 篇 → 20 篇 精排:20 篇 → 重排序

这就是两阶段检索的核心逻辑:Embedding 负责”快”,Reranker 负责”准”。

六、Self-Attention 怎么知道哪边是 Query、哪边是 Doc?

Cross-Encoder 把两句话拼在一起喂给模型,模型怎么分得清边界?靠三层机制叠加:

第一层:[SEP] 分隔符

[SEP] 是词表里的特殊 token(编号 102),它的 embedding 向量在训练过程中学会了”我是分隔符,我不属于任何一边”。同时,[SEP] 附近会形成注意力的自然断崖——同侧的词互相看,跨侧的注意力天然偏低。

第二层:位置编码

每个位置有一个固定的位置编码向量,直接加到 token 向量上:

1
2
3
4
位置 1: "Java"   ← 位置 1 的编码
位置 2: "如何"
位置 5: [SEP] ← 位置 5 的编码
位置 6: "使用" ← 位置 6 的编码,跟前 5 个位置差异明显

即使两个不同位置的词语义相关(比如 query 里有”遍历”,doc 里也有”遍历”),位置编码也会告诉模型:”这两个’遍历’在不同位置,不在同一个上下文中。”

第三层:注意力矩阵自然形成的聚类

Self-Attention 通过计算词与词之间的相似度,会自然地把语义相近的词聚在一起:

1
2
3
4
5
6
7
8
注意力矩阵(简化):
Java 如何 遍历 [SEP] 使用 ArrayList
Java [0.8, 0.1, 0.1, 0.0, 0.0, 0.0 ] ← Q 侧词互相看
如何 [0.1, 0.8, 0.1, 0.0, 0.0, 0.0 ]
遍历 [0.1, 0.1, 0.8, 0.0, 0.0, 0.0 ]
[SEP] [0.0, 0.0, 0.0, 1.0, 0.0, 0.0 ] ← [SEP] 只看自己
使用 [0.0, 0.0, 0.0, 0.0, 0.8, 0.1 ] ← Doc 侧词互相看
ArrayList [0.0, 0.0, 0.0, 0.0, 0.1, 0.8 ]

Query 侧的词互相之间注意力高,Doc 侧也各自抱团。[SEP] 作为分隔符没有语义,谁也不吸引,自然形成了一道墙。

这三层叠加,模型不需要被显式告知”边界在哪”——[SEP] 标出边界 + 位置编码拉开距离 + 注意力自然聚类 = 模型自己学会了区分。

有趣的是,bge-reranker 模型甚至去掉了 BERT 原版的 token_type_ids(显式标注”这是句子 A / 这是句子 B”的字段)。实验证明:去掉这个显式标签后,模型被逼着靠语义和位置来判断边界,泛化能力反而更好。

七、总结:一张图看清全过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
用户搜索: "C语言指针和数组的区别"

┌──────────────────────────────────────────────────────────┐
│ 第一阶段:粗筛(Embedding) │
│ │
│ Query → Tokenizer → processInput → ONNX 推理 │
│ → processOutput(Mean Pooling + L2归一化) │
│ → float[512] │
│ │
│ float[512] × pgvector 向量检索 │
│ → 从 10000 篇文档中召回 Top-20 │
│ │
│ 耗时:~10ms 精度:粗 │
└──────────────────────────────────────────────────────────┘

▼ 20 篇候选文档
┌──────────────────────────────────────────────────────────┐
│ 第二阶段:精排(Reranker) │
│ │
│ 对每篇候选: │
│ "[CLS] Query [SEP] Doc [SEP]" │
│ → Tokenizer pair encode │
│ → ONNX 推理(12 层 Self-Attention 逐词交互) │
│ → processOutput(sigmoid) │
│ → 0~1 分 │
│ │
│ 按分数重排序 20 篇 → 返回最终排序 │
│ │
│ 耗时:~500ms(20篇) 精度:准 │
└──────────────────────────────────────────────────────────┘

关键区别就一句话:Embedding 是两个句子分别压成向量、算完再比较(快但粗糙);Reranker 是把两句话拼在一起,让 Transformer 逐词交互后直接打分(慢但精准)。 两阶段配合,才能在毫秒级响应下做到精准检索。

后记

这篇文章源自我在消化 EmbeddingServiceOnnxEmbeddingTranslatorOnnxRerankerTranslator 这几个文件时反复追问的问题。最让我意外的发现是:这些代码里”没人调”的方法(processInput/processOutput)实际上被 DJL 框架在 predictor.predict() 内部按约定好的顺序调用——跟 Spring 调你的 preHandle()、Tomcat 调你的 doGet() 是一回事。

如果你也是传统后端出身、想理解 AI 检索的底层机制,建议从这几个文件入手。代码量不大(一共不到 400 行),但每行都有它存在的道理。