桌面助手用本地嵌入实现离线语义记忆
Ankita AI 新增本地嵌入后端,通过一个环境变量连接兼容 OpenAI 请求格式的本地服务,无需云账户即可进行语义召回。实现将缓存、后台预热和余弦排序放入共享基类,后端只需实现向量请求方法。
AI 深度解读
桌面助手记住了「牙医预约」,你问「下次什么时候去看牙」,它却未必找得到。Ankita AI 在 v2.7.5 加入本地语义召回,让记忆检索不再只能依赖 Cloudflare Workers AI:已有本地服务时,配置一个地址就能接入。
这套实现值得看的不只是模型接入,还有缓存隔离、超时预算和故障回退。作者把「对话不能被检索拖住」放在首位,也留下了两个问题:降级该不该告诉用户,标榜本地优先的应用又该默认选谁?
一个索引,为什么能接两种服务
语义召回先把文本变成向量,再比较含义的接近程度,因此有机会跨过关键词不同的表达。它负责找回相关记忆,不等于让助手重新理解所有历史对话。
实现位于 src/memory/embeddings.mjs。共享基类 EmbeddingIndex 管缓存、后台预热和余弦相似度排序,子类只实现 request(texts, signal) -> vectors。服务怎么调用可以变,检索流程不必跟着复制。
Cloudflare 子类使用 qwen3-embedding-0.6b;新增的 LocalEmbeddings 向 {base}/embeddings 发送 { model, input },采用 Ollama、LM Studio、llama.cpp 支持的 OpenAI 兼容格式。默认本地模型是 nomic-embed-text,向量维度为 768,上下文为 8k,可通过 Ollama 使用。
下面是原文给出的本地配置:
EMBED_LOCAL_BASE=http://localhost:11434/v1
EMBED_LOCAL_MODEL=nomic-embed-text
第一行决定请求地址;第二行指定模型,使用默认模型时无需另选。标题里的「一个环境变量」有前提:本地服务已经启动,模型已经可用。
后端由 EMBED_PROVIDER 决定,可选 auto、cloudflare、local、off。默认 auto 在存在 Cloudflare 凭证时优先选云端,否则再选本地;local 强制本地,off 即使有凭证也关闭语义召回。embeddingProvider() 与 embeddingsEnabled() 的判断保持一致,避免启用状态与实际后端对不上。
缓存不能只管命中,还得防止向量串用
文本相同,不代表不同模型生成的向量可以混用。即便维度一样,坐标所表达的含义也未必一致。
作者用文本的 sha256 作为磁盘缓存键,缓存文件保存向量,不保存原文;缓存目录再按 namespace:model 的哈希隔离。这样,切换模型或从本地切到 Cloudflare,都不会误用另一套向量。
向量写入前还要过检查:最多 4,096 维,所有数值必须有限,范数不能为零,然后归一化到单位长度。无效响应不能进入缓存,也不能取代本地召回结果。作者提到,测试专门覆盖了这条故障路径。
后台索引同样考虑了部分失败。warm() 收集未缓存记忆,每批处理 32 条,总期限为 30 秒;后续批次失败,已经完成的批次仍能供下次检索使用。
启动时的 probe() 则提前处理 DNS、TLS 和服务冷启动。但探测失败不会关闭语义能力,也不会触发退避,下次 rank() 仍正常尝试。一次启动失败,不足以判定服务持续不可用。
300 ms 内没找完,聊天还要继续
召回发生在每轮对话之前,检索慢会直接变成回复慢。作者因此给它设置了两层时间预算。
rank() 用 EMBED_TIMEOUT_MS 覆盖冷索引、查询向量生成和响应解析的整个过程,默认 2 秒。自动执行的轮前召回更严格,MEMORY_RECALL_BUDGET_MS 默认只有 300 ms。
前台停止等待,不代表后台必须放弃结果。
服务较慢时,前台先返回本地关键词候选,查询向量继续在后台生成并缓存,让后续显式召回有机会直接复用。若服务明确失败,索引退避 30 秒,期间只走关键词检索。作者给出的测试覆盖了整体超时不阻塞聊天,以及本地服务失败后返回关键词结果。
检索输入也做了加工。每条查询前都会加上指令:
「Given a conversation message, retrieve relevant personal preferences, facts, past decisions and tasks that help answer it」
存储的事实则把文本、所属项目和类型一起编码,补充检索上下文。输入文本上限是 6,000 字节,作者用它控制长度,使其低于托管模型的 token 窗口;字节数与 token 数不能直接画等号。
改写测试验证了一个具体结果:查询与记忆没有任何关键词重合,语义召回仍找到了目标。但原文没有提供更大规模的准确率、误召回率或延迟分布。
我的判断
我认同这套实现对职责和故障边界的处理。共用索引、隔离缓存、统一超时,再保留关键词回退,比单纯把本地 embedding API 接通更有参考价值。项目以 MIT 开源,相关测试位于 test/memory/embeddings.test.mjs,读代码时可以对照检查。
但「后台完成后,下次就快」仍有条件:服务得恢复可用,相关向量也得成功缓存。原文没有硬件配置、记忆规模和模型效果对照,不能据此判断 nomic-embed-text 是一般团队的最佳选择,更不能直接认定它适合中文混合代码的记忆检索。这部分需要自行验证。
静默回退保住了对话连续性,却让用户难以区分「没记住」与「当前检索能力下降」。我的取舍是继续回复,同时提供轻量、持续可见的检索状态,不必每轮插入一段故障提示。
auto 优先云端的问题更直接。存在凭证,只能证明可以调用,不能证明用户想把记忆交给云端处理。本地优先应用应把后端选择显式呈现出来。至于云端是否开箱即用、效果是否更好,原文没有给出对照数据。
能拿来做什么
- 给 Node 或桌面 AI 应用增加 embedding 服务时,可以把缓存、排序和超时留在公共索引层,让适配器只负责请求与响应,降低后续换服务的成本。
- 在日常 AI 编程助手里,可以参照「统一期限、关键词回退、后台缓存」设计召回,但应按自己的响应要求测量预算,别直接照搬 300 ms。
- 给记忆检索补测试时,优先覆盖无关键词重合的改写、服务不可用、无效向量和切换模型后的缓存隔离,这些比只测成功请求更能暴露问题。
- 做前端设置页时,应明确显示当前后端和降级状态,并让用户选择本地或云端,避免用一个
auto隐藏数据流向。