← Back to Blog
EN中文

双模语义搜索落地:当"删代码"变成"都要"

博客搜索系列第三篇。第一篇做了浏览器端混合搜索,第二篇写了迁移到 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。浏览器端方案的代码还在,改个配置就能切回去。