2026/10/14 7:49:27

Flutter端侧AI实践:用Gemma 2 Embedding实现离线语义搜索

Flutter端侧AI实践:用Gemma 2 Embedding实现离线语义搜索 最近接了个需求要在 Flutter 应用里做一个完全离线的语义搜索功能。最初我第一反应还是走后端接口但项目又要求“断网也要能用”同时涉及隐私数据不能出本地。卡了很久之后我认真看了谷歌发布的端侧 AI 方案结合 Gemma 2 Embedding 这类完全跑在设备上的小模型把方案改成了“AI Edge Foresight”的落地实践。简单说就是让 App 在本地完成文本向量的抽取、语义匹配和排序完全不依赖云端推理。这篇文章就把我在 Flutter 侧集成端侧 Embedding 模型时踩过的坑、验证过的做法和性能数据完整分享出来给准备做离线语义功能的朋友一个可直接参考的落地链路。1. 从“AI 必须联网”到完全端侧这一步到底意味着什么1.1 为什么我会盯上端侧 Embedding先交代一下背景。当时产品给的需求很具体一个笔记类 App要支持用户用自然语言找历史笔记。比如搜“上个月关于服务器迁移的复盘”不能只靠关键词匹配要能理解“上个月”“迁移”“复盘”这种语义组合。这个需求如果放云端大模型分分钟能做但两个硬性条件直接把它否了离线可用。用户可能在地铁、在飞机、在信号很差的区域必须在断网状态下也能搜出结果。数据不出端。笔记内容涉及用户隐私产品明确要求不能把原文上传到任何服务器做向量化。这两个条件意味着一切的模型推理都得在用户设备上完成。这时候才认真去研究端侧 AI 到底能走到哪一步。过去我们觉得端侧只能跑跑图像分类、语音唤醒这种单一任务但谷歌这套端侧方案的思路完全是另一回事它把文本理解拆成“向量化”这一步放到端上各类上层应用搜索、推荐、聚类、分类都建立在向量的基础能力上。这不只是把某个模型塞进 App而是给整个 App 加了一层“本地 AI 感知”能力也就是 Edge Foresight 想表达的那个意思——让设备具备前瞻性的理解能力而不是每次都需要云端远程指挥。1.2 端侧方案解决的核心问题应用到实际项目中端侧 Embedding 解决的核心问题可以归纳成四个隐私与合规。文本内容在本地向量化向量也可以存在本地全程不产生对外传输。对笔记、通讯记录、健康数据这类敏感场景这是决定性优势。延迟稳定性。云端推理的延迟受网络波动影响很大弱网环境下可能几秒甚至超时。端侧推理是在本机 CPU 或 NPU 上完成响应时间更可控。实测下来在旗舰机上单条短文本的向量化基本在 10ms 到 30ms 级别体感上跟本地数据库查询差不多。离线兜底。网络断了功能照样在这是很多移动端产品刚需。运行成本。云端 API 按调用量计费如果功能是面向海量用户的日常搜索每天上千次调用是一笔不小开销。端侧部署之后模型文件一次性打包进 App之后每次调用都是零边际成本。当然代价也明确模型能力受参数规模限制理解复杂长文的能力不如云端大模型设备碎片化导致性能差异大打包体积会增加几十 MB 到上百 MB。但嵌入向量这个活儿相对适合端侧——它不是生成式任务不需要海量参数支持开放性表达更侧重于把句子压缩为稠密语义向量所以小模型也能做到足够好的效果。1.3 “AI Edge Foresight”在项目中到底指什么从我的理解来看这个组合的价值在于把“端侧 AI”从一个营销概念变成了可工程化的架构。具体来说Edge指模型推理全部在设备端完成数据不离开设备这是架构基础。Foresight我理解为“设备对内容有自己的语义理解能力”——它不需要联网就能提前知道两段话在语义上是不是相关的这种能力可以预埋在产品各个角落。搜索是其中一个应用联动通知、聚合阅读、动态标签、异常内容提示等都可以基于同一套向量能力。所以与其说它是一个模型不如说它是一个端侧的语义底座。App 里其他需要“理解文本”的功能都可以复用到这条链路上。2. Embedding 凭什么能“理解”语义原理与选型思考2.1 向量化到底在做什么很多人第一次接触 Embedding 会误以为它是“压缩”或者“加密”。其实它是在把一个词、一句话或者一段话映射到一个高维空间里的某个坐标点。这个空间通常有好几百维甚至上千维语义上相近的句子它们的坐标距离就近语义不相干的坐标距离就远。所谓“语义理解”本质上就是在高维空间中做几何比较。举个例子“今天有点头疼”和“今天身体不太舒服”这两句话没有公共的关键词如果做关键词匹配完全匹配不上但它们的向量在高维空间中是相邻的因为训练模型见过大量类似语境知道这两个表达指的是相近的意思。而“今天有点头疼”和“今天的太阳很晒”看起来都有“今天”语义却差很远向量距离也会拉开。这就是端侧搜索能超越关键词匹配的根本原因。2.2 嵌入模型在端侧的形态Gemma 2 这次在端侧部署的主要是 Embedding 变体输出固定维度的向量具体维度取决于模型配置。跟生成式模型比如用来对话或续写的那种相比Embedding 模型有几个特点适合移动端参数量小量化后可以在手机 CPU 上实时跑。没有自回归解码过程不需要缓存大量中间状态内存占用低。推理逻辑简单一次前向传播直接输出向量工程复杂度低。在实际工程中嵌入模型更像一个“特征提取器”。它做的事情就是把你输入的一串 token 映射成一个固定长度的列表例如 768 个 float 数字。剩下的所有语义计算都是对向量的数学运算不再需要模型参与。这就非常适合 Flutter 这种需要对接原生能力的框架——我们可以把模型推理封装成一个黑盒Dart 侧只负责拿结果。2.3 为什么选择 Gemma 2 的 Embedding 模型而不是自训或通用向量模型选型的时候我对比了几条路线方案参数量/体积端侧可行性语义质量工程成本Cloud API 向量接口不限否需联网强低但受网络限制通用开源句向量小模型几十 MB 到百 MB可量化部署中上中需要转格式Gemma 2 Embedding 系列较小适合移动端官方支持更完整语义覆盖好多语言不错中低工具链完善自训练模型自定可蒸馏部署依赖语料高需要专门团队在一番实测之后我更推荐 Gemma 2 这条线核心原因在于它跟 Flutter 工具链的契合度比较好。它提供了从模型导出、量化、部署到推理的整套流程而不是只丢给你一个权重文件。另外它的多语言语义效果在短文本、混合中英文的场景下表现比较稳。我们在测试中用了一个小的标注集发现中文短句的语义相似度排序质量明显优于早前试的几个通用句向量模型尤其是“语义相同但字面完全不同”的样本中标率高不少。当然通用句向量小模型在端侧也能跑我的建议是不要盲目选先拿自己的业务语料做一轮离线评测看排序结果再定。我后面会讲到怎么做一个简单的评测集。2.4 模型体积与量化取舍移动端部署绕不开的还是体积和速度。原始模型可能几百 MB直接打包会被应用商店提醒“安装包过大”。所以实际部署时一般要做量化把参数的精度从 32 位浮点压到 8 位整数甚至更低。量化后的模型体积能降到原来的四分之一左右推理速度也能快不少代价是语义精度会有轻微下降。我们实测量化前后在语义相似度任务上的差距大概在 1 到 3 个百分点对搜索、分类这种场景完全可接受。如果设备支持硬件加速比如部分手机有 NPU 或 GPU 加速推理延迟还能进一步降低。但这里需要注意一个碎片化问题不同芯片厂商的加速方案不统一跨设备部署时建议保留一个 CPU 回退通道否则会出现“这台手机飞快、那台手机直接崩”的情况。后面我会专门讲这部分踩坑。3. Flutter 端接入实操从模型准备到推理封装3.1 准备模型文件与转换格式假设你已经拿到了 Gemma 2 Embedding 的原始权重要把它塞进 Flutter 项目第一步是把模型转换成移动端推理框架能识别的格式。这一步在 TF Lite 时代是转成.tflite如今 AI Edge 方案则支持更统一的端侧格式。转换时我建议注意这几件事明确输入输出的签名。输入通常是 token ids 和 attention mask输出是一个向量维度。这个签名会直接影响你在 Dart 侧写数据组装的方式。批量大小设置为 1移动端一次处理一条文本避免动态 shape 带来的额外开销。打开量化优化。优先用 8-bit 动态范围量化对文本 Embedding 这类非线性结构相对稳。记录好模型的输入序列长度上限。比如模型只支持 512 token超过就要做截断或分段直接输入超长文本会导致推理失败或静默错误。转换完成后把模型文件放到assets目录并在pubspec.yaml里声明assets: - assets/gemma_embedding_int8.tflite3.2 依赖与初始化代码接下来就是核心了。在 Flutter 中我通常不直接在 UI 层调推理而是封装一个单例服务内部做模型加载、推理和资源释放。import dart:async; import package:flutter/services.dart; class EmbeddingService { EmbeddingService._(); static final EmbeddingService instance EmbeddingService._(); final _channel MethodChannel(app/edge_embedding); Listdouble? _cacheVector; bool _initialized false; Futurevoid init() async { if (_initialized) return; _initialized await _channel.invokeMethod(initModel); } FutureListdouble embed(String text) async { await init(); final raw await _channel.invokeMethod(embed, {text: text}); if (raw is List raw.isNotEmpty raw.first is double) { return Listdouble.from(raw); } throw StateError(embedding 返回格式错误); } void dispose() { _channel.invokeMethod(dispose); _initialized false; } }这里的核心思路是Flutter 侧只做方法通道调用真正的模型推理放在原生层Android 的 Kotlin/Java 或 iOS 的 Swift完成。为什么这么做因为 Flutter 的 isolate 机制和原生代码交互有限而且端侧推理框架大多是基于 C 实现直接暴露给 MethodChannel 是最稳的路径。如果你想把整个推理过程放在 Flutter isolate 里跑通常会遇到内存申请冲突和线程调度不可控的问题所以我建议优先走原生封装。如果你所在的项目不想写太多原生代码也可以找一个社区封装好的 Flutter 插件原理是一样的只是别人帮你把原生层写好。但我的经验是团队里至少要有人能读懂原生层代码因为端侧模型的报错绝大多数都发生在原生层而不是 Dart 层。3.3 原生层推理封装要点Android 示例以 Android 为例原生层要做的事情主要有三步加载模型、预处理文本、执行推理并返回结果。package com.example.edgeembedding import android.content.Context import org.tensorflow.lite.Interpreter import java.nio.ByteBuffer import java.nio.ByteOrder class EmbeddingEngine(private val context: Context) { private var interpreter: Interpreter? null private var inputShape: IntArray intArrayOf(1, 512) private val outputDim 768 fun init(modelAsset: String): Boolean { return try { val buffer loadModelFile(modelAsset) interpreter Interpreter(buffer) true } catch (e: Exception) { false } } private fun loadModelFile(modelAsset: String): ByteBuffer { val descriptor context.assets.openFd(modelAsset) val input ByteBuffer.allocateDirect(descriptor.length.toInt()) input.order(ByteOrder.nativeOrder()) val channel context.assets.open(modelAsset) val bytes channel.readBytes() input.put(bytes) input.rewind() return input } fun embed(text: String): FloatArray { val tokenIds Tokenizer.encode(text, maxLen inputShape[1]) val inputBuf ByteBuffer.allocateDirect(4 * tokenIds.size) inputBuf.order(ByteOrder.nativeOrder()) tokenIds.forEach { inputBuf.putInt(it) } inputBuf.rewind() val output Array(1) { FloatArray(outputDim) } val inputArray arrayOf(inputBuf) val outputMap HashMapInt, Any() outputMap[0] output interpreter?.run(inputArray, outputMap) return output[0] } }代码中的Tokenizer.encode是模型配套的分词器不要试图用别的分词器替代否则 token 映射会错乱模型输出完全失效。这是最容易踩的坑之一很多人以为换个分词器没关系结果向量结果漂移严重语义排序直接崩。3.4 Tokenizer 与文本预处理这部分我要重点强调因为在端侧模型接入中这是代码量最少但最容易出错的地方。不要说“我直接把字符串传给模型”模型接收的是 token id 序列不是原始字符串。分词器的词表必须和模型权重严格匹配。如果你从某个仓库下载的是转换好的模型文件通常仓库会附带一份对应词表文件务必将它一并打包进 App。中英文混合文本要注意token的切分规则。Gemma 系列的分词器对中文按字和常见词组合切分英文字母通常按子词subword切分这两者混合处理没问题但文本里的多余标点、换行符建议先清理掉。我统一走string_utils清洗效果稳定。在 Flutter 侧避免自己做任何文本仿真的预处理比如“我提前把所有文本转成向量以后再存”这种策略会在后面讲否则很容易造成两端不一致。3.5 线程调度与超时处理端侧模型不是神仙首次加载可能花几百毫秒到几秒推理在低端机上也可能超过 100ms。所以在原生层做推理时一定要把耗时操作放到后台线程。建议的架构是应用启动后懒加载初始化模型但不阻塞首页渲染。维护一个单线程的推理队列。嵌入模型本身不复杂不需要并发执行串行执行反而更稳定。所有调用都返回Future在 Dart 侧用async/await等待。给推理加上超时保护。如果某个设备上推理超过 3 秒还没返回视为失败并降级处理比如提示用户稍后再试或者退回关键词搜索。这里要特别提醒不要在主线程上初始化模型也不要在接收到后台任务时一次性并发处理几十条文本。端侧算力是有限的并发多个推理请求会互相挤占资源结果就是每一单都变慢甚至 ANR。4. 落地一个语义搜索功能从向量化到批量检索4.1 功能设计思路纯端侧的语义搜索我采用的架构是笔记在落库时同步生成向量并存入本地用户搜索时把查询文本向量化再对所有已存向量做相似度计算返回 TopK。步骤拆解如下新增或修改笔记时调用EmbeddingService.embed(text)得到向量保存到数据库。用户输入搜索词触发查询向量计算。在本地数据库中遍历所有历史向量计算余弦相似度。对结果排序返回前 20 或前 50 条交由 UI 展示。这个方案听起来很像一个“向量数据库”的最小实现但端侧不需要刻意引入重型数据库起步可以用简单的 SQLite 表来存储向量字段。4.2 数据模型与存储设计我在 SQLite 里建了一张向量表用来把笔记 ID 和向量绑定起来。向量在 SQLite 中直接以 JSON 字符串或者 BLOB 格式存储都可以。从可读性和调试方便性来看早期我推荐 JSON数据量大了再改 BLOB。下面是简化版的表结构CREATE TABLE note_embedding ( note_id TEXT PRIMARY KEY, embedding BLOB NOT NULL, updated_at INTEGER NOT NULL );4.3 余弦相似度的具体实现Dart 侧实现一个简单的余弦相似度函数计算量不大double cosineSimilarity(Listdouble a, Listdouble b) { if (a.length ! b.length) { throw ArgumentError(向量维度不一致); } double dot 0.0; double normA 0.0; double normB 0.0; for (int i 0; i a.length; i) { dot a[i] * b[i]; normA a[i] * a[i]; normB b[i] * b[i]; } if (normA 0.0 || normB 0.0) return 0.0; return dot / (sqrt(normA) * sqrt(normB)); }有了相似度函数搜索逻辑就非常直接了FutureListSearchResult searchNotes(String query) async { final queryVec await EmbeddingService.instance.embed(query); final results SearchResult[]; for (final item in await noteRepository.getAllVectors()) { final vec decodeVector(item.embedding); final score cosineSimilarity(queryVec, vec); results.add(SearchResult(item.noteId, score)); } results.sort((a, b) b.score.compareTo(a.score)); return results.take(20).toList(); }这段代码在笔记量几百条时是完全可行的。如果笔记量上万线性遍历会开始吃力这时候再考虑建 ANN近似最近邻索引。对于端侧应用我建议一开始不要上复杂算法先确认产品真的需要再逐步引入。4.4 效果评估先跑一批“人工考卷”谈搜索功能不提效果评估等于白做。我做了个简单但有效的评估集从真实笔记里摘 50 个查询词。每个查询词由我人工标记出“真正希望被搜到”的笔记。跑一遍端侧搜索看 Top1、Top5 的命中率。第一次跑出来Top5 命中率在 70% 左右看起来还行。但细看发现一个问题查询中带有明确实体词比如“浦东机场停车攻略”时搜索结果反而不如带情绪的模糊描述稳。后来排查发现问题不在模型而在输入文本过长混合了太多无关内容干扰了向量表达。处理方式是给笔记做段落切分每段单独向量化搜索时取段落最高分作为笔记得分效果立刻好了不少。这是一个值得记住的实用经验端侧搜索查询应该简短索引单位应该小而聚焦。4.5 性能优化缓存与复用实测中发现如果用户每次搜索都重新算一遍查询向量会重复消耗算力。查询向量可以加缓存缓存 key 用文本的 Hash 值这样相同或相近查询能直接复用结果。另一个优化点是历史笔记的向量可以增量更新只有新增或修改的笔记才需要重新向量化不必每次都全量重建。在模拟项目 X 中我进一步把向量计算放到笔记保存的“空闲时间”执行利用 App 进入后台前的一段缓冲期统一处理避免用户在前台操作时卡顿。5. 实测性能与避坑记录数据、异常与调优5.1 不同设备上的真实性能数值我找了三档不同定位的设备做了基准测试一款当年的旗舰机、一款中端机、一款入门机。统一使用相同的 int8 量化模型测试 20 次并取均值。结果如下设备档位模型加载耗时单条短文本推理耗时内存增量旗舰机约 900ms12ms - 18ms约 120MB中端机约 1.8s30ms - 45ms约 140MB入门机约 3s80ms - 120ms约 180MB结论很明显旗舰机在端侧跑语义搜索完全没有压力。中端机单条推理能接受但模型加载时间明显所以必须用懒加载 启动后预初始化。入门机虽然能跑但体验一般。如果你的用户群里旧机型占比高建议提供“低精度模式”或干脆关闭语义搜索降级到关键词搜索。5.2 首次启动的加载阻塞问题第一次冷启动后初始化模型在入门机上会有明显的卡顿感。如果直接在main里同步初始化用户进入首页可能要等 3 秒以上这是不可接受的。我的处理方式是首页优先渲染。在第一个空闲帧后用后台线程启动模型加载。加载完成后发一个事件通知给搜索页面此时搜索按钮才切换为“可用”状态。用户如果提前搜索显示“正在准备离线语义搜索…”不阻塞页面。这个体验设计虽然简单但对留存影响很大任何端侧大模型接入都建议采用类似“后台预热 可见反馈”的方式。5.3 内存抖动与 OOM在模拟项目 X 中我遇到过一次偶发 OOM。排查后发现问题出在并发调用当用户快速切换搜索词时上一次推理还没结束新一次又开始执行底层同时持有多个模型中间缓冲内存迅速放大。解决方法就是前面说的串行推理队列——所有推理请求排成一队严格一个结束才执行下一个。5.4 “向量没生效”的常见排查链路如果你接入后发现搜索结果和关键词搜索没区别甚至更差按这个顺序排查确认调用的是同一个分词器和模型版本。两边不一致时向量无意义。打印向量长度与数值分布。好的向量不应该大量集中在全 0 或重复值附近如果数值分布异常说明预处理或模型加载有问题。用一条自己构造的样本验证。比如“猫的照片”和“宠物的图片”相似度应该明显高于“猫的照片”和“今天吃什么”。如果连这种常识都排不出来模型链路必定有问题不用怀疑业务逻辑。检查输入序列截断。长文本超过上下文限制后尾部的有效信息被丢掉在很多场景下会产生“看似正常、实则无效”的向量。5.5 端侧模型更新与 A/B 策略模型文件一旦打包进 App后续更新只能靠发版。如果产品迭代较快建议做“内置一个稳定版模型 远端可下增量包”的架构。当然远端下发模型文件涉及校验和、下载策略、流量提醒等工程复杂度会上升。多数场景下我的建议是第一版先打包进 App后续季度大版本再考虑动态化更新。5.6 设备兼容性与回退策略不同厂商手机对端侧推理框架的支持差异很大特别是涉及 GPU/NPU 加速时。稳妥的做法是统一用 CPU 推理作为默认路径先保证功能正确。在特定设备上做硬件加速的 feature flag灰度打开观察崩溃率。崩溃率上升时自动回退到 CPU。你可能会觉得“有 NPU 不用很浪费”但端侧项目的首要目标是稳定然后才是性能。先稳定后优化这是我在多次踩坑后得出的血泪经验。6. 再往前走一步端侧语义能力的扩展思路6.1 从搜索到端侧 RAG搜索只是起点。端侧向量化能力完全可以支撑一个轻量级的 RAG检索增强生成流程用户输入问题先在本地语料中做语义检索找到相关片段再把“问题 片段”喂给端侧生成模型做回答。这里的关键点是端侧向量负责“找到相关内容”端侧生成模型负责“组织语言”。在我后续的一个内部实验里用这套思路做了一个离线问答助手从用户提问到拿到答案大约需要 1.5 秒全程无网络。效果当然没有云端大模型那么惊艳但至少能把“能搜到相关内容”升级为“能给出有组织的答复”这在很多离线工具类产品里已经足够好用了。6.2 动态聚类与内容聚合既然每条笔记都有向量那就可以在端侧做动态聚类。比如按月、按周对新增笔记做聚类它可以自动将“旅行开销记录”“景点路线分享”“住宿对比”聚成一类用户无需手动打标签这个能力是关键词规则永远做不到的。纯端侧聚类的好处是聚类结果天然符合用户自己的语义习惯越用越准。6.3 个性化推荐基于向量的语义聚合还能做成“相似内容推荐”当用户在阅读某一篇笔记或文章时直接在本地向量库中找出语义相似的其他内容展示在页面底部。与云端推荐不同这种推荐不依赖用户画像上传完全本地运算不需要打点也没有隐私负担。6.4 多模态扩展Gemma 2 这个生态逐渐在往多模态方向走。如果后续支持的模型能同时处理文本和图像特征那端侧能力会更强。举例来说用户在本地相册输入“和朋友的滑雪照片”时语义搜索不仅匹配相册的文字标签还能匹配图片语义。这一步很让人期待但目前工程落地还需要等待更成熟的模型和工具链。6.5 对研发体系的启发端侧 Embedding 的接入不只是引入一个模型那么简单。它会倒逼团队把数据处理链路做规范统一的文本清洗规则、统一的向量化服务、统一的存储格式。这些地基一旦打好后面再上多模态、端侧 RAG 都是顺势而为的事。所以不要把它当做一个一次性功能来做建议按“平台能力”来设计让多个业务方都能复用。7. 最后的经验总结做了几个月的端侧语义能力后我最大的感受是完全端侧的 AI 并没有想象中那么“降级”它只是换了一种方式在解决问题。云端擅长的是海量知识和复杂生成端侧擅长的是本地、实时、隐私敏感的场景。Flutter 接入 Gemma 2 Embedding 这整套链路真正让我觉得“产品能力被打开了”——搜索不再只是搜关键词推荐不再依赖于平台后台问答也不一定需要连网。在实际操作中有几个习惯值得坚持每个端侧模型接入前先准备好评测集哪怕只有几十条样本也能避免上线后才发现语义效果不对。任何长文本处理都要谨慎索引单元尽量切小查询尽量短。永远保留 CPU 回退路径和关键词搜索降级方案端侧能力是体验增强不是唯一的救命稻草。如果你也在做类似需求建议不要一开始就追求全能模型而是先把一条最核心的链路跑通文本输入、向量化、相似度计算、TopK展示。走通之后你会发现后续所有增强功能都变得顺理成章。希望这篇分享对你接下来的项目有实际帮助。