搜索架构升级方案:从浏览器推理到边缘计算
前两篇文章里,我把一套完整的混合搜索引擎塞进了浏览器——BM25 关键词检索 + multilingual-e5-small ONNX 模型做语义扩展,Int8 量化关键词向量,Service Worker 缓存模型文件,零后端依赖。
技术上很酷。然后我用手机打开了自己的博客。
iOS Chrome 直接白屏,报了一句冷冰冰的 "Can't open this page"。
是时候重新审视这个架构了。这篇文章记录我的迁移方案设计——把语义搜索从浏览器端迁移到 Cloudflare Workers 边缘计算。
浏览器端模型推理的致命问题
问题的根源是客户端需要下载 ~156MB 的资源才能启用语义搜索:
| 资源 | 大小 |
|---|---|
| transformers.js 运行时 | 867 KB |
| ONNX Runtime WASM | 21 MB |
| multilingual-e5-small 模型 | 113 MB |
| 分词器 tokenizer.json | 16 MB |
| 关键词向量 keywords_vectors.bin | 3 MB |
| 倒排索引 + 元数据 | 2.6 MB |
| 合计 | ~156 MB |
我曾试图用设备检测来规避这个问题——移动端跳过模型加载,只用关键词搜索:
function isMobileDevice() {
if (navigator.userAgentData?.mobile) return true;
if (/Android|iPhone|iPad|iPod|Opera Mini|IEMobile/i.test(navigator.userAgent)) return true;
return window.innerWidth <= 768;
}
但这个方案有三个硬伤:
- iPadOS 伪装 Mac UA:从 iPadOS 13 起,Safari 的 User Agent 默认报告为 macOS,
/iPad/匹配不到,navigator.userAgentData?.mobile返回false,iPad 照样尝试加载模型然后崩溃。 - Service Worker 缓存污染:一旦 SW 注册成功就常驻,即使模型下载了一半也会缓存。下次访问时 SW 返回损坏的缓存数据,导致页面无法正常加载。
- 语义搜索变成二等公民:移动端用户永远只有关键词搜索,而移动端占访问量的 60%+。
纯浏览器方案虽然技术上有趣,但它脆弱、笨重,以牺牲移动端体验为代价。
目标架构:Client + Edge
核心思路:把最重的计算任务(模型推理)从浏览器移到 Cloudflare Workers 边缘节点。
构建期 (node) 运行时
┌─────────────────┐ ┌──────────────────────┐
│ Markdown/Notebook│ │ 客户端 (browser) │
│ ↓ │ │ │
│ index-builder │ → inverted.json → │ BM25 关键词搜索 (即时) │
│ ↓ │ metadata.json │ ↓ │
│ embed-builder │ │ fetch /api/semantic │
│ (OpenAI API) │ │ ↓ │
│ ↓ │ │ 融合排序 → 展示结果 │
│ upload → KV │ └──────────────────────┘
│ │ ┌──────────────────────┐
└─────────────────┘ │ Cloudflare Worker │
│ 1. embed query │
│ (OpenAI, ~150ms) │
│ 2. KV 读取文档向量 │
│ (edge cache, ~2ms) │
│ 3. 余弦相似度 201×512 │
│ (<1ms) │
│ 4. 返回 top-K │
└──────────────────────┘
关键设计决策:
- 模型选择:OpenAI
text-embedding-3-small(512 维),天然多语言,中英文都能搞定,不再需要 multilingual-e5-small 在浏览器里跑。 - 向量存储:Cloudflare KV。201 篇文档 × 512 维 ≈ 800KB JSON,远小于 KV 单个 value 的 25MB 上限。不需要 Vectorize 这样的专用向量数据库——暴力搜索 201 个向量不到 1ms。
- 文档级向量:不做 chunk 级别。201 篇文章全量比较,召回率 100%。
- BM25 保持不变:客户端的关键词搜索完全不动,即时响应,零回退风险。
构建期设计:embed-builder.mjs
计划新增一个构建脚本,在 index-builder.mjs 之后运行,为每篇文档生成 embedding 并上传到 KV。
文档表示
每篇文章的向量化输入是 title + tags + content 前 500 字。这给了模型足够的上下文来理解文章主题,同时标题和标签的权重自然偏高(因为它们短且信息密度大)。
增量构建
为了避免每次构建都重新调用 API,脚本维护一个本地缓存文件。策略很简单:对输入文本算 hash,hash 没变就跳过。
#!/usr/bin/env node
/**
* Embedding Builder
* 为所有文档生成 OpenAI embedding,支持增量构建。
* 输出: .cache/search-embeddings.json
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
const CONFIG = {
metadataFile: 'public/search-metadata.json',
cacheFile: '.cache/openai_embeddings.json',
outputFile: '.cache/search-embeddings.json',
model: 'text-embedding-3-small',
dimensions: 512,
batchSize: 50, // OpenAI 支持批量请求
};
function contentHash(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16);
}
async function embedBatch(texts) {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
input: texts,
model: CONFIG.model,
dimensions: CONFIG.dimensions,
}),
});
const data = await response.json();
return data.data.map(d => d.embedding);
}
async function main() {
const metadata = JSON.parse(fs.readFileSync(CONFIG.metadataFile, 'utf-8'));
// 加载缓存
let cache = {};
try { cache = JSON.parse(fs.readFileSync(CONFIG.cacheFile, 'utf-8')); } catch {}
// 找出需要 embed 的文档
const toEmbed = [];
for (const [url, doc] of Object.entries(metadata)) {
const text = [doc.title, doc.text || ''].join('\n').slice(0, 500);
const hash = contentHash(text);
if (cache[url]?.hash === hash) continue; // 内容未变,跳过
toEmbed.push({ url, text, hash });
}
console.log(`[Embed] ${toEmbed.length} new/modified, ${Object.keys(metadata).length - toEmbed.length} cached`);
// 批量调用 OpenAI
for (let i = 0; i < toEmbed.length; i += CONFIG.batchSize) {
const batch = toEmbed.slice(i, i + CONFIG.batchSize);
const embeddings = await embedBatch(batch.map(d => d.text));
batch.forEach((doc, j) => {
cache[doc.url] = { hash: doc.hash, embedding: embeddings[j] };
});
console.log(`[Embed] Batch ${Math.floor(i / CONFIG.batchSize) + 1} done`);
}
// 输出:只包含当前 metadata 中存在的文档
const output = {
model: CONFIG.model,
dimensions: CONFIG.dimensions,
updated: new Date().toISOString(),
documents: Object.entries(metadata).map(([url, doc]) => ({
url,
title: doc.title,
date: doc.date,
text: (doc.text || '').slice(0, 200),
type: doc.type || 'article',
cover: doc.cover || '',
embedding: cache[url]?.embedding || null,
})).filter(d => d.embedding),
};
fs.mkdirSync('.cache', { recursive: true });
fs.writeFileSync(CONFIG.cacheFile, JSON.stringify(cache));
fs.writeFileSync(CONFIG.outputFile, JSON.stringify(output));
console.log(`[Embed] Output: ${output.documents.length} documents, ~${Math.round(JSON.stringify(output).length / 1024)}KB`);
}
main().catch(console.error);
成本:初次构建 201 篇文档,约 40,000 tokens,费用 $0.0008。增量构建只处理新增/变更的文章,更便宜。
上传到 KV
#!/usr/bin/env node
/**
* 将 embeddings 上传到 Cloudflare KV
* 使用 Cloudflare API 或 wrangler CLI
*/
import { execSync } from 'child_process';
// 方式一:wrangler CLI
execSync(
`wrangler kv:key put --namespace-id=${process.env.CF_KV_NAMESPACE_ID} "search_embeddings" --path=.cache/search-embeddings.json`,
{ stdio: 'inherit' }
);
也可以集成到 CI/CD 中,在 git push 触发构建后自动上传。
Worker 端设计:/api/semantic-search
计划在现有的 embed-worker.js 中新增一个路由。这个 Worker 已经有 /api/embedding、/api/chat、/api/translate 三个端点,以及配好的 KV namespace。
// 新增路由:POST /api/semantic-search
if (path === "/api/semantic-search") {
const { query, topK = 10 } = await request.json();
if (!query || typeof query !== 'string') {
return new Response(JSON.stringify({ error: 'Missing query' }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// 1. 查询向量化(OpenAI API,~150ms)
const embResponse = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
input: query,
model: "text-embedding-3-small",
dimensions: 512,
}),
});
const embData = await embResponse.json();
const queryVec = embData.data[0].embedding;
// 2. 从 KV 读取文档向量(edge 缓存,~2ms)
const embeddings = await env.TRANSLATIONS.get("search_embeddings", { type: "json" });
if (!embeddings || !embeddings.documents) {
return new Response(JSON.stringify({ results: [] }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// 3. 暴力余弦相似度(201×512,<1ms)
const results = embeddings.documents.map(doc => {
let dot = 0, normA = 0, normB = 0;
const docVec = doc.embedding;
for (let i = 0; i < 512; i++) {
dot += queryVec[i] * docVec[i];
normA += queryVec[i] * queryVec[i];
normB += docVec[i] * docVec[i];
}
const score = dot / (Math.sqrt(normA) * Math.sqrt(normB));
return { url: doc.url, title: doc.title, date: doc.date, text: doc.text,
type: doc.type, cover: doc.cover, score };
});
// 4. 排序,过滤低分,返回 top-K
results.sort((a, b) => b.score - a.score);
const topResults = results.slice(0, topK).filter(r => r.score > 0.3);
return new Response(JSON.stringify({
results: topResults.map(({ embedding, ...r }) => ({
...r, score: Math.round(r.score * 1000) / 1000,
})),
}), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
预期延迟:OpenAI API ~150ms + KV 读取 ~2ms + 余弦计算 <1ms = 总共 ~200ms。对比目前方案首次加载需要 5-20 秒下载模型,这将是天壤之别。
客户端简化方案
迁移后最爽的部分将是——大量删代码。
计划删除清单
| 文件/代码 | 大小 | 说明 |
|---|---|---|
lib/transformers/ |
22 MB | transformers.js + ONNX WASM |
components/sw.js |
2 KB | Service Worker |
public/keywords_vectors.bin |
3 MB | Int8 关键词向量 |
public/search-vocab.json |
2.4 MB | 词汇表统计 |
_tools/vector-builder.mjs |
277 行 | 向量构建脚本 |
KeywordVectors 类 |
~90 行 | Int8 向量点积计算 |
_loadBGEModel() |
~50 行 | 模型下载与初始化 |
_embedQuery() |
~10 行 | 查询向量化 |
isMobileDevice() |
~5 行 | 设备检测 |
| SW 注册代码 | 3 行 | navigator.serviceWorker.register() |
预期客户端资源总量:156MB → 2.6MB,减少 98.3%。
search-client.js 精简方案
迁移后 init() 将不再加载向量和模型,semanticSearch() 变成一个简单的 fetch() 调用:
export class SearchClient {
constructor(options = {}) {
this.semanticSearchUrl = options.semanticSearchUrl || '/api/semantic-search';
this.enableSemantic = options.enableSemantic !== false;
// 不再需要:keywordVectors, bgeExtractor, modelReady, vectorsReady
}
async init() {
// P0:只加载倒排索引 + 元数据(~2.6MB)
// 不再有 P1(向量)和 P2(模型)
const [invertedData, metadataData] = await Promise.all([
fetch('/public/search-inverted.json').then(r => r.json()),
fetch('/public/search-metadata.json').then(r => r.json()),
]);
this.invertedIndex = invertedData;
this.metadata = metadataData;
this.ready = true;
}
// BM25 关键词搜索:完全不变
keywordSearch(query, limit) { /* ... 原有代码 ... */ }
// 语义搜索:从 ONNX 推理变成 API 调用
async semanticSearch(query, limit = 10) {
if (!this.enableSemantic) return [];
try {
const res = await fetch(this.semanticSearchUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, topK: limit }),
});
if (!res.ok) return [];
const data = await res.json();
return (data.results || []).map(r => ({
...r, sources: ['ai'],
}));
} catch (err) {
console.warn('[Search] Semantic API failed:', err.message);
return []; // 优雅降级:语义搜索失败不影响关键词结果
}
}
// 混合搜索:融合逻辑不变(0.6 keyword + 0.4 semantic)
async search(query, limit = 5) {
const [kwResults, semResults] = await Promise.all([
Promise.resolve(this.keywordSearch(query, limit * 3)),
this.semanticSearch(query, limit * 3),
]);
return this.fuseResults(kwResults, semResults, limit);
}
}
sidebar.js 清理方案
// 删除:Service Worker 注册
// if ('serviceWorker' in navigator) {
// navigator.serviceWorker.register('/components/sw.js').catch(() => {});
// }
// 删除:isMobileDevice() 函数
// 删除:模型加载 spinner UI
// 删除:onModelProgress / onModelReady 回调
// 简化:SearchClient 初始化,不再区分移动端
searchClient = new SearchClient({
enableSemantic: sidebarConfig.semanticSearch !== false,
semanticSearchUrl: sidebarConfig.semanticSearchApi || '/api/semantic-search',
// 不再需要 mobile check,服务端搜索对所有设备一视同仁
});
预期性能与成本对比
| 指标 | 当前(浏览器端) | 预期(Workers 端) |
|---|---|---|
| 客户端下载 | ~156 MB | ~2.6 MB |
| 首次语义搜索 | 5-20 秒(模型加载) | ~200 ms |
| 后续语义搜索 | ~50 ms(模型已缓存) | ~200 ms |
| 移动端支持 | 崩溃 | 预期完全正常 |
| 离线语义搜索 | 支持(SW 缓存后) | 不支持 |
| 离线关键词搜索 | 支持 | 支持 |
| 月度 API 成本 | $0 | ~$0.01 |
| 构建成本(每次) | $0 | ~$0.001 |
| 维护复杂度 | 高(SW + WASM + 模型版本) | 低(一个 API 调用) |
后续搜索从 50ms 涨到 200ms,将是唯一的"退步"——但这 150ms 的差异用户几乎感知不到,换来的是全平台可用和零维护成本。
迁移路径
分四步走,每步都向后兼容:
Step 1:构建管线(不影响线上)
- 新增
_tools/embed-builder.mjs,生成文档向量 - 新增
_tools/upload-embeddings.mjs,上传到 KV - 更新
package.json的build:search脚本
Step 2:Worker 端点(不影响现有功能)
- 在
embed-worker.js中新增/api/semantic-search路由 - 部署并用 curl 验证
Step 3:客户端切换
- 更新
search-client.js,语义搜索从本地推理改为 API 调用 - 更新
sidebar.js,删除 SW 注册和移动端检测 - 更新
sidebar-config.json,添加semanticSearchApi
Step 4:清理
- 删除
lib/transformers/、components/sw.js、public/keywords_vectors.bin - 删除
public/search-vocab.json、_tools/vector-builder.mjs - 从
devDependencies移除@huggingface/transformers
架构审查:预期的得与失
预期收益
| 收益 | 说明 |
|---|---|
| 全平台可用 | 移动端、iPad、低端设备都能用语义搜索 |
| 98% 资源缩减 | 156MB → 2.6MB |
| 维护简化 | 可以删掉 SW、WASM、模型版本管理的所有复杂性 |
| 模型可升级 | 后端换模型不需要客户端改动,text-embedding-3-large 随时可切 |
| 搜索质量提升 | OpenAI embedding 质量预期 > 浏览器端量化后的 e5-small |
预期代价
| 代价 | 说明 |
|---|---|
| 离线语义搜索 | 断网时将只有 BM25 关键词搜索可用 |
| 外部依赖 | OpenAI API 宕机会影响语义搜索(关键词搜索不受影响) |
| 微量成本 | 从 $0 到 ~$0.01/月 |
| 搜索延迟略增 | 50ms → 200ms(用户无感知) |
| 词级语义扩展 | 当前方案的"embed 词汇表而非文档"是个有趣的创新,迁移后将不再需要 |
有意为之的偏离
从词级语义扩展到文档级向量搜索
前一版的核心创新是"不 embed 文档,embed 词汇表"——把 8,000 个关键词向量化,查询时找到相似关键词再做 BM25 检索。这个方案在浏览器受限的环境下很巧妙:只需要一次模型推理(embed 查询),剩下的是纯 CPU 的 Int8 点积。
迁移到服务端后,这层间接性将不再必要。直接对文档做 embedding + 余弦相似度,概念更直接,效果应该也不差(OpenAI 的模型质量足以弥补粒度差异)。8,000 个关键词向量、KVEC 二进制格式、Int8 量化——这些精巧的设计将完成它们的历史使命。
从 multilingual-e5-small 到 text-embedding-3-small
两个模型都支持多语言,但 OpenAI 的模型在中英跨语言匹配上表现更好,而且无需管理模型文件。代价是每次查询有一次 API 调用,但对于个人博客的搜索频率来说,这点成本可以忽略不计。
总结
这次架构迁移方案的本质是一次务实的取舍:
- 放弃"零后端"的执念,换取全平台可用的搜索体验
- 放弃离线语义搜索,换取 98% 的资源缩减和首次搜索从 20 秒到 200ms 的飞跃
- 放弃词级语义扩展的技术趣味,换取更简单、更可维护的架构
浏览器端跑 ONNX 模型的方案是一次有价值的探索,它证明了在浏览器里做语义搜索是技术上可行的。但可行不等于最优。对于一个在线博客来说,把重计算交给边缘节点,让客户端回归轻盈,应该是更务实的选择。
接下来就是动手实施了。如果一切顺利,博客搜索将能在任何设备上稳定运行——关键词即时响应,语义结果 200ms 内到达,没有崩溃,没有 150MB 的下载等待。这才是我真正想要的搜索体验。