双模语义搜索落地:当"删代码"变成"都要"
博客搜索系列第三篇。第一篇做了浏览器端混合搜索,第二篇写了迁移到 Cloudflare Workers 的方案。这篇是实施记录。
原计划是把浏览器端 ONNX 方案删掉,换成 Workers。实际做的时候没删,两套都留了——默认走 Workers,浏览器端 ONNX 通过配置切换。
为什么保留两套
浏览器端方案的问题很明确:156MB 下载、移动端崩溃。但代码本身没问题——Int8 量化向量、KVEC 二进制格式、浏览器内模型推理这些东西都是能跑的,只是不适合当默认方案。留着不碍事,删了就没了。
做法是加一个配置字段:
{
"semanticSearchMode": "server",
"semanticSearchApi": "https://yuxu.ge/api/semantic-search"
}
"server" 走 Cloudflare Workers,"browser" 走浏览器端 ONNX。默认 "server"。
Worker 端实现
在已有的 Worker 里加了 /api/semantic-search 路由:
if (path === "/api/semantic-search") {
const { query } = await request.json();
const topK = Math.min(Math.max(parseInt(body.topK) || 10, 1), 50);
// 1. 查询向量化 → OpenAI (~150ms)
const embData = 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,
}),
}).then(r => r.json());
const queryVec = embData.data[0].embedding;
// 2. KV 读文档向量 (~2ms)
const embeddings = await env.SEARCH_EMBEDDINGS.get("search_embeddings", { type: "json" });
// 3. 余弦相似度 203×512 (<1ms)
const results = embeddings.documents.map(doc => {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < 512; i++) {
dot += queryVec[i] * doc.embedding[i];
normA += queryVec[i] * queryVec[i];
normB += doc.embedding[i] * doc.embedding[i];
}
return {
url: doc.url, title: doc.title,
score: dot / (Math.sqrt(normA) * Math.sqrt(normB)),
};
});
// 4. 排序过滤
results.sort((a, b) => b.score - a.score);
return results.slice(0, topK).filter(r => r.score > 0.3);
}
203 篇文档(含 148 篇文章和 55 张图片)的向量存在 KV 一个条目里,1.3MB。暴力余弦相似度不到 1ms,不需要向量数据库。总延迟 ~200ms,主要花在 OpenAI API 调用上。
构建管线
生成向量
embed-builder.mjs 为每篇文档调 OpenAI API 生成 embedding。输入是 title + tags + content 前 500 字。用 SHA256 做增量缓存,内容没变的跳过。
const hash = crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
if (cache[url]?.hash === hash) continue;
首次构建 203 篇,batch 50,成本 ~$0.0005。增量构建零 API 调用。
智能调度
build-vectors.mjs 根据配置决定跑哪个脚本:
if (mode === 'server') {
runScript('embed-builder.mjs'); // OpenAI 文档向量
if (!fs.existsSync(browserOutput))
runScript('vector-builder.mjs'); // 自动补齐浏览器向量
} else {
runScript('vector-builder.mjs'); // e5-small 关键词向量
if (!fs.existsSync(serverOutput))
runScript('embed-builder.mjs');
}
npm run build:search 一条命令,不用管当前什么模式。
客户端改造
SearchClient 根据模式走不同路径,对外接口不变。
初始化
async init() {
const loadVectors = this.enableSemantic && !this.useServerSemantic;
const [invertedData, metadataData, vectorsData] = await Promise.all([
this._loadWithCache(this.invertedIndexUrl, 'json'),
this._loadWithCache(this.metadataUrl, 'json'),
loadVectors ? this._loadWithCache(this.vectorsUrl, 'arraybuffer') : null,
]);
// server 模式到这就结束了
// browser 模式还要在后台加载 BGE 模型
if (!this.useServerSemantic && this.enableSemantic && this.vectorsReady) {
this._loadBGEModel();
}
}
Server 模式只加载倒排索引和元数据(~2.6MB)。Browser 模式额外下载 3MB 向量 + ~150MB ONNX 模型。
语义搜索
async semanticSearch(query, limit) {
if (this.useServerSemantic) {
return this._serverSemanticSearch(query, limit);
}
return this._browserSemanticSearch(query, limit);
}
async _serverSemanticSearch(query, limit) {
const res = await fetch(this.semanticSearchUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.trim(), topK: limit }),
});
const data = await res.json();
return (data.results || []).map((r, i) => ({
url: r.url, score: r.score, rank: i + 1, source: 'semantic',
}));
}
Server 模式是一个 fetch 调用。Browser 模式走 BGE 推理 + 关键词向量扩展。
状态
isSemanticReady() {
if (this.useServerSemantic) return this.enableSemantic;
return this.modelReady && this.vectorsReady;
}
isModelLoading() {
if (this.useServerSemantic) return false;
return this.enableSemantic && this.vectorsReady && !this.modelReady;
}
混合搜索融合逻辑不变:BM25 权重 0.6,语义权重 0.4,共现加成 1.2 倍,标题匹配加成 1.5 倍。
sidebar.js
const mode = sidebarConfig.semanticSearchMode || 'server';
const enableSemantic = mode === 'server'
? sidebarConfig.semanticSearch !== false
: !isMobileDevice() && sidebarConfig.semanticSearch !== false;
searchClient = new SearchClient({
enableSemantic,
semanticSearchMode: mode,
semanticSearchUrl: sidebarConfig.semanticSearchApi,
});
Server 模式不检测移动端、不注册 Service Worker。Browser 模式保留原有逻辑。
对比
向量
| 服务端 | 浏览器端 | |
|---|---|---|
| 模型 | OpenAI text-embedding-3-small | multilingual-e5-small |
| 维度 | 512 | 384 |
| 粒度 | 文档级(203 篇) | 关键词级(~8000 个) |
| 存储 | 1.3MB JSON (KV) | 3MB Int8 二进制 |
| 构建成本 | ~$0.0005 | $0 |
服务端直接 embed 文档,浏览器端 embed 词汇表做语义扩展再查 BM25,思路不一样。
性能
| Browser | Server(默认) | |
|---|---|---|
| 客户端下载 | ~156 MB | ~2.6 MB |
| 首次语义搜索 | 5-20 秒 | ~200 ms |
| 后续语义搜索 | ~50 ms | ~200 ms |
| 移动端 | 崩溃 | 正常 |
| 离线语义 | 支持 | 不支持 |
| 月度成本 | $0 | ~$0.01 |
总结
做了什么:把语义搜索默认切到 Cloudflare Workers,浏览器端 ONNX 保留为可切换选项。一个配置字段控制,构建管线自动适配,客户端代码同一套接口两种实现。
结果:移动端不再崩溃,首次搜索从 5-20 秒降到 200ms,客户端下载从 156MB 降到 2.6MB。浏览器端方案的代码还在,改个配置就能切回去。