返回星港

RK3588 盒子上用 NPU 跑本地嵌入:给私有网盘加中文语义搜索

OBSERVER又菜又爱玩的小朱 LAUNCH JUMPS1 次跃迁
RK3588 盒子上用 NPU 跑本地嵌入:给私有网盘加中文语义搜索 · 封面

把网盘文件索引做成全文搜索之后,很快就会遇到一个瓶颈:关键词匹配能查到"包含这个词的文件",但查不到"意思相近的文件"。想解决这件事,通常有两条路——接一个云端嵌入 API,或者在自己机器上跑一个嵌入模型。前者要在费用和隐私之间做取舍,后者听起来很重,但如果手边恰好有一台带 NPU 的 ARM 盒子,它就变成一件值得试的事。

这篇记录的就是后者:在一台 RK3588 的 ztl A588 盒子上,用 NPU 跑 bge-small-zh-v1.5(512 维),给 Cloudreve 做本地语义搜索。全文不出现任何云端 API 调用——所有业务文本只在盒子内部的私有网桥和宿主机服务之间传输,没有模型服务费用,也不存在把文档正文发给第三方的问题。在线阶段只执行已经下载到本地的模型,不会从远程加载任何业务数据。

为什么要本地嵌入

先说清动机,因为这决定了后面所有架构选择。

  • 不上云:索引的对象是自己的文档正文。嵌入 API 意味着把正文切片发出去,即便服务商承诺不留存,链路本身也变长了。

  • 不烧 API 费用:语义搜索需要为每个分块生成向量,还要在文档更新时重新生成。用量一涨,按量计费就很显眼。

  • 延迟可控:模型在本机,往返只有内网一跳,不受外部服务限流影响。

  • 设备本来就有算力:RK3588 的 NPU 标称 6 TOPS,平时处于闲置状态,正好拿来跑一个轻量编码器。

需要说明的是,NPU 的"6 TOPS"不能直接换算成检索速度。真正决定体感的是编码器调用耗时,这必须实测。

硬件与环境前提

本次实测环境:

项目

值

设备

ztl A588,RK3588(aarch64)

系统

Ubuntu 24.04,内核 5.10.198

NPU 驱动

RKNPU 0.9.8(现有驱动,未升级)

Python

3.12

NPU 设备节点

/dev/dri/renderD129

模型选择 BAAI/bge-small-zh-v1.5:512 维、4 层 BERT、最多 512 token,是面向中文检索的轻量嵌入模型。运行库用 RKNN Toolkit2 / Lite2 / Runtime 2.3.2。整套做法不需要升级驱动、内核或固件,这一点很重要——在 ARM 盒子上动内核往往是最大的风险来源。

架构:一个宿主机 systemd 服务,不是 Docker 容器

先给出最终落地的结构,再解释为什么不是别的样子。

Cloudreve → Tika 提取 → Meilisearch 文本分块
                             ↓ 私网桥 HTTP + Bearer 认证
                   宿主机 NPU 嵌入服务
                   CPU 分词/查表/归一化
                   NPU Transformer 编码器
                             ↓
                  Meilisearch 关键词 + 语义混合搜索

正式服务是宿主机上的 systemd 服务,服务名 cloudreve-embedding.service,状态 enabled/active。之所以强调这一点,是因为它直接对应下面这个坑。

为什么 Docker 实验没有通过

最初的尝试是把服务放进容器,结果卡在 RKNN SDK 的平台识别上:SDK 会读取设备树来识别平台,而 Docker 默认屏蔽了 /sys/firmware,即便把该路径只读映射进容器,也不满足 SDK 的识别条件。最终没有去改 SDK 的磁盘文件,也没有去动系统硬件信息,而是改成专用用户的宿主机服务。实验容器已停止并删除;实验源码保留在项目的 experiments 目录里,但它不能作为正式部署入口,生产期间也不得运行实验用的 Compose。

结论:在 RK3588 上跑 RKNN 这类依赖设备树的 SDK 时,先确认容器是否遮挡了平台识别所需的信息,不要把"改 SDK"当成解法。

服务的安全与资源边界

一个常驻的模型服务不应该拿宿主机当实验场,所以边界写得比较细:

项目

配置

服务

cloudreve-embedding.service,enabled/active

用户

cloudreve-embedding,nologin,UID 10002

运行程序 / 虚拟环境

/opt/cloudreve-embedding,依赖全部在 venv 内

模型 / 运行库

/opt/DockerDate/cloudreve-embedding/models,系统 eMMC,约 101 MiB

密钥

/opt/DockerDate/cloudreve-embedding/embedding_key,随机 64 hex、服务 UID / 权限 400,不入 Git 或普通归档

监听

172.27.0.1:18090,仅 FTS 私网桥地址

CPU / 资源

CPUAffinity=0 1 2 3,MemoryMax=1G,TasksMax=64,nofile 65535

设备

仅允许 NPU card1 / renderD129 读写,render 与 video 组;不访问 Mali / MPP / RGA

系统保护

专用用户、NoNewPrivileges、ProtectSystem=strict、ProtectHome、PrivateTmp、空 capability 集、DevicePolicy=closed

网络限制

IPAddressDeny=any,只允许 FTS 私网 172.27.0.0/16 与 localhost;不绑定 eth0,也不做 IPv6 通配

上面这些边界最终就是下面这个 systemd 单元(源文件在 /root/AI/cloudreve-embedding/cloudreve-embedding.service,部署为 /etc/systemd/system/cloudreve-embedding.service):

[Unit]
Description=Cloudreve local RK3588 NPU embedding
After=network-online.target docker.service
Wants=network-online.target docker.service
StartLimitIntervalSec=0

[Service]
Type=simple
User=cloudreve-embedding
Group=cloudreve-embedding
SupplementaryGroups=render video
WorkingDirectory=/opt/cloudreve-embedding
Environment=MODEL_DIR=/opt/DockerDate/cloudreve-embedding/models
Environment=AUTH_FILE=/opt/DockerDate/cloudreve-embedding/embedding_key
Environment=BIND_ADDRESS=172.27.0.1
Environment=BIND_PORT=18090
Environment=OMP_NUM_THREADS=4
Environment=OPENBLAS_NUM_THREADS=4
Environment=TOKENIZERS_PARALLELISM=false
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PYTHONUNBUFFERED=1
Environment=MAX_WINDOWS=32
ExecStart=/opt/cloudreve-embedding/venv/bin/python /opt/cloudreve-embedding/server.py
Restart=on-failure
RestartSec=15
CPUAffinity=0 1 2 3
MemoryMax=1G
TasksMax=64
LimitNOFILE=65535
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
CapabilityBoundingSet=
DevicePolicy=closed
DeviceAllow=/dev/dri/renderD129 rw
DeviceAllow=/dev/dri/card1 rw
RestrictAddressFamilies=AF_INET AF_UNIX
IPAddressDeny=any
IPAddressAllow=172.27.0.0/16
IPAddressAllow=localhost

[Install]
WantedBy=multi-user.target

Meilisearch 一侧也要显式放行这个私网地址:MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS=172.27.0.1/32,只开放这一个 NPU 接口的宿主地址。Cloudreve 侧的两项 AI 设置通过备份之后窄范围的 SQL 事务更新并重启读取,不修改其他设置、库结构或用户数据;原值保存在 private/before-ai.json(权限 600)。

另外,服务不依赖外盘,所以不会在硬盘缺失时阻塞开机;它 After/Wants Docker,绑定网桥暂未就绪时按 15 秒策略重试,不做无限等待。

部署要点

依赖与部署脚本

服务端只依赖 5 个 Python 包(/root/AI/cloudreve-embedding/requirements.txt):

numpy==1.26.4
tokenizers==0.19.1
psutil==7.2.2
ruamel.yaml==0.19.1
setuptools==75.8.0

部署入口是 /root/AI/cloudreve-embedding/install.sh:默认只预演,显式加 --apply 之后才会创建专用用户、落模型与虚拟环境、安装 systemd 单元并启动服务。

#!/usr/bin/env bash
# 默认预演;部署宿主机NPU服务,不自动改业务AI设置。
set -euo pipefail
base=$(dirname "$(readlink -f "$0")")
mode=${1:---dry-run}
[[ $mode == --apply || $mode == --dry-run ]] || exit 2
[[ $(uname -m) == aarch64 ]] || { echo '仅支持ARM64'; exit 1; }
[[ -c /dev/dri/renderD129 && -c /dev/dri/card1 ]] || { echo '请核对NPU设备'; exit 1; }
[[ $(findmnt -no SOURCE -T /opt) == $(findmnt -no SOURCE /) ]] || exit 1
[[ $(docker network inspect cloudreve-fts_fts --format '{{(index .IPAM.Config 0).Gateway}}') == 172.27.0.1 ]] || { echo '网桥地址变化,请先调整服务和嵌入配置'; exit 1; }
printf '准备系统盘模型、私密密钥及systemd专用用户服务:%s\n' "$mode"
[[ $mode == --apply ]] || exit 0
cd "$base"
sha256sum -c assets.sha256
getent passwd cloudreve-embedding >/dev/null || useradd --system --uid 10002 --user-group --home-dir /opt/cloudreve-embedding --shell /usr/sbin/nologin cloudreve-embedding
[[ $(id -u cloudreve-embedding) == 10002 ]] || exit 1
usermod -a -G render,video cloudreve-embedding
runtime=/opt/DockerDate/cloudreve-embedding
[[ $(realpath -m "$runtime") == "$runtime" ]] || exit 1
mkdir -p "$runtime/models" /opt/cloudreve-embedding
cp "$base"/assets/models/* "$runtime/models/"
chmod 755 "$runtime" "$runtime/models"
chmod 644 "$runtime"/models/*
[[ -x "$base/.venv/bin/python" ]] || python3 -m venv "$base/.venv"
if [[ ! -f "$runtime/embedding_key" ]]; then
 "$base/.venv/bin/python" -c 'import os,secrets;from pathlib import Path;p=Path("/opt/DockerDate/cloudreve-embedding/embedding_key");p.write_text(secrets.token_hex(32));os.chown(p,10002,10002);p.chmod(0o400)'
fi
cp "$base"/{server.py,engine.py} /opt/cloudreve-embedding/
[[ -x /opt/cloudreve-embedding/venv/bin/python ]] || python3 -m venv /opt/cloudreve-embedding/venv
ulimit -n 65535
taskset -c 0-3 /opt/cloudreve-embedding/venv/bin/python -m pip install --only-binary=:all: --index-url "${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" -r "$base/requirements.txt" "$base"/assets/rknn_toolkit_lite2-*.whl
unit=/etc/systemd/system/cloudreve-embedding.service
if [[ -e "$unit" ]]; then cp -a "$unit" "$unit.bak.$(date +%Y%m%d-%H%M%S)"; fi
cp "$base/cloudreve-embedding.service" "$unit"
systemctl daemon-reload
systemd-analyze verify "$unit"
systemctl enable --now cloudreve-embedding
printf '%s\n' '服务已部署;Meilisearch须允许172.27.0.1/32,测试后单独执行integrate.py --apply,再重启Cloudreve加载设置。'

模型怎么跑在 NPU 上

实际的分工是拆开的:

  • CPU 负责分词、词/位置/类型嵌入查表和嵌入层 LayerNorm;

  • NPU 负责 4 层 Transformer 编码器与 CLS 输出;

  • CPU 再做 L2 归一化。

导出固定 batch=1、128/512 token,ONNX opset17;RKNN 不做 INT8 量化,内部为浮点 FP16。运行库是项目私有的 vendor/librknnrt.so,不复制到 /usr/lib、不执行 ldconfig、不安装也不启动 rknn_server。

这里不宣称"全部算子都完全在 NPU 执行",也不宣称达到 6 TOPS 峰值。可以确认的是:运行日志与三个 NPU 核心的非零负载证明是实机推理,不是 PC 模拟器;推理进程的内存映射已核对,确实加载了那个私有库。

长文本怎么处理

编码器单次最多 512 token,而 Cloudreve 的分块可能超过这个长度。直接截断然后悄悄丢掉后半段是绝对不能接受的——那等于索引了一半正文。

采用的方案是滑窗:128 token 用于短查询和短文,512 用于较长文本;长文本按 510 正文 token(另加 CLS/SEP)、16 token 重叠分窗,逐窗归一化后按窗口 token 数加权平均,再二次 L2 归一化,保持每段一个 512 维向量,以兼容 Meilisearch 的 REST 接口。所有窗口都参与,尾部不会被丢弃。

但要说清代价:聚合会稀释只出现在少数窗口里的主题,它不等同于多向量逐段搜索。验证时用了一个尾部航天主题做测试——完整长文与查询的相似度约 0.435,头部截断只有 0.173。真实复杂文档的召回质量仍需要用户用实际查询去评估。

另外,Cloudreve 固定使用 Chunk #... in a file named '...': ... 这样的模板来识别文档,其他自动类型输入会被当作查询并加上 BGE 中文检索指令;手工调接口时可以用 input_type=document/query 显式指定。如果将来 Cloudreve 升级改了模板,要先回归类型识别,不能照搬旧配置。

请求侧的护栏

  • 单 NPU 并发,最多 8 个 HTTP 处理请求,每次最多 10 段文本,每段最多 16384 字符 / 32 窗口,请求体上限 1 MiB;

  • 排队超过 15 秒返回 429,热区温度 ≥75°C 返回 503;

  • 空文本或超限返回 400,不静默截断;

  • HTTP 请求与错误日志都不输出正文、文件名、密钥或向量。

常见坑

除了上面 Docker 那条,还有几个都是在这台机器上真实撞到的:

  • RKNN 枚举需要同时允许 NPU 的 card1 与 renderD129。只放行 renderD129 会导致初始化失败。白名单只加 NPU 相关设备与 video 组,不放开 GPU 和媒体设备。

  • 端口冲突:8090 已被 1Panel 占用,正式部署改用检查为空的 18090。

  • Meilisearch 拒绝私网模型接口:1.54 默认会拦掉指向私网地址的模型请求(Rejected URI)。只增加 172.27.0.1/32 一个允许值,并且仅重建 Meilisearch 本容器来载入,不动 Tika、网桥、密钥和索引目录。

  • 宿主 curl 打不通网桥:从宿主机直接 curl 网桥地址会超时,但从 Meilisearch 容器访问是成功的,这符合本机既有的网络限制。所以健康检查从 FTS 容器里发起,没有为了调试方便去改防火墙或开放监听。

  • 空文本:512 版本对空字符串的向量余弦约 0.998493,低于常规样本但仍高于验证门槛。生产侧应当拒绝或跳过空文本,而不是照常入库。

隔离验证:先证明它真的能跑

在正式部署之前,先做了一个完全隔离的验证,结论是:128 与 512 token 固定长度 FP16 RKNN 编码器可以真实 NPU 推理,向量与 FP32 CPU 基准接近,无需升级驱动、内核或固件。这一阶段未部署常驻服务、未开放端口、未开启 Cloudreve AI、未写入 Meilisearch 业务向量。

所有样本都是脚本内自造的中文文本,不读取网盘正文,不上传业务内容。下面为热身后的单条中位数,同一模型、同一固定填充长度、CPU 只用实测的 A55 小核 CPU0–3、4 线程基准。端到端包括分词、查表/预处理、编码器和归一化,不含 HTTP、排队、Meilisearch 检索、文件提取或首次模型加载。

指标

128 token

512 token

CPU 编码器中位数

150.13 ms

655.38 ms

NPU 编码器调用中位数

12.55 ms

61.85 ms

CPU 端到端中位数

154.06 ms

664.06 ms

NPU 端到端中位数

19.16 ms

78.92 ms

NPU 端到端 P95

22.96 ms

93.60 ms

端到端中位数倍数

约 8.0 倍

约 8.4 倍

重复计时样本

30

20

24 个向量最低 CPU/NPU 余弦

0.999972

0.999782

12 查询首位结果一致

12/12

12/12

人工预期首位命中

11/12

11/12

比较进程峰值 RSS

约 447 MiB

约 509 MiB

短文本实际只有 26–34 token,但被分别填充到 128/512,所以不能把这个倍数当作相对所有动态长度 CPU 实现的通用加速比。

检索样本共 12 段文档与 12 个查询,主题涵盖旅行、备份、做饭、登山、离职、网络安全、疫苗、预算、充电、维修、摄影、引用。查询"如何提高家里 WiFi 的安全性?"在 CPU 和 NPU 上都误把家庭预算文档排在第一,所以 11/12 不能说成真实业务 91.7% 的准确率——这不是正式的检索评测集,必须保留关键词与语义的混合检索。

重复性与长文本

  • 连续 300 次 512-token 推理约 24.9 秒,中位 81.74 ms,P95 105.97 ms,重复向量最低余弦 0.99999988;该对比进程 RSS 约 468 MiB。

  • 464 token 近上限中文样本最低余弦 0.999967。

  • 1995 token 文本分 5 窗,逐窗 CPU/NPU 最低余弦 0.999819。

  • 2414 token 样本分 5 窗,尾部标识保留。

  • 临时任务采样 11 个线程全部落在 CPU0–3;各任务温度最高 49.923°C,现有的 80°C CPU 守护保持运行。

上线之后又验了什么

  • 128/512 的 NPU 数值、完整 Torch 与 ONNX 拆分一致性、6 项局部测试、300 次重复 / 混合中英文 / 近上限和尾部验证全部通过。

  • REST 认证拒绝、空值 / 超长 / 批量超限处理、512 维归一化、长文尾部主题参与全部通过。

  • 用 Meilisearch 临时 4 文档测试自动嵌入:纯语义旅行查询首位命中 trip,owner_id=1 的结果不包含 owner 2,无权 owner 999 返回 0 条,临时索引随后清理。

  • 业务索引 554 个分块全部嵌入为 554 个向量,任务 46 成功,待处理 0;混合查询返回 5 条结果,其中 5 条为语义命中,owner 999999 为 0。这个业务请求只输出计数和 ID,不打印正文——它不是网页登录搜索的回归测试。

  • NPU 服务重启后健康检查、混合搜索和全线程 CPU0–3 通过;Meilisearch 重启后向量持久化、服务继续可用。

  • 正式服务进程启动后约 212 MiB RSS,索引完成后约 313 MiB RSS;两个编码器常驻。内存上限 1 GiB 是约束,不代表实际用量。模型 101 MiB、正式虚拟环境约 130 MiB,索引 du 约 16 MiB。

注意事项与局限

这些边界比结论本身更重要,直接照抄部署前请先读完。

  • 没有整机重启验证,没有真实功耗测量,也没有长时间高并发测试。

  • 没有做网页登录后的语义搜索结果评估。上面那 5 条语义命中来自带计数和 ID 的接口请求。

  • 测试过程中 Cloudreve 曾短暂重启导致 HTTP 000,那是启动窗口,随后恢复 200;历史失败日志保留,不隐去。

  • 没有长期烤机或用功耗计测量,不能宣称已经测出节电比例。

  • 宿主 curl 到网桥超时属于本机既有网络限制,验证都是从 FTS 网络发起的。

  • 模型接口或网桥地址变化时,要先评估正在使用的其他应用;不要删 Docker 网桥。需要同步修改的至少包括 systemd 的 BIND_ADDRESS/端口、IPAddressAllow、Meilisearch 的最小 /32 允许地址,以及 Cloudreve/Meilisearch 的 embedder URL。绝对不要把允许范围改成 0.0.0.0/0。

日常管理与回退

systemctl status cloudreve-embedding --no-pager
journalctl -u cloudreve-embedding -n 30 --no-pager
systemctl restart cloudreve-embedding

# 健康检查从 FTS 容器访问;宿主访问网桥可能被既有 1Panel 网络规则拦截
# 不要为了方便检查而开放局域网或公网监听
docker exec cloudreve-fts-meilisearch sh -c 'wget -T 5 -qO- http://172.27.0.1:18090/health'

cd /root/AI/cloudreve-embedding
.venv/bin/python runtime_check.py
.venv/bin/python check_index.py

如果要恢复成纯关键词搜索,顺序很重要——先恢复应用设置,不要先停接口,否则 AI 请求会直接失败:

cd /root/AI/cloudreve-embedding
.venv/bin/python integrate.py --rollback           # 默认预演
.venv/bin/python integrate.py --rollback --apply   # 明确应用原两项设置与原嵌入配置
systemctl restart cloudreve
# 确认设置恢复、任务成功后,再禁用 NPU 服务
systemctl disable --now cloudreve-embedding

回退时去掉 cr-text 嵌入器会删除向量,但不删除文本索引和网盘文件;再次启用会重新生成向量。首次普通部署用 install.sh 默认预演,--apply 安装源码、模型和服务;接入需要单独执行 integrate.py --apply 并重启 Cloudreve,不会自动重建文档。Meilisearch 侧只 PATCH 嵌入器,没有重建或删除业务文本。

这套东西也做了还原归档:部署源码是独立 Git 仓库 /root/AI/cloudreve-embedding,归档在 /root/AI/backup/cloudreve-embedding-npu,包含源码、模型 / RKNN / Lite2 运行库与 wheel、systemd 单元、测试、运维文档、Meili Compose 参考;排除私密密钥、实际 Cloudreve AI 配置、数据库 / 业务索引 / 正文、虚拟环境和失败实验的镜像层。依赖是 ARM64 Ubuntu、Python 3.12 venv、兼容 RKNPU 驱动、Docker FTS 网络、render/video 组,以及空闲的 18090 端口。

cd /root/AI/backup/cloudreve-embedding-npu
sha256sum -c MANIFEST.sha256
./restore.sh          # 默认 dry-run
./restore.sh --apply

重装时先恢复 Cloudreve 与 FTS / 固定 USB 挂载,再恢复本服务;恢复脚本不自动覆盖 FTS Compose,也不自动打开 Cloudreve AI——需要手工合并 Meilisearch 的最小允许地址、重建 Meilisearch、测试接口并执行 integrate.py --apply,最后重启 Cloudreve。新密钥会自动生成,需要重新接入;旧密钥应另作加密私密备份。归档务必拷出设备,eMMC 重装会清空。

验证过程另外归档在 /root/AI/backup/embedding-npu-verify,它没有常驻服务;其中旧文档属于隔离阶段的历史,不会反向覆盖生产设置。

参考:Cloudreve 的 AI 语义搜索文档、Meilisearch 自托管配置参考、airockchip 的 rknn-toolkit2,以及 BAAI/bge-small-zh-v1.5 模型页。

附:完整脚本

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

  • server.py —— HTTP 服务与鉴权

  • engine.py —— RKNN 推理与长文本聚合

  • db.py —— 数据库连接

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