← Back to Blog

搜索架构升级方案:从浏览器推理到边缘计算

前两篇文章里,我把一套完整的混合搜索引擎塞进了浏览器——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;
}

但这个方案有三个硬伤:

  1. iPadOS 伪装 Mac UA:从 iPadOS 13 起,Safari 的 User Agent 默认报告为 macOS,/iPad/ 匹配不到,navigator.userAgentData?.mobile 返回 false,iPad 照样尝试加载模型然后崩溃。
  2. Service Worker 缓存污染:一旦 SW 注册成功就常驻,即使模型下载了一半也会缓存。下次访问时 SW 返回损坏的缓存数据,导致页面无法正常加载。
  3. 语义搜索变成二等公民:移动端用户永远只有关键词搜索,而移动端占访问量的 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.jsonbuild: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.jspublic/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 的下载等待。这才是我真正想要的搜索体验。