跳到主内容

离线也能问文档: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
    第 1 天 构建期分块
    实现 Markdown 切片与 content loader
    产物 chunks.json 随静态资源发布
  2. 2
    第 2 天 向量与缓存
    跑通 embedding 并接入 IndexedDB
    二次加载跳过重复编码
  3. 3
    第 3 天 流式问答
    Web Worker 承载 WebLLM 与生成循环
    主线程保持滚动与输入流畅
  4. 4
    第 4 天 降级与验收
    补齐 WebGPU 探测、加载态与引用来源
    无 WebGPU 时退回关键词检索

整套接入没有引入常驻服务,构建产物依旧是纯静态文件,模型权重可以放在内网 OSS 或随安装包分发。对文档站而言,这种“构建期准备数据、运行时全在浏览器”的思路,比堆一个推理后端更贴合私有化交付的现实。本文首发于开发者日记,后续我会继续记录模型量化选择与内网模型分发缓存的实测数据。 ===CONTENT===

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。