<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Automation on Matt Goodrich</title><link>https://mattgoodrich.com/categories/automation/</link><description>Recent content in Automation on Matt Goodrich</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Thu, 23 Jul 2026 00:01:00 -0700</lastBuildDate><atom:link href="https://mattgoodrich.com/categories/automation/index.xml" rel="self" type="application/rss+xml"/><item><title>Layering Local AI on Paperless-ngx: Let It Store, Not Think</title><link>https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/</link><pubDate>Thu, 23 Jul 2026 00:01:00 -0700</pubDate><guid>https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/</guid><description>&lt;img src="https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/header.png" alt="Featured image of post Layering Local AI on Paperless-ngx: Let It Store, Not Think" />&lt;p>I started this project to replace &lt;a class="link" href="https://github.com/paperless-ngx/paperless-ngx" target="_blank" rel="noopener"
>Paperless-ngx&lt;/a>. I ended up keeping it and building on top.&lt;/p>
&lt;p>The container kept crashing every few days. Auto-tagging missed anything it hadn&amp;rsquo;t seen before. There was no vision support for scanned PDFs. And it was mine and only mine: the family&amp;rsquo;s documents were locked in a web UI my wife was never going to open, and I never wanted her to have to. The plan was to migrate everything out, stand up something new, and shut Paperless down.&lt;/p>
&lt;p>That plan was wrong, and I&amp;rsquo;m glad I figured it out before I committed to the rebuild.&lt;/p>
&lt;h2 id="the-years-of-brittle-rules">The Years of Brittle Rules
&lt;/h2>&lt;p>None of this was the first attempt, and it is worth saying how the earlier ones failed, because that failure is why the pipeline reasons about a document instead of matching against it.&lt;/p>
&lt;p>For a long time I tried to file everything with deterministic rules. Hazel watching folders and renaming by pattern. Hard-coded filenames. Parsing file metadata to guess where a document came from. Dozens of scanner presets, each dropping to its own directory with its own processing rules, and piles of custom Python underneath all of it. Every rule worked for the documents I wrote it for and broke on the next slightly different one: a new biller, a scanner that named a file differently, a statement with a new layout, and I was writing another rule. Across thousands of documents that keep growing as I scan old paper and new mail arrives, it became unmanageable. There is no finite set of rules that covers the documents you haven&amp;rsquo;t seen yet.&lt;/p>
&lt;p>That is the whole case for classification over matching, and it is the same case against Paperless&amp;rsquo;s own classifier. A rule fits the documents you already had. A model reads the one in front of it.&lt;/p>
&lt;h2 id="the-pivot">The Pivot
&lt;/h2>&lt;p>What flipped my thinking: I started auditing what Paperless was actually doing well versus what was making me crazy. The two lists weren&amp;rsquo;t what I expected.&lt;/p>
&lt;p>The &amp;ldquo;doing well&amp;rdquo; list: OCR. Archive. Postgres-backed metadata. Search and indexing. The web UI for power users (me). The ability to ingest from a watched directory. Years of accumulated correspondent and document-type data on the documents I&amp;rsquo;d already filed by hand. None of those were the things I was complaining about. All of them were things I&amp;rsquo;d have to rebuild from scratch if I replaced it.&lt;/p>
&lt;p>The &amp;ldquo;making me crazy&amp;rdquo; list: confidence-aware classification. Correspondent-first priors. Vision models for scanned PDFs. A non-developer-friendly front door for the family. The ability to know &lt;em>when not to guess&lt;/em>.&lt;/p>
&lt;p>Read those side by side, and the conclusion is obvious in retrospect. Paperless was 80% of a good system. The 20% that was missing was specifically the part that needed AI. Replacing the whole thing meant rebuilding the 80% I already had to add the 20% I didn&amp;rsquo;t. Augmenting meant adding only the 20%.&lt;/p>
&lt;p>The container instability was real, but it was a contained operational problem: Watchtower restart policies, monitoring on actual silence rather than just exit codes, alerting when uptime drops. Solvable, not fundamental.&lt;/p>
&lt;p>So I kept Paperless and built around it.&lt;/p>
&lt;h2 id="the-architecture">The Architecture
&lt;/h2>&lt;p>Four pieces, each doing one job:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Google Drive Inbox&lt;/strong>: where my wife and I drop documents, plus a watcher on my email that pulls new attachments in automatically. The front doors.&lt;/li>
&lt;li>&lt;strong>Paperless-ngx&lt;/strong>: OCR, archive, metadata store, Postgres backend. The engine room.&lt;/li>
&lt;li>&lt;strong>Local AI classification layer&lt;/strong>: Ollama on the Mac Studio, with the patterns described below. The brain.&lt;/li>
&lt;li>&lt;strong>Google Drive organized output&lt;/strong>: a sync job that mirrors the filed structure back to Drive so the family can usually find what they need on their own. The lobby.&lt;/li>
&lt;/ol>
&lt;p>The flow: a document lands in the Drive Inbox, or arrives as an attachment my email watcher pulls in. A sync job lands it in Paperless&amp;rsquo;s consume directory. Paperless OCRs it. The AI layer classifies it using local models. The filing rules engine decides where it goes. rclone copies the OCR&amp;rsquo;d version to the right path under a separate Drive root.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/diagram-pipeline.png"
width="545"
height="759"
srcset="https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/diagram-pipeline_hu6780615735532799383.png 480w, https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/diagram-pipeline_hu17433464415500903212.png 1024w"
loading="lazy"
alt="The document pipeline end to end. Two front doors, a Google Drive inbox my wife and I drop documents into and an email watcher that pulls attachments, both feed Paperless-ngx, the engine room that handles OCR, PDF/A archiving, storage, and full-text search. Paperless hands off to a local AI brain that classifies type, correspondent, and date and writes it back through the REST API. A filing-rules engine builds the destination path, and the organized copy lands in a Google Drive folder the family looks in."
class="gallery-image"
data-flex-grow="71"
data-flex-basis="172px"
>&lt;/p>
&lt;p>My wife never sees Paperless. She sees a Drive folder structure. I get the Paperless web UI for the cases where I want to drill into a document directly. Both interfaces serve the same underlying data.&lt;/p>
&lt;h2 id="turn-off-paperlesss-own-classifier">Turn Off Paperless&amp;rsquo;s Own Classifier
&lt;/h2>&lt;p>Paperless-ngx has its own automatic classifier, and the first real decision was to stop using it. It is a scikit-learn MLP, a small neural network over one- and two-word token counts, trained on the metadata of documents you have already filed. It is bag-of-words: it can&amp;rsquo;t reason about a document, can&amp;rsquo;t classify a correspondent it has never seen, and only relearns on a schedule. It also has a cold-start tax. It gives you nothing until you have hand-labeled a few hundred documents, where a language model classifies the first document correctly with no training at all.&lt;/p>
&lt;p>So I took the fields I wanted the LLM to own away from it. In Paperless, every tag, correspondent, and document type has a matching algorithm; set it to &lt;strong>None&lt;/strong> and the built-in classifier stops touching that object, and it gets applied only by whatever I point at the API. I set every correspondent and the tags that matter to None. The all-the-way version is &lt;code>PAPERLESS_TRAIN_TASK_CRON=disable&lt;/code>, which stops the classifier from training at all so the MLP and the LLM aren&amp;rsquo;t fighting over the same fields.&lt;/p>
&lt;p>The division of labor that falls out of this is the whole architecture in one sentence. Paperless owns ingestion, OCR, the PDF/A archive, storage, and full-text search. My code owns classification and writes its answers back through the REST API. Paperless is the engine room. The thinking happens outside it.&lt;/p>
&lt;h2 id="the-local-models">The Local Models
&lt;/h2>&lt;p>The classification layer runs on a Mac Studio M3 Ultra with 96GB of unified memory, models served by Ollama and picked by a small model-selection router. The pattern is compare-two-local, escalate-to-cloud:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qwen3 30B-A3B (MoE, 3.3B active)&lt;/strong> does the fast first pass, pulling type, correspondent, and date out of the OCR text. Local, roughly $0 per document.&lt;/li>
&lt;li>&lt;strong>GPT-OSS 120B&lt;/strong> is the heavier local model. It runs as the second opinion the fast pass gets compared against, still on the Mac Studio, still free.&lt;/li>
&lt;li>A &lt;strong>paid cloud model&lt;/strong>, Gemini Flash, is the tie-breaker, pulled in only when the two local models disagree or confidence is low. Even at thousands of documents a month the cloud bill stays under $5.&lt;/li>
&lt;/ul>
&lt;p>The combination matters more than any single model. Two local passes catch most of the disagreement for free, and the cloud is a quality lever I pull only when local can&amp;rsquo;t decide. Nothing routes to a paid model by default.&lt;/p>
&lt;h2 id="the-patterns">The Patterns
&lt;/h2>&lt;p>The classification layer is a stack of patterns, each addressing a specific failure mode in document classification.&lt;/p>
&lt;h3 id="correspondent-first-classification">Correspondent-First Classification
&lt;/h3>&lt;p>The first thing I built was a prior probability table. I queried the whole history of documents I&amp;rsquo;d already filed in Paperless and built a knowledge base: for each correspondent, what document types do I actually have?&lt;/p>
&lt;pre tabindex="0">&lt;code>Anthem Blue Cross:
- Explanation of Benefits: 70%
- Insurance Policy: 23%
- Statement: 7%
King County:
- Property Tax: 91%
- Notice: 9%
Chase:
- Statement: 85%
- Credit Card Agreement: 10%
- Notice: 5%
&lt;/code>&lt;/pre>&lt;p>This changes the classification problem fundamentally. Instead of asking &amp;ldquo;what is this document?&amp;rdquo;, the model asks &amp;ldquo;given that this is from Anthem, what is it most likely to be?&amp;rdquo; You&amp;rsquo;re not starting from zero, you&amp;rsquo;re starting from a 70% prior.&lt;/p>
&lt;p>The LLM still reads the document, but now it&amp;rsquo;s confirming or overriding a strong expectation rather than guessing cold. Accuracy jumped substantially when this was added. The knowledge base is up to 191 correspondents now, all with real probability distributions mined from documents I&amp;rsquo;d already filed by hand over the years.&lt;/p>
&lt;h3 id="three-tier-confidence-routing">Three-Tier Confidence Routing
&lt;/h3>&lt;p>Not everything should be auto-filed. This was one of the most important design decisions: build a system that knows &lt;em>when not to guess&lt;/em>.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tier 1: Auto-file.&lt;/strong> High confidence + known correspondent + unambiguous filing rule. The system is sure. File it and move on.&lt;/li>
&lt;li>&lt;strong>Tier 2: Compare and escalate.&lt;/strong> Medium confidence, or a known correspondent with an ambiguous document type. The system runs both local models, and if they disagree it escalates to the cloud tie-breaker rather than trusting one answer.&lt;/li>
&lt;li>&lt;strong>Tier 3: Human review.&lt;/strong> Low confidence, unknown correspondent, or a document type the system hasn&amp;rsquo;t seen before. Goes to Discord for me to review.&lt;/li>
&lt;/ul>
&lt;p>The numbers say it is working, and they have moved. Over the last seven days the pipeline classified 5,019 documents and sent 90 of them to human review: under 2%, at an average confidence of 94%. Over the system&amp;rsquo;s whole life the review rate has run closer to 10%, so the fraction I have to touch has dropped about 5x as the correspondent knowledge base filled in. The review pile is the number I actually watch, because it is the count of documents still on me, and watching it shrink is watching the uncertainty calibration come right.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/diagram-classification.png"
width="481"
height="1530"
srcset="https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/diagram-classification_hu17464828379719133426.png 480w, https://mattgoodrich.com/posts/local-ai-on-paperless-document-pipeline/diagram-classification_hu8423643752891581478.png 1024w"
loading="lazy"
alt="The classification decision. A documents OCR text goes to a fast local model, Qwen3 30B; if it is confident and the correspondent is known, the document auto-files, which is about 98% of them. If it is unsure, a second local model, GPT-OSS 120B, runs and the two are compared: agreement auto-files, disagreement escalates to a paid cloud model, Gemini Flash, to break the tie. If it is still low confidence or the correspondent is new, the document goes to human review, about 2%, sent to Discord."
class="gallery-image"
data-flex-grow="31"
data-flex-basis="75px"
>&lt;/p>
&lt;h3 id="compare-two-local-models-escalate-to-cloud">Compare Two Local Models, Escalate to Cloud
&lt;/h3>&lt;p>For anything the fast pass is unsure about, the system does not trust a single model. It runs the document through both local models and compares their answers. Agreement is the signal. Disagreement escalates to the paid cloud model as the tie-breaker, with both local answers in front of it to choose between.&lt;/p>
&lt;p>The trick is that it is often easier for a model to pick the better of two answers than to generate the right one cold, and disagreement between two local models is a cheap, reliable flag for the documents worth a cloud call. Most documents never trigger it.&lt;/p>
&lt;h3 id="human-in-the-loop-with-learning">Human-in-the-Loop with Learning
&lt;/h3>&lt;p>The Discord interface for Tier 3 does two things. It clears the review queue, and every decision I make there trains the system.&lt;/p>
&lt;p>When a document lands in Tier 3, I get a Discord message with the document preview, the proposed classification, and three buttons: &lt;strong>Apply&lt;/strong>, &lt;strong>Reject&lt;/strong>, or &lt;strong>Reclassify&lt;/strong>. If I click Apply, two things happen: the document gets filed, and the correspondent knowledge base updates with this new data point. The next time a similar document comes through, the system has a better prior.&lt;/p>
&lt;p>Over time, the review queue shrinks because the system gets smarter. That&amp;rsquo;s the design working as intended.&lt;/p>
&lt;h3 id="filing-rules-engine">Filing Rules Engine
&lt;/h3>&lt;p>Files don&amp;rsquo;t go into a flat bucket. They go into structured paths:&lt;/p>
&lt;pre tabindex="0">&lt;code>Financial/Chase/Checking/2026/04/
Medical/Insurance/Anthem/EOBs/2026/
Aviation/Pacific NW Aviation/Invoices/2026/
Legal/Estate Planning/
&lt;/code>&lt;/pre>&lt;p>The rules engine is a template system: a list of dicts, each with conditions and a path template. Adding a new rule is appending a dict. Templates support variables like &lt;code>{correspondent}&lt;/code>, &lt;code>{account}&lt;/code>, &lt;code>{year}&lt;/code>, &lt;code>{month}&lt;/code>. About 27 rules cover the active document types: financial, aviation, medical, property, legal, household.&lt;/p>
&lt;h3 id="custom-attributes-the-rest-of-my-systems-read">Custom Attributes the Rest of My Systems Read
&lt;/h3>&lt;p>Type, correspondent, and date are the obvious fields. The more useful ones are the custom attributes the model pulls straight out of the document: the account a statement belongs to, the provider on a medical bill, whether a letter is a diagnosis, a lab result, or a visit summary. Models are unreasonably good at this. Hand one a page of unstructured text and it will reliably extract the structured fields I ask for, which is exactly the job the old regex rules kept failing at.&lt;/p>
&lt;p>Those attributes do two jobs. They shape the storage path, so a document lands under the right account or provider without a rule written for each case. And they feed the other systems I run beyond Paperless: a document the model tagged as a lab result or a diagnosis carries enough structure that a downstream service can decide, on its own, whether it belongs in a running medical history. Paperless stores the document. The attributes are what let something else act on it.&lt;/p>
&lt;h2 id="the-guardrails">The Guardrails
&lt;/h2>&lt;p>The patterns above are what makes the system &lt;em>work&lt;/em>. The guardrails below are what keep it from doing damage when it doesn&amp;rsquo;t.&lt;/p>
&lt;h3 id="never-guess-a-wrong-folder">Never Guess a Wrong Folder
&lt;/h3>&lt;p>When nothing matches, the fallback rule files to &lt;code>Review/Unsorted/{year}/{month}/&lt;/code>. Not a best-guess folder. The unsorted folder.&lt;/p>
&lt;p>Better to have an &amp;ldquo;I don&amp;rsquo;t know&amp;rdquo; pile than a wrong folder that hides the document forever. Confidence is queryable; misfiling is not.&lt;/p>
&lt;h3 id="confidence-thresholds-are-tuned-not-magic">Confidence Thresholds Are Tuned, Not Magic
&lt;/h3>&lt;p>The thresholds for tier routing came from running real data and seeing where errors clustered. They get retuned as the correspondent knowledge base grows. There&amp;rsquo;s no magic number, only &amp;ldquo;what does the data say is the right cutoff this quarter?&amp;rdquo;&lt;/p>
&lt;h3 id="the-judge-is-bounded">The Judge Is Bounded
&lt;/h3>&lt;p>The pairwise judge can override the classifier, but only between two presented options. It cannot invent a third option. This prevents the failure mode where a &amp;ldquo;smarter&amp;rdquo; model goes off-script and decides the document is something neither tier-1 model considered.&lt;/p>
&lt;h3 id="dual-hash-lineage">Dual-Hash Lineage
&lt;/h3>&lt;p>This one was painful to figure out. Paperless modifies files when it OCRs them: it embeds a text layer in the PDF. The bytes you put in are &lt;em>not&lt;/em> the bytes Paperless stores.&lt;/p>
&lt;p>We track three hashes per document: &lt;code>source_hash&lt;/code> (the original file before Paperless), &lt;code>archive_checksum&lt;/code> (the post-OCR file Paperless stores), and &lt;code>stored_hash&lt;/code> (what rclone sees after the sync). When matching pipeline records against Paperless data, you have to match on &lt;code>archive_checksum&lt;/code>, not &lt;code>original_checksum&lt;/code>. Switching to the right field took the dedup match rate from 0% to 82% in a single afternoon. I lost most of a day to debugging this before the right field clicked.&lt;/p>
&lt;p>The source hash also does something I lean on constantly. When I turn up a file in iCloud or an old backup and can&amp;rsquo;t remember whether I already filed it, I hash it and ask the system, and it answers yes or no without my opening a single document. As I claw fifteen years of scattered documents back into one place, that programmatic &amp;ldquo;do I already have this?&amp;rdquo; is what keeps the migration from becoming its own mess.&lt;/p>
&lt;h3 id="rclone-copy-not-rclone-sync">&lt;code>rclone copy&lt;/code>, Not &lt;code>rclone sync&lt;/code>
&lt;/h3>&lt;p>&lt;code>rclone sync&lt;/code> makes the destination match the source, including deletes. We had pipeline-uploaded files vanish from Drive because rclone ran 15 minutes later, didn&amp;rsquo;t see them in the Paperless source tree, and deleted them as &amp;ldquo;extra files.&amp;rdquo;&lt;/p>
&lt;p>The rule we landed on: &lt;code>rclone copy&lt;/code> for pipeline output, &lt;code>rclone sync&lt;/code> only for the Paperless-mirrored content. Two hours of debugging, then a one-line config change. Always.&lt;/p>
&lt;h2 id="google-drive-inbox-and-organized-output">Google Drive: Inbox and Organized Output
&lt;/h2>&lt;p>The Drive Inbox is a single folder. Drop a PDF into it, scan a doc with the iPhone, save an EOB from a portal. A sync job pulls from Drive Inbox into Paperless&amp;rsquo;s consume directory and the rest of the pipeline takes over.&lt;/p>
&lt;p>The organized output is a separate Drive root that mirrors the filed structure. &lt;code>Financial/Chase/Checking/2026/04/&lt;/code>, &lt;code>Medical/Insurance/Anthem/EOBs/2026/&lt;/code>, and so on. After the filing rules pick a destination, an rclone job copies the OCR&amp;rsquo;d version of the document to that path.&lt;/p>
&lt;p>This is where my wife looks, and keeping her here was the whole point. The structure is honestly organized more for me than for her, but the patterns are consistent enough that she can usually infer where a document landed. Finding things had always been the hard part, and for years we bounced between Google Drive and iCloud, paying for storage and still never sure where anything was. Google Drive is the easiest for us, so that is where documents live now, and the goal was to keep her right there. I never mentioned Paperless to her, because from where she works nothing changed: the pipeline just made the Drive folders organize themselves. It looks like the structure a thoughtful human would build by hand, except it stays consistent because no human is touching it.&lt;/p>
&lt;h2 id="what-this-doesnt-solve">What This Doesn&amp;rsquo;t Solve
&lt;/h2>&lt;p>iCloud cleanup is a separate project. There are thousands of files in iCloud Documents that aren&amp;rsquo;t all documents: screenshots, downloads, junk that accumulated over 15 years. That needs a pre-filter pass before classification can do anything useful.&lt;/p>
&lt;p>The Discord interface works, but a real web UI for managing 191 correspondents and 27 filing rules would be better. That&amp;rsquo;s an investment I haven&amp;rsquo;t made yet because the current setup isn&amp;rsquo;t actively painful.&lt;/p>
&lt;h2 id="paperless-is-growing-its-own-ai">Paperless Is Growing Its Own AI
&lt;/h2>&lt;p>Worth being honest about: the thing I built is partly being absorbed into the tool. Recent Paperless-ngx ships native LLM features. Point it at a local Ollama model and it will suggest a title, correspondent, and tags for a human to accept. That is real, and for a lot of people it will be enough.&lt;/p>
&lt;p>It is not enough for mine, for two specific reasons. It suggests; it does not file. There is a person in the loop by design, which is the opposite of what I want for the 98% of documents that are unambiguous. And it does not do the parts that make the pipeline useful to my family: the correspondent-first priors, the routing that decides when &lt;em>not&lt;/em> to guess, and the filing all the way out to a Drive folder my wife actually opens. The native feature is a much better default than the old MLP. It is not yet a pipeline that files unattended. The day it can file end to end with calibrated confidence, I will happily delete code.&lt;/p>
&lt;h2 id="why-this-matters">Why This Matters
&lt;/h2>&lt;p>The bigger lesson, the one that generalized for me: augmenting open-source tools with AI is a more sustainable pattern than replacing them.&lt;/p>
&lt;p>Paperless will keep getting better as a project. The local model layer will keep getting better as the open-weight ecosystem matures. The two improvements compose. If I&amp;rsquo;d replaced Paperless, I&amp;rsquo;d have inherited the obligation to maintain everything Paperless was doing, and the underlying tool would have kept getting better in directions I&amp;rsquo;d never benefit from.&lt;/p>
&lt;p>The same logic applies in a lot of places. Find the joint where the existing tool stops being good. Augment exactly there. Don&amp;rsquo;t demolish the parts that work.&lt;/p></description></item></channel></rss>