本文是一份完整的操作手册,介绍如何通过 paperless-ai 和本地运行的 Ollama 为 paperless-ngx 集成 AI 自动标签和分类功能。整套方案使用本地大语言模型读取文档文本,自动填充元数据字段——包括标题、文档类型、标签、联系人、日期以及自定义字段。
硬件与架构#
- NAS(群晖 DS1621+,
10.0.10.10):在 5656 端口运行 paperless-ngx - 台式 PC:Windows,安装了 WSL2、Docker Desktop,配备 RTX 4090
- 目标:使用本地 LLM 实现 AI 自动打标/分类,零云端依赖
核心架构决策是拉取模式(pull model):paperless-ai 运行在 WSL2 的 Docker 容器中,轮询 paperless-ngx API 寻找带有 ai-pending 标签的文档,调用 Ollama 处理后将元数据写回。对于不是 24 小时开机的台式机而言,这是最正确的方案——NAS 保存待处理队列,台式机开机后自动消费。
| |
Ollama 以原生方式运行在 Windows 上(而非 WSL 内),以获得最佳 GPU 访问性能。在 WSL2 的 Docker 容器内,通过特殊主机名 host.docker.internal 访问 Ollama。
前置条件#
- paperless-ngx 已运行并可通过 API 访问
- Windows 上已安装 Docker Desktop,并启用了 WSL2 集成
- Windows 上已安装 Ollama
第一步 — 让 Ollama 监听所有网络接口#
Ollama 默认只监听 127.0.0.1,导致 WSL2 Docker 容器无法访问。需要设置一个 Windows 系统环境变量。
- 打开系统属性 → 高级 → 环境变量
- 在系统变量下点击新建
- 变量名:
OLLAMA_HOST - 变量值:
0.0.0.0 - 点击确定,然后重启 Ollama(关闭系统托盘图标后重新启动)
在 WSL2 中验证:
| |
从 Docker 容器内部,Ollama 可通过 host.docker.internal:11434 访问。
第二步 — 拉取正确的模型#
所用模型必须支持 Ollama 结构化输出(即 format / JSON Schema 参数)。该功能通过约束 token 级别的解码来强制输出 JSON,并非所有模型都支持。
关键注意:qwen3-vl:8b(视觉-语言多模态变体)不支持结构化输出。传入 format schema 时,Ollama 会静默返回空响应字符串。这个失败是无声的,非常难以排查。
请使用 qwen3:8b(基础模型):
| |
测试结构化输出是否正常工作:
| |
响应中的 response 字段应为非空的 JSON 字符串。如果是 "",说明该模型不支持结构化输出。
第三步 — 在 paperless-ngx 中创建标签#
在 paperless-ngx 中创建两个标签(设置 → 标签):
| 标签 | 用途 |
|---|---|
ai-pending | 输入过滤器——拥有此标签的文档将被 paperless-ai 处理 |
ai-processed | 输出标记——paperless-ai 处理成功后添加此标签 |
两个标签的匹配算法均设为无(它们由工作流和 paperless-ai 分配,不使用自动匹配规则)。
通过 API 可验证标签 ID(无需用到,但便于调试):
| |
第四步 — 在 paperless-ngx 中创建工作流#
paperless-ai 从不移除标签——它只会添加标签。ai-pending 标签必须在处理完成后通过工作流移除。在 paperless-ngx 中设置两个工作流(设置 → 工作流):
工作流 1:“AI 处理队列”#
- 触发条件:文档已添加
- 操作:分配标签
ai-pending
确保每一份新添加的文档都自动进入 AI 处理队列。
工作流 2:“AI 处理完成后移除 ai-pending”#
- 触发条件:文档已更新——含有标签
ai-processed - 操作:移除标签
ai-pending
在 paperless-ai 完成处理后清理队列标记。如果没有这个工作流,ai-pending 标签会一直留在每份文档上,Ollama 会反复重新处理它们。
第五步 — 创建 paperless-ai 项目文件#
创建项目目录:
| |
docker-compose.yml#
| |
user: "0:0" 指令至关重要。paperless-ai 在 /app/data 目录内写入配置文件和 SQLite 数据库。在 WSL2/Docker Desktop 环境下,权限映射问题会导致默认的 node 用户无法在 volume 中创建文件——以 root 身份运行可彻底规避这些问题。
.env#
| |
关键配置说明:
TAGS=ai-pending:paperless-ai 只处理带有该标签的文档SCAN_INTERVAL=*/30 * * * *:每 30 分钟轮询一次 paperless-ngxPROCESS_PREDEFINED_DOCUMENTS=yes:处理已存在的文档(不只是新文档)ADD_AI_PROCESSED_TAG=yes:处理后添加ai-processed标签(清理工作流必须依赖此标签)USE_EXISTING_DATA=yes:不用 AI 结果覆盖原始空字段
第六步 — 编写系统提示词#
paperless-ai 将文档文本连同你的自定义系统提示词一起发送给 Ollama。提示词从容器内的 /app/data/PROMPT.md 文件读取(也可通过 http://localhost:3000 的 Web UI 设置)。
提示词应定义:
- 存在哪些文档类型(使用一致的命名)
- 有哪些主题标签可用
- 需要填写哪些自定义字段
- 边界情况的明确规则
针对本套方案的提示词工程经验总结:
- 明确列举所有合法值——不要让模型自己发明文档类型或标签
- 明确禁止保留标签——如果有人工管理的状态标签,将其列为绝对禁止项
- 要求自定义字段值为字符串类型——paperless-ai 期望所有自定义字段值为字符串;在提示词中注明:“所有自定义字段的值必须是字符串(加引号)或 null。写
"2017.08"而不是2017.08” - 对歧义情况给出明确示例——例如:“医疗账单 → 使用类型 发票收据 + 标签 #医疗,而非类型 医疗记录”
示例提示词结构(部分):
| |
第七步 — 启动容器#
| |
在 http://localhost:3000 打开 Web UI 验证配置。该界面支持查看和编辑配置,以及手动触发扫描。
重要:通过 Web UI 保存配置后,权威配置存储在 Docker volume 内部的 /app/data/.env 文件中。docker-compose.env 文件设置初始环境变量;UI 会写入自己的配置文件,某些设置以该文件为准。如果编辑了 .env 并需要容器读取新值,请使用 docker compose up -d(而非 docker compose restart——restart 命令不会重新读取 env 文件)。
故障排查#
Docker 守护进程未运行#
| |
在 Windows 上启动 Docker Desktop。建议在 Docker Desktop 设置中启用"开机自动启动"。
Ollama 在 WSL2 中不可访问#
| |
说明 OLLAMA_HOST=0.0.0.0 未设置,或设置后未重启 Ollama。在 PowerShell 中验证 Ollama 的监听地址:
| |
本地地址应显示 0.0.0.0:11434,而非 127.0.0.1:11434。
.env 修改未生效#
docker compose restart 不会重新读取 env_file。始终使用:
| |
这会重新创建容器并加载新的环境变量。
结构化输出返回空响应#
| |
所用模型不支持 Ollama 的 format 参数。检查当前运行的模型:
| |
将所有 *-vl 变体替换为基础模型,例如将 qwen3-vl:8b 替换为 qwen3:8b。
自定义字段值类型错误#
| |
AI 返回了数值类型(如 2017.08),而 paperless-ai 期望字符串("2017.08")。在系统提示词中添加规则:“所有自定义字段的值必须是字符串(加引号)或 null。”
自定义字段名中的 # 字符导致 env 解析错误#
| |
名为 Account / Policy # 之类的自定义字段包含 #,该字符在 .env 文件解析时被视为注释符。在 paperless-ngx 中将字段重命名以去掉 #,例如改为 Account / Policy Number。通过 API 重命名:
| |
处理后 ai-pending 标签未被移除#
文档处理完成后标签仍然存在,说明清理工作流未设置或未触发。检查:
- 设置 → 工作流中存在"AI 处理完成后移除 ai-pending"工作流
- 触发条件为:文档已更新,且含有标签
ai-processed - 操作为:移除标签
ai-pending
记住:paperless-ai 源码中会合并标签,从不移除任何标签。移除操作必须依赖工作流。
sed 命令意外修改无关环境变量#
如果用 sed 编辑容器内的 /app/data/.env,需注意子字符串匹配问题。例如:
| |
这条命令同样会匹配 ACTIVATE_CUSTOM_FIELDS=(因为 CUSTOM_FIELDS 是其子字符串)。改用 Python 加锚定模式:
| |
AI 分配了被禁止的状态标签#
模型偶尔会分配你为人工保留的标签。在提示词中加强禁止措辞:
| |
paperless-ai 内部工作原理#
理解内部机制有助于调试:
- paperless-ai 轮询 paperless-ngx API,寻找带有
ai-pending标签的文档 - 对每份文档,获取完整的文本内容
- 将文本和系统提示词一起以
format: jsonSchema参数发送给 Ollama - Ollama 使用约束解码(在 token 采样层面强制执行),生成合法的 JSON
- paperless-ai 解析响应:
title、document_type、tags、correspondent、document_date、language、custom_fields - 调用
paperlessService.updateDocument(),该方法合并标签:[...new Set([...currentDoc.tags, ...updates.tags])]——从不移除标签 - 添加
ai-processed标签以表示完成 - paperless-ngx 工作流检测到
ai-processed后,移除ai-pending
文件权限深度解析#
docker-compose 中的 user: "0:0" 设置值得详细说明。paperless-ai 的基础镜像以 node 用户身份运行。命名 Docker volume 的根目录由 root:root 所有,权限为 755。node 用户可以读取和进入该目录,但无法在其中创建新文件(应用采用原子写入:先创建临时文件,再重命名——两个操作都需要对目录的写入权限)。以 root 身份运行可绕过所有这些问题。
另一种思路——改用 bind mount——在 WSL2/Docker Desktop 环境下也行不通,因为 WSL2 与 Windows 之间的 uid/gid 映射会导致 SQLite 无法创建数据库文件。
日常使用#
- paperless-ai 在启动时处理文档,之后按
SCAN_INTERVAL每 30 分钟轮询一次 - 在
http://localhost:3000监控状态和手动触发扫描 - Web UI 显示处理历史和当前队列状态
- 在 paperless-ngx 中批量移除标签:列表视图 → 选择一份文档 → 出现"选择全部 X 份文档"→ 操作 → 编辑标签
方案总结#
| 组件 | 位置 | 备注 |
|---|---|---|
| paperless-ngx | NAS 10.0.10.10:5656 | 文档存储与 API |
| paperless-ai | WSL2 Docker,端口 3000 | 协调 AI 处理流程 |
| Ollama | Windows 原生,端口 11434 | GPU 加速 LLM 推理 |
| 模型 | qwen3:8b | 基础模型,非 VL 变体 |
| 触发标签 | ai-pending | 由 paperless-ngx 工作流添加 |
| 完成标签 | ai-processed | 由 paperless-ai 添加 |
整套流程完全自托管、GPU 加速,无需任何云服务。文档在本地处理,完全保护隐私。
后续修复#
“限制为现有文档类型"设置无效(已知 Bug)#
paperless-ai 提供了一个 UI 开关,用于限制只使用已有的文档类型,但截至 2026 年 3 月该功能完全失效(#834、#799)。根本原因:services/paperlessService.js 中的 getOrCreateDocumentType() 没有限制逻辑,而 getOrCreateCorrespondent() 已正确实现。修复 PR #865 已提交但被关闭,未合并。
**解决方法:**将文件从容器中复制出来,打补丁,再通过 bind mount 挂载回去。
| |
修改 paperlessService.js,参照 getOrCreateCorrespondent 的写法加上限制逻辑:
| |
在 docker-compose.yml 中挂载补丁文件:
| |
执行 docker compose up -d 重建容器。bind mount 在镜像更新后依然有效。待上游修复后可移除。
AI 将 correspondent 设置为字符串 “null”#
模型在找不到机构名时会输出字符串 "null",导致 paperless-ai 创建一个名为 null 的 correspondent。修复方式:在 prompt 中明确说明该字段应使用 JSON null(而非字符串 “null”),并将模板中的占位符改为无引号的 null:
| |
通过 API 清理误创建的 null correspondent:
| |
文档类型分类体系重设计#
运行一段时间后,发票收据 变成了万能桶——酒店预订确认、维修估价、项目报价、通行证全堆在里面。解决方案是新增两个精准分类,而不是继续强行套用同一个 type。
新增两个文档类型:
| 类型 | 适用场景 |
|---|---|
行程预订 | 酒店/机票确认单、活动门票、通行证、SNO-PARK 等许可证 |
报价估价 | 维修估价、施工报价、项目提案——尚未付款的询价类文件 |
删除了所有 AI 自行创建的 0 文档英文类型(Estimate、Invoice、Quote、repair_estimate、Travel Itinerary、Technical Manual、Product Manual、manual,ids 20–27)。
重新分类 ~9 个文档:酒店确认单 → 行程预订,航空行程单 → 行程预订,车辆/房屋维修估价 → 报价估价,施工授权书 → 合同协议。
在 prompt 中新增了防误分类规则:
| |
不用 Web UI 更新 System Prompt#
paperless-ai 的 Web UI 可以修改 system prompt,但改起来不方便。实际上 prompt 以 \n 转义的单行格式存储在容器内 /app/data/.env 的 SYSTEM_PROMPT 字段(Docker volume 内)。
注意: dotenv v16 在解析未加引号的值时,遇到 \n 后跟 # 会将其视为注释截断,导致含有 ## 章节标题 的 prompt 被静默截断。解决方案:写入时用双引号包裹值。
工作流:将 prompt 保存为 PROMPT.md(与 docker-compose.yml 同目录),用辅助脚本推送到容器:
| |
脚本位于 ~/repo/paperless-ai/update-prompt.sh,已设为可执行。