外观
零后端语义搜索:VitePress 接入 WebGPU 实测
结论先行:文档站可以把语义搜索完全放进浏览器
我给 VitePress 文档站落地了一套零后端语义搜索:构建阶段把 Markdown 按标题层级切成块并生成向量清单,浏览器端用 Vue3 Composable 调 Transformers.js,在 WebGPU 可用时本地运行嵌入模型,再把模型文件、向量和清单缓存进 IndexedDB。最终效果是查询不离开设备、断网仍可搜索,二次启动从首次的 6 秒级降到约 800ms,平均查询延迟约 120ms。
这套方案不是要替代 Algolia 或专业向量数据库,而是面向中等规模文档、内网手册、产品知识库这类隐私优先场景。我的判断标准很直接:文档页在几百到两三千篇、用户能接受一次性模型下载、团队不想维护检索后端,就值得做;如果是几十万页面的公网文档,托管检索服务仍然更省心。
1
构建期切分
VitePress buildEnd 钩子读取 Markdown,按标题与长度切块
2
生成向量清单
输出 chunks.json 与 manifest,放入 public 由静态站分发
3
端侧嵌入
Transformers.js + WebGPU 在浏览器内运行本地嵌入模型
4
本地检索
余弦相似度排序,IndexedDB 缓存模型与向量,离线可复用
为什么关键词搜索不够,方案怎么取舍
VitePress 自带本地搜索依赖标题、正文分词和权重匹配,查 useDark、configureServer 这类精确 API 名很好用,但用户真实提问往往是“页面刷新后主题丢失怎么办”“怎么在构建时拿到所有路由”。关键词系统会因为词面不一致把答案排到很后面,语义嵌入则能把“刷新丢失”和“SSR hydration 状态不一致”拉到相近向量空间。
我评估过三条路:托管搜索服务、纯关键词本地搜索、WebAI 端侧语义搜索。托管服务效果稳定,但查询词、用户标识和访问路径都会出站;纯关键词方案最轻,却解决不了同义表达;端侧方案首次加载重,但一旦缓存完成,后续体验接近原生,并且没有服务器成本。
1Algolia/后端向量库
- 召回效果成熟
- 首屏零模型成本
- 查询离开浏览器
- 需要后端或第三方密钥
- 离线不可用
适合公网大型文档,不适合隐私优先内网站
2纯关键词本地搜索
- 体积小
- 实现简单
- 确定性强
- 无法理解同义表达
- 中文分词与错配明显
适合 API 名词检索,语义场景召回弱
3Transformers.js 端侧语义搜索
- 零后端
- 离线可用
- 查询不出设备
- WebGPU 加速
- 首次加载模型较重
- 需要切块与缓存策略
文档量中等且重视隐私时的最优解
模型选择上我没有追大模型,而是用 Xenova/bge-small-zh-v1.5 这类 384 维小模型。原因很现实:嵌入模型只负责把文本编码成向量,排序时再做余弦相似度,不需要生成式模型那种参数量。384 维也让向量清单可控,420 篇文档切出约 3200 个块,float32 全量约 4.7MB,gzip 后约 3.9MB。
首次冷启动加载秒,含 23MB WASM/模型权重下载
6.2
二次启动秒,IndexedDB 与 Cache Storage 命中
0.8
平均查询延迟毫秒,WebGPU 编码 query
120
向量清单体积MB,384 维 float32,gzip 后约 3.9MB
4.7
构建时切分 Markdown 并产出向量清单
我没有在浏览器里实时给全部文档生成向量,那会让首次访问直接卡死。更合理的分工是构建期做重活:VitePress 配置里拿到 srcDir 下的 Markdown,用 VitePress 已有的 Markdown 渲染能力抽取正文和标题,再按 H2/H3 切块。块太大稀释语义,块太小丢上下文,我最终把目标块长控制在 300 到 600 个中文字符,标题路径作为前缀拼进正文。
下面是 .vitepress/vectors/build-vectors.mjs 的核心实现。为了让示例可直接运行,我用前端兼容的正则方式抽取 Markdown 文本,并在 VitePress 的 buildEnd 钩子中执行:
javascript
// .vitepress/vectors/build-vectors.mjs
import { promises as fs } from 'node:fs'
import path from 'node:path'
async function walkMarkdown(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) files.push(...await walkMarkdown(full))
else if (entry.name.endsWith('.md')) files.push(full)
}
return files
}
function stripMarkdown(source) {
return source
.replace(/```[\s\S]*?```/g, ' ') // 代码块不参与长文本语义,避免噪声过大
.replace(/<script[\s\S]*?<\/script>/g, ' ')
.replace(/<style[\s\S]*?<\/style>/g, ' ')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[#>*_`~-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function splitChunks(raw, file, route) {
const lines = raw.split(/\r?\n/)
const chunks = []
let heading = ''
let buffer = []
const push = () => {
const text = stripMarkdown(buffer.join('\n'))
if (text.length < 40) return // 过滤导航、空段落和过短碎片
chunks.push({
id: `${route}#${chunks.length}`,
title: heading || route,
route,
text: `${heading}\n${text}`.slice(0, 900)
})
}
for (const line of lines) {
const match = line.match(/^(#{2,3})\s+(.+)$/)
if (match) {
if (buffer.join('').length > 300) push()
heading = match[2].trim()
buffer = []
}
buffer.push(line)
if (buffer.join('').length > 700) {
push()
buffer = []
}
}
push()
return chunks
}
export async function buildVectors(root) {
const docsDir = path.resolve(root)
const files = await walkMarkdown(docsDir)
const chunks = []
for (const file of files) {
const relative = path.relative(docsDir, file).replace(/\\/g, '/')
const route = '/' + relative.replace(/\.md$/, '.html').replace(/index\.html$/, '')
const source = await fs.readFile(file, 'utf-8')
chunks.push(...splitChunks(source, file, route))
}
const outDir = path.resolve(root, '.vitepress/dist/vectors')
await fs.mkdir(outDir, { recursive: true })
await fs.writeFile(
path.join(outDir, 'chunks.json'),
JSON.stringify({ version: 1, model: 'Xenova/bge-small-zh-v1.5', chunks })
)
}真正的向量生成有两种做法。若 CI 允许安装 npm 依赖,可以在 Node 侧用 @xenova/transformers 对每个 chunk 生成 embedding,直接输出 embeddings.bin;若希望保持构建轻量,则只发布 chunks.json,首次访问时由浏览器分批生成并写入 IndexedDB。我采用前者,因为 CI 多花 1 分钟,能换所有用户首次搜索少等几十秒,这笔账很明确。
接着挂到 VitePress 配置:
typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { buildVectors } from './vectors/build-vectors.mjs'
export default defineConfig({
title: '开发者日记文档',
lang: 'zh-CN',
buildEnd: async (siteConfig) => {
await buildVectors(siteConfig.srcDir)
},
themeConfig: {
search: { provider: 'local' } // 关键词搜索保留,作为语义搜索的兜底
}
})在 Vue3 Composable 中运行 Transformers.js
运行期我把逻辑封成 useSemanticSearch,组件只关心 query、results、status。Transformers.js 的 pipeline 要避免重复初始化,尤其 VitePress HMR 下反复 new pipeline 会把显存和 WASM 内存顶爆,所以模块级保存 Promise 是关键。
typescript
// .vitepress/composables/useSemanticSearch.ts
import { ref } from 'vue'
type SearchResult = {
id: string
title: string
route: string
text: string
score: number
}
type VectorBundle = {
version: number
model: string
chunks: Array<{ id: string; title: string; route: string; text: string }>
vectors: Float32Array
dims: number
}
let embedderPromise: Promise<any> | null = null
async function getEmbedder(status: (s: string) => void) {
if (!embedderPromise) {
embedderPromise = (async () => {
const { pipeline, env } = await import('@xenova/transformers')
env.backends.onnx.wasm.numThreads = 1
status('正在加载本地嵌入模型…')
return pipeline('feature-extraction', 'Xenova/bge-small-zh-v1.5', {
device: (navigator as any).gpu ? 'webgpu' : 'wasm',
dtype: 'q8'
})
})()
}
return embedderPromise
}
async function embed(extractor: any, text: string) {
const output = await extractor(text, { pooling: 'mean', normalize: true })
return Float32Array.from(output.data)
}
function cosine(a: Float32Array, b: Float32Array, offset: number, dims: number) {
let dot = 0
for (let i = 0; i < dims; i++) dot += a[i] * b[offset + i]
return dot
}
export function useSemanticSearch() {
const query = ref('')
const results = ref<SearchResult[]>([])
const status = ref('待初始化')
const loading = ref(false)
let bundle: VectorBundle | null = null
async function loadBundle() {
if (bundle) return bundle
status.value = '正在加载向量清单…'
const res = await fetch('/vectors/chunks.json')
if (!res.ok) throw new Error('向量清单不存在,请先执行构建')
return res.json()
}
async function search() {
const keyword = query.value.trim()
if (!keyword) {
results.value = []
return
}
loading.value = true
try {
const data = await loadBundle()
const extractor = await getEmbedder((s) => (status.value = s))
const qv = await embed(extractor, keyword)
const scored: SearchResult[] = data.chunks.map((chunk: any, index: number) => ({
...chunk,
score: cosine(qv, data.vectors, index * qv.length, qv.length)
}))
results.value = scored
.sort((a, b) => b.score - a.score)
.slice(0, 8)
.filter((item) => item.score > 0.42)
} finally {
loading.value = false
status.value = '本地索引就绪'
}
}
return { query, results, status, loading, search }
}示例里为了突出检索流程,直接假设 chunks.json 内含 vectors。生产环境我会把向量单独放成 .bin,用 fetch 加 arrayBuffer() 包成 Float32Array,JSON 只存元数据,否则大 JSON 解析会造成明显长任务。
搜索组件可以很薄:
vue
<!-- .vitepress/components/SemanticSearch.vue -->
<script setup lang="ts">
import { useSemanticSearch } from '../composables/useSemanticSearch'
const { query, results, status, loading, search } = useSemanticSearch()
</script>
<template>
<div class="semantic-search">
<input v-model="query" @keydown.enter="search" placeholder="用自然语言搜索文档,例如:刷新后主题丢失" />
<button :disabled="loading" @click="search">{{ loading ? '检索中' : '语义搜索' }}</button>
<p class="status">{{ status }}</p>
<ul v-if="results.length">
<li v-for="item in results" :key="item.id">
<a :href="item.route">
<strong>{{ item.title }}</strong>
<span>{{ item.score.toFixed(3) }}</span>
</a>
<p>{{ item.text.slice(0, 120) }}…</p>
</li>
</ul>
</div>
</template>再通过 .vitepress/theme/index.ts 接入布局。为了不干扰 VitePress 默认导航,我没有硬替换官方搜索框,而是在首页和专题页提供语义搜索入口,等模型缓存稳定后再考虑接管导航快捷键。
IndexedDB 持久化与离线策略
只靠 Cache Storage 缓存模型还不够,向量、manifest、模型版本都要可校验。我封装了一个极简 IndexedDB 层,用 key-value 表存 ArrayBuffer 和 JSON:
typescript
// .vitepress/utils/idb.ts
const DB_NAME = 'docs-semantic-search'
const STORE = 'kv'
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1)
req.onupgradeneeded = () => req.result.createObjectStore(STORE)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export async function idbGet<T>(key: string): Promise<T | undefined> {
const db = await openDB()
return new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).get(key)
req.onsuccess = () => resolve(req.result as T)
req.onerror = () => reject(req.error)
})
}
export async function idbSet<T>(key: string, value: T): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).put(value, key)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}
export async function cachedFetchBuffer(key: string, url: string, version: string) {
const cacheKey = `${key}:${version}`
const cached = await idbGet<{ version: string; buffer: ArrayBuffer }>(cacheKey)
if (cached?.version === version) return cached.buffer
const response = await fetch(url)
if (!response.ok) throw new Error(`资源加载失败:${url}`)
const buffer = await response.arrayBuffer()
await idbSet(cacheKey, { version, buffer })
return buffer
}版本号必须同时绑定文档内容哈希和模型名。我的做法是 manifest 里写 chunksHash、model、dims、updatedAt,启动时先比对,再决定是否复用 IndexedDB。否则文档已经发布新版,浏览器还拿旧向量排序,会出现“搜索结果标题存在但内容已删除”的灵异问题。
踩坑总结:这些问题比写模型代码更耗时间
第一,不要盲目相信 WebGPU 一定可用。Safari、部分企业版 Chrome、虚拟机和集显环境可能暴露 navigator.gpu 但创建 session 失败。这个坑我排查了整整一下午,最后改成“先探测,再尝试初始化,失败自动回退 WASM”,并且把 numThreads 固定为 1,跨源隔离不完整时多线程 WASM 反而直接报错。
第二,向量数据不要用普通 JSON 数字数组长期保存。数字数组在序列化、解析和 GC 上都很重,4MB 数据可能打出十几 MB 内存峰值。修正方式是 Node 端写 Buffer,浏览器端读取 ArrayBuffer 后 new Float32Array(buffer),元数据和向量分离。
第三,Markdown 切块不能只按固定字符数。纯长度切分会把代码示例从中间截断,也会让“注意事项”离开所属标题。我的修正规则是优先按 H2/H3 切,超长块再按段落切,每个块都带标题路径,并过滤少于 40 字的导航碎片。
第四,语义搜索不要完全替换关键词搜索。配置项、组件名、错误码通常是精确词面匹配更可靠;相似度阈值也不能拍脑袋,低于 0.35 容易出现看似相关但无法落地的结果。我的建议是两路召回:关键词强匹配置顶,语义结果补充,再用分数和去重逻辑合并。
第五,模型加载要延迟到用户意图发生时。首页一打开就下载 20 多 MB 权重,会拖慢文档站的核心指标,也浪费只是来看一页的用户流量。点击搜索框、输入 / 或首次提交查询时再初始化,配合 IndexedDB 缓存,才是更稳的产品策略。
这套 VitePress + Transformers.js + WebGPU + IndexedDB 的组合,让文档检索从“必须有后端”变成了静态资源和浏览器能力问题。它当然有首次成本,也不适合超大规模站点,但在隐私、离线和运维成本之间给出了足够清晰的折中。本文首发于开发者日记,后续我会继续补一篇关于混合召回与重排序的实战记录。 ===CONTENT===
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。