RAG 底层探秘:从文本到向量的全过程——Embedding、Tokenizer、Reranker 与 Self-Attention 科普
引言:一个传统 Java 开发者的困惑
最近在维护 EduMind 这个 AI 教学平台时,我盯着 EmbeddingService.java 和 OnnxEmbeddingTranslator.java 看了很久——里面没有一个方法是我在业务代码里显式调用的,但它们确实在跑。更让我好奇的是:为什么需要两个模型(Embedding + Reranker)先后处理同一批数据?”加几句话”为什么就能让排序变准?
这篇文章从我最困惑的几个问题出发,用尽可能通俗的方式讲清楚 RAG 检索管线里那几段”看不懂但又确实在跑”的代码。
如果你是一个传统后端开发者,对深度学习不太熟,这篇文章是写给你的。
一、EmbeddingService:看起来像下载器,实际上是个翻译官
EmbeddingService.java 有 230 行代码,其中约 120 行在下载模型文件(model.onnx, tokenizer.json 等),看起来像个下载工具。但它的核心功能只有三个方法:
1 | float[] embedQuery(String query); // 把用户问题转成向量 |
输入中文,输出 512 个浮点数。 这 512 个数就是这段文字的”语义指纹”——两段意思相近的文字,它们的向量在数学空间里也挨得近。
1 | 输入: "C语言中指针和数组的区别" |
但这个转换不是一步完成的。中间有一个关键角色:OnnxEmbeddingTranslator——它是 DJL 框架和 ONNX 模型之间的”翻译官”。
二、Tokenizer:计算机是怎么”读”中文的?
模型不懂中文,只认识数字。Tokenizer 的任务就是把一句话翻译成一串数字。
1 | Encoding encoding = tk.encode("C语言中指针和数组的区别"); |
这一个调用,内部做了四件事:
1 | ① 分词(Tokenization) |
为什么不是简单按字切? 因为 String.split("") 会把”指针”切成”指”和”针”——丢失了它是一个语义单元的信息。Tokenizer 知道”指针”是一个词、”数组”是一个词,这个能力来自预训练词表。
三、processInput:打包快递,喂给模型
processInput 不做推理,只做打包——把 Tokenizer 的输出转换成 ONNX 模型吃得进去的张量格式。
1 | // ① long[] → NDArray,加 batch 维度 |
逐行解释:
| 操作 | 干了什么 | 类比 |
|---|---|---|
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 暂存到跨方法的”快递袋”里 | processInput 和 processOutput 是两个独立方法,没有共享变量,只能靠 context 传递 |
四、processOutput:洗数据,把模型输出变成可用向量
ONNX 推理完后输出的是 [1, 15, 512] ——1 个 batch × 15 个 token × 每个 512 维。但我们要的是整句话的 512 维向量,不是 15 个。需要两步加工:
第一步:Mean Pooling(平均池化)
15 个 token 各有一个 512 维向量,怎么合成一个?最简单的想法是取平均——但 padding 位置(全是 0 的 token)不能参与平均。
1 | hidden = [ |
代码里就是用 hidden * mask 把 padding 位置清零,然后求和除以有效 token 数。
为什么不能用 [CLS] token 直接当句子向量? BERT 原版设计的 [CLS] 是给”判断两句话是否相似”这种分类任务用的。直接拿它做句子嵌入效果很差——这是业界反复验证过的结论。BGE 模型的标准做法是 Mean Pooling。
第二步:L2 归一化
把向量的长度缩放到 1。归一化后的向量,两个向量的点积 = 余弦相似度——pgvector 用点积做检索,省了每次算余弦。
1 | 归一化前:[3.0, 4.0, 0.0] 长度 = √(9+16) = 5 |
五、粗筛 vs 精排:为什么需要两个模型?
这是我最困惑的问题。有了 Embedding 做向量相似度检索,为什么还要一个 Reranker?
因为它们是两种完全不同的模型架构:
Bi-Encoder(Embedding):各自编码,事后比较
1 | 喂模型时: 比较时: |
Query 编码时,模型完全不知道 Doc 长什么样。Doc 编码时也不知道 Query 是什么。两个向量各自独立生成,最后才做一次点积比较。
优点:Doc 向量可以离线算好存进 pgvector,搜索时只算 Query 向量(毫秒级),能在一万篇里快速召回。
缺点:压缩成 512 个数时丢失了细节。比如”Python 中遍历列表”和”Java 中遍历列表”在向量空间里可能非常接近(都在说”遍历列表”),但它们的编程语言不同——这个关键差异可能被压缩过程模糊掉了。
Cross-Encoder(Reranker):拼成一句话,让注意力逐词交互
1 | 喂模型时: |
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 | 位置 1: "Java" ← 位置 1 的编码 |
即使两个不同位置的词语义相关(比如 query 里有”遍历”,doc 里也有”遍历”),位置编码也会告诉模型:”这两个’遍历’在不同位置,不在同一个上下文中。”
第三层:注意力矩阵自然形成的聚类
Self-Attention 通过计算词与词之间的相似度,会自然地把语义相近的词聚在一起:
1 | 注意力矩阵(简化): |
Query 侧的词互相之间注意力高,Doc 侧也各自抱团。[SEP] 作为分隔符没有语义,谁也不吸引,自然形成了一道墙。
这三层叠加,模型不需要被显式告知”边界在哪”——[SEP] 标出边界 + 位置编码拉开距离 + 注意力自然聚类 = 模型自己学会了区分。
有趣的是,bge-reranker 模型甚至去掉了 BERT 原版的
token_type_ids(显式标注”这是句子 A / 这是句子 B”的字段)。实验证明:去掉这个显式标签后,模型被逼着靠语义和位置来判断边界,泛化能力反而更好。
七、总结:一张图看清全过程
1 | 用户搜索: "C语言指针和数组的区别" |
关键区别就一句话:Embedding 是两个句子分别压成向量、算完再比较(快但粗糙);Reranker 是把两句话拼在一起,让 Transformer 逐词交互后直接打分(慢但精准)。 两阶段配合,才能在毫秒级响应下做到精准检索。
后记
这篇文章源自我在消化 EmbeddingService、OnnxEmbeddingTranslator、OnnxRerankerTranslator 这几个文件时反复追问的问题。最让我意外的发现是:这些代码里”没人调”的方法(processInput/processOutput)实际上被 DJL 框架在 predictor.predict() 内部按约定好的顺序调用——跟 Spring 调你的 preHandle()、Tomcat 调你的 doGet() 是一回事。
如果你也是传统后端出身、想理解 AI 检索的底层机制,建议从这几个文件入手。代码量不大(一共不到 400 行),但每行都有它存在的道理。






