算法可视化与交互学习平台
学习 Embedding:从 Token 表示到语义向量空间Embedding: From Tokens to Semantic Vector Spaces
作为 RAG → Agent 系列的起点,从 text → tokenizer → embedding → vector space → cosine similarity 建立完整数学与工程直觉;通过 2D/3D 空间、Pooling、归一化、相似度矩阵和 Top-k 检索实验,把经典词向量自然过渡到 modern sentence embedding。
RAG 的第一块地基:把文本变成可以比较的坐标
No.9 已经给出关键边界:**领域知识不应该全部塞进 LoRA 权重,而应由 RAG 在需要时检索。** 但检索系统首先要回答一个更基础的问题:计算机怎样判断 query 与 document 在语义上接近?
本模块不把 Embedding 当成黑盒 API,也不使用手写语义轴或模拟向量。全部语言实验调用同一个多语言 MiniLM 模型,在服务器 CPU 上执行真实 tokenizer、Transformer forward、Pooling、Normalization、PCA 与最近邻检索;每组实验附有可复制的 Python 代码。
No.14–18:从语义坐标走到可行动的 Agent
| 模块 | 核心问题 | 交付给下一站 |
|---|---|---|
| No.14 Embedding | 文本怎样成为可比较的语义向量? | query / chunk vectors、similarity、top-k |
| No.15 Vector Search | chunk 数增长后,怎样用 Exact / HNSW / IVF 高效找回近邻? | 可评估的向量索引与 search contract |
| No.16 RAG Pipeline | 怎样组合 chunking、retrieval、context、citation 与 evaluation? | 有来源、可诊断的生成回答 |
| No.17 ReAct Agent | 模型怎样依据 Observation 选择下一动作,并在预算内恢复、停止? | 可控的 state → action → observation 循环 |
| No.18 Planning Agent | 怎样把完整 Goal 分解成 DAG,执行、追踪并在结构性失败后重新规划? | 可验收的全局任务完成闭环 |
先消除歧义:参数表、上下文表示、句向量与相关性分数
先严格区分四个常被统称为 Embedding 的量,再深入拆解 nn.Embedding:词表如何确定行地址、weight 怎样初始化、训练目标怎样经 backward 聚合到参数行、optimizer 怎样更新并保存 checkpoint。页面同时读取当前多语言 MiniLM 的真实 250,037×384 参数表,并执行可逐项复核的 PyTorch autograd 实验。
| 准确名称 | 形状示例 | 怎样得到 | 用途 | 是 embedding? |
|---|---|---|---|---|
| Token embedding table row | [d_model] | token id 从可训练参数矩阵 W 中选中的一行;尚未结合上下文 | Transformer 输入的组成部分 | 是 |
| Contextual token state | [tokens, d_model] | 经过 Transformer 后、已经吸收整句上下文的每个 token 表示 | NER、token prediction、下游 pooling | 通常是 |
| Sentence / chunk embedding | [d_embedding] | 把 contextual states 聚合并按训练约定处理后的单一文本向量 | RAG、聚类、去重、语义搜索 | 是 |
| Cross-encoder relevance score | [1] | query 与 document 联合编码后输出的相关性标量,不是向量表示 | 对召回结果做 rerank | 不是 |
Tokenizer 先固定 token ↔ id 映射。id 只回答“访问 W 的哪一行”,相邻 id 没有相邻语义。
创建 W∈ℝ^{V×d}。随机初值只打破对称性,此时各行还没有可靠语义。
W[input_ids] 进入后续网络,模型依据 MLM、对比学习等真实训练目标计算 loss。
同一 token 在批次出现多次时,对应位置的梯度会累加到 W 的同一行;未出现行通常没有本步梯度。
optimizer 修改被访问的参数行;海量批次反复更新后,checkpoint 保存学到的 W,推理时再加载并只做查表。
token_id = input_ids[0, position]
embedding_table = model.get_input_embeddings().weight
vector = embedding_table[token_id]
one_hot = F.one_hot(token_id, num_classes=embedding_table.shape[0])
same_vector = one_hot.to(embedding_table.dtype) @ embedding_tableW 的数值由训练学习;k 只负责选择哪一行。下面的实验直接读取模型当前参数,不使用手写向量。
真实 MiniLM 代码:读取已训练参数表、验证 lookup、计算完整 BERT embedding 子层
import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer
MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
TEXT = "The bank approved the loan."
TOKEN_POSITION = 1
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
model = AutoModel.from_pretrained(MODEL_ID).eval().to("cpu")
batch = tokenizer([TEXT], return_tensors="pt", truncation=True, max_length=128)
with torch.inference_mode():
input_ids = batch["input_ids"]
embedding_layer = model.get_input_embeddings()
embedding_table = embedding_layer.weight
token_id = input_ids[0, TOKEN_POSITION]
# 实际 forward:nn.Embedding 使用整数索引选择参数矩阵的一行。
lookup_by_index = embedding_table[token_id]
# 仅用于验证代数等价;生产推理不会构造这个巨大 one-hot。
one_hot = F.one_hot(token_id, num_classes=embedding_table.shape[0])
lookup_by_one_hot = one_hot.to(embedding_table.dtype) @ embedding_table
torch.testing.assert_close(lookup_by_index, lookup_by_one_hot)
# 当前 BERT/MiniLM 的完整“模型输入 embedding 子层”还会加入位置与类型。
token_type_ids = batch.get("token_type_ids", torch.zeros_like(input_ids))
model_input = model.embeddings(
input_ids=input_ids,
token_type_ids=token_type_ids,
) # word + position + token_type → LayerNorm → Dropout(eval 时关闭)
tokens = tokenizer.convert_ids_to_tokens(input_ids[0].tolist())
parameter_name = next(
name for name, parameter in model.named_parameters()
if parameter is embedding_table
)
print("tokens:", tokens)
print("selected token / id:", tokens[TOKEN_POSITION], token_id.item())
print("module:", embedding_layer)
print("parameter:", parameter_name)
print("embedding table shape:", tuple(embedding_table.shape))
print("parameter count:", embedding_table.numel())
print("memory bytes:", embedding_table.numel() * embedding_table.element_size())
print("lookup[:8]:", lookup_by_index[:8].tolist())
print("max lookup error:", (lookup_by_index - lookup_by_one_hot).abs().max().item())
print("model input[:8]:", model_input[0, TOKEN_POSITION, :8].tolist())真实 PyTorch 代码:nn.Embedding 初始化、forward、backward 与 SGD 更新
import torch
import torch.nn.functional as F
# 固定种子让页面与读者本地得到同一组真实 PyTorch 初值。
torch.manual_seed(17)
V, d, padding_idx = 6, 3, 0
learning_rate = 0.25
input_ids = torch.tensor([[1, 2, 2, 0], [3, 2, 4, 0]])
target_value = 0.5
# 原生 nn.Embedding:W 是 Parameter,不是由 token id 临时算出的数组。
embedding = torch.nn.Embedding(V, d, padding_idx=padding_idx)
optimizer = torch.optim.SGD(embedding.parameters(), lr=learning_rate)
optimizer.zero_grad(set_to_none=True)
W_before = embedding.weight.detach().clone()
# 1) forward lookup
Y = embedding(input_ids)
# 2) 用小词表真实验证 one_hot(input_ids) @ W 与 lookup 完全相同
one_hot = F.one_hot(input_ids, num_classes=V).to(W_before.dtype)
Y_by_one_hot = one_hot @ W_before
torch.testing.assert_close(Y, Y_by_one_hot)
# 3) 一个真实执行、但目标受控的 MSE 机制审计步。
# 0.5 是为了便于手算而选择的常数,不是语料提供的语义标签。
target = torch.full_like(Y, target_value)
Y.retain_grad()
loss = F.mse_loss(Y, target, reduction="mean")
loss.backward()
# 4) 按微积分公式手算应有的行梯度,再与 autograd 比较
manual_grad = torch.zeros_like(W_before)
for token_id in input_ids.flatten().tolist():
if token_id != padding_idx:
manual_grad[token_id] += 2 * (
W_before[token_id] - target_value
) / Y.numel()
torch.testing.assert_close(embedding.weight.grad, manual_grad)
# 5) SGD 更新:W_new = W_old - learning_rate * dL/dW
optimizer.step()
W_after = embedding.weight.detach().clone()
print("input_ids:\n", input_ids)
print("W_before:\n", W_before)
print("Y shape:", tuple(Y.shape))
print("loss:", loss.item())
print("dL/dY:\n", Y.grad)
print("dL/dW:\n", embedding.weight.grad)
print("W_after:\n", W_after)
print("one-hot max error:", (Y.detach() - Y_by_one_hot).abs().max().item())
print("gradient max error:", (embedding.weight.grad - manual_grad).abs().max().item())实验:text → tokenizer → embedding → pooling → vector
承接上一卡已经解释清楚的模型输入 embedding 子层:输入中英文句子,由真实多语言 MiniLM tokenizer 与 12 层 Transformer 在 CPU 上计算 contextual hidden states,再对同一真实结果切换 Mean / [CLS] / Max Pooling 和 L2 normalization,得到固定 384 维句向量。
paraphrase-multilingual-MiniLM-L12-v2
它是 Sentence-Transformers 体系中的多语言句子编码器:把句子或段落映射到固定 384 维空间,适合语义搜索、聚类和相似句比较。它不是生成式语言模型,也不是把 query 与 document 拼接后打分的 cross-encoder;在 RAG 中它承担的是可离线预计算的 bi-encoder 向量生成。
为什么本实验选择它?怎样得到正确的 sentence embedding?
AutoModel 首先返回形状为 [batch, tokens, 384] 的 contextual token states。必须再按 attention mask 做 mean pooling,才能得到 [batch, 384] 的句向量;这也是该模型发布时给出的 Transformers 用法。
384 维比大型生成模型的 hidden size 更轻,模型可在 CPU 常驻运行;多语言词表让中英文输入使用同一个向量空间。页面因此能用同一真实 encoder 演示 pooling、normalization、cosine 与 top-k。
项目直接复用已经安装的 torch 与 transformers,显式实现 tokenizer、forward 和 masked mean pooling。这样每一步都可见、可复制,也更适合只有 CPU 的服务器。
模型架构最多支持 512 个位置;页面为控制 CPU 延迟把单次输入截断到 128 tokens。Mean pooling 是默认句向量路径,[CLS] 与 Max 只作为对照实验。更换模型、pooling 或 normalization 后,旧向量不能继续混用。
这个 checkpoint 是怎样训练出来的?(教师模型 → 多语言学生 → 384 维蒸馏目标)
它不是一次训练直接得到的,而是把两条已经训练过的分支汇合起来:英文教师负责提供“什么样的句向量空间是有语义的”,多语言学生负责读懂不同语言的 token。最后再用平行翻译句把学生的多种语言映射到教师的同一语义坐标。
↓ sentence-level training + mean pooling
paraphrase-MiniLM-L12-v2
英文 MiniLM 基座本身由 BERT-Base 规模的 UniLM v2 教师做深层 self-attention 蒸馏,得到 12 层、hidden size 384 的小模型;随后用多来源的语义相关句对继续训练,才成为这里冻结的英文 sentence-embedding 教师。
↓ deep self-attention distillation
microsoft/Multilingual-MiniLM-L12-H384
这个学生起点同样是 12 层、384 hidden size,但由 XLM-R Base 压缩而来,并沿用 XLM-R 的多语言 tokenizer。它已经能处理多语言 token,却还没有继承英文教师那套适合相似度与检索的句向量几何。
+ trainable multilingual student
+ 50+ languages parallel sentences
↓ MSE
current checkpoint
对每个“英文原句 ↔ 译文”样本,教师只编码英文原句并生成目标向量;学生分别编码英文与译文。训练同时拉近两条学生向量与同一个教师向量,结束后只保留学生参数。
z_T = mean_pool(Teacher(s_en)) # [B, 384], stop-gradient z_S,en = mean_pool(Student(s_en)) # [B, 384] z_S,x = mean_pool(Student(t_x)) # [B, 384] L = MSE(z_S,en, z_T) + MSE(z_S,x, z_T)
这里的 z_T 是教师根据真实英文句子算出的 384 个浮点数。对 PyTorch 默认的 mean reduction,单个学生分支中第 j 个坐标的梯度为 2(z_S[j] − z_T[j]) / (B × 384)。梯度再穿过 mean pooling、12 层 Transformer,回到学生的 attention、MLP 与 embedding table;教师参数始终不更新。
教师和学生使用不同 tokenizer 也没有矛盾:训练不要求两边 token 一一对应,只要求整句 pooling 后的 384 维输出一致。这样“同义句应接近”的英文几何会迁移给中文、德文等语言。
官方 Sentence-Transformers 资料列出的教师训练来源包括 AllNLI、sentence-compression、SimpleWiki、AltLex、MS MARCO triples、Quora duplicates、COCO / Flickr30k captions、Yahoo title-question、S2ORC citation pairs、Stack Exchange duplicate questions 与 Wikipedia atomic edits。它们提供复述、蕴含、问答、检索、图文描述和编辑前后文本等多种“语义相关句对”。
因此,多语言阶段不是从零发明 384 个坐标的含义,而是让学生模仿一个已经由这些句级任务塑造好的教师空间。译文与原句共享同一 z_T,跨语言对齐由此出现。
只使用项目已有的 torch 与 transformers。示例句对是官方 parallel-sentences-talks 文档公开的真实 en-de 样本;首次运行会下载教师与学生模型。单步可在 CPU 运行,完整 50+ 语言训练则是离线训练任务,不应放进网页请求。
import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer
# The published models used by the original training recipe.
TEACHER_ID = "sentence-transformers/paraphrase-MiniLM-L12-v2"
STUDENT_INIT_ID = "microsoft/Multilingual-MiniLM-L12-H384"
MAX_LENGTH = 128
# This is the real en-de sample row shown in the official
# sentence-transformers/parallel-sentences-talks documentation.
source_texts = [
"So I think practicality is one case where it's worth "
"teaching people by hand."
]
translated_texts = [
"Ich denke, dass es sich aus diesem Grund lohnt, den Leuten "
"das Rechnen von Hand beizubringen."
]
device = torch.device("cpu")
teacher_tokenizer = AutoTokenizer.from_pretrained(TEACHER_ID)
student_tokenizer = AutoTokenizer.from_pretrained(STUDENT_INIT_ID)
teacher = AutoModel.from_pretrained(TEACHER_ID).to(device).eval()
student = AutoModel.from_pretrained(STUDENT_INIT_ID).to(device).train()
# The teacher is a fixed target generator: only the student is updated.
for parameter in teacher.parameters():
parameter.requires_grad_(False)
# A runnable continuation-step setting; the original full optimizer schedule
# was not preserved in this old checkpoint's public reproducibility metadata.
optimizer = torch.optim.AdamW(student.parameters(), lr=2e-5)
def masked_mean(last_hidden_state, attention_mask):
mask = attention_mask.unsqueeze(-1).to(last_hidden_state.dtype)
summed = (last_hidden_state * mask).sum(dim=1)
counts = mask.sum(dim=1).clamp_min(1e-9)
return summed / counts
def encode(model, tokenizer, texts):
batch = tokenizer(
texts,
padding=True,
truncation=True,
max_length=MAX_LENGTH,
return_tensors="pt",
).to(device)
hidden = model(**batch).last_hidden_state
return masked_mean(hidden, batch["attention_mask"])
# z_T is a real 384D teacher output, not a hand-written scalar label.
with torch.no_grad():
z_teacher = encode(teacher, teacher_tokenizer, source_texts)
# The student has its own multilingual tokenizer. Token boundaries and IDs
# may differ from the teacher; only the final sentence vectors must agree.
z_student_source = encode(student, student_tokenizer, source_texts)
z_student_translation = encode(student, student_tokenizer, translated_texts)
# PyTorch MSE averages over the batch and all 384 coordinates.
loss_source = F.mse_loss(z_student_source, z_teacher)
loss_translation = F.mse_loss(z_student_translation, z_teacher)
loss = loss_source + loss_translation
optimizer.zero_grad()
loss.backward() # gradients pass through pooling, Transformer and lookup
first_gradient = next(
parameter.grad
for parameter in student.parameters()
if parameter.grad is not None
)
optimizer.step() # one real continuation-distillation update
print("teacher shape:", tuple(z_teacher.shape)) # (1, 384)
print("source MSE:", loss_source.item())
print("translation MSE:", loss_translation.item())
print("total loss:", loss.item())
print("first gradient norm:", first_gradient.norm().item())完整可运行代码:串联 tokenizer、forward、pooling、normalization
import torch
from transformers import AutoModel, AutoTokenizer
MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
TEXT = "A kitten is sleeping on the sofa."
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
model = AutoModel.from_pretrained(MODEL_ID).eval().to("cpu")
batch = tokenizer(
[TEXT],
padding=True,
truncation=True,
max_length=128,
return_tensors="pt",
)
POOLING = "mean" # mean | cls | max
with torch.inference_mode():
hidden = model(**batch).last_hidden_state
mask = batch["attention_mask"].unsqueeze(-1).to(hidden.dtype)
if POOLING == "cls":
pooled = hidden[:, 0]
elif POOLING == "max":
masked = hidden.masked_fill(mask == 0, torch.finfo(hidden.dtype).min)
pooled = masked.max(dim=1).values
else:
pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp_min(1.0)
NORMALIZE = True
embedding = (
torch.nn.functional.normalize(pooled, p=2, dim=1)
if NORMALIZE
else pooled
)
print(tokenizer.convert_ids_to_tokens(batch["input_ids"][0].tolist()))
print("hidden shape:", tuple(hidden.shape))
print("embedding:", embedding[0].tolist())Pooling 与归一化:从 token matrix 到 sentence vector
Encoder 产生 n 个 contextual token vectors。Pooling 把 [n,d] 压成 [d];L2 normalization 再把它投到单位球面。Mean pooling 给每个有效 token 相同权重,[CLS] pooling 依赖专门训练过的汇总位置,Max pooling 保留每一维最强激活。
Mean pooling 必须使用 attention mask
批量输入含有 padding 时,只能平均真实 token;否则不同长度句子的向量会被 padding 污染。
维度不是可直接解释的标签
384 维并不表示 384 个可以逐一命名的主题。语义通常分布在许多方向组合中;维度越高,存储和索引成本也越高。
模型决定 Pooling 规则
不要看到 Transformer 就任意取 [CLS]。应遵循 embedding 模型的训练方式、query/document prefix 与官方 pooling 配置。
Cosine similarity:比较方向,而不是绝对长度
Dot product 同时受方向与向量长度影响;cosine 除以两个范数后只比较夹角。如果 q 与 d 都预先 L2-normalize,那么 dot product 与 cosine 数值相同,可以用高效内积索引完成检索。
方向相同但长度不同
cosine 仍接近 1;raw dot product 会偏向长度更大的向量。
零向量没有 cosine
工程实现需要防止除以 0,并检查空文本或异常 encoder 输出。
距离度量必须与索引一致
模型文档、离线向量生成、向量数据库 metric 与在线 query normalization 必须使用同一约定。
实验:Dot product 与 Cosine 到底差在哪里
拖动夹角和两个向量的范数,比较 raw dot product、cosine 与 normalized dot。重点观察:向量长度改变时,cosine 不变,而 dot product 会改变排序偏好。
从坐标逐项算出 Dot Product 与 Cosine
所有显示值都直接来自当前 a、b 数组;没有语义标签或预设分数。
θ = 34° = 0.593412 rad a = [1.2000, 0] b = [2.4000cosθ, 2.4000sinθ] = [1.9897, 1.3421]
b 的两个坐标由当前长度与角度直接计算。
a·b = a₁b₁ + a₂b₂
= 1.2000×1.9897 + 0.0000×1.3421
= 2.3876内积同时包含长度和方向。
‖a‖₂ = √(1.2000²+0.0000²) = 1.2000 ‖b‖₂ = √(1.9897²+1.3421²) = 2.4000
范数来自同一坐标数组。
cos(a,b) = 2.3876 / (1.2000×2.4000) = 0.8290 â·b̂ = 0.8290
两向量归一化后,dot 与 cosine 数值相同。
复制并运行:NumPy 复现当前滑杆数值
import numpy as np
theta = np.deg2rad(34)
a = np.array([1.2, 0.0])
b = np.array([2.4 * np.cos(theta), 2.4 * np.sin(theta)])
dot_product = a @ b
norm_a, norm_b = np.linalg.norm(a), np.linalg.norm(b)
cosine = dot_product / (norm_a * norm_b)
normalized_dot = (a / norm_a) @ (b / norm_b)
print({"a": a, "b": b, "dot": dot_product,
"cosine": cosine, "normalized_dot": normalized_dot})实验:句子如何在 2D / 3D 语义空间中移动
先用真实 MiniLM 生成 384 维句向量,再通过实际 PCA 投影到 2D / 3D;在向量插值过程中观察真实高维 cosine 最近邻怎样改变,并明确区分高维检索与低维投影。
target: A kitten is sleeping on the couch.
复制并运行:真实编码、PCA 与最近邻
import numpy as np
import torch
from transformers import AutoModel, AutoTokenizer
MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
sentences = [
"A cat is resting on the sofa.",
"A kitten is sleeping on the couch.",
"A dog is running through the park.",
"Python reads a CSV file with pandas.",
"React renders an interactive user interface.",
"The train arrives at a station in Paris.",
"Passengers transfer between railway platforms.",
"Tomato soup tastes better with fresh basil."
]
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
model = AutoModel.from_pretrained(MODEL_ID).eval().to("cpu")
batch = tokenizer(sentences, padding=True, truncation=True,
max_length=128, return_tensors="pt")
with torch.inference_mode():
hidden = model(**batch).last_hidden_state
mask = batch["attention_mask"].unsqueeze(-1).to(hidden.dtype)
E = (hidden * mask).sum(1) / mask.sum(1).clamp_min(1)
E = torch.nn.functional.normalize(E, p=2, dim=1).numpy()
t = 0
target_index = 1
current = (1 - t) * E[0] + t * E[target_index]
current /= np.linalg.norm(current)
# Real PCA: center then SVD; plotting uses only the projection.
centered = E - E.mean(axis=0, keepdims=True)
_, singular_values, vt = np.linalg.svd(centered, full_matrices=False)
points_3d = centered @ vt[:3].T
current_3d = (current - E.mean(axis=0)) @ vt[:3].T
scores = E @ current
print("PCA point:", current_3d)
print("nearest indices:", np.argsort(-scores)[:4])实验:Semantic similarity matrix 与 Query–Document Nearest Neighbors
用同一真实 encoder 编码 query 与公开可见的文档语料,改变 top-k,并切换 normalized cosine 与 raw dot product;所有排行榜和 pairwise similarity matrix 都由当前 384 维模型输出即时计算。
D1–D6 在当前代码实验中分别是什么?(展开查看完整语料与向量行号)
D1–D6 是这个小型检索实验的 6 条候选文档,不是模型内置标签,也不是 embedding 的第 1–6 个维度。 页面只把每条记录的 text 送入 tokenizer;id / title / category 只用于把模型输出重新对应到可读文档。
| ID | E 中的行 | D_raw 中的行 | 主题 | 标题 | 真正送入模型的文本 |
|---|---|---|---|---|---|
| D1 | E[1] | D_raw[0] | code | Python CSV | Load a CSV file with Python by calling pandas.read_csv. |
| D2 | E[2] | D_raw[1] | code | Pandas table | A pandas DataFrame stores tabular rows and columns. |
| D3 | E[3] | D_raw[2] | travel | Paris train | Gare du Nord is a major railway station in Paris. |
| D4 | E[4] | D_raw[3] | travel | Rail transfer | Passengers can transfer between trains at the central station. |
| D5 | E[5] | D_raw[4] | pets | Cat sofa | A cat is sleeping quietly on the sofa. |
| D6 | E[6] | D_raw[5] | pets | Kitten couch | The kitten rests on a comfortable couch. |
批次顺序是 [query, D1, D2, D3, D4, D5, D6],所以输出矩阵 E 的第 0 行属于 Query,第 1–6 行依次属于 D1–D6。切出 D_raw = E[1:] 后,Python 的 0-based 行号才变成 D_raw[0] ↔ D1。
documents = [
{
"id": "D1",
"title": "Python CSV",
"category": "code",
"text": "Load a CSV file with Python by calling pandas.read_csv."
},
{
"id": "D2",
"title": "Pandas table",
"category": "code",
"text": "A pandas DataFrame stores tabular rows and columns."
},
{
"id": "D3",
"title": "Paris train",
"category": "travel",
"text": "Gare du Nord is a major railway station in Paris."
},
{
"id": "D4",
"title": "Rail transfer",
"category": "travel",
"text": "Passengers can transfer between trains at the central station."
},
{
"id": "D5",
"title": "Cat sofa",
"category": "pets",
"text": "A cat is sleeping quietly on the sofa."
},
{
"id": "D6",
"title": "Kitten couch",
"category": "pets",
"text": "The kitten rests on a comfortable couch."
}
]
# 只有 text 会送入 tokenizer;id、title、category 是展示与追踪用 metadata。
texts = [query, *[document["text"] for document in documents]]
# 模型完成 forward + mean pooling 后,行号关系保持不变:
# E[0] = query
# E[1] = D1, E[2] = D2, ..., E[6] = D6
q_raw = E[0]
D_raw = E[1:] # D_raw[0] 是 D1,D_raw[5] 是 D6复制并运行:真实 query-document 检索与相似度矩阵
import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer
MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
query = "How can Python open a CSV file?"
documents = [
{
"id": "D1",
"title": "Python CSV",
"category": "code",
"text": "Load a CSV file with Python by calling pandas.read_csv."
},
{
"id": "D2",
"title": "Pandas table",
"category": "code",
"text": "A pandas DataFrame stores tabular rows and columns."
},
{
"id": "D3",
"title": "Paris train",
"category": "travel",
"text": "Gare du Nord is a major railway station in Paris."
},
{
"id": "D4",
"title": "Rail transfer",
"category": "travel",
"text": "Passengers can transfer between trains at the central station."
},
{
"id": "D5",
"title": "Cat sofa",
"category": "pets",
"text": "A cat is sleeping quietly on the sofa."
},
{
"id": "D6",
"title": "Kitten couch",
"category": "pets",
"text": "The kitten rests on a comfortable couch."
}
]
# 顺序决定输出行号:E[0]=Query,E[1]=D1,...,E[6]=D6。
texts = [query, *[document["text"] for document in documents]]
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
model = AutoModel.from_pretrained(MODEL_ID).eval().to("cpu")
batch = tokenizer(texts, padding=True, truncation=True,
max_length=128, return_tensors="pt")
with torch.inference_mode():
hidden = model(**batch).last_hidden_state # [7, L, 384]
mask = batch["attention_mask"].unsqueeze(-1).to(hidden.dtype)
E = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp_min(1)
q_raw = E[0] # [384],当前 Query
D_raw = E[1:] # [6, 384],D1...D6
print("hidden:", tuple(hidden.shape))
print("E / q / D:", tuple(E.shape), tuple(q_raw.shape), tuple(D_raw.shape))
NORMALIZE_BEFORE_SEARCH = True
if NORMALIZE_BEFORE_SEARCH:
# 对每一个向量单独做 L2 normalization。
q_search = F.normalize(q_raw, p=2, dim=0)
D_search = F.normalize(D_raw, p=2, dim=1)
metric_name = "cosine"
else:
# 不改变长度;点积会同时受到方向与向量模长影响。
q_search = q_raw
D_search = D_raw
metric_name = "raw dot product"
print("raw query norm:", torch.linalg.vector_norm(q_raw).item())
print("search query norm:", torch.linalg.vector_norm(q_search).item())
# [6, 384] @ [384] -> [6]:每个文档得到一个真实分数。
scores = D_search @ q_search
for row_index, score in enumerate(scores):
document = documents[row_index] # row 0 -> D1, ..., row 5 -> D6
print(document["id"], document["text"], float(score))
TOP_K = 3
order = torch.argsort(scores, descending=True) # 对 6 个真实分数降序排列
top_indices = order[:TOP_K] # exact top-k,不是 ANN
for rank, row_index in enumerate(top_indices.tolist(), start=1):
document = documents[row_index]
print(rank, document["id"], document["title"],
f"{metric_name}={scores[row_index].item():.6f}")
# Similarity matrix 始终使用 cosine,与上面的检索开关相互独立。
# dim=1 表示每一行(每一篇文档)独立归一化,而不是按列归一化。
D_unit = F.normalize(D_raw, p=2, dim=1)
row_norms = torch.linalg.vector_norm(D_unit, ord=2, dim=1)
print(row_norms) # tensor([1., 1., 1., 1., 1., 1.])
# [6, 384] @ [384, 6] -> [6, 6]
M = D_unit @ D_unit.T
# Python 是 0-based:M[0, 1] 就是页面标出的 M₁₂,即 D1 与 D2。
i, j = 0, 1
cell = M[i, j]
manual = (D_raw[i] * D_raw[j]).sum() / (
torch.linalg.vector_norm(D_raw[i]) * torch.linalg.vector_norm(D_raw[j])
)
print("M12:", cell.item(), "manual cosine:", manual.item())
# 验证全部 36 个格子,而不只验证示例 M12。
for i in range(len(documents)):
for j in range(len(documents)):
direct = F.cosine_similarity(D_raw[i], D_raw[j], dim=0)
torch.testing.assert_close(M[i, j], direct)
torch.testing.assert_close(M, M.T) # Mij = Mji
torch.testing.assert_close(torch.diag(M), torch.ones(6))
print("all 36 cells verified")
print("similarity matrix:
", M)从相似度到 RAG 召回:Top-k 不是最终答案
Query encoder f_q 与 document encoder f_d 把两侧文本映射到兼容空间,再按 similarity 取前 k 个候选。Dual encoder 让文档向量可以离线预计算;Top-k 只负责召回,后面仍可能需要 metadata filter、dedup、reranker 与 context budget 控制。
Exact search vs ANN
小数据可以逐一计算相似度;百万级向量通常使用 HNSW、IVF 等近似最近邻索引,以少量召回损失换取速度与内存效率。
Bi-encoder recall + cross-encoder rerank
第一阶段独立编码、快速召回;第二阶段把 query 与候选拼在一起联合编码,成本更高但相关性判断更细。
Top-k 不是越大越好
过小可能漏证据,过大可能引入噪声、挤占上下文并诱发错误回答。应使用 Recall@k、MRR、nDCG 与端到端回答指标共同评估。
真实检验:king − man + woman ≈ queen 吗?走向 Contextual Sentence Embedding
让真实 sentence encoder 检验 king − man + woman 是否真的把 queen 排在最前,并直接比较 bank 在金融与河岸句子中的 lookup vector、最终 contextual hidden state 和 sentence embedding;不预设答案,不伪造 Attention 贡献。
复制并运行:真实类比排名与 bank contextual hidden states
import numpy as np
import torch
from transformers import AutoModel, AutoTokenizer
MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
words = ["king","queen","man","woman","prince","princess","duke","duchess","emperor","empress","boy","girl","monarch","royal"]
contexts = ["The bank approved the loan.","We sat on the river bank."]
texts = words + contexts
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True)
model = AutoModel.from_pretrained(MODEL_ID).eval().to("cpu")
batch = tokenizer(texts, padding=True, truncation=True,
max_length=128, return_tensors="pt")
with torch.inference_mode():
hidden = model(**batch).last_hidden_state
lookup = model.get_input_embeddings()(batch["input_ids"])
mask = batch["attention_mask"].unsqueeze(-1).to(hidden.dtype)
pooled = (hidden * mask).sum(1) / mask.sum(1).clamp_min(1)
E = torch.nn.functional.normalize(pooled, p=2, dim=1).numpy()
index = {word: i for i, word in enumerate(words)}
analogy = E[index["king"]] - E[index["man"]] + E[index["woman"]]
analogy /= np.linalg.norm(analogy)
scores = E[:len(words)] @ analogy
for blocked in ["king", "man", "woman"]:
scores[index[blocked]] = -np.inf
print("actual analogy ranking:", [(words[i], float(scores[i]))
for i in np.argsort(-scores)[:6]])
# Compare the real contextual bank hidden states.
for row, sentence in enumerate(contexts, start=len(words)):
tokens = tokenizer.convert_ids_to_tokens(batch["input_ids"][row].tolist())
bank_index = next(i for i, token in enumerate(tokens)
if token.lstrip("▁Ġ").lower() == "bank")
print(sentence, "token_id=", int(batch["input_ids"][row, bank_index]),
"lookup[:6]=", lookup[row, bank_index, :6].tolist(),
"hidden[:6]=", hidden[row, bank_index, :6].tolist())Modern sentence embedding:训练目标比模型名字更重要
| 方案 | 怎样得到相关性 | 速度 | 最适合的位置 |
|---|---|---|---|
| Static Word2Vec / GloVe | 词级静态向量,句子常靠简单平均 | 快 | 教学、词语相似与轻量基线 |
| 通用 LLM hidden states + 任意 pooling | 有上下文,但未必为全句相似度优化 | 中 | 实验基线,不应默认等同检索模型 |
| Sentence dual encoder | 通过对比学习拉近正样本、推远负样本 | 检索快 | 大规模 embedding 与首阶段召回 |
| Cross encoder / reranker | query 与 document 联合 Attention 后打分 | 慢 | 对少量候选做精排 |
选择模型时检查什么
- 语言与领域是否匹配,是否支持跨语言 query-document 对齐。
- 最大 token 长度、截断策略、embedding dimension 与许可证。
- 是否要求
query:/passage:前缀,是否默认 normalize。 - 训练目标是 symmetric similarity、asymmetric retrieval,还是 instruction-aware embedding。
交给 No.15 的接口:Embedding 已经准备好了什么
| 本模块已经解决 | No.15 继续解决 |
|---|---|
| 文本怎样映射到固定维度向量 | Brute-force 全扫描与 Top-K heap 怎样工作 |
| dot / cosine / normalization 的关系 | metric 契约怎样进入 HNSW / IVF 索引 |
| similarity matrix 与 query top-k | 怎样用 Exact ground truth 评估 Recall@K |
| 低维图是投影,不是完整语义空间 | 怎样权衡 latency、内存、更新与重建成本 |
正在检查登录状态与模型配置…