WebView里跑RAG——浏览器内知识检索增强实战

说真的,一开始想到要在 WebView 里跑 RAG,我自己都觉得这想法有点疯。不是那种常规的"客户端请求后端 API,后端做向量检索和大模型调用"的套路,而是把整套检索增强生成都塞进一个 WebView 里面。没错,包括 embedding 模型、向量库、甚至一个小到能跑在浏览器里的生成模型,全都丢进去,在用户手机上离线跑。听起来像自虐,但需求就那么奇葩:一个嵌入到 App 里的知识库问答模块,要求数据不出客户端,断网也能用,响应还要快。

我当时心想,这不就等于把大象装进冰箱么,而且冰箱门还得是 WebView 这个小窟窿。

先交代下背景,项目里原本就有一个 Android 原生壳,里面主要跑 Web 页面,大部分交互都用 H5 做,业务方希望这个智能问答也走 WebView,减少维护两套代码的成本。但他们又要离线可用、数据私有化。我第一反应是:那你在原生层做不行吗?他们摇摇头,表示后续还要跨 iOS 和快应用,不想碰原生逻辑。行吧,那就是要在浏览器环境里解决 RAG 的全部事情。

于是我开始了折腾之旅。

选型与架构:把战场限定在浏览器里

要在一个纯浏览器环境跑 RAG,你马上会意识到几件事:

  • 没有 Node.js 后端,没有 Python 脚本,一切都要 JavaScript 自己扛。
  • 不能指望 GPU 加速,哪怕 WebGL 能蹭点矩阵运算,可 embedding 模型和生成模型还是靠 CPU 硬算。
  • 内存限制严重。移动端 WebView 能给网页分配的内存可能也就几百兆,一个稍微大点的模型就 OOM。
  • 但好处是 Web 技术这些年进化了不少,WebAssembly、Web Worker、IndexedDB、Service Worker 都有了,认真捣鼓的话,真能在浏览器里搭一个微型 AI 栈。

所以我定下的技术栈是:

  • Embedding 模型:用 transformers.js(Hugging Face 的浏览器端推理库),选一个尺寸足够小但中文效果还行的模型,比如 shibing624/text2vec-base-chinese,或者更小的 multilingual-e5-small,转成 ONNX 用 WASM 跑。
  • 向量存储:不可能用 Milvus 或者 Qdrant 这种正经数据库,只能在内存里搞。用 IndexedDB 做持久化,向量索引干脆用简单的暴力搜索 + 分桶,后来因为数据量不大(几千条),就直接上了 hnswlib 的 JavaScript 版(hnswlib-wasm),实测还行。
  • 生成模型:这最难。要想在浏览器里跑大模型,得用 llama.cpp 的 WebAssembly 版本,或者用 MLC-LLM 的 Web 运行时。我选了稍微折中的方案:先弄了一个 tinyllama-1.1b 的 ggml 模型,用 llm.js 之类的加载器(后来发现它的 WebAssembly 版跑在移动端实在太慢,回答一个问题要几十秒),后面又试了 RWKV 的 WASM 推理,那玩意儿是 RNN 结构,推理解码快一点,但中文效果稀烂。最终妥协:生成部分如果允许联网,走服务端 API;如果必须离线,用一个极小的 GPT-2 中文版微调模型(20MB 左右),搭上模型量化,生成速度能在 5 秒内给出短回答。没办法,端侧的算力就那点,不能既要又要。
  • RAG 的逻辑:检索上下文拼接 prompt,再用模型生成。这个倒不难,自己写个链式调用的 JS 类就搞定,没必要硬套 LangChain.js,太重。

架构图其实简单到爆:用户输入问题 -> 网页内 JS 调用 embedding 模型把问题转向量 -> 从本地向量库搜 top_k 文档 -> 拼 prompt -> 喂给生成模型或 API -> 流式返回答案。

听着挺顺是不是?但真正动手的时候,坑多得我差点把键盘砸了。

跑起来的第一步:embedding 模型的浏览器适配

我先拿 transformers.js 搞 embedding,心想 Hugging Face 官方维护的东西应该稳了吧,结果一跑就挂。

在 Android WebView 里加载 shibing624/text2vec-base-chinese,模型文件 400 多 MB,下载耗时不算,光是 ONNX Runtime Web 初始化就卡死主线程。因为 WebView 的 JavaScript 引擎虽然能开 Worker,但模型加载是在主线程发起的,一堆 WASM 初始化、内存分配,直接把 UI 卡成 PPT。而且你猜怎么着?WebView 默认不会给网页分配那么大的 WASM 内存堆,加载到一半直接 OOM 崩溃。我赶紧查文档,发现 Chrome 系浏览器允许通过 HTTP 响应头 Cross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin 启用 SharedArrayBuffer 和高内存限制,但 Android WebView 里这些头部默认不生效,除非你是在 Android 8 以上且开启了 WebView.setWebContentsDebuggingEnabled?不对,内存限制跟那个没关系。实际上 WebView 对于 wasm 最大内存页有个隐含限制,需要通过 android:largeHeap="true"?不,那是针对原生 Dalvik 的,对 WebView 内部 V8 内存分配影响不大。后来发现有一个方法:在 WebView 的设置里启用一些 flag,比如通过 WebView.getSettings().setRenderPriority 早就没用了。正确的姿势是在应用端给 WebView 设置 WebView.setWebContentsDebuggingEnabled(true) 只是调试用。最终解决是靠分裂模型:不用 400MB 的大模型,换成 sbert 的轻量版本 all-MiniLM-L6-v2,它只有 80MB,而且我用量化后的 ONNX 模型,体积压到 23MB,加载飞快,中文效果虽然不如专用中文模型,但凑合能用。更重要的是,我把它放到 Web Worker 里加载,主线程只负责 postMessage 通信,完美避免卡顿。

可接下来又出妖蛾子:在 Worker 里跑 transformers.js,需要一个单独的 pipeline 实例,每次实例化都要下载一次模型?我明明缓存到了 IndexedDB 啊。翻了 issue,原来要指定 localModelPathcache_dir,把模型文件预先存进 IndexedDB,然后用 env.localModelPath 指定。这里我用到了 IndexedDB 手动存取文件,然后用 transformers.jsenv.customCache 替换默认的浏览器缓存,折腾了两天才搞定。口语化一点说,就是这玩意儿文档写的是给标准浏览器看的,一到 WebView 那个阴间环境,各种暗坑。

向量库的"能用就行"哲学

向量存储这块,我一开始想得特美:用 hnswlib 直接在浏览器里建图索引,增删改查都快。但是 hnswlib 的 JavaScript 绑定烂得一批,官方的是用 N-API 写原生模块给 Node.js 的,浏览器怎么用?好在有人做了个 hnswlib-wasm,用 Emscripten 编译的。我拿过来一试,嗯,构建索引确实快,可问题在于它的所有数据都存在 WASM 线性内存里,一旦 WebView 杀掉后台进程,内存就没了。得持久化。

我想把索引序列化存到 IndexedDB,但 hnswlib-wasm 没有提供简单的方法导出整个索引的二进制,它只能按点查询。后来我干脆不用图索引了,回归暴力搜索:反正知识库就两千多条,每条向量维度 384,算一下余弦相似度,纯 JS 循环一遍也就十几毫秒。配合一个简单的分块机制,我把所有向量存到一个 Float32Array,然后用 IndexedDB 做持久化,每次加载时全读进内存。两三千条数据的距离计算完全够用,没有明显的卡顿,还要啥自行车?有时候最简单的方案就是最好的方案。

这里有个细节,当时我在 WebView 里读写 IndexedDB 发现事务老是超时。查了下,部分 Android 系统 WebView 在页面进入后台后,IndexedDB 连接会被强制断开,不报错但操作静默失败。只能在每次操作前检查连接,加一个健康检查的简易 wrapper,出问题就重试。这都什么破事。

生成模型的"用爱发电"阶段

离线生成这块我真是踩了无数坑。最早用 llama.cpp 的 JavaScript 移植版,一个叫 llama.runtime.js 的东西,把 ggml 模型编译成了 WASM。模型我选了 TinyLlama-1.1B-Chat,量化成 q4_0 后大概 600MB。在 PC Chrome 上跑得还不错,单次回答 3~5 秒。放在 Android WebView 里,第一把直接 OOM 杀死页面。我调了 WASM 内存上限,也开了 largeHeap(虽然没卵用),最后还是通过降低 contextSize 到 512 token 勉强加载成功。然后推理速度感人------一个简单问题,解码阶段每个 token 将近 1 秒,生成 20 个 token 就要等 20 秒,用户早疯了。我意识到基于 Transformer 的自回归模型在移动端 CPU 上做推理就是找罪受,尤其是没有 SIMD 和线程优化时。

换成 RWKV 的思路,我找了个 rwkv.cpp 的 WASM 构建,模型小到只有 100MB,RNN 解码常数时间,理论上快得多。但是中文训练数据太少,出来的回答基本是乱码+胡言乱语。不能忍。

最后折中方案:在不能联网的场景,我用了一个极小 GPT-2 中文模型(gpt2-chinese-cluecorpussmall),大概 30MB,用 ONNX Web runtime 跑,虽然生成质量差,但至少能根据检索到的文档片段拼出几个字,比如"该问题的答案是:xxx",作为兜底。反正离线场景用户预期也不高。如果有网,就用 fetch 调服务端的生成 API,流式返回,WebView 里用 fetch + ReadableStream 完美支持,只需注意跨域和 CORS 头。

把整套流程串起来:RAG 管线在 Web 里长啥样

我写了一个叫 InWebViewRAG 的类,接收一个配置对象,指定 embedding 模型路径、文档集合、是否启用离线生成等。伪代码大概如下:

javascript 复制代码
class InWebViewRAG {
  constructor(options) {
    this.embeddingModel = options.embeddingModel;
    this.vectorStore = options.vectorStore;
    this.generator = options.generator; // 可能是本地模型或远程 API
  }

  async init() {
    // 加载模型和向量库
    await this.embeddingModel.load();
    await this.vectorStore.loadFromIndexedDB();
  }

  async query(question) {
    const qVec = await this.embeddingModel.embed(question);
    const docs = this.vectorStore.search(qVec, 3);
    const context = docs.map(d => d.content).join('\n');
    const prompt = `参考以下内容回答问题:\n${context}\n\n问题:${question}\n答案:`;
    return await this.generator.generate(prompt);
  }
}

实际开发时发现一个问题:每次问问题都要调用 embedding 模型一次,而模型推理即使是 23MB 的轻量版本,在低端机上也要 200~500ms,加上检索和生成,总延迟超过 3 秒。我用了一个小技巧:对用户输入做 MD5 缓存,如果同样的问题短时间内再问,直接用上一次的向量。这方法在知识库问答场景下还挺管用,因为用户经常重复问相同或类似问题。

然后就是流式输出的展示。如果生成来自远程 API,直接 fetch 读流,用 TextDecoder 逐步解析并显示,像打字机效果。但本地模型生成时,如何不阻塞主线程?我把生成过程放到 Web Worker 里,每一步解码产生一个新 token 就 postMessage 给主线程,实现伪流式。不过部分模型推理库不支持逐步回调,只能等全部生成完再返回,那也得硬着头皮等。

WebView 特有的那些崩溃事

说到 WebView,就不得不提各种设备兼容性问题。有一天测试同事拿着一台红米 Note 9,Android 11,一打开问答页面就白屏。我一看 logcat,崩溃信息是"Cannot enlarge memory arrays"。又是 WASM 内存分配失败。那台手机系统 WebView 版本还是 83 左右,对 SharedArrayBuffer 支持不好,当 WASM 需要连续内存块时就会崩溃。我后来做了一层降级:检测到不支持足够大内存的 WebView 时,直接切到服务端检索+生成,放弃离线能力。顺带一提,检测方法是通过 WebAssembly.Memory 申请某个大小测试,如果抛异常就降级。

还有坑是 localStorage 和 IndexedDB 的存储限制。部分 WebView 把浏览器存储配额设得很低,比如 50MB。我存的向量数据和模型文件加起来有 40MB 左右,差点爆掉。为了节省空间,向量使用 Float16 半精度存储(用 Float32Array 存,但值压缩成 16 位用两个 16 位拼成 32 位,自己写编解码),省了一半。模型文件尽量量化。

数据的准备和索引的构建

RAG 不光推理麻烦,前置的文档处理和索引生成也必须在浏览器里做。知识库的原始文档是 markdown 格式,大约一百多篇帮助文档。我需要在 WebView 里把它们分块、生成 embedding、构建向量索引。这项工作如果用服务器算力分分钟搞定,但我们要的是客户端独立完成。于是我在首次启动时,利用空闲时间,通过一个 hidden iframe 或者直接在 Worker 里跑批处理。文档分割采用简单的 fixed-size 切片,每块 256 字符,重叠 40 字符。生成 embedding 时用队列控制并发,因为一次同时跑多个推理会把 CPU 打满,导致手机发烫、卡顿。我限制 Worker 中同时只进行一个 embedding 推理,确保性能和散热平衡。初始索引构建大概需要一分多钟,这期间用户可能没耐心等,所以我在 UI 上搞了个进度条和"正在准备本地知识库"的提示,数据量大就建议连接网络用服务端构建。

这里有一个非常 CSDN 风格的吐槽:你永远想不到用户会在什么时候打开 WebView。可能是在地铁上信号飘忽,也可能是在手机存储满得只剩 20MB 的情况下。所以必须做足异常处理,索引构建失败就 fallback 到在线搜索,模型加载失败也要有提示。

前端交互与 WebView 的通信

因为我们是在 App 壳的 WebView 里,有时还是需要一些原生能力,比如读取本地文件。这个时候必须通过 JSBridge 让原生把文件内容传给 WebView。我写了一个内容提供者接口,如果知识库文档是 App 本地存储的,就调用原生方法获取文本,而不是走 HTTP 下载。这就涉及到 WebView 的 @JavascriptInterface 注入。当然,这里要留意安全,只暴露必要方法。

更头疼的是文件权限。Android 10 以后的分区存储,通过 WebView 加载 file:// 协议几乎不可能了,需要通过 ContentProvider 或原生读取后以字符串形式传递。这部分和 RAG 核心关系不大,但在实战中占了不少时间。

性能压榨与最终效果

一番折腾下来,最终方案在主流中端机(骁龙 7 系列)上表现:

  • embedding 模型加载时间:3~5 秒(首次下载模型需要网络,之后从 IndexedDB 秒开)。
  • 单次问题向量化:200~400ms。
  • 2000 文档暴力检索:~15ms。
  • 在线生成(API):网络延迟+生成时间约 1.5~3 秒,流式展示。
  • 离线生成(微型 GPT-2):首 token 2 秒,后续每 token 约 80ms,一个短答案约 6 秒左右。

虽然离线生成慢,但好歹给了答案。业务方勉强接受,说至少比白屏强。我撇嘴,心想你之前要这要那的时候可不是这么说的。

写在最后

在 WebView 里跑 RAG 这事儿,技术上很有意思,但也充满了妥协。你不能像在服务端那样肆意挥霍算力,必须斤斤计较每一兆内存、每毫秒延迟。这也让我深刻感受到 Web 技术栈的能力边界。好在浏览器生态还在进化,WebGPU 的落地会让端侧推理更快,以后说不定真能在浏览器里丝滑运行 7B 模型。

回过头看,这篇文章的标题叫"实战",其实更像是"踩坑记录"。如果你也想在客户端搞这个,我的建议是:能别在 WebView 里跑就别跑,原生不香吗?如果非要跑,把模型压到最小,离线生成当个噱头,真正好用的还是得走网络调大模型 API。毕竟现在大模型卷成这样,响应又快效果又好,让用户等本地小模型磕磕巴巴吐答案,除非你真的不能联网,否则没必要。

行了,就扯这么多。希望过两年有更强的浏览器内 AI 基础设施,那时候我们再回来把这个方案升级一把。到那时,我没准儿能轻松地写一篇《WebView里跑 70 亿大模型,丝滑如德芙》。等着吧。

相关推荐
wanger611 小时前
Vue学习笔记
前端·javascript·vue.js
杨先生哦1 小时前
【2026热端攻防系列 3/12】反射型&存储型XSS全解:AI批量免杀、WAF绕过与企业级防御
前端·人工智能·笔记·web安全·xss
问心无愧05131 小时前
ctf show web入门123
android·前端·笔记
大刚测试开发实战1 小时前
TestHub数据工厂发布!附更新指南
前端·后端·github
by————组态1 小时前
Ricon组态组件生态 - 丰富的可视化组件库
运维·前端·物联网·组态·组态软件
天蓝色的鱼鱼2 小时前
Node.js 现在能直接跑 TypeScript 了,tsx 和 ts-node 还需要吗?
前端·typescript·node.js
阿猫的故乡2 小时前
Vue动态组件+异步组件实战:Tab切换、按需加载、KeepAlive缓存,一次搞定
前端·vue.js·缓存
风骏时光牛马2 小时前
Stylus预处理器完整语法与项目实战详细代码案例
前端
tangdou3690986552 小时前
DevOps Skill工具链:CI/CD流水线搭建全攻略
前端