外观
离线也能问文档:VitePress 接入端侧 AI 问答实战
结论先行:文档不出内网,问答也能跑起来
给 VitePress 文档站加 AI 问答,不必把 Markdown 灌进云端接口。我的落地方案是:构建期把 Markdown 按标题切成小块并生成清单,运行时用 WebLLM 在浏览器里算 embedding、把向量存进 IndexedDB,再用余弦相似度召回上下文,最后让模型在 Web Worker 里流式吐字。首问需要下载一次模型(约 1.5GB,可放内网静态服务器),之后完全离线可用,本地检索稳定在 50ms 以内。
这个方案最适合内部组件库文档、私有化部署手册、展厅演示机这类“网络受限 + 不能接公网 API”的场景。如果你的文档本身就是公开站点,云端 RAG 依然更省心,不必为了端侧而端侧。
为什么放弃云端 RAG:三种方案的取舍
最初我也考虑过直接调云端 embedding 与 chat 接口,半天就能接完,但安全评审第一关就卡住了:内部接口文档包含鉴权细节和错误码,不允许出内网。改成内网 GPU 服务器部署开源模型倒是可行,可运维同事明确表示不想为一个文档站养常驻推理服务。
端侧方案的代价同样真实。WebLLM 依赖 WebGPU,首次加载模型体积大,低配笔记本上生成速度只有每秒 5~8 个 token;但它的边际成本为零,静态资源放到任何机器上都能跑,文档更新只需要重新发布 JSON 清单。权衡之后,我选择端侧,并把“无 WebGPU 时降级为关键词检索”作为硬性兜底。
1云端 RAG 接口
- 模型能力强
- 首屏体积小
- 文档需出内网
- 离线不可用
- 按调用量计费
公网 SaaS 文档可选,内网场景直接排除
2本地后端 + 内网模型
- 数据可控
- 响应较快
- 需要 GPU 服务器
- 运维成本高
- 单机离线仍不可用
团队有基础设施时的折中方案
3WebLLM 端侧方案
- 零后端
- 完全离线
- 边际成本为零
- 首次模型下载较大
- 依赖 WebGPU
- 低端设备吃力
内网文档站与演示环境的最终选择
构建期 Markdown 分块:让索引成为静态产物
分块逻辑放在 VitePress 的构建钩子中,而不是运行时扫页面。运行时切分有两个问题:SPA 切换路由时拿不到未访问页面的内容,而且每次访问都重复解析。构建期直接读取 docs 目录,按二级标题切块,保留标题路径作为引用来源,产物 chunks.json 跟静态资源一起发布。
typescript
// .vitepress/plugins/chunk-plugin.ts
import { defineHook } from 'vitepress';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';
interface DocChunk {
id: string;
title: string; // 标题路径,如 “指南 > 部署 > 离线包”
content: string; // 切片正文
url: string; // 用于回答中渲染来源链接
hash: string; // 内容指纹,判断向量缓存是否失效
}
// 简单稳定的字符串 hash,避免引入额外加密依赖
function hash(text: string): string {
let h = 5381;
for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
}
function splitMarkdown(raw: string, relPath: string): DocChunk[] {
const { content } = matter(raw);
const lines = content.split('\n');
const chunks: DocChunk[] = [];
const stack: Record<number, string> = {};
let current: { heading: string; body: string[] } | null = null;
for (const line of lines) {
const m = /^(#{1,4})\s+(.+)/.exec(line);
if (m) {
if (current && current.body.join('\n').trim().length > 40) {
const title = Object.values(stack).join(' > ');
const text = current.body.join('\n').trim();
chunks.push({
id: hash(relPath + title + chunks.length),
title,
content: text,
url: '/' + relPath.replace(/\.md$/, '.html') + '#' + slugify(current.heading),
hash: hash(text),
});
}
const level = m[1].length;
stack[level] = m[2].trim();
Object.keys(stack).forEach(k => Number(k) > level && delete stack[Number(k)]);
current = { heading: m[2].trim(), body: [] };
} else if (current) {
current.body.push(line);
}
}
return chunks;
}
// VitePress 配置中通过 buildEnd 写出清单
export const chunkBuildHook = defineHook({
async buildEnd() {
const docsDir = path.resolve(process.cwd(), 'docs');
const files = await walk(docsDir); // 递归收集 .md,省略实现
const all: DocChunk[] = [];
for (const file of files) {
const raw = await fs.readFile(file, 'utf8');
all.push(...splitMarkdown(raw, path.relative(docsDir, file)));
}
const outDir = path.resolve(process.cwd(), '.vitepress/dist/shared');
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(path.join(outDir, 'chunks.json'), JSON.stringify(all));
},
});
function slugify(s: string) {
return s.toLowerCase().replace(/[^\w一-龥]+/g, '-').replace(/^-|-$/g, '');
}切片粒度我踩过坑:一开始按固定 800 字硬切,代码示例被拦腰截断,检索命中后模型看到的是半段配置,答案频繁臆造。改成“按标题切 + 超长块再按段落二次切”之后,引用来源也能精确到锚点。
IndexedDB 向量缓存:第二次打开必须秒开
embedding 模型同样跑在端侧。我选用 WebLLM 内置的小型 embedding 模型,384 维向量对中文文档够用,关键是体积小、初始化快。向量不能只存在内存里,否则刷新页面就要重新编码几百个分块;以 chunk.hash 为主键存进 IndexedDB,文档没变就直接复用,变了只重算对应分块。
typescript
// src/ai/vector-store.ts
import type { DocChunk } from './types';
const DB_NAME = 'doc-assistant';
const STORE = 'vectors';
interface VectorRecord {
hash: string;
vector: number[];
updatedAt: number;
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'hash' });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function getCachedVectors(hashes: string[]): Promise<Map<string, number[]>> {
const db = await openDB();
const map = new Map<string, number[]>();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).getAll();
req.onsuccess = () => {
for (const rec of req.result as VectorRecord[]) {
if (hashes.includes(rec.hash)) map.set(rec.hash, rec.vector);
}
resolve();
};
req.onerror = () => reject(req.error);
});
return map;
}
export async function saveVectors(records: VectorRecord[]): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
// 只保留当前清单内的 hash,避免文档删除后垃圾无限堆积
records.forEach(r => store.put(r));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export function cosine(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
}这里有个隐蔽的坑:IndexedDB 里结构化克隆 Float32Array 没问题,但我早期把向量数组直接 JSON 序列化存进 localStorage,500 个分块就爆容量。换成 IndexedDB 后上限不再是瓶颈,但记得在写入前清理旧 hash,否则文档迭代几十次后,库里会堆满永远不会再命中的僵尸向量。
WebLLM 流式输出:Worker 是必选项
模型加载和生成绝不能放主线程。我第一次图省事直接在 Vue 组件里 await engine.chat.generate(),结果生成时页面滚动卡死、输入框丢字,这个坑我排查了整整一下午,最后确认是推理占满主线程所致。改成 Web Worker 后,主线程只负责接收 token 并 patch 到响应式变量。
typescript
// src/ai/worker.ts
import { WebLLM } from '@mlc-ai/web-llm';
import type { DocChunk } from './types';
import { cosine } from './vector-store';
const MODEL = 'Llama-3.2-1B-Instruct-q4f32_1-MLC';
const EMBED = 'snowflake-arctic-embed-xs-q0f32-MLC';
let engine: any = null;
let embedder: any = null;
async function ensureEngine(onProgress: (p: string) => void) {
if (!engine) {
engine = await WebLLM.CreateMLCEngine(MODEL, {
initProgressCallback: (p: any) => onProgress(`${p.text} ${(p.progress * 100).toFixed(0)}%`),
});
}
if (!embedder) embedder = await WebLLM.CreateMLCEngine(EMBED);
return { engine, embedder };
}
async function retrieve(query: string, chunks: DocChunk[], vectors: Map<string, number[]>) {
const { data } = await embedder.embeddings.create({ input: [query] });
const qv: number[] = data[0].embedding;
return chunks
.map(c => ({ chunk: c, score: cosine(qv, vectors.get(c.hash)!) }))
.filter(x => x.vectorSafe())
.sort((a, b) => b.score - a.score)
.slice(0, 4);
}
self.onmessage = async (e: MessageEvent) => {
const { type, query, chunks, vectors } = e.data;
if (type !== 'ask') return;
const { engine } = await ensureEngine(p => self.postMessage({ type: 'progress', text: p }));
const hits = await retrieve(query, chunks, vectors);
const context = hits.map(h => `【${h.chunk.title}】\n${h.chunk.content}`).join('\n\n');
const messages = [
{ role: 'system', content: '你是文档助手,只能依据给定资料回答,不确定要明说,末尾给出引用标题。' },
{ role: 'user', content: `资料:\n${context}\n\n问题:${query}` },
];
const stream = engine.chat.completions.create({ messages, stream: true, temperature: 0.2 });
let answer = '';
for await (const part of await stream) {
const delta = part.choices[0]?.delta?.content ?? '';
answer += delta;
self.postMessage({ type: 'delta', text: delta });
}
self.postMessage({ type: 'done', sources: hits.map(h => h.chunk.url) });
};
// 检索阶段过滤尚未缓存向量的分块,避免算出 NaN
declare global { interface any { } }上面 filter(x => x.vectorSafe()) 只是示意,实际代码应写成 filter(x => vectors.has(x.chunk.hash));首次编码未完成的分块要明确跳过,不能让 undefined 进入余弦计算,否则整条排序结果会变成 NaN,表现为“问答偶发完全不引用文档”,又一个让我加了半天日志的坑。
Vue 组件接入:加载态、降级与来源引用
组件里做三件事:探测 WebGPU 决定是否启用 AI 模式,拉取 chunks.json 并补算缺失向量,把 Worker 推来的增量文本拼进响应式字符串。模型加载进度必须可见,否则用户盯着空白只会反复点按钮。
vue
<!-- src/components/AskAssistant.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { DocChunk } from '../ai/types';
import { getCachedVectors, saveVectors } from '../ai/vector-store';
const question = ref('');
const answer = ref('');
const progress = ref('');
const sources = ref<string[]>([]);
const supported = ref(true);
let worker: Worker | null = null;
let chunks: DocChunk[] = [];
const vectorMap = new Map<string, number[]>();
onMounted(async () => {
supported.value = !!(navigator as any).gpu;
if (!supported.value) return;
const res = await fetch('/shared/chunks.json');
chunks = await res.json();
const cached = await getCachedVectors(chunks.map(c => c.hash));
cached.forEach((v, h) => vectorMap.set(h, v));
// 缺失向量交给 worker 中的 embedder 批量补齐后回存,逻辑类似 ask 分支
});
async function ask() {
if (!question.value.trim() || !worker) return;
answer.value = '';
sources.value = [];
worker = new Worker(new URL('../ai/worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (e: MessageEvent) => {
const msg = e.data;
if (msg.type === 'progress') progress.value = msg.text;
if (msg.type === 'delta') answer.value += msg.text;
if (msg.type === 'done') {
sources.value = msg.sources;
progress.value = '';
}
};
worker.postMessage({
type: 'ask',
query: question.value,
chunks,
vectors: Object.fromEntries(vectorMap),
});
}
</script>
<template>
<section class="ask-box">
<p v-if="!supported" class="warn">当前浏览器不支持 WebGPU,已切换为关键词检索模式。</p>
<div v-else class="bar">
<input v-model="question" placeholder="问问这份文档,例如:离线包如何更新?" @keyup.enter="ask" />
<button @click="ask">提问</button>
</div>
<p v-if="progress" class="progress">{{ progress }}</p>
<article v-if="answer" class="answer" v-text="answer" />
<ul v-if="sources.length" class="sources">
<li v-for="url in sources" :key="url"><a :href="url">引用来源</a></li>
</ul>
</section>
</template>1
构建期分块
VitePress buildEnd 钩子扫描 Markdown,按标题切块并生成 manifest
2
向量缓存
首次加载用 WebLLM embedding 编码,结果写入 IndexedDB
3
本地检索
余弦相似度召回 Top-K 分块,全程不发起网络请求
4
流式生成
WebLLM 在 Web Worker 中逐 token 推送到 Vue 组件
避坑总结:四个让我返工的细节
第一,分块不要按固定字数硬切。现象是回答里出现半截配置和不存在的参数,根因是代码块被切断。修正方式是以标题为主边界,超长块在段落或围栏代码块边界二次切分,并把标题路径写进每个分块。
第二,向量缓存必须带内容指纹并清理旧记录。现象是文档更新后答案仍引用旧行为,根因是 IndexedDB 命中了无版本标识的旧向量。用 chunk.hash 做键、每次发布用新清单反向清理,可以同时解决失效与膨胀。
第三,推理必须放进 Web Worker,并显式处理流式背压。现象是生成时页面卡死、输入丢字。把生成循环移到 Worker 后,还要避免一次性 postMessage 超大字符串,按 token 增量发送最稳。
第四,永远准备无 WebGPU 的降级路径。企业内网浏览器版本参差,Safari 与部分 Chromium 环境无法初始化模型。我的做法是探测 navigator.gpu,失败时退化为基于标题与正文的 BM25 式关键词匹配,虽然没有生成式总结,但检索跳转依然可用,远好过一个报错按钮。
- 1第 1 天 构建期分块实现 Markdown 切片与 content loader产物 chunks.json 随静态资源发布
- 2第 2 天 向量与缓存跑通 embedding 并接入 IndexedDB二次加载跳过重复编码
- 3第 3 天 流式问答Web Worker 承载 WebLLM 与生成循环主线程保持滚动与输入流畅
- 4第 4 天 降级与验收补齐 WebGPU 探测、加载态与引用来源无 WebGPU 时退回关键词检索
整套接入没有引入常驻服务,构建产物依旧是纯静态文件,模型权重可以放在内网 OSS 或随安装包分发。对文档站而言,这种“构建期准备数据、运行时全在浏览器”的思路,比堆一个推理后端更贴合私有化交付的现实。本文首发于开发者日记,后续我会继续记录模型量化选择与内网模型分发缓存的实测数据。 ===CONTENT===
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。