跳过正文
  1. 文章/

使用 paperless-ai 与 Ollama 为 paperless-ngx 添加 AI 文档分类功能

·1295 字·7 分钟
作者
Yang Hu
目录

本文是一份完整的操作手册,介绍如何通过 paperless-ai 和本地运行的 Ollamapaperless-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 保存待处理队列,台式机开机后自动消费。

1
2
3
4
5
6
7
paperless-ngx (NAS)
       ↑  ↓  (REST API)
 paperless-ai (WSL2 Docker)
       ↑  ↓  (HTTP)
    Ollama (Windows 原生)
    RTX 4090 (GPU)

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 系统环境变量。

  1. 打开系统属性高级环境变量
  2. 系统变量下点击新建
  3. 变量名:OLLAMA_HOST
  4. 变量值:0.0.0.0
  5. 点击确定,然后重启 Ollama(关闭系统托盘图标后重新启动)

在 WSL2 中验证:

1
curl http://$(ip route | awk '/default/ {print $3}'):11434/api/tags

从 Docker 容器内部,Ollama 可通过 host.docker.internal:11434 访问。

第二步 — 拉取正确的模型
#

所用模型必须支持 Ollama 结构化输出(即 format / JSON Schema 参数)。该功能通过约束 token 级别的解码来强制输出 JSON,并非所有模型都支持。

关键注意qwen3-vl:8b(视觉-语言多模态变体)不支持结构化输出。传入 format schema 时,Ollama 会静默返回空响应字符串。这个失败是无声的,非常难以排查。

请使用 qwen3:8b(基础模型):

1
2
# 在 Windows PowerShell 中运行
ollama pull qwen3:8b

测试结构化输出是否正常工作:

1
2
3
4
5
6
curl http://localhost:11434/api/generate -d '{
  "model": "qwen3:8b",
  "format": {"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]},
  "prompt": "Return a JSON object with a title field set to hello world.",
  "stream": false
}'

响应中的 response 字段应为非空的 JSON 字符串。如果是 "",说明该模型不支持结构化输出。

第三步 — 在 paperless-ngx 中创建标签
#

在 paperless-ngx 中创建两个标签(设置 → 标签):

标签用途
ai-pending输入过滤器——拥有此标签的文档将被 paperless-ai 处理
ai-processed输出标记——paperless-ai 处理成功后添加此标签

两个标签的匹配算法均设为(它们由工作流和 paperless-ai 分配,不使用自动匹配规则)。

通过 API 可验证标签 ID(无需用到,但便于调试):

1
2
curl -s http://10.0.10.10:5656/api/tags/ \
  -H "Authorization: Token <YOUR_TOKEN>" | python3 -m json.tool | grep -A3 "ai-pending"

第四步 — 在 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 项目文件
#

创建项目目录:

1
2
mkdir -p ~/repo/paperless-ai
cd ~/repo/paperless-ai

docker-compose.yml
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  paperless-ai:
    image: clusterzx/paperless-ai
    container_name: paperless-ai
    restart: unless-stopped
    user: "0:0"
    env_file:
      - .env
    ports:
      - "3000:3000"
    volumes:
      - paperless-ai_data:/app/data

volumes:
  paperless-ai_data:

user: "0:0" 指令至关重要。paperless-ai 在 /app/data 目录内写入配置文件和 SQLite 数据库。在 WSL2/Docker Desktop 环境下,权限映射问题会导致默认的 node 用户无法在 volume 中创建文件——以 root 身份运行可彻底规避这些问题。

.env
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
PAPERLESS_API_URL=http://10.0.10.10:5656/api
PAPERLESS_API_TOKEN=<YOUR_TOKEN>
PAPERLESS_USERNAME=yang
AI_PROVIDER=ollama
OLLAMA_API_URL=http://host.docker.internal:11434
OLLAMA_MODEL=qwen3:8b
SCAN_INTERVAL=*/30 * * * *
PROCESS_PREDEFINED_DOCUMENTS=yes
TAGS=ai-pending
ADD_AI_PROCESSED_TAG=yes
AI_PROCESSED_TAG_NAME=ai-processed
USE_EXISTING_DATA=yes

关键配置说明:

  • TAGS=ai-pending:paperless-ai 只处理带有该标签的文档
  • SCAN_INTERVAL=*/30 * * * *:每 30 分钟轮询一次 paperless-ngx
  • PROCESS_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 设置)。

提示词应定义:

  • 存在哪些文档类型(使用一致的命名)
  • 有哪些主题标签可用
  • 需要填写哪些自定义字段
  • 边界情况的明确规则

针对本套方案的提示词工程经验总结:

  1. 明确列举所有合法值——不要让模型自己发明文档类型或标签
  2. 明确禁止保留标签——如果有人工管理的状态标签,将其列为绝对禁止项
  3. 要求自定义字段值为字符串类型——paperless-ai 期望所有自定义字段值为字符串;在提示词中注明:“所有自定义字段的值必须是字符串(加引号)或 null。写 "2017.08" 而不是 2017.08
  4. 对歧义情况给出明确示例——例如:“医疗账单 → 使用类型 发票收据 + 标签 #医疗,而非类型 医疗记录”

示例提示词结构(部分):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
你是一个个人家庭档案的文档分类助手。

## 文档类型(精确选择一个)
- 发票收据:账单、发票、收据
- 税务文件:W-2、1099、税务申报表
- 移民文件:签证、护照、I-94
...

## 标签(仅从以下列表中选择所有适用的)
- #保险
- #医疗
- #财务
...

## 自定义字段
- Amount(金额):金额,字符串格式,例如 "2017.08",或 null
- Bill Period(账单周期):对账单周期结束日期,YYYY-MM-DD 格式,或 null
- Expiry Date(到期日期):证件/签证/保险到期日,YYYY-MM-DD 格式,或 null
- Document Year(文档年份):年份字符串,例如 "2024",或 null
- Account / Policy Number(账号/保单号):账号或保单号字符串,或 null

## 规则
- 所有自定义字段值必须是字符串(加引号)或 null
- 医疗账单 → 类型:发票收据,标签:#医疗——不要使用类型 医疗记录
- 任何情况下都绝对不得分配:#待处理 #重要 #归档——这些是保留的人工状态标签,
  任何文档类型、任何内容都绝不能出现在你的输出中

第七步 — 启动容器
#

1
2
cd ~/repo/paperless-ai
docker compose up -d

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 守护进程未运行
#

1
Error response from daemon: dial unix /var/run/docker.sock: no such file or directory

在 Windows 上启动 Docker Desktop。建议在 Docker Desktop 设置中启用"开机自动启动"。

Ollama 在 WSL2 中不可访问
#

1
connect ETIMEDOUT 10.255.255.254:11434

说明 OLLAMA_HOST=0.0.0.0 未设置,或设置后未重启 Ollama。在 PowerShell 中验证 Ollama 的监听地址:

1
netstat -ano | findstr 11434

本地地址应显示 0.0.0.0:11434,而非 127.0.0.1:11434

.env 修改未生效
#

docker compose restart 不会重新读取 env_file。始终使用:

1
docker compose up -d

这会重新创建容器并加载新的环境变量。

结构化输出返回空响应
#

1
No response data from Ollama API

所用模型不支持 Ollama 的 format 参数。检查当前运行的模型:

1
curl http://localhost:11434/api/tags

将所有 *-vl 变体替换为基础模型,例如将 qwen3-vl:8b 替换为 qwen3:8b

自定义字段值类型错误
#

1
TypeError: customField.value?.trim is not a function

AI 返回了数值类型(如 2017.08),而 paperless-ai 期望字符串("2017.08")。在系统提示词中添加规则:“所有自定义字段的值必须是字符串(加引号)或 null。”

自定义字段名中的 # 字符导致 env 解析错误
#

1
SyntaxError: Unterminated string in JSON at position 44

名为 Account / Policy # 之类的自定义字段包含 #,该字符在 .env 文件解析时被视为注释符。在 paperless-ngx 中将字段重命名以去掉 #,例如改为 Account / Policy Number。通过 API 重命名:

1
2
3
4
curl -X PATCH http://10.0.10.10:5656/api/custom_fields/5/ \
  -H "Authorization: Token <YOUR_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"name": "Account / Policy Number"}'

处理后 ai-pending 标签未被移除
#

文档处理完成后标签仍然存在,说明清理工作流未设置或未触发。检查:

  1. 设置 → 工作流中存在"AI 处理完成后移除 ai-pending"工作流
  2. 触发条件为:文档已更新,且含有标签 ai-processed
  3. 操作为:移除标签 ai-pending

记住:paperless-ai 源码中会合并标签,从不移除任何标签。移除操作必须依赖工作流。

sed 命令意外修改无关环境变量
#

如果用 sed 编辑容器内的 /app/data/.env,需注意子字符串匹配问题。例如:

1
sed -i 's/CUSTOM_FIELDS=.*/NEW_VALUE/' /app/data/.env

这条命令同样会匹配 ACTIVATE_CUSTOM_FIELDS=(因为 CUSTOM_FIELDS 是其子字符串)。改用 Python 加锚定模式:

1
2
3
4
5
6
python3 -c "
import re, sys
content = open('/app/data/.env').read()
content = re.sub(r'^CUSTOM_FIELDS=.*', 'CUSTOM_FIELDS=NEW_VALUE', content, flags=re.MULTILINE)
open('/app/data/.env', 'w').write(content)
"

AI 分配了被禁止的状态标签
#

模型偶尔会分配你为人工保留的标签。在提示词中加强禁止措辞:

1
2
任何情况下都绝对不得分配:#待处理 #重要 #归档——这些是保留的人工状态标签,
无论文档类型如何、内容是什么,都绝不能出现在你的输出中。

paperless-ai 内部工作原理
#

理解内部机制有助于调试:

  1. paperless-ai 轮询 paperless-ngx API,寻找带有 ai-pending 标签的文档
  2. 对每份文档,获取完整的文本内容
  3. 将文本和系统提示词一起以 format: jsonSchema 参数发送给 Ollama
  4. Ollama 使用约束解码(在 token 采样层面强制执行),生成合法的 JSON
  5. paperless-ai 解析响应:titledocument_typetagscorrespondentdocument_datelanguagecustom_fields
  6. 调用 paperlessService.updateDocument(),该方法合并标签:[...new Set([...currentDoc.tags, ...updates.tags])]——从不移除标签
  7. 添加 ai-processed 标签以表示完成
  8. 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-ngxNAS 10.0.10.10:5656文档存储与 API
paperless-aiWSL2 Docker,端口 3000协调 AI 处理流程
OllamaWindows 原生,端口 11434GPU 加速 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 挂载回去。

1
docker cp paperless-ai:/app/services/paperlessService.js ./paperlessService.js

修改 paperlessService.js,参照 getOrCreateCorrespondent 的写法加上限制逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 修改前:
async getOrCreateDocumentType(name) {

// 修改后:
async getOrCreateDocumentType(name, options = {}) {
  const restrictToExistingDocumentTypes = options.restrictToExistingDocumentTypes === true ||
      (options.restrictToExistingDocumentTypes === undefined &&
       process.env.RESTRICT_TO_EXISTING_DOCUMENT_TYPES === 'yes');

  // 在搜索 existingDocType 之后、创建之前加入:
  if (restrictToExistingDocumentTypes) {
      console.log(`[DEBUG] Document type "${name}" does not exist and restrictions are enabled, returning null`);
      return null;
  }

docker-compose.yml 中挂载补丁文件:

1
2
3
volumes:
  - paperless-ai_data:/app/data
  - ./paperlessService.js:/app/services/paperlessService.js:ro

执行 docker compose up -d 重建容器。bind mount 在镜像更新后依然有效。待上游修复后可移除。

AI 将 correspondent 设置为字符串 “null”
#

模型在找不到机构名时会输出字符串 "null",导致 paperless-ai 创建一个名为 null 的 correspondent。修复方式:在 prompt 中明确说明该字段应使用 JSON null(而非字符串 “null”),并将模板中的占位符改为无引号的 null

1
2
3
"correspondent": "Name or null",   ← 提示模型
...
## Correspondent:不明确时填 JSON null,不是字符串 "null"

通过 API 清理误创建的 null correspondent:

1
2
3
4
5
# 查找并删除
curl -s http://NAS:5656/api/correspondents/ -H "Authorization: Token TOKEN" | \
  python3 -c "import sys,json; [print(c['id'], c['name']) for c in json.load(sys.stdin)['results']]"

curl -X DELETE http://NAS:5656/api/correspondents/ID/ -H "Authorization: Token TOKEN"

文档类型分类体系重设计
#

运行一段时间后,发票收据 变成了万能桶——酒店预订确认、维修估价、项目报价、通行证全堆在里面。解决方案是新增两个精准分类,而不是继续强行套用同一个 type。

新增两个文档类型:

类型适用场景
行程预订酒店/机票确认单、活动门票、通行证、SNO-PARK 等许可证
报价估价维修估价、施工报价、项目提案——尚未付款的询价类文件

删除了所有 AI 自行创建的 0 文档英文类型(Estimate、Invoice、Quote、repair_estimate、Travel Itinerary、Technical Manual、Product Manual、manual,ids 20–27)。

重新分类 ~9 个文档:酒店确认单 → 行程预订,航空行程单 → 行程预订,车辆/房屋维修估价 → 报价估价,施工授权书 → 合同协议

在 prompt 中新增了防误分类规则:

1
2
3
- 估价/报价(未付款)→ "报价估价";已付发票 → "发票收据"
- 酒店/机票确认单 → "行程预订"(不是"发票收据")
- 施工授权书、装修合同 → "合同协议"(不是"发票收据")

不用 Web UI 更新 System Prompt
#

paperless-ai 的 Web UI 可以修改 system prompt,但改起来不方便。实际上 prompt 以 \n 转义的单行格式存储在容器内 /app/data/.envSYSTEM_PROMPT 字段(Docker volume 内)。

注意: dotenv v16 在解析未加引号的值时,遇到 \n 后跟 # 会将其视为注释截断,导致含有 ## 章节标题 的 prompt 被静默截断。解决方案:写入时用双引号包裹值。

工作流:将 prompt 保存为 PROMPT.md(与 docker-compose.yml 同目录),用辅助脚本推送到容器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# update-prompt.sh — 编辑 PROMPT.md 后运行
docker cp PROMPT.md paperless-ai:/tmp/PROMPT.md
docker exec paperless-ai node -e "
  const fs = require('fs'), dotenv = require('dotenv');
  const prompt = fs.readFileSync('/tmp/PROMPT.md', 'utf8').trimEnd();
  const escaped = prompt.replace(/\\\\/g,'\\\\\\\\').replace(/\"/g,'\\\\\"').replace(/\n/g,'\\\\n');
  let env = fs.readFileSync('/app/data/.env','utf8');
  fs.writeFileSync('/app/data/.env', env.replace(/^SYSTEM_PROMPT=.*$/m,'SYSTEM_PROMPT=\"'+escaped+'\"'));
  const val = dotenv.parse(fs.readFileSync('/app/data/.env')).SYSTEM_PROMPT;
  console.log('Updated:', val?.length, 'chars');
"
docker restart paperless-ai

脚本位于 ~/repo/paperless-ai/update-prompt.sh,已设为可执行。