❯ apps · Running

Du papier à l'archive indexée : un pipeline de scan recto-verso avec classification par LLM local From Paper to Searchable Archive: a Duplex Scanner Pipeline with Local LLM Classification

Construction d'un pipeline sans papier sur le NAS : un scanner eSCL qui alimente Paperless-ngx avec OCR en 3 langues, un algorithme d'entrelacement de pages écrit en TDD pour simuler le recto-verso sur un bac simplex, et un LLM local sur un PC séparé qui lit chaque document pour en déduire titre, expéditeur et tags — zéro cloud, zéro abonnement. Building a paperless pipeline on the NAS: an eSCL scanner feeding Paperless-ngx over OCR in 3 languages, a hand-written page-interleaving algorithm (built test-first) to fake duplex scanning on a simplex-only feeder, and a local LLM on a separate PC that reads each document and fills in title, sender and tags — no cloud, no subscription.

· 12 min de lecture 12 min read ·
paperless-ngxocrscanservjsollamallmdockertddapps
🇫🇷 Lire cet article en français

The household was producing paper faster than it could file it: invoices, statements, administrative letters, piling up in a drawer waiting for a hypothetical sorting session. The real problem was not the scanning itself — any Wi-Fi multifunction printer can digitise a page — it was everything that had to happen afterwards: naming the file, guessing the sender, filing it in the right place, remembering where. This post documents building a pipeline that makes that manual step disappear, with a piece of pure algorithmics in the middle that turned out to be more interesting than expected.

The simplex feeder problem

The starting point is a blunt hardware constraint. The household’s multifunction printer exposes its scanner over eSCL (the standard protocol most modern network printers use to be driven without a proprietary driver). Querying its capabilities quickly reveals the limit:

GET /eSCL/ScannerCapabilities
→ <scan:AdfSimplexInputCaps> present
→ <scan:AdfDuplexInputCaps> absent

The automatic document feeder (ADF) can only scan one side per pass. No hardware duplex. To archive a double-sided letter without retyping anything on a keyboard, only two options remain: scanning page by page on the flatbed (tedious from the second sheet onwards), or doing two passes through the ADF — first all the fronts in normal order, then flipping the entire stack and running all the backs — and reconstructing the logical order in software.

The second option is the only one that scales. But it turns a simple gesture into a genuine index puzzle: after the two passes you have two separate PDFs (front.pdf, back.pdf), and you have to work out which physical page corresponds to which sheet, and in which direction. It is that puzzle — not the scanning — that became the technical heart of this project.

Architecture

Everything runs in Docker Compose on the homelab NAS (x86 architecture, therefore outside the ARM64 Kubernetes cluster, and outside GitOps — the repo lives in a dedicated local git repository, not in the cluster’s infrastructure repo). The flow:

scanservjs drives the eSCL capture from a web interface (a bookmark on the phone or the PC is enough). For a simple single-sided scan, it drops the PDF straight into consume/, the folder watched by Paperless. For a duplex scan, two distinct actions write staging/front/front.pdf then staging/back/back.pdf; a small dedicated Python service watches that folder, waits until both files are present and stable (no more size growth for a few seconds — to avoid pairing a forgotten old front.pdf with a brand-new back.pdf), assembles the final PDF in logical order, and drops it in turn into consume/. From that point on, the duplex flow and the single-sided flow are strictly identical: Paperless does not even know a document went through two scans.

One infrastructure detail had a real impact on the implementation: the NAS already hosts about thirty containers (media, Nextcloud, CI…), so several of the usual ports were already taken. Port 8080, for instance, is occupied by the NAS’s BitTorrent client — scanservjs had to take 8081. Paperless got 8000, which was still free.

ServicePortRole
scanservjs8081scan trigger UI
Paperless webserver8000archive + search + API
Postgres, Redis, Tika, Gotenberginternalnot exposed outside the Docker network

Persistent data (document archive, Postgres database, consume/staging folders) lives on the NAS’s RAID1 volume rather than on its system SSD — a convention already in place for the other services hosted there (see part 1 on building the NAS).

The interleaving algorithm, written test-first

This is the part that deserved the most rigour, and the one where a test-first approach avoided a silent bug that would have been hard to spot after the fact — because it breaks nothing visibly, it just swaps pages around, something you only notice when re-reading a document months later.

The service rests on three pure functions, written and tested before a single line of surrounding business logic:

  • plan_order(n_front, n_back) — computes the final interleaved sequence, indicating for each position whether to take a front or a back, and which one.
  • reindex_back(n_back) — handles the central trap (detailed below).
  • is_blank(coverage, threshold) — decides whether a page should be dropped because it is empty.

The trap: the stack of backs is physically reversed

The naive intuition would be to say: “the first page of back.pdf is the back of the first sheet”. That is wrong, and it is the kind of mistake you only catch when re-reading a reassembled document and realising the pages are jumbled.

Here is why. You first scan all the fronts in order (sheet 1, 2, 3…). Then you flip the entire stack to run the backs through the ADF. Flipping a stack of sheets reverses their physical order: the sheet that was at the bottom ends up on top. As a result, back.pdf contains the backs in reverse order relative to the corresponding fronts — the first back scanned is the one belonging to the last sheet, not the first.

An explicit remapping between “sheet number” and “raw index in the back PDF” is therefore required. That is exactly the job of reindex_back:

def reindex_back(n_back: int) -> list[int]:
    """La pile 'back' est scannée après retournement -> ordre inversé."""
    return list(range(n_back - 1, -1, -1))

With 3 backs, reindex_back(3) returns [2, 1, 0]: the back of sheet 0 is at raw index 2 in back.pdf, the one for sheet 1 is at index 1, the one for sheet 2 is at index 0. plan_order builds the full logical sequence on top of it:

def plan_order(n_front: int, n_back: int) -> list[tuple[str, int]]:
    """Séquence entrelacée recto/verso.
    ("B", k) = verso de la feuille k (back ré-inversé). Fronts excédentaires en fin.
    """
    back_read = reindex_back(n_back)
    order: list[tuple[str, int]] = []
    for i in range(n_front):
        order.append(("F", i))
        if i < n_back:
            order.append(("B", back_read[i]))
    return order

The test that captures the trap directly — the case of a 3-sheet batch where the last one only has text on the front (2 backs only):

def test_reindex_back_reverses():
    assert reindex_back(3) == [2, 1, 0]
    assert reindex_back(0) == []

def test_plan_order_last_sheet_single_sided():
    # 3 fronts, 2 backs (dernière feuille recto seul)
    assert plan_order(3, 2) == [
        ("F", 0), ("B", 0),
        ("F", 1), ("B", 1),
        ("F", 2),
    ]

def test_plan_order_no_backs_is_simplex():
    assert plan_order(2, 0) == [("F", 0), ("F", 1)]

The second test verifies that an odd count (a front-only sheet at the end of the batch) does not break the interleaving — the orphan front is simply appended with no associated back. The third checks the degenerate case: zero backs, i.e. the plain single-sided flow, which must produce exactly the same sequence as a normal simplex scan. Having that case as an explicit test guarantees that the “front only” path and the “duplex” path share the same function without a fragile special branch.

Dropping blank pages

One practical problem remains: a batch with an odd number of sheets produces a final blank back page (nothing was printed on the back of the last sheet), which should not be archived. Detection works by pixel coverage: the page is rendered in greyscale at low resolution and the fraction of pixels that are not near-white is counted.

def is_blank(coverage: float, threshold: float = 0.004) -> bool:
    """coverage = fraction de pixels non-blancs. Sous le seuil -> page blanche."""
    return coverage < threshold

The threshold (0.4% coverage) was chosen to tolerate the normal background noise of a scan (dust, slight brightness variation) without letting through a page that contains so much as a line of text or a stamp. The final assembly function walks plan_order, resolves each ("B", i) through reindex_back to find the real raw index, computes the coverage of every candidate page and only adds it to the final PDF if it is not blank. The whole thing is validated by an end-to-end test that builds a real small 3-sheet double-sided PDF (6 numbered pages) and checks that the assembly does produce the order 1, 2, 3, 4, 5, 6.

The OCR → classification handover

Once the archive is being fed and OCR is in place (Tesseract, three configured languages — French, English, German, to cover local mail as well as professional documents and German manuals), every document becomes full-text searchable within minutes of being scanned. That is already half the value of the project: finding any document by typing a keyword, rather than by rummaging through a drawer.

But the real day-to-day time saving comes from elsewhere: no longer having to type the title, the sender or the type of each scanned document yourself. A second service reads the OCR text of every new document as soon as it is consumed and has it analysed by a language model, which returns a title, a correspondent, a document type and relevant tags — written back automatically into the Paperless metadata.

The “local first” choice

Sending invoices, bank statements or health documents to a cloud API to have them classified was the technically simplest option, and the first one ruled out. The language model runs instead on a PC separate from the NAS, reachable only from the local network — never on the NAS itself, whose low-power processor is not sized for heavy inference. A fallback to a cloud API was considered during design but deliberately deferred: for now, if the machine hosting the model is powered off, automatic classification simply waits for it to be turned back on rather than failing over somewhere else.

The trade-off is a deliberate one: a local model, smaller and sometimes less accurate than a state-of-the-art cloud API, against the guarantee that sensitive personal documents never leave the local network. The model used is a general-purpose model of about 12 billion parameters, quantised, served through Ollama — more than enough to extract a title, a sender and a handful of tags from already-clean OCR text, an extraction task rather than a creative generation one.

Iterating on classification: from “readable title” to “filing by sender”

Automatic classification did not reach its current state in one go — it was built in two distinct stages.

First stage. The initial goal was simply to get away from a filename like scan-20260615-143200.pdf and obtain a readable title, an identified correspondent and a few coherent tags. The service first ran in manual mode: every suggestion from the model went through a review before being written into Paperless, long enough to verify that the quality was there and to adjust the instructions given to the model (in particular to force answers in French). Once quality was judged reliable on a sample of real documents, classification was switched to automatic.

Second stage, more recent. Once the archive was well populated, a flaw showed up in daily use: documents from the same sender — a public administration, an insurer — ended up scattered by year across the filing, plus slightly different name variants from one document to the next (“Insurance X” on one letter, “INSURANCE X LTD” on another) that prevented any natural grouping. The fix added a canonical sender resolution layer: a rules table recognises the usual spellings of a given sender and makes them converge on a single name, with a fallback to the language model for senders not covered by the table, and a generic default value for unidentifiable cases. The final filing in the network storage accessible from the household’s devices now follows the Person / Sender / Year hierarchy, rather than Person / Year alone — all the mail from a given sender grouped together, with the year as a sub-level rather than the primary sort key.

What stays out of scope (on purpose)

Two areas identified at initial design time remain deliberately unimplemented. The first is automatic distribution: generating a summary note and a notification for every processed document, so you no longer have to open the archive to know what has just been scanned. The second is hardening: authentication on the web interfaces of the scanner and of the classification service, and explicitly integrating the document archive into the NAS’s existing backup strategy.

These are not holes discovered after the fact — they are phases explicitly deferred from the start, once the essential value (scan → OCR → filing) had been delivered and was working. The logical next step rather than hidden debt.

Transferable lesson

The easiest reflex when facing “I want my document archive to file itself” is to rush towards the biggest model available and delegate everything to it. This project ended up proving the opposite: the part that demanded the most technical rigour — the double-sided interleaving — has nothing to do with AI. It is a classic algorithmics problem, with a precise trap (the reversal of the flipped stack) that lends itself perfectly to tests written before the code. The language model only comes in at the very end, on already OCR’d and clean text, for a bounded extraction task — not to compensate for a sloppy architecture upstream.

The full pipeline, including eSCL capture and the migration of the NAS media stack, builds on the infrastructure laid down in part 2 of building the NAS.

paperless-ngxocrscanservjsollamallmdockertddapps