
Paperless-ngx 很适合做个人文档库:扫描件、票据、证件、合同、说明书,都能统一归档、打标签、全文搜索。可是一到中文扫描件,尤其是手机拍照、老资料、低对比度图片,原生 Tesseract OCR 经常会出现乱码、漏字、数字错位。
这篇笔记记录一个更稳妥的做法:不替换 Paperless-ngx 本身,也不破坏原来的 OCR 流程,而是在文档导入完成后,外挂一个轻量 OCR API,把更适合中文的识别结果写回 Paperless 文档内容字段。这样搜索、归档、后续 AI 分析都会更好用。

方案亮点
- 不拆 Paperless 原生 OCR:Paperless 仍然正常导入、生成 PDF/A、缩略图和元数据。
- 增强中文识别:RapidOCR 基于 ONNXRuntime,中文和中英混排识别更友好。
- 适合纯 CPU 服务器:不要求显卡,适合 NAS、小主机、VPS、家庭服务器。
- 可控资源占用:可以限制容器 CPU、内存和单张图片尺寸。
- 安全边界清晰:OCR API 只给 Paperless 内部调用,不需要暴露到公网。
- 失败不影响导入:即使增强 OCR 暂时失败,Paperless 原文档仍然保留。

适合谁用
- 已经部署了 Paperless-ngx,但中文 OCR 效果不理想。
- 文档里有大量中文扫描件、证件照、票据、书页、历史资料。
- 服务器没有显卡,只想用 CPU 稳定跑。
- 希望用 Docker Compose 管理,方便迁移和备份。
- 不想把文档发给在线 OCR 或第三方 AI 服务。
整体思路
Paperless-ngx 官方支持导入前脚本和导入后脚本。这里用的是导入后脚本:文档已经进入数据库后,Paperless 会把 DOCUMENT_ID、DOCUMENT_SOURCE_PATH 等变量传给脚本。脚本再把原始图片发给 OCR API,拿到文本后写回 Document.content,最后更新搜索索引。
RapidOCR 官方也提供 ONNXRuntime CPU 推理方案。相比直接跑 PaddleOCR,它对老 CPU 更友好,部署包也更轻。遇到旧款 Xeon、低功耗 NAS CPU 时,这个方案通常比 PaddlePaddle 方案更省心。
目录规划
<code class="language-bash"># 创建 Paperless 与 OCR 的统一工作目录,便于备份和迁移 mkdir -p /opt/paperless-stack # 创建 RapidOCR API 服务目录,单独保存代码、模型缓存和临时文件 mkdir -p /opt/paperless-stack/rapidocr-api/app # 创建 RapidOCR 模型缓存目录,首次下载后可长期复用 mkdir -p /opt/paperless-stack/rapidocr-api/models # 创建 RapidOCR 临时目录,存放上传识别时的中间文件 mkdir -p /opt/paperless-stack/rapidocr-api/tmp # 创建 Paperless 自定义脚本目录,保存导入前后处理脚本 mkdir -p /opt/paperless-stack/paperless/scripts</code>
创建内部网络
OCR API 不需要开放给外部浏览器访问,只要 Paperless 容器能访问即可。建议建立一个专用 Docker 网络。
<code class="language-bash"># 创建 Paperless 与 OCR API 共用的 Docker 内部网络 docker network create paperless-ocr-net # 如果网络已经存在,这条命令会提示已存在,可以忽略 docker network inspect paperless-ocr-net</code>
部署 RapidOCR API
先创建 app/server.py。下面这个 API 接收图片或 PDF,默认更推荐用于图片增强;PDF 页数较多时请谨慎打开,避免纯 CPU 服务器压力太大。
<code class="language-python">import os # 读取环境变量,控制线程和尺寸限制
import time # 记录 OCR 请求耗时,方便排查慢请求
from pathlib import Path # 读取上传文件扩展名
from typing import Any # 标注动态返回结构
import cv2 # 使用 OpenCV 解码和缩放图片
import fitz # 使用 PyMuPDF 把 PDF 页面渲染为图片
import numpy as np # 使用 NumPy 承载图片矩阵
from fastapi import FastAPI, File, HTTPException, UploadFile # 提供 HTTP API 和文件上传能力
from rapidocr_onnxruntime import RapidOCR # 调用 RapidOCR ONNXRuntime 推理引擎
os.environ.setdefault("OMP_NUM_THREADS", os.getenv("RAPIDOCR_CPU_THREADS", "4")) # 限制 OpenMP 线程数
os.environ.setdefault("MKL_NUM_THREADS", os.getenv("RAPIDOCR_CPU_THREADS", "4")) # 限制 MKL 线程数
app = FastAPI(title="RapidOCR CPU API", version="1.0.0") # 创建 FastAPI 应用
OCR_MODEL: RapidOCR | None = None # 保存全局 OCR 模型,避免重复加载
def get_ocr_model() -> RapidOCR: # 获取或初始化 OCR 模型
global OCR_MODEL # 声明使用全局模型变量
if OCR_MODEL is not None: # 如果模型已经加载
return OCR_MODEL # 直接返回已加载模型
OCR_MODEL = RapidOCR() # 初始化 RapidOCR 模型
return OCR_MODEL # 返回模型实例
def limit_image_size(image: np.ndarray) -> np.ndarray: # 限制图片尺寸,降低 CPU 压力
max_side = int(os.getenv("RAPIDOCR_MAX_IMAGE_SIDE", "2400")) # 读取最大边长
height, width = image.shape[:2] # 获取图片尺寸
longest = max(height, width) # 计算最长边
if longest <= max_side: # 如果图片不超限
return image # 直接返回原图
scale = max_side / float(longest) # 计算缩放比例
return cv2.resize(image, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_AREA) # 缩小图片
@app.get("/health") # 注册健康检查接口
def health() -> dict[str, Any]: # 返回服务状态
return {"ok": True, "model_loaded": OCR_MODEL is not None} # 返回模型是否已加载
@app.post("/ocr") # 注册 OCR 上传识别接口
async def ocr(file: UploadFile = File(...)) -> dict[str, Any]: # 接收上传文件
started = time.time() # 记录开始时间
data = await file.read() # 读取上传文件内容
if not data: # 如果文件为空
raise HTTPException(status_code=400, detail="上传文件为空") # 返回客户端错误
suffix = Path(file.filename or "").suffix.lower() # 获取文件扩展名
images = [] # 准备页面图片列表
if suffix == ".pdf": # 如果上传的是 PDF
dpi = int(os.getenv("RAPIDOCR_PDF_DPI", "180")) # 读取 PDF 渲染 DPI
max_pages = int(os.getenv("RAPIDOCR_MAX_PDF_PAGES", "12")) # 读取最大页数
with fitz.open(stream=data, filetype="pdf") as document: # 从字节打开 PDF
for index, page in enumerate(document): # 遍历 PDF 页面
if index >= max_pages: # 如果超过页数上限
break # 停止处理后续页面
pixmap = page.get_pixmap(matrix=fitz.Matrix(dpi / 72, dpi / 72), alpha=False) # 渲染页面
raw = np.frombuffer(pixmap.samples, dtype=np.uint8) # 转为 NumPy 数组
rgb = raw.reshape((pixmap.height, pixmap.width, pixmap.n)) # 还原图片形状
images.append(limit_image_size(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))) # 转 BGR 并加入列表
else: # 如果上传的是普通图片
array = np.frombuffer(data, dtype=np.uint8) # 把图片字节转为数组
image = cv2.imdecode(array, cv2.IMREAD_COLOR) # 解码图片
if image is None: # 如果解码失败
raise HTTPException(status_code=400, detail="无法解码图片文件") # 返回客户端错误
images.append(limit_image_size(image)) # 加入图片列表
model = get_ocr_model() # 获取 OCR 模型
page_texts = [] # 保存每页文本
scores = [] # 保存置信度
for image in images: # 遍历页面图片
result, _ = model(image) # 执行 OCR
lines = [line for line in (result or []) if len(line) >= 3] # 过滤合法行
page_texts.append("n".join(str(line[1]).strip() for line in lines if str(line[1]).strip())) # 合并页面文本
scores.extend(float(line[2]) for line in lines) # 汇总置信度
return {"text": "nn".join(page_texts).strip(), "pages": len(images), "avg_score": sum(scores) / len(scores) if scores else 0, "seconds": round(time.time() - started, 3)} # 返回识别结果</code>
再创建 RapidOCR 服务的 Dockerfile。
<code class="language-dockerfile"># 使用 Python slim 镜像,保持容器体积相对可控 FROM python:3.10-slim # 设置 Python 输出不缓冲,方便查看容器日志 ENV PYTHONUNBUFFERED=1 # 设置 Python 默认 UTF-8,避免中文日志乱码 ENV PYTHONUTF8=1 # 安装系统依赖、OCR 依赖和 Web API 依赖 RUN apt-get update && apt-get install -y --no-install-recommends curl libgomp1 libglib2.0-0 libgl1 libsm6 libxext6 libxrender1 poppler-utils fonts-noto-cjk && python -m pip install --no-cache-dir --upgrade pip && python -m pip install --no-cache-dir fastapi "uvicorn[standard]" python-multipart pillow numpy==1.26.4 opencv-python==4.6.0.66 opencv-contrib-python==4.6.0.66 pyclipper==1.3.0.post6 onnxruntime==1.23.2 rapidocr_onnxruntime==1.4.4 pymupdf && rm -rf /var/lib/apt/lists/* # 设置服务工作目录 WORKDIR /app # 复制 API 程序 COPY app/server.py /app/server.py # 暴露容器内部端口 EXPOSE 39090 # 启动单进程 API 服务,避免 CPU 小机器被多 worker 打满 CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "39090", "--workers", "1"]</code>
最后创建 docker-compose.yml。端口建议只绑定到 127.0.0.1,并让 Paperless 通过 Docker 内部网络访问。
<code class="language-yaml">name: rapidocr-api # 定义项目名称,方便 Docker Compose 管理
services: # 定义服务列表
rapidocr-api: # 定义 OCR API 服务
build: . # 使用当前目录构建镜像
image: local/rapidocr-api:cpu # 指定本地镜像名称
container_name: rapidocr-api # 指定容器名称
restart: unless-stopped # 容器异常退出后自动重启
cpus: "4.0" # 最多使用 4 个 CPU 核心
mem_limit: 6g # 最多使用 6GB 内存
shm_size: 1g # 设置共享内存,减少图像处理异常
environment: # 设置运行环境变量
RAPIDOCR_CPU_THREADS: "4" # 限制 OCR 推理线程数
RAPIDOCR_MAX_IMAGE_SIDE: "2400" # 限制图片最长边,避免超大图拖慢服务器
RAPIDOCR_MAX_PDF_PAGES: "12" # 限制 PDF 最大识别页数
RAPIDOCR_PDF_DPI: "180" # 设置 PDF 渲染 DPI
ports: # 设置端口映射
- "127.0.0.1:39090:39090" # 只允许宿主机本机访问 OCR API
volumes: # 设置数据卷
- ./models:/models # 保存模型缓存
- ./tmp:/tmp/rapidocr-api # 保存临时文件
networks: # 设置容器网络
- paperless-ocr-net # 加入 Paperless 共用 OCR 网络
networks: # 定义网络
paperless-ocr-net: # 使用外部共享网络
external: true # 表示网络由 docker network create 提前创建</code>
接入 Paperless-ngx
Paperless 的 webserver 服务需要加入同一个内部网络。下面只展示需要加入的片段。
<code class="language-yaml">services: # 定义服务列表
webserver: # Paperless Web 服务
networks: # 给 Web 服务增加网络
- default # 保留原来的默认网络,保证能访问数据库和 Redis
- paperless-ocr-net # 增加 OCR 内部网络,保证能访问 RapidOCR API
networks: # 定义网络
default: # 保留 Docker Compose 默认网络
paperless-ocr-net: # 定义 OCR 共享网络
external: true # 使用外部已创建网络</code>
然后在 Paperless 的环境文件里增加导入后脚本配置。
<code class="language-env">PAPERLESS_POST_CONSUME_SCRIPT=/usr/src/paperless/scripts/post_rapidocr_enhance.sh # 设置导入后执行的增强 OCR 脚本 PAPERLESS_RAPIDOCR_URL=http://rapidocr-api:39090/ocr # 设置 Paperless 容器访问 OCR API 的内部地址 PAPERLESS_RAPIDOCR_TIMEOUT=240 # 设置 OCR API 超时时间,避免大图一直卡住</code>
导入后脚本
创建 post_rapidocr_enhance.sh。它只默认处理图片格式,PDF 仍交给 Paperless 原生 OCR,避免纯 CPU 服务器压力过大。
<code class="language-bash">#!/usr/bin/env bash
# 使用严格模式,让脚本遇到错误立即停止
set -euo pipefail
# 读取 OCR API 地址,如果没有配置就使用默认服务名
OCR_URL="${PAPERLESS_RAPIDOCR_URL:-http://rapidocr-api:39090/ocr}"
# 读取 Paperless 传入的文档 ID
DOCUMENT_ID_VALUE="${DOCUMENT_ID:-}"
# 读取 Paperless 传入的原始文件路径
SOURCE_FILE="${DOCUMENT_SOURCE_PATH:-}"
# 读取 Paperless 传入的原始文件名
ORIGINAL_NAME="${DOCUMENT_ORIGINAL_FILENAME:-}"
# 把文件名转成小写,方便判断扩展名
LOWER_NAME="$(printf '%s' "${ORIGINAL_NAME:-$SOURCE_FILE}" | tr '[:upper:]' '[:lower:]')"
# 只处理常见图片格式,避免大 PDF 批量压垮 CPU
case "$LOWER_NAME" in *.jpg|*.jpeg|*.png|*.tif|*.tiff|*.webp) ;; *) echo "跳过非图片文档:$LOWER_NAME"; exit 0 ;; esac
# 如果文档 ID 为空,则无法写回数据库
if [ -z "$DOCUMENT_ID_VALUE" ]; then echo "缺少 DOCUMENT_ID,跳过增强"; exit 0; fi
# 如果源文件不存在,则无法上传给 OCR API
if [ ! -f "$SOURCE_FILE" ]; then echo "源文件不存在:$SOURCE_FILE"; exit 0; fi
# 创建临时文件保存 OCR API 的 JSON 响应
RESPONSE_FILE="$(mktemp --suffix=.rapidocr.json)"
# 脚本退出时清理临时响应文件
trap 'rm -f "$RESPONSE_FILE"' EXIT
# 调用 OCR API 获取增强识别文本
curl --silent --show-error --fail --max-time "${PAPERLESS_RAPIDOCR_TIMEOUT:-240}" -X POST -F "file=@${SOURCE_FILE}" "$OCR_URL" > "$RESPONSE_FILE"
# 调用 Python 脚本,把增强文本写回 Paperless
python3 /usr/src/paperless/scripts/apply_rapidocr_text.py "$DOCUMENT_ID_VALUE" "$RESPONSE_FILE"</code>
创建 apply_rapidocr_text.py。它会把 RapidOCR 文本写到 Paperless 原内容前面,并保留原生 OCR 文本,方便以后比较和回滚。
<code class="language-python">import json # 读取 OCR API 返回的 JSON 数据
import os # 设置 Django 环境变量
import sys # 读取命令行参数并调整模块路径
from pathlib import Path # 读取响应文件路径
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings") # 指定 Paperless 的 Django 配置模块
sys.path.insert(0, "/usr/src/paperless/src") # 加入 Paperless 源码路径
import django # 导入 Django 初始化模块
django.setup() # 初始化 Paperless 的 Django 环境
from documents.models import Document # 导入 Paperless 文档模型
from documents.search import get_backend # 导入 Paperless 搜索后端
MARKER_START = "--- RapidOCR 增强识别文本 ---" # 定义增强文本开始标记
MARKER_ORIGINAL = "--- Paperless 原始 OCR 文本 ---" # 定义原始文本开始标记
def strip_old_block(content: str) -> str: # 清理旧的增强块,避免重复追加
if MARKER_START not in content: # 如果没有旧增强块
return content.strip() # 返回原始内容
if MARKER_ORIGINAL not in content: # 如果旧内容结构不完整
return content.replace(MARKER_START, "").strip() # 尽量移除标记
return content.split(MARKER_ORIGINAL, 1)[1].strip() # 返回原始 OCR 文本
document_id = int(sys.argv[1]) # 读取文档 ID
response_path = Path(sys.argv[2]) # 读取 OCR 响应文件路径
payload = json.loads(response_path.read_text(encoding="utf-8")) # 读取 OCR JSON
rapid_text = str(payload.get("text") or "").strip() # 提取增强识别文本
if len(rapid_text) >= 4: # 如果识别文本有效
document = Document.objects.get(pk=document_id) # 查询 Paperless 文档
original_content = strip_old_block(document.content or "") # 获取原始 OCR 内容
document.content = f"{MARKER_START}n{rapid_text}nn{MARKER_ORIGINAL}n{original_content}".strip() # 合并增强文本和原文
document.save(update_fields=["content"]) # 保存文档内容字段
with get_backend().batch_update() as batch: # 打开搜索索引批量更新器
batch.add_or_update(document) # 更新 Paperless 搜索索引
print(f"RapidOCR 已增强文档:{document_id}") # 输出成功日志
else: # 如果识别文本太短
print(f"RapidOCR 文本过短,跳过文档:{document_id}") # 输出跳过日志</code>
启动与验证
<code class="language-bash"># 启动 RapidOCR API 服务 docker compose up -d # 检查 OCR API 健康状态 curl http://127.0.0.1:39090/health # 重启 Paperless,让网络和导入后脚本生效 docker compose up -d # 在 Paperless 容器内测试访问 OCR API docker exec paperless-ngx curl http://rapidocr-api:39090/health # 上传一张中文图片到 Paperless,观察日志是否出现增强成功 docker logs -f paperless-ngx</code>
常见问题
1. 为什么不用 PaddleOCR v3 或 PP-OCRv6?
如果服务器 CPU 较新,PaddleOCR v3 当然可以尝试。但在一些老 Xeon、老 NAS CPU 上,PaddlePaddle 预编译包可能触发 Illegal instruction。这不是模型文件坏了,而是二进制包用了 CPU 不支持的指令。RapidOCR 的 ONNXRuntime CPU 路线更容易在这类机器上跑稳。
2. 已经有本地模型文件,能不能直接复制?
可以,但要注意模型缓存路径和推理框架要对应。PaddleOCR/PaddleX 的缓存不一定能直接给 RapidOCR 使用;RapidOCR 会使用自己的 ONNX 模型缓存。建议把 models 目录持久化,首次下载完成后以后就不用重复下载。
3. 为什么只默认增强图片,不增强 PDF?
PDF 可能几十页甚至上百页,纯 CPU OCR 很容易把服务器打满。更稳的做法是先让 Paperless 原生 OCR 处理 PDF,只把手机拍照、扫描图片、证件照这类中文识别困难的图片交给 RapidOCR。
4. 怎么回滚?
<code class="language-env">PAPERLESS_POST_CONSUME_SCRIPT= # 清空导入后脚本配置即可停止增强 OCR</code>
<code class="language-bash"># 重启 Paperless,让环境变量变更生效 docker compose up -d # 停止 RapidOCR API 容器 docker compose down</code>
部署建议
- OCR API 端口只绑定
127.0.0.1,不要直接暴露公网。 - 用 Docker 内部网络连接 Paperless 和 OCR API,比反向代理更简单也更安全。
- 给 OCR 容器设置 CPU 和内存限制,避免导入大量图片时影响其他服务。
- 首次识别会加载模型,速度会慢一点;模型常驻后会明显变快。
- 处理真实证件、合同、票据时,公开截图前一定要脱敏。
- 迁移服务器时,备份 Paperless 数据目录、数据库目录、OCR 模型缓存目录和自定义脚本目录。
参考资料
- Paperless-ngx 配置文档:https://docs.paperless-ngx.com/configuration/
- Paperless-ngx 高级用法与 post-consume 脚本:https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/advanced_usage.md
- RapidOCR 项目主页:https://github.com/RapidAI/RapidOCR
- RapidOCR ONNXRuntime 使用说明:https://github.com/RapidAI/RapidOCRDocs/blob/main/docs/install_usage/rapidocr/how_to_use_infer_engine.md
总结一句:这套方案的价值不是“替代 Paperless”,而是给 Paperless 补上一层更懂中文的识别能力。原系统继续稳定归档,外挂 OCR 专注把中文文本识别得更准,组合起来就很适合个人知识库、家庭文档库和小团队资料库。
