引子:文件能看见,内容搜不到
网盘用久了一定会遇到这个问题:文件名起得随意,但内容记得很清楚。想在几百个 PDF、Word 里找“某份合同里提到的那句话”,Cloudreve 默认是做不到的——它只能按文件名匹配。
Cloudreve 的全文搜索需要两个外部依赖:一个负责把 PDF、Office 文档里的文字抽出来(Apache Tika),一个负责建索引和检索(Meilisearch)。这台 RK3588 盒子上的 Cloudreve 是社区版 4.19.1 的宿主机服务,本次选在 2026-10-09 把这两个依赖以 Docker Compose 的方式补上。
需要先说明边界:这一轮只完成了依赖部署和局部验证,没有开启 Cloudreve 后台开关、没有改数据库设置、没有重建业务索引,也没有扫描现有网盘文件。AI 语义搜索在这一轮是暂不开启的,无需 Embedding API 或模型。后续这台机器改用本地 NPU 跑嵌入模型,把语义检索补上了,详见本系列里关于 NPU 本地嵌入的那一篇;本文只覆盖全文检索这一层。
环境与部署结果
容器 cloudreve-fts-tika 与 cloudreve-fts-meilisearch 使用 unless-stopped 自启。原 USB 守护不变:这两个依赖没有外盘 bind,所以不纳入 USB 守护;Cloudreve 仍由守护确认外盘后再启动,缺盘不能因为这次新增依赖而引入新的开机等待链。
镜像没有使用 amd64 模拟,也没有在宿主机上安装 Java / Node / Go。两个容器都做了加固:只读 rootfs、移除全部 capabilities、no-new-privileges,日志每服务最多 3 × 10 MB;Meilisearch 以 production 模式运行、关闭匿名遥测、API 要求密钥。
compose.yaml 全文
下面就是实际部署的编排文件,镜像用 @sha256 摘要锁定到 ARM64 版本,端口只发布到回环地址:
name: cloudreve-fts
services:
tika:
image: apache/tika:3.2.3.0@sha256:8c160e01119845fb497e23f08e5fe13db7b0c085143916df4fb2f006e0aebcce
platform: linux/arm64
container_name: cloudreve-fts-tika
restart: unless-stopped
cpuset: "0-3"
mem_limit: 2g
pids_limit: 256
environment:
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m -XX:ActiveProcessorCount=4 -Djava.io.tmpdir=/tmp"
ports:
- "127.0.0.1:9998:9998"
read_only: true
tmpfs:
- /tmp:rw,nosuid,nodev,size=512m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ulimits:
nofile:
soft: 65535
hard: 65535
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- fts
meilisearch:
image: getmeili/meilisearch:v1.54.3@sha256:80921338089709b607e6aff3bf7f6633a6b2800f5bb2756e6bb92c681304baa4
platform: linux/arm64
container_name: cloudreve-fts-meilisearch
user: "10001:10001"
restart: unless-stopped
cpuset: "0-3"
mem_limit: 2g
pids_limit: 256
environment:
MEILI_ENV: production
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?必须在私密.env中配置随机密钥}
MEILI_NO_ANALYTICS: "true"
MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS: "172.27.0.1/32"
MEILI_DB_PATH: /meili_data/data.ms
MEILI_DUMP_DIR: /meili_data/dumps
MEILI_SNAPSHOT_DIR: /meili_data/snapshots
MEILI_MAX_INDEXING_MEMORY: 512Mb
MEILI_MAX_INDEXING_THREADS: "2"
ports:
- "127.0.0.1:7700:7700"
volumes:
- type: bind
source: ./meili_data
target: /meili_data
bind:
create_host_path: false
read_only: true
tmpfs:
- /tmp:rw,nosuid,nodev,size=128m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ulimits:
nofile:
soft: 65535
hard: 65535
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- fts
networks:
fts:
driver: bridgeinstall.sh 全文
安装脚本默认只预演,会检查架构、运行目录和系统盘归属,已有的 .env 与索引不会被覆盖,密钥只在不存在时随机生成:
#!/usr/bin/env bash
# 全文搜索依赖安装入口,默认只预演;不修改Cloudreve配置或业务索引。
set -euo pipefail
SOURCE=$(cd -- "$(dirname -- "$0")" && pwd)
RUNTIME=${FTS_RUNTIME:-/opt/DockerDate/cloudreve-fts}
if [[ ${1:-} != "" && ${1:-} != --apply ]]; then
echo '用法:install.sh [--apply]' >&2
exit 2
fi
[[ $RUNTIME == /* && $RUNTIME != / && $RUNTIME != */../* ]] || { echo '运行目录必须是明确的绝对路径' >&2; exit 1; }
[[ $(uname -m) == aarch64 ]] || { echo '仅适用于aarch64,其他架构请先调整镜像' >&2; exit 1; }
command -v docker >/dev/null
command -v openssl >/dev/null
docker compose version
[[ -f $SOURCE/compose.yaml ]] || { echo '缺少Compose文件' >&2; exit 1; }
echo "将安装到:$RUNTIME"
echo '镜像及索引必须位于系统盘;不会触碰USB盘、Cloudreve数据库或业务文件。'
echo '将保留已有密钥和索引;新环境随机生成密钥,端口只绑定127.0.0.1。'
if [[ ${1:-} != --apply ]]; then
echo '预演完成;明确执行 --apply 才落盘、拉取镜像和启动容器。'
exit 0
fi
[[ $EUID == 0 ]] || { echo '实际安装需root权限' >&2; exit 1; }
parent=$RUNTIME
while [[ ! -d $parent ]]; do parent=$(dirname -- "$parent"); done
[[ $(findmnt -T "$parent" -no TARGET) == / ]] || { echo '目标不是系统根盘,请先重新评估存储和USB守护配置' >&2; exit 1; }
[[ ! -L $RUNTIME ]] || { echo '运行目录不能是符号链接' >&2; exit 1; }
install -d -m 700 "$RUNTIME"
if [[ ! -e $RUNTIME/meili_data ]]; then
install -d -m 700 -o 10001 -g 10001 "$RUNTIME/meili_data"
fi
[[ -d $RUNTIME/meili_data && ! -L $RUNTIME/meili_data && $(findmnt -T "$RUNTIME/meili_data" -no TARGET) == / ]] || { echo '索引目录不存在、是符号链接或不在系统盘' >&2; exit 1; }
if [[ ! -e $RUNTIME/.env ]]; then
(umask 077; printf 'MEILI_MASTER_KEY=' > "$RUNTIME/.env"; openssl rand -hex 32 >> "$RUNTIME/.env")
fi
chmod 600 "$RUNTIME/.env"
if [[ -f $RUNTIME/compose.yaml ]]; then
cp -a "$RUNTIME/compose.yaml" "$RUNTIME/compose.yaml.bak.$(date +%Y%m%d-%H%M%S)"
fi
install -m 644 "$SOURCE/compose.yaml" "$RUNTIME/compose.yaml"
docker compose --project-directory "$RUNTIME" config --quiet
docker compose --project-directory "$RUNTIME" pull
docker compose --project-directory "$RUNTIME" up -d
echo '安装完成。请等待服务就绪,再按使用说明填写Cloudreve后台。'
echo '1Panel可查看容器;本脚本未调用面板API登记编排,不要重复创建同名服务。'后台怎么填
访问 http://192.168.10.62:5212/admin,打开 文件系统 → 全文搜索,按实际页面填写:
扩展名要按页面控件分别添加,不要把整个清单当成一个条目;不选视频、音乐、压缩包、图片,避免无效抽取。CSV 之类能提取文字,不等于保留了全部表格语义,复杂文档还是得实际验证。
Cloudreve 跑在宿主机上,所以这里的
127.0.0.1是服务器自己的回环地址,用于后台连接,不是让你电脑的浏览器直接访问。不要填http://tika:9998或http://meilisearch:7700(那是 Cloudreve 也在同一个 Compose 网络里的场景),也不要因为浏览器打不开就去放开监听。
保存后使用后台提供的验证配置功能,以当前页面提示为准。如果页面要求重启,可以在没有上传/下载时重启 cloudreve 服务;不要重新 enable 它的开机入口,保持由 USB 守护负责启动。
安全地取出 API 密钥
sed -n 's/^MEILI_MASTER_KEY=//p' /opt/DockerDate/cloudreve-fts/.env输出只有密钥值,复制到后台的“API 密钥”框即可。密钥是随机生成的 64 位十六进制字符串,不要发进公开截图、文档、Git 或普通备份。只有 root 能读该文件,但 Docker 管理员本来就可以查看容器环境变量,所以这并不等于对宿主机管理员隐藏密钥。
私密配置里其实只有一个变量,值就是那串密钥,这里只列变量名与占位符,本文不写它的真实值(生成方式见上面的 install.sh,即 openssl rand -hex 32):
MEILI_MASTER_KEY=<随机 64 位十六进制密钥>已有文件不会自动进索引
开启之后,只有新上传或更新的、符合范围的文件才会触发内容提取和索引;已有文件不会自动补齐全文索引,而且只包括 Cloudreve 管理的文件,不是自动扫描整块盘或 SMB 共享。
验证方法很简单:先上传一个很小的 TXT 或 Word 文件,在正文里写一串独有词,文件名不要包含这个词;等相关任务完成后用全文/内容搜索检索它,确认应用链路是通的。
要处理历史文件,去 文件系统 → 全文搜索 → 索引器(Meilisearch)→ 索引操作 → 重建索引。该操作会读取符合条件的历史文件,可能唤醒机械盘并增加 CPU、内存和系统盘写入,建议在没有大文件传输时手工运行,并观察任务完成情况和磁盘占用。
不要因为总文件容量约 133 GiB 就以为索引也是 133 GiB:空间由抽取出的文字和索引结构决定,没有可靠的固定比例。
另外两个容易踩的预期问题:普通 Tika 没有装 Tesseract / 中文 OCR 语言包,文字型 PDF 可以提取正文,扫描件和图片不能指望文字检索——加图片扩展名并不会自动获得 OCR;AI 语义搜索这一轮也没有部署,不要打开后期待可用(后续这台机器已用本地 NPU 嵌入模型把语义检索补上,做法见本系列关于 NPU 本地嵌入的那一篇)。
管理与检查
# 查看状态;1Panel 容器页面也能查看。未通过面板 API 登记编排,勿重复创建同名项目。
docker compose --project-directory /opt/DockerDate/cloudreve-fts ps
# 启动/重启/停止依赖(无外盘 bind,不会被 USB 守护反向恢复)
docker compose --project-directory /opt/DockerDate/cloudreve-fts up -d
docker compose --project-directory /opt/DockerDate/cloudreve-fts restart
docker compose --project-directory /opt/DockerDate/cloudreve-fts stop
# 日志(不要使用 config 输出密钥)
docker logs --tail 50 cloudreve-fts-tika
docker logs --tail 50 cloudreve-fts-meilisearch
# 容量与端点
du -sh /opt/DockerDate/cloudreve-fts/meili_data
df -h /
curl -fsS http://127.0.0.1:7700/health
curl -fsS http://127.0.0.1:9998/version
ss -lnt '( sport = :7700 or sport = :9998 )'
# 重跑局部验证,Python 必须使用已建虚拟环境
/root/AI/cloudreve-fts/.venv/bin/python /root/AI/cloudreve-fts/tests/verify.py安装后、删除测试索引时 meili_data 实际 du 约 272 KiB(当时未建立业务索引,后续会增长);两个镜像解包标称合计约 0.95 GiB,与索引空间要分开计算。系统盘部署后约 79 GiB 可用。
Docker stats 现场显示 0B / 0B,与实际进程不符,不要据此报告真实内存占用;如需长期资源评估,另查进程或 cgroup 实测。
依赖代码是独立 Git 项目 /root/AI/cloudreve-fts,安装脚本默认预演。Compose 运行副本不要在不同入口同时编辑;1Panel 如需正式登记编排,请使用受支持的入口导入,并保留绝对系统盘数据路径、密钥、127.0.0.1 端口、CPU 和安全设置,不要另起重复容器。
验证范围:做了什么、没做什么
通过的部分:ARM64 镜像、安全选项、私密配置 600、系统盘存储、所有线程绑定 CPU0–3 及重启复验;中文 TXT / DOCX 提取、中文索引检索、用户字段过滤、未认证请求拒绝、临时索引删除、重启后索引持久化、Cloudreve 服务用户访问端点、Cloudreve 网页 HTTP 200,以及 Windows 经局域网无法连接 7700/9998。
未做的部分:网页登录后的配置保存/验证、真实业务上传与搜索、历史重建、整机重启、长时间负载与实际索引容量/功耗测试、OCR 与 AI 语义搜索。以上都是本文这一轮的边界,语义检索后来已由本地 NPU 嵌入模型补上,做法见本系列关于 NPU 本地嵌入的那一篇。验证样本只在内存中构造并进入随机临时索引,没有读取现有业务文件。
部署中还发现一个环境相关的坑:本机 Docker 29 的内部网络会抑制端口发布,因此改成独立普通 bridge 网络 + 显式 127.0.0.1 发布,并复验了 Windows 无法访问;没有改动其他网桥、iptables 或 Docker 守护配置。
备份、还原与撤销
归档位于 /root/AI/backup/cloudreve-fts,包含 README、默认 dry-run 的 restore.sh、按绝对路径镜像的 files/ 与 MANIFEST.sha256;不含 .env、Docker 镜像层、meili_data、数据库、上传文件或口令。重装前请先把归档拷到设备外,恢复 Docker ARM64 / Compose 及 Cloudreve 业务后再执行:
cd /root/AI/backup/cloudreve-fts
sha256sum -c MANIFEST.sha256
./restore.sh
./restore.sh --apply实际恢复会重新下载摘要锁定的镜像,并保留已有密钥与索引;没有密钥时会生成新的,必须重新在后台填 API 密钥。新环境不会恢复旧业务索引,需要恢复就得另行加密保存的索引快照,或通过 Cloudreve 重建。可重建不等于索引无隐私——它包含正文,应按业务数据来保护。若单独备份索引,先确认应用的索引任务已停止,再停止 Meilisearch 后备份 meili_data,或按官方 dump / snapshot 方案处理;不要直接复制正在写入的数据库当成一致性备份,密钥也要另做加密私密备份。
撤销很简单:在后台关闭全文搜索,再停止两个依赖即可。docker compose ... down 只移除本项目容器和网络,不要加 -v,不要删除 meili_data、.env,也不要运行 Docker prune。已有的 Cloudreve、USB 守护、MySQL 和原文件都不会因此被删除。
附:完整脚本
上面步骤里用到的完整脚本不再整段贴在正文,已作为附件上传到对象存储,点击文件名即可下载:
verify.py —— 局部验证脚本
对象存储策略会自动把文件名改写成 UUID(例如
95D96A8A-FB38-4CCB-AB42-F6E906E70760.py),这是存储策略的行为,不影响文件内容;下载后按正文里的路径改名放好即可。