返回星港

一个本地向量模型,三个调用方:RK3588 上 NPU 嵌入服务的多客户接入实践

OBSERVER又菜又爱玩的小朱 LAUNCH JUMPS2 次跃迁
一个本地向量模型,三个调用方:RK3588 上 NPU 嵌入服务的多客户接入实践 · 封面

上一件事是给 Cloudreve 接上本机的 NPU 嵌入模型,做完之后会出现一个新的诱惑:既然这台盒子上已经常驻了一个能出 512 维向量的服务,那别的程序是不是也能直接用它?

这篇记录的就是这一步。两个新"客户"先后接进同一个进程:一是 Windows 上的 DSH graph-memory 插件(要求零源码修改),二是自建的笔记应用。三个调用方共用同一个 NPU 模型、同一把推理锁、同一个进程,但彼此之间在端口、密钥和来源白名单上完全隔离——Cloudreve 那条链路的技术参数一个都没动。

为什么不新起一个服务

最直观的做法是复制一份服务、换个端口、再加载一次模型。没有这么做,原因很实际:

  • NPU 只有一份,模型已经常驻在内存里。再起一份意味着再占一份模型内存和虚拟环境开销。

  • 三个调用方都是低频请求,共享一个进程完全够用,没必要各自持锁。

  • 多一个常驻进程就多一份要维护、要归档、要绑定小核、要写 systemd 单元的东西。

所以最终形态是:同一个 cloudreve-embedding 进程,两个监听地址。

调用方

入口

编码策略

来源白名单

Cloudreve(原链路)

172.27.0.1:18090

原有 auto / query / document 非对称编码

FTS 私网网桥 + localhost

Windows DSH graph-memory

192.168.10.62:18091/v1/embeddings

统一无指令对称编码(symmetric-v1)

192.168.10.202/32

小朱笔记(nowen-note)

192.168.10.62:18091/v1

同上,对称编码

172.24.0.2/32

两个监听共享同一个模型、同一把锁,全局最多 8 个并发请求。排队超时是分开的:DSH 入口 5 秒,Cloudreve 原入口保持 15 秒。没有另加载模型,也没有启动新容器。

同一进程怎么隔离开

共享一个进程不等于共享一切,隔离靠三样东西:网络来源、凭据、监听地址。

只监听指定内网地址,按单 IP 放行

新的 18091 入口不使用通配监听,只绑定指定的 IPv4 内网地址。应用层来源白名单是 192.168.10.202/32;systemd 侧在这个基础上增加该单 IP 的允许范围,同时保留原有的 FTS 网桥与 localhost。笔记应用接入时再增加 172.24.0.2/32。

应用白名单必须是明确的 RFC1918 单 IP /32,不扩大成整网段。Cloudreve 的私网网桥设置不改,两套授权并存。

这一步在 systemd 里就是一个 drop-in。仓库里的源文件是 /root/AI/cloudreve-embedding/dsh.conf,此时只授权 Windows 这一台:

[Service]
# DSH专用内网入口,和Cloudreve共享模型;来源为当前Windows主机。
Environment=DSH_BIND_ADDRESS=192.168.10.62
Environment=DSH_BIND_PORT=18091
Environment=DSH_AUTH_FILE=/opt/DockerDate/cloudreve-embedding/dsh_key
Environment=DSH_ALLOWED_NETWORKS=192.168.10.202/32
IPAddressAllow=192.168.10.202/32

独立认证密钥

DSH 与新客户使用独立密钥,路径 /opt/DockerDate/cloudreve-embedding/dsh_key,属主是专用服务用户、权限 400,不进 Git 也不进普通归档。Cloudreve 原来的 Bearer 密钥保持不变。

需要说清一点:HTTP 没有 TLS,认证和来源白名单都不等于加密。因此禁止把它做公网映射或通配监听;如果链路不可信,必须自己再套 HTTPS、SSH 隧道或 VPN。

两套编码策略互不干扰

这是最容易踩的一个坑。原版 DSH 插件并不发送"查询 / 文档"模式信息,而 Cloudreve 那条链路用的是非对称编码(查询和文档加不同的指令前缀)。如果让同一个入口按正文内容去猜类型,结果会不可控。

解决办法是给 DSH 入口一条统一的、无指令的对称编码——调用引擎时固定用 document 模式,不去猜。关键设计是:模型标识里带策略版本,这里用的名字是 bge-small-zh-v1.5-rknn-symmetric-v1。一旦编码策略变化,就必须换一个新的模型标识并重算向量,避免新旧向量混在同一个索引里。

客户一:DSH graph-memory 插件,只改配置

需求很明确:插件要接本机 NPU 服务,但不改插件源码。原本尝试过的插件 input_type 补丁全部撤回,没有构建也没有安装。

插件侧改了什么

Windows 上只修改了 profile:graph-memory 条目的 embedding 指向上述 baseURL、模型名 bge-small-zh-v1.5-rknn-symmetric-v1、512 维,凭据通过环境变量引用 GRAPH_MEMORY_RK3588_API_KEY。密钥只存在本机凭据库里。旧的百炼凭据和用户已有的环境变量都保留,不需要覆盖。压缩开关仍为 false,抽取用的 LLM 不变(它仍可能使用云端,这一点没有被改动)。

阈值方面,semanticScoreThreshold 校准为 0.58,仍可由原环境变量覆盖。settings 文件放在插件仓库之外,路径是 C:/Users/17737/Code/other/DSH/plugins/docs/graph-memory-rk3588-settings.json,配套的迁移 / 评估运维脚本也在这个目录下;迁移脚本默认 dry-run,加 --apply 才会备份并原子写入向量,源数据在并发变化时会回滚。

落到配置上就是这样一个片段(即插件仓库之外的 settings 文件内容,密钥字段只写环境变量名,本机凭据库里的真实值不出现在文档、仓库和归档里):

{
  "baseURL": "http://192.168.10.62:18091/v1",
  "model": "bge-small-zh-v1.5-rknn-symmetric-v1",
  "dimensions": 512,
  "semanticScoreThreshold": 0.58,
  "apiKeyEnv": "GRAPH_MEMORY_RK3588_API_KEY"
}

其中 apiKeyEnv 指向凭据库中的变量名 GRAPH_MEMORY_RK3588_API_KEY,值只存在本机;semanticScoreThreshold 是 0.58 这个校准值。

已有向量怎么迁移

  • 已有 223 条摘要全部转为 512 维:原 217 条 1024 维被重算,另外补齐缺失的 6 条。

  • 其他非向量表内容逐表 hash 一致,quick_check=ok,短事务原子提交。

  • 没有重新抽取 LLM 摘要,只是重算向量。

实验数据在本机备份:~/.dsh/backups/graph-memory-rk3588-20261009-221125,以及 graph-memory-rk3588-migrate-2026-10-09T14-24-35-680Z。这两个包含凭据、数据库和摘要报告,不进入普通服务器归档。

验证到什么程度

  • 8 项双接口局部测试通过:原版请求、对称类型、Cloudreve 默认策略、独立密钥隔离、非法模型 / 维度 / 正文、来源白名单,以及共享实例。

  • Windows 原版插件的 createEmbedFn 探针、query、db 均成功;原版 DSH 适配器的隔离内存库初始化 vector-ready。没有启动第二个 DSH 服务。

  • Cloudreve 侧同时复验:554 分块 / 554 向量、待处理任务 0、混合命中 5 条、用户过滤通过、HTTP 200,认证 / 批量 / 长文尾部回归通过。

  • 服务重启后两个监听和全部 5 个线程都落在 CPU0–3 通过,实测服务 cgroup 约 189 MiB。内存上限 1 GiB 不代表实际用量。

一个必须说明的状态

Windows 当前的 DSH 进程没有重启。配置和历史向量都已准备好,但需要用户重启 DSH 并刷新页面才生效。不能把没有观察到的宿主热重载当作成功。旧进程如果新增了 1024 维向量,重启后可以用补齐脚本重跑。

运维与回退

systemctl status cloudreve-embedding --no-pager
systemctl show cloudreve-embedding -p IPAddressAllow -p CPUAffinity -p MemoryCurrent
ss -lntp '( sport = :18090 or sport = :18091 )'
docker exec cloudreve-fts-meilisearch sh -c 'wget -T 5 -qO- http://172.27.0.1:18090/health'

cd /root/AI/cloudreve-embedding
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python tests/dsh_compat.py
.venv/bin/python check_index.py

修改来源地址前,先备份 /etc/systemd/system/cloudreve-embedding.service.d/dsh.conf,再同步 DSH_ALLOWED_NETWORKS 和 IPAddressAllow,然后 daemon-reload / restart 并复验。

回退时服务器原源码有就地备份 /opt/cloudreve-embedding/server.py.bak.20261009-221125。顺序是:先停止 DSH 使用远程入口,再备份移走 dsh.conf、恢复原源码、daemon-reload / restart。不要停用整个 NPU 服务,否则会影响 Cloudreve。Windows 回退只需合并旧的 embedding / 阈值配置,并恢复对应旧向量或重算新增摘要;禁止用整个旧数据库覆盖,那会丢掉新记忆。

客户二:自建笔记应用

笔记应用是 Docker 部署的(nowen-note v1.5.0),这里遇到的坑和 DSH 完全不同。

容器里的 127.0.0.1 不是宿主机

这是最容易误判的一点:请求是由笔记后端发出的,不是浏览器直接发往模型。所以当配置里写 127.0.0.1 时,指的是容器自己,自然连不上宿主机的模型服务。

修复分三步:

  1. 原 nowen-note_nowen-network 网桥 172.24.0.0/16 保留不重建;Compose 以外部引用的方式使用这个既有网络,笔记容器固定为 172.24.0.2。内部的网络、端口、镜像、持久目录和 CPU0–3 / 1 GiB 限制都保留。

  2. 在 dsh.conf 的 DSH_ALLOWED_NETWORKS 中增加 172.24.0.2/32,IPAddressAllow 同样增加这个单 IP。Windows 的 192.168.10.202/32 和 Cloudreve 私网设置保留,不扩大整网段,也不新增监听。

  3. 页面此前没有保存局域网地址,数据库里的原值仍是 127.0.0.1。做一致性备份之后,只把该用户的 ai_embedding_url 改成 192.168.10.62 地址;Key、模型、对话设置和正文都没有修改。

第二步落盘之后,这个 drop-in 就是下面这样(部署路径 /etc/systemd/system/cloudreve-embedding.service.d/dsh.conf,两条 IPAddressAllow 对应两个调用方):

[Service]
# 内网兼容入口与Cloudreve共享模型,仅授权Windows及固定IP的笔记容器。
Environment=DSH_BIND_ADDRESS=192.168.10.62
Environment=DSH_BIND_PORT=18091
Environment=DSH_AUTH_FILE=/opt/DockerDate/cloudreve-embedding/dsh_key
Environment=DSH_ALLOWED_NETWORKS=192.168.10.202/32,172.24.0.2/32
IPAddressAllow=192.168.10.202/32
IPAddressAllow=172.24.0.2/32

关于索引恢复

这一点需要如实记录:按用户后续要求,已经取消索引恢复。助手没有重置失败任务、没有重新入队、没有调用重建接口、也没有清空向量。修复地址时队列、向量和正文指纹都没有变化。收尾时观察到队列与向量已由应用侧发生变化,因此不能宣称最终仍是"8 个失败 / 0 向量"——那些索引操作不是助手执行的。失败索引由用户自行决定何时重建。

验证结果

  • 笔记容器读取已保存配置,调用应用原版 testEmbeddingModel:success=true,512 维,约 215 ms。

  • 笔记容器健康接口 HTTP 200;不带 Key 的嵌入请求 HTTP 401。

  • Windows 原入口 HTTP 200、512 维;Cloudreve 私网健康接口与网页 HTTP 200。

  • Compose 重建后静态 IP 为 172.24.0.2,两个网络 ID 都没有变化,其他容器 ID 保持。

  • 用户确认真实页面测试可用。

已知限制

  • 模型服务没有 /v1/models 接口,刷新模型列表返回 404 是已知限制;手动填写模型名后再用测试功能即可。本次没有为此增加接口或修改模型 / 应用源码。

  • 模型与笔记共用单 NPU 及推理锁。

  • HTTP 无 TLS,仅限可信内网,禁止公网映射。

  • 容器重建或更新必须保留静态 IP 配置;应用内更新器将来是否会继承静态 IP 尚未实测,更新后要核查 Docker IPAMConfig 并重测模型调用。

  • 既有网桥是 external=true,重装时需要先准备同名的 172.24.0.0/16 网络。

  • 另外提醒一句:AI 聊天模型不是这个 Embedding 模型,两者不要混为一谈。

阈值 0.58 这类经验值,能信到什么程度

校准过程中得到的召回表现是:

  • 10 组相关查询里 9 组命中预期主题,1 组因为上下文压缩问题失败。

  • 5 组无关查询返回 0 条记忆。

这是小样本校准,不代表普遍准确率,既可能漏召回也可能误召回。0.58 这个数应当被当作起点而不是定论,索引内容和查询风格变化后需要重新评估。

同样的态度适用于本文所有数字:8 项局部测试通过、223 条摘要 512 维、189 MiB cgroup 内存——它们是当时那一轮的证据,不是长期承诺。

共同的注意事项

  • 保留专用非 root 用户、CPU0–3 绑定、MemoryMax 1 GiB、NPU 设备白名单和原有沙箱设置。

  • Windows 或 RK 的 IP 变化时需要同步修改配置,建议在路由器上做 DHCP 地址保留。

  • 三个调用方共享单 NPU 和推理锁,任何一方的批量任务都会影响另外两方的排队时间。

  • 未做网页完整回归、整机重启、长期并发或实测功耗。

  • 归档在 /root/AI/backup/dsh-embedding-rk3588 与 /root/AI/backup/nowen-embedding-access,均为四件套;不含密钥、Windows 数据库、原始问答、召回摘要、模型和虚拟环境。恢复前要先还原 cloudreve-embedding-npu 基础环境、服务用户和 venv,然后预演 ./restore.sh,确认后 --apply。新密钥需要重新更新到 Windows 凭据库;restore 不会修改 Windows 或 Cloudreve 的 AI 设置。

  • 顺序上有依赖:基础归档如果最后覆盖 server.py 会撤销 DSH 补丁,所以本主题必须最后应用。归档要拷出设备,旧的全量备份包不包含这个主题。

回头看,这件事最值得记的不是"多接了两个人",而是那个设计选择:在模型标识里带上编码策略版本,用端口、密钥和单 IP 白名单把不同客户隔开。正因为这样,一个已经在跑的服务才能在不打扰原有业务的前提下,安静地多服务两个调用方。

附:完整脚本

上面步骤里用到的完整脚本不再整段贴在正文,已作为附件上传到对象存储,点击文件名即可下载:

对象存储策略会自动把文件名改写成 UUID(例如 95D96A8A-FB38-4CCB-AB42-F6E906E70760.py),这是存储策略的行为,不影响文件内容;下载后按正文里的路径改名放好即可。