使用 Transformers.js 和句子嵌入构建语义搜索

TL;DR · AI 摘要
语义搜索可以通过 Transformers.js 和句子嵌入(Sentence Embeddings)完全在客户端实现,无需服务器或 API 密钥即可通过向量空间的几何距离检索含义相近的内容。
核心要点
- 使用 Transformers.js 可在浏览器端运行 all-MiniLM-L6-v2 等模型,实现零后端基础设施的语义检索。
- 句子嵌入将文本映射到 384 维向量空间,语义相近的句子在几何距离上更接近。
- 必须通过 Mean Pooling(平均池化)将 Token 级向量转换为句子级向量,并进行归一化以简化相似度计算。
结构提纲
按章节快速跳转。
传统关键词搜索仅比较字符而非概念,导致无法识别含义相同但用词不同的查询。
句子嵌入将文本转换为浮点数向量,使语义相似的句子在向量空间中具有较短的几何距离。
all-MiniLM-L6-v2 模型将句子映射到 384 维空间,该模型基于 10 亿个句子对进行了微调。
通过平均池化(Mean Pooling)处理 Token 向量并进行归一化,可生成代表整个句子的单位长度向量。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Client-side Semantic Search
- Core Mechanism
- Sentence Embeddings (Text to Vector)
- Geometric Proximity (Similarity = Distance)
- Technical Pipeline
- Transformers.js (Client-side Execution)
- Mean Pooling & Normalization
- all-MiniLM-L6-v2 (384-dim Model)
- Advantages
- No Server/API Key required
- Privacy & Low Latency
金句 / Highlights
值得收藏与分享的关键句。
关键词搜索将两者视为无关字符串……它比较的是字符,而不是概念。
含义相似的句子会变成在同一个向量空间中几何距离接近的向量。
所使用的模型将每个句子映射到 384 维向量空间的一个点,并在超过 10 亿个句子对上进行了微调。
在本文中,您将了解句子嵌入(sentence embeddings)的工作原理,并学习如何使用 Transformers.js 构建一个完全基于客户端的语义搜索引擎——无需服务器、API 密钥或后端基础设施。
我们将涵盖的主题包括:
- 句子嵌入与余弦相似度如何构成语义搜索的基础。
- 如何使用 Transformers.js 的 feature‑extraction 管道生成并缓存嵌入,包括批处理和 Web Worker 的离线计算。
- 如何构建完整、可复用的
SemanticSearch类,并在页面加载之间持久化其索引。

Building Semantic Search with Transformers.js and Sentence Embeddings
介绍
您可能遇到过这种情况:用户在搜索栏输入“affordable laptop”,却得不到任何结果。但您知道数据库里有数十篇关于笔记本电脑的文章,它们的标题都是“budget notebook”。词语不同,意义却相同。关键词搜索把它们视为无关的字符串。
这不是一个边缘案例,而是关键词匹配的核心局限:它比较的是字符,而不是概念。它不知道“cancel”和“return”描述的是相关动作,“broken”和“defective”意味着同一件事,或者“I can’t log in”与“account access issue”是同一问题的两种说法。
句子嵌入到底是什么
语义搜索通过比较意义来解决这个问题。借助 Transformers.js,您可以在浏览器中完全实现它——不需要服务器、API 密钥或后端基础设施。本教程将完整演示整个流程:句子嵌入的工作原理、如何生成它们、余弦相似度如何评估相关性,以及如何将其整合到一个可运行的知识库搜索应用中。
Transformer 模型无法直接处理原始文本。在任何计算之前,句子必须被转换为数字。嵌入就是这种转换的结果:一句话被表示为一系列浮点数的向量。
关键属性不只是句子变成了数字,而是具有相似意义的句子会映射为几何上彼此接近的向量,且位于同一向量空间中。
本教程中使用的模型,sentence-transformers/all-MiniLM-L6-v2,将每个句子映射到一个 384 维向量空间中的点。该模型在超过 10 亿个句子对上进行微调,专门学习这种几何属性。“_I need to cancel my order_” 与 “_How do I return a product?_” 最终会靠得很近,而 “_The weather is beautiful today_” 则远离它们两者。
384 维度并非人类可读。您无法查看第 47 维并说它编码了什么。对搜索而言,重要的不是单个维度,而是两个向量之间的距离。距离越短,意义越相似;距离越大,意义越无关。

A 3D scatter plot diagram illustrating how semantically similar sentences cluster together in vector space (click to enlarge)
池化与归一化
原始 Transformer 模型为每个 token 输出一个向量;句子中的每个单词和子词都有自己的向量。对于语义搜索,您需要每个句子对应一个向量。
均值池化(mean pooling)通过对所有 token 向量进行平均(按注意力掩码加权),使得填充 token 不会贡献任何信息。随后进行归一化,将结果缩放到单位长度(模长 = 1),这简化了下一节中讨论的相似度计算。
在 Transformers.js 中,只需在管道调用时传入 { pooling: 'mean', normalize: true },上述两步会自动完成。若不使用这些选项,您将得到 token 级别的嵌入,适用于命名实体识别等任务,但不适合句子级搜索。
Feature‑Extraction 管道
feature‑extraction 任务与 Transformers.js 的其他管道不同。像 text‑classification 或 question‑answering 这样的任务会返回人类可读的输出:标签、分数、字符串。feature‑extraction 则返回模型内部计算的原始向量表示。您在更低一级工作,获取的是所有高级任务所依赖的数字。
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.2';
// Load the feature‑extraction pipeline
// Xenova/all-MiniLM-L6-v2 是 sentence-transformers/all-MiniLM-L6-v2 的 ONNX 转换版本
// 具有相同的模型权重,浏览器兼容格式
const extractor = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',{dtype:'q8'}// 8 位量化:下载量更小(约 23 MB),准确率良好
);
// 嵌入单句
// pooling: 'mean'——将所有 token 向量平均成一个句子向量
// normalize: true——将结果缩放到单位长度(用于余弦相似度)
const output=await extractor('I need help with my order',{
pooling:'mean',
normalize:true
});
console.log(output);
// Tensor {
// dims: [1, 384],// 1 句子,384 维
// type: 'float32',
// data: Float32Array(384)// 实际数值
// }
// 转成普通 JavaScript 数组,方便后续使用
const vector=output.tolist()[0];// [0.045, 0.073, -0.012, ...] -- 384 个数
console.log(`Vector length:${vector.length}`);// 384
代码作用:
* **pipeline()** 在首次运行时下载并初始化模型(随后浏览器会缓存,后续页面加载即刻完成)
* 你随后用字符串和两个选项调用 extractor,得到一个归一化的句子向量
* 结果是一个 **Tensor** 对象;调用 **.tolist()[0]** 将其转换为 384 个数的普通 JavaScript 数组,直接可用
### 理解输出 Tensor
feature‑extraction 返回的 **Tensor** 对象包含三个重要字段:
* **dims** 是形状 **[n_sentences, 384]**。传入一句话时 **dims[0]** 为 1;一次传入十句话时 **dims[0]** 为 10。第二维始终为 384
* **type** 为 ‘**float32**’,表示每个 384 个值都是 32 位浮点数
* **data** 是一个 **Float32Array**,按行主序存放所有数值。若批量 3 句,数组长度为 3 × 384 = 1,152
**.tolist()** 将 Tensor 转成嵌套 JavaScript 数组,每个内部数组对应一句话。**output.tolist()[0]** 给出第一句话的 384 维向量。
## 批处理:一次嵌入多句
将字符串数组传给 extractor,所有句子一次性在同一次模型调用中处理。相比每句单独调用 pipeline,速度快得多,因为 transformer 在一次前向传播中并行处理所有输入。
// 一次性嵌入多篇文档——始终优先使用此方式而非循环
const sentences=[
'How do I track my shipment?',
'What is your return policy?',
'How can I reset my password?',
'Do you offer international delivery?'
];
const batchOutput=await extractor(sentences,{
pooling:'mean',
normalize:true
});
// batchOutput.dims = [4, 384] -- 4 句子,每句 384 维
console.log(Batch shape:[${batchOutput.dims}]);
// 转成数组列表——每句 384 元素数组
const vectors=batchOutput.tolist();
console.log(Number of vectors:${vectors.length});// 4
console.log(Each vector has:${vectors[0].length}dimensions);// 384
代码作用:
* 用一次 extractor() 调用处理四句,而非四次单独调用
* transformer 体系结构针对批量输入做了优化,四句一起嵌入的时间远接近单句嵌入,而非四句分别嵌入
批处理是语义搜索系统中最重要的性能决策。对 50 篇文档的语料库进行一次批量调用,比 50 次单独调用快得多。随着语料库规模扩大,差异会进一步放大。
## 余弦相似度:搜索背后的数学
得到文档向量和查询向量后,需要一种方法来衡量任意两向量的相似度——这就是余弦相似度。
余弦相似度测量两向量之间的夹角。得分 1.0 表示向量方向完全相同(意义相同);得分 0 表示完全无关。由于我们在生成嵌入时使用了 **normalize: true**,两向量已是单位长度 **(|A| = |B| = 1)**,公式大大简化:
cosine_similarity(A,B) = (A·B) / (|A| × |B|)
因为 |A| = |B| = 1,变为:
cosine_similarity(A,B) = A·B = Σ(A[i] × B[i])
只需求两向量对应元素乘积之和,即为余弦相似度。对于使用 mean pooling 和归一化的句子嵌入,实用得分大致落在以下区间:
| 得分区间 | 解释 |
| --- | --- |
| 0.90–1.00 | 意义几乎相同 |
| 0.70–0.90 | 强语义匹配 |
| 0.50–0.70 | 相关主题,角度不同 |
| 0.30–0.50 | 关联不强 |
| <0.30 | 可能无关 |
实现代码如下:
/**
- 计算两个已归一化向量的余弦相似度。
*
- 由于 normalize: true,向量已单位长度,分母为 1,直接相当于点积。
*
- @param {number[]|Float32Array} vecA - 第一个已归一化嵌入向量
- @param {number[]|Float32Array} vecB - 第二个已归一化嵌入向量
- @returns {number} - 相似度得分,范围 -1 到 1(句子通常为 0~1)
*/ function cosineSimilarity(vecA, vecB) { if (vecA.length !== vecB.length) { throw new Error(Vector length mismatch:${vecA.length}vs${vecB.length}); }
let dotProduct = 0; for (let i = 0; i < vecA.length; i++) { dotProduct += vecA[i] * vecB[i]; // 对应元素相乘后求和 }
// 对浮点误差边缘情况做裁剪 return Math.max(-1, Math.min(1, dotProduct)); }
// 示例用法(假设已通过 extractor 生成向量):
// cosineSimilarity(vecA, vecB) —— "I need to return a product" vs "How do I send an item back for a refund?" // // 结果:~0.82(语义相似)
// cosineSimilarity(vecA, vecC) —— "I need to return a product" vs "The stock market had a volatile week"
// 结果:约 0.08(无关)
这段代码的作用:
* 函数并行遍历两个 384 元素的向量,逐个相乘后求和
* 该和即为点积,若两向量已归一化,则等价于余弦相似度
* 末尾的 **Math.max(-1, Math.min(1, …))** 用来处理浮点运算偶尔产生 1.0000002 等超出 [-1,1] 范围的情况
## 构建语义搜索类
无论规模如何,语义搜索的模式始终相同:在启动时一次性为文档生成嵌入向量,在搜索时为每个查询生成嵌入向量,对每个文档与查询计算得分,按得分排序。
最耗时的步骤是为每个句子生成 384 维向量。将这些向量缓存到内存中后,后续搜索只需对查询进行嵌入,耗时毫秒级。
/**
- SemanticSearch -- 一个简单的客户端语义搜索引擎。
*
- 用法:
- const search = new SemanticSearch(extractor);
- await search.indexDocuments(myDocs);
- const results = await search.search('my query', 5);
*/ class SemanticSearch{ constructor(extractor){ // 已加载的特征提取管道实例 this.extractor = extractor; // 索引后的文档存储:{ id, text, metadata, vector } this.index = []; }
/**
- 将所有文档嵌入并将向量存入内存。
- 在启动时调用一次。搜索时复用这些缓存向量。
*
- @param {Array} docs
*/ async indexDocuments(docs){ console.time('indexing'); // 只取文本字段进行批量嵌入 const texts = docs.map(doc => doc.text); // 单次批量调用一次性嵌入所有文档,速度远快于循环 const output = await this.extractor(texts, { pooling: 'mean', normalize: true }); // 将张量转换为 384 维数组列表,每个文档对应一个 const vectors = output.tolist(); // 将每个向量附加到原始文档对象 // ...doc 保留所有原始字段:title、URL、tags 等 this.index = docs.map((doc, i) => ({ ...doc, vector: vectors[i] })); console.timeEnd('indexing'); console.log(Indexed ${this.index.length} documents); return this; }
/**
- 在已索引的文档中搜索最具语义相关性的结果。
*
- @param {string} query - 纯文本搜索查询
- @param {number} topK - 返回结果数(默认 5)
- @returns {Promise<Array>} 按相关性降序排列的结果
*/ async search(query, topK = 5){ if (this.index.length === 0){ throw new Error('No documents indexed. Call indexDocuments() first.'); } console.time('query embedding'); // 嵌入搜索查询——搜索期间唯一的模型推理调用 const queryOutput = await this.extractor(query, { pooling: 'mean', normalize: true }); const queryVector = queryOutput.tolist()[0]; console.timeEnd('query embedding'); console.time('scoring'); // 对每个已索引文档与查询向量计算得分 // 纯 JavaScript 计算——无模型参与,瞬间完成 const scored = this.index.map(doc => ({ doc, score: cosineSimilarity(queryVector, doc.vector) })); // 降序排序——最高相关度排在前面 scored.sort((a, b) => b.score - a.score); console.timeEnd('scoring'); // 返回前 k 条结果,去除向量字段 return scored.slice(0, topK).map(({doc, score}) => ({ id: doc.id,
text: doc.text, metadata: doc.metadata, score: score })); }
/**
- 将索引序列化为 JSON,便于存储在 localStorage 或 IndexedDB。
- 之后页面加载时可跳过嵌入步骤。
*/ toJSON(){ return JSON.stringify(this.index); }
/**
- 从已序列化的 JSON 恢复索引,无需重新嵌入。
- 向量在 JSON 中为普通数组,直接反序列化即可。
*/ fromJSON(json){ this.index = JSON.parse(json); return this; } }
这段代码的作用:
* **indexDocuments** 接收一个文档对象数组(每个至少需要 **text** 字段),一次性批量嵌入所有文本,并将结果存入 **this.index**
* 展开运算符 **(...doc)** 保留你传入的任何元数据,确保不会丢失信息
* **search** 仅嵌入查询(一次推理调用,通常不到 100 ms),随后在纯 JavaScript 循环中对每个缓存文档向量执行 **cosineSimilarity**。得分阶段不再涉及模型推理,这也是为什么在索引完成后搜索几乎是瞬时的原因
* **toJSON** 与 **fromJSON** 方法让你在页面刷新后仍能保留索引,完全跳过嵌入步骤
## 完整可运行演示:知识库搜索
下面的应用程序完整且自包含。将其复制到 **.html** 文件中,在任何现代浏览器中打开即可运行。该应用使用 12 条来自虚构电商支持知识库的 FAQ 条目。示例查询故意与匹配文档零关键词重叠,以展示语义搜索真正发挥作用。
完整代码可在 [此处](https://gist.github.com/zenUnicorn/8bf182d89c787cdb3e4e50bd6440ef37) 找到。
这段代码的作用:- 当页面加载时,init() 会立即执行。它创建了一个特征提取管道,并提供一个进度回调,用来在模型下载期间更新状态行。模型准备好后,indexDocuments 会一次性将所有 12 篇文章嵌入并将向量存入内存。搜索输入框和按钮会在此步骤完成前保持禁用状态,防止用户在索引完成前触发搜索。
- 当用户发起搜索时,search() 只会对查询文本进行一次嵌入(一次推理调用,通常不到 100 ms),随后遍历所有 12 个缓存的文档向量,计算每个向量的余弦相似度。这个评分循环完全是 JavaScript 算术运算,不涉及模型,因此在不到一毫秒内完成。结果按得分排序渲染,并用颜色编码的匹配百分比徽章显示。
示例查询展示了核心能力。“Cheap shipping option” 仍能在顶部返回 “Economy Delivery Options”,即使两者没有共享任何关键词。
在 Web Worker 中运行推理
上述演示在主浏览器线程上执行所有模型推理。对于内部工具和演示来说这没问题,但对于面向用户的生产应用则不合适:模型加载和嵌入生成会阻塞主线程,导致滚动、输入和动画在推理期间冻结。老旧硬件上,浏览器甚至可能弹出“页面无响应”警告。
Web Worker 通过在后台线程中运行 JavaScript 来解决这个问题。主线程保持响应,而 Worker 负责所有模型工作。
Worker 文件(`embedder-worker.js`)
// embedder-worker.js
// 在后台线程中运行——无法访问 DOM。
import { pipeline } from
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.2';
// 单例模式:仅加载一次管道并复用。
// 防止在多条消息快速到达时重复下载模型。
let extractor = null;
async function getExtractor() {
if (!extractor) {
extractor = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',
{
dtype: 'q8',
progress_callback: (p) => {
// 将进度更新转发回主线程以供 UI 显示
self.postMessage({ type: 'progress', payload: p });
}
}
);
}
return extractor;
}
// 监听来自主线程的嵌入请求
self.addEventListener('message', async (event) => {
const { type, id, payload } = event.data;
try {
const ext = await getExtractor();
if (type === 'embed') {
// payload.texts 可以是单个字符串或字符串数组
const output = await ext(payload.texts, {
pooling: 'mean',
normalize: true
});
// 在返回前将张量转换为普通数组
// (张量对象无法跨线程传输)
self.postMessage({
type: 'embed_result',
id, // 复映请求 ID,方便主线程匹配响应
payload: output.tolist()
});
}
} catch (err) {
self.postMessage({ type: 'error', id, payload: err.message });
}
});主线程通信(`main.js`)
// 创建 Worker —— 它会立即在后台开始加载模型
const worker = new Worker('./embedder-worker.js', { type: 'module' });
// 追踪正在进行的请求,以便结果返回时能解析
const pending = new Map();
let requestId = 0;
// 发送嵌入请求到 Worker 并返回一个 Promise
function embedText(texts) {
return new Promise((resolve, reject) => {
const id = requestId++;
// 存储 resolve/reject,等 Worker 响应时调用
pending.set(id, { resolve, reject });
// 发送请求到后台线程
worker.postMessage({ type: 'embed', id, payload: { texts } });
});
}
// 处理来自 Worker 的消息
worker.addEventListener('message', (event) => {
const { type, id, payload } = event.data;
if (type === 'progress') {
// 在此更新加载 UI
if (payload.status === 'progress') {
console.log(`Model loading: ${Math.round(payload.progress)}%`);
}
return;
}
// 根据 ID 找到对应的 Promise
const p = pending.get(id);
if (!p) return;
pending.delete(id);
if (type === 'embed_result') {
p.resolve(payload); // payload 是 384 维向量数组
} else if (type === 'error') {
p.reject(new Error(payload));
}
});
// 用法 —— 与非 Worker 版本相同,但保持主线程空闲
const vectors = await embedText(['How do I return a product?']);
console.log(`Embedding dimensions: ${vectors[0].length}`); // 384代码说明
- Worker 使用单例模式(
getExtractor())在多条消息快速到达时避免重复下载模型。 - 每条消息的
id字段是关联键:当 Worker 返回embed_result时,主线程利用id在pendingMap 中找到对应的 Promise 并解析。若没有此机制,两个嵌入请求同时进行时无法区分结果。 pendingMap 只保持当前正在进行的请求条目,响应到达后即自动清理,保持内存占用最小。
在页面加载间持久化索引
嵌入计算是最慢的步骤。对于访问之间不变的文档语料库,可以将索引序列化为 JSON 并存入 localStorage,下次页面加载时直接跳过嵌入步骤。
// 索引完成后——保存到 localStorage
const serialized = JSON.stringify(searcher.index);
localStorage.setItem('kb-index', serialized);
localStorage.setItem('kb-index-version', '2025-06-01'); // 内容变更时更新// 页面加载时 -- 如果存在并且仍然是最新的索引,则恢复它
const storedVersion = localStorage.getItem('kb-index-version');
const currentVersion = '2025-06-01';
if (storedVersion === currentVersion) {
const stored = localStorage.getItem('kb-index');
if (stored) {
searcher.index = JSON.parse(stored);
// 向量在 JSON 中是普通数组,无需特殊反序列化
console.log('Index restored from cache, skipping embedding step');
}
}localStorage 能容纳约 5 MB,具体取决于浏览器。对于 12 篇文档、384 维浮点向量,序列化后的索引大约 200 KB,远低于限制。对于更大的语料库,IndexedDB 没有实际大小限制,并且使用稍微冗长一点的 API 方式与上面相同。
超过数百篇文档的扩展
上述方法在每个查询时对所有文档进行评分。对于数百篇文档时表现良好,延迟仍可接受;但当文档数量增多时,延迟会显著上升。对于更大的语料库,官方 Transformers.js 示例仓库 提供了一个 pglite-semantic-search 演示,它在浏览器中运行 PostgreSQL 实例,并使用 pgvector 扩展进行近似最近邻搜索。相比暴力评分,这种方式在大集合上显著更快,同时仍保持完全客户端化。
选择合适的模型
Xenova/all-MiniLM-L6-v2 是大多数英文用例的默认选择。它速度快、体积小,并且在语义搜索中能产生强劲的结果。下面的表格列出了主要选项:
| 模型 | 维度 | 下载大小(q8) | 适用场景 | | --- | --- | --- | --- | | Xenova/all-MiniLM-L6-v2 | 384 | ~23 MB | 通用英文搜索,速度快 | | Xenova/all-mpnet-base-v2 | 768 | ~86 MB | 更高准确度,下载量更大 | | Xenova/multilingual-e5-small | 384 | ~34 MB | 100+ 语言 |
对于多语言场景,例如知识库同时包含法语、德语和英语内容,multilingual-e5-small 能处理跨语言查询。用户用英文搜索时,模型会将相同含义的文档(即使是法语写成)映射到相近的向量,从而被检索出来。
结论
整个流程分为四步:一次性加载模型,批量嵌入文档语料库,在搜索时嵌入查询,使用余弦相似度评分,并按得分排序。本文中的所有示例均可通过单个 CDN 导入完成,无需服务器、API 密钥,也不会有任何数据离开用户设备。
这些核心概念——向量、相似度和排序——同样构成了推荐系统、重复内容检测、聚类以及检索增强生成等应用的基础。每个应用都建立在相同的 feature‑extraction 管道和 cosineSimilarity 函数之上。先从知识库演示开始,扩展到自己的文档集,一旦看到基础功能运行,后续更高级的模式就会变得易于理解。
##### 还没有评论。