<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Monjurul Karim</title>
    <description>The latest articles on DEV Community by Muhammad Monjurul Karim (@mmkarim_).</description>
    <link>https://dev.to/mmkarim_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3540142%2Fae6ad94b-d206-4c57-94c8-df351f34e7e0.jpg</url>
      <title>DEV Community: Muhammad Monjurul Karim</title>
      <link>https://dev.to/mmkarim_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mmkarim_"/>
    <language>en</language>
    <item>
      <title>Redacting PII 100% Offline: A Technical Deep Dive</title>
      <dc:creator>Muhammad Monjurul Karim</dc:creator>
      <pubDate>Mon, 10 Aug 2026 16:04:49 +0000</pubDate>
      <link>https://dev.to/mmkarim_/redacting-pii-100-offline-a-technical-deep-dive-346a</link>
      <guid>https://dev.to/mmkarim_/redacting-pii-100-offline-a-technical-deep-dive-346a</guid>
      <description>&lt;p&gt;Cross-posted from Medium. Originally published at &lt;a href="https://medium.com/data-science-collective/redacting-pii-100-offline-a-technical-deep-dive-a326f9fed3b8" rel="noopener noreferrer"&gt;https://medium.com/data-science-collective/redacting-pii-100-offline-a-technical-deep-dive-a326f9fed3b8&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you handle contracts, medical records, legal discovery, or even just research data, redacting PII is not optional. HIPAA, GDPR, PCI, FOIA — they all want it gone. And yet the tools we reach for are stuck in two failure modes.&lt;/p&gt;

&lt;p&gt;The first is the cloud route: you upload a document full of the exact personal data you're trying to protect, and a server sends back a "redacted" copy. That's a privacy paradox — you solve the privacy problem by first exposing the document. For anything sensitive, that's a non-starter.&lt;/p&gt;

&lt;p&gt;The second is the manual route: draw black rectangles over every SSN by hand. Slow, error-prone, and because most viewers black the &lt;em&gt;rendering&lt;/em&gt; rather than the &lt;em&gt;text&lt;/em&gt;, the layer underneath can often be selected or deleted to reveal what was "redacted." Or the tool auto-blacks whole lines and you get a wall of censor bars.&lt;/p&gt;

&lt;p&gt;Neither is good enough. I wanted accurate, automatic, value-only redaction that runs on my own machine. So I built one. (If you want the tool and not the story, it's here: &lt;a href="https://github.com/monjurulkarim/privateredact" rel="noopener noreferrer"&gt;github.com/monjurulkarim/privateredact&lt;/a&gt;.) This post walks through how it works — extraction, hybrid regex + local-LLM detection, the part nobody gets right (drawing the box), and proving the output is actually clean.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/XtSbOfy0b18"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The "why"
&lt;/h2&gt;

&lt;p&gt;The goal wasn't just to scribble black over a PDF. It was to build something I'd trust with a real medical record or contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read anything&lt;/strong&gt; — text PDFs, scanned/image PDFs (OCR), DOCX, TXT, images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find PII semantically&lt;/strong&gt;, not just by format. Regex catches a phone number. It does not catch "patient presented with Type 2 diabetes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redact the value, not the label&lt;/strong&gt; — &lt;code&gt;555-1234&lt;/code&gt;, not &lt;code&gt;Phone: 555-1234&lt;/code&gt;. Never the whole line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve the document&lt;/strong&gt; — DOCX formatting intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove it worked&lt;/strong&gt; — a post-export scan that re-OCRs the output and reports what survived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100% local&lt;/strong&gt; — no file, hash, or byte leaves the machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Everything below runs on the user's machine. No backend.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Electron&lt;/strong&gt; — bundles the renderer, the PDF/OCR engines, and the LLM into one installable app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf.js (Mozilla)&lt;/strong&gt; — reads born-digital PDFs, and crucially exposes each text item's bounding box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tesseract (v6)&lt;/strong&gt; — OCR for scanned PDFs and images; v6 returns word-level boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama&lt;/strong&gt; — local LLM runtime for semantic detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf-lib (encrypted-aware fork)&lt;/strong&gt; — output assembly + password-protected I/O.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sharp + node-canvas&lt;/strong&gt; — rasterizing pages and painting redaction boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;jszip&lt;/strong&gt; — DOCX files are zip-of-XML; this lets you edit OOXML in place.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Extraction: born-digital vs. scanned
&lt;/h2&gt;

&lt;p&gt;A PDF isn't a PDF. Some have a real text layer; others are wrapped images of scanned paper. Redacting text you never read is how leaks happen — so you fork on whether the page actually has a text layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Conceptual — two extraction paths
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_text_layer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# born-digital: pdf.js gives each text item + its bounding box
&lt;/span&gt;    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;box&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# scanned / image: OCR it, and we need WORD-level boxes, not just text
&lt;/span&gt;    &lt;span class="n"&gt;ocr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render_to_image&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;box&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ocr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Non-obvious bit: when you OCR, you must ask for word-level boxes (Tesseract's &lt;code&gt;{ text: true, blocks: true }&lt;/code&gt;). Otherwise the engine hands you whole paragraphs as one box, and you're back to blacking entire lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection: regex first, LLM second
&lt;/h2&gt;

&lt;p&gt;Two layers, because neither is sufficient alone.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;regex layer&lt;/strong&gt; is fast and precise for well-formatted PII. The trick is value-only matching — you don't want to black &lt;code&gt;SSN:&lt;/code&gt;, you want to black the number after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Redact the VALUE (capture group 2), not the label (group 1)
&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(SSN|Social Security)[:\s]+([0-9X\-]{9,})&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value_end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the number's offsets only
&lt;/span&gt;    &lt;span class="n"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;offsets_to_boxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real gotcha: ID patterns need a digit lookahead. Without one, they false-positive on prose — "passport control", "case study", "member number two."&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;LLM layer&lt;/strong&gt; catches what patterns structurally cannot: a diagnosis, a codename, an address buried in a sentence. The page text is chunked and sent to the local Ollama model with a strict JSON schema, and it returns &lt;code&gt;{ type, text, start, end, confidence }&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;detections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;regex_scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# fast, known formats
&lt;/span&gt;&lt;span class="n"&gt;detections&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;llm_scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# context-only PII
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;detections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# tolerant of case/whitespace
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard part isn't calling the model — it's trusting its output enough to draw a box. You have to actually find that string in the page text and map it to a real bounding box; if the match rate is low, you drop the detection rather than risk a misplaced box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody gets right: drawing the box
&lt;/h2&gt;

&lt;p&gt;This is where most redaction tools fall apart.&lt;/p&gt;

&lt;p&gt;pdf.js doesn't return one box per word. It returns &lt;em&gt;runs&lt;/em&gt; of text, and a single run can span an entire line — sometimes 80% of the page width. If you naively black the run's box for any entity inside it, you black the label, the surrounding words, everything.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;proportional sub-boxes&lt;/strong&gt;. Inside a run, you know the character offset where the entity starts and ends. Assuming roughly uniform char width within that run, black only that horizontal slice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Don't black the whole run. Black the SLICE the entity occupies.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;proportional_box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ent_start_char&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ent_end_char&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_len&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x0&lt;/span&gt;
    &lt;span class="n"&gt;pad&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;run_len&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# a little breathing room
&lt;/span&gt;    &lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ent_start_char&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;run_len&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pad&lt;/span&gt;
    &lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ent_end_char&lt;/span&gt;   &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;run_len&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pad&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;Phone: ████-████&lt;/code&gt; redacts just the bold part. The label survives. The document stays readable.&lt;/p&gt;

&lt;p&gt;Two more rules, learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One union function, used twice.&lt;/strong&gt; The boxes shown in the review overlay must be exactly the boxes painted on export. If they differ, what the user approves is not what they get. Both paths call the same routine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reject pathological boxes.&lt;/strong&gt; If a computed box covers 95%+ of the page, something went wrong. Reject that detection outright rather than black the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Export and proof
&lt;/h2&gt;

&lt;p&gt;For PDF, each page is rasterized, the unioned boxes are painted solid black, and the result is embedded into a fresh PDF. Rasterized output is the point: no recoverable text layer. You cannot select-and-delete the black box to reveal the original.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Rasterize -&amp;gt; paint -&amp;gt; embed. No text layer survives.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;raster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paint_black&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unioned_boxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;output_pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raster&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DOCX is trickier — you want to keep the formatting. A DOCX is a zip of XML, so redaction is in-place surgery on the XML text nodes: find the runs, replace the PII characters inside them, leave every other node untouched.&lt;/p&gt;

&lt;p&gt;The last stage is the one I care most about: the &lt;strong&gt;leak scan&lt;/strong&gt;. After the file is written, the output is OCR'd again and re-scanned. It runs on the exact bytes written, and it never claims "clean" if the scan was skipped or failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;output_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ocr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exported_pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;remaining&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scan_for_pii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scan_failed_or_skipped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could not verify — do not ship&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# exactly what survived
&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 PII remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"0 PII remaining" isn't a button label — it's the result of actually re-reading the redacted file and checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend: approve what you export
&lt;/h2&gt;

&lt;p&gt;A backend this autonomous is also dangerous — you don't hand a model the keys to black out a contract unsupervised. The frontend is a review surface: a React overlay on the page image showing every proposed redaction as a box, with a toggle between image and raw text. Remove a box the model got wrong, or draw one it missed. Because the overlay uses the same union-boxes logic as the exporter, what you see is byte-for-byte what gets painted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want to try it yourself?
&lt;/h2&gt;

&lt;p&gt;I packaged the whole thing — extraction, hybrid detection, the review UI, the leak-scan proof — into a desktop app. It runs entirely on your machine: no account, no upload, nothing leaving your disk.&lt;/p&gt;

&lt;p&gt;The fastest way to see it work on your own documents is the free build on GitHub. You can load, detect, and review a real file end to end — exporting the redacted output is the only thing behind a license.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://github.com/monjurulkarim/privateredact" rel="noopener noreferrer"&gt;github.com/monjurulkarim/privateredact&lt;/a&gt;&lt;br&gt;
→ &lt;strong&gt;Or grab a license to export:&lt;/strong&gt; &lt;a href="https://monjurulkarim.gumroad.com/l/wqnyp" rel="noopener noreferrer"&gt;in this link&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Over to you:&lt;/strong&gt; what's the hardest PII you've had to redact from a document — and did the tool you used actually get it right? I'm genuinely curious what detection gaps people hit in practice. Drop it in the comments 👇&lt;/p&gt;

</description>
      <category>ai</category>
      <category>privacy</category>
      <category>security</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Built a Private, Multi-User “Chat with Your Documents” App That Runs 100% Offline</title>
      <dc:creator>Muhammad Monjurul Karim</dc:creator>
      <pubDate>Tue, 30 Sep 2025 18:33:31 +0000</pubDate>
      <link>https://dev.to/mmkarim_/how-i-built-a-private-multi-user-chat-with-your-documents-app-that-runs-100-offline-59ic</link>
      <guid>https://dev.to/mmkarim_/how-i-built-a-private-multi-user-chat-with-your-documents-app-that-runs-100-offline-59ic</guid>
      <description>&lt;h4&gt;
  
  
  A quick note: This article was originally published on Medium. You can read it there &lt;a href="https://medium.com/@raju.monjurulkarim/how-i-built-a-private-multi-user-chat-with-your-documents-app-that-runs-100-offline-713e10da573a" rel="noopener noreferrer"&gt;by clicking here&lt;/a&gt;.
&lt;/h4&gt;

&lt;p&gt;Last month, I wrote an article on &lt;a href="https://medium.com/data-science-collective/i-built-an-self-hosted-ai-meeting-note-taker-that-runs-100-offline-heres-how-you-can-too-d110b7ef0b95" rel="noopener noreferrer"&gt;how I built a self-hosted AI Meeting Note Taker&lt;/a&gt;. The response was fantastic and showed a clear demand for private, offline AI tools that put users back in control of their data.&lt;/p&gt;

&lt;p&gt;Whether you’re a small business uploading sensitive client data, a developer working on proprietary code, or just someone who wants to organize personal files without sending them to a third-party server, the problem is the same. You’re forced to choose between powerful AI tools and data privacy.&lt;/p&gt;

&lt;p&gt;Building on that momentum, I decided to create the solution I wanted to see: a full-featured, multi-user “Chat with Your Documents” application that runs entirely on your own hardware. This is the second part of my self-hosted AI series — a technical deep-dive into a production-ready RAG application for everyone.&lt;/p&gt;

&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/AsCBroOevGA"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mission: A Private AI for Everyone
&lt;/h3&gt;

&lt;p&gt;The core technical goals for this project were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;100% Local Processing:&lt;/strong&gt; All data, from your company’s strategy documents to your personal financial records, is processed locally. Nothing ever leaves your server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team &amp;amp; Family Ready:&lt;/strong&gt; The system is designed for multiple users, with isolated knowledge bases and a central admin dashboard to manage everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Efficient &amp;amp; Smart:&lt;/strong&gt; The RAG pipeline intelligently syncs only new or modified documents, saving time and computational resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incredibly Simple to Deploy:&lt;/strong&gt; The entire application is packaged into a single executable with easy-to-use installer scripts. No complex setup required.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Tech Stack: The Powerhouses
&lt;/h3&gt;

&lt;p&gt;This application is built on a foundation of powerful open-source tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Flask serves as the lightweight web server, with Gunicorn for threaded performance to handle concurrent users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI &amp;amp; Embeddings:&lt;/strong&gt; Ollama runs open-source LLMs (like Llama 3, Gemma) and embedding models locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector Store:&lt;/strong&gt; ChromaDB provides a persistent, on-disk vector database for storing document embeddings efficiently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAG Orchestration:&lt;/strong&gt; LangChain glues the components together, managing the flow from document loading to question-answering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User Management:&lt;/strong&gt; A custom authentication layer using SQLite for persistence and PyJWT for secure sessions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Hardware Requirements &amp;amp; Performance
&lt;/h4&gt;

&lt;p&gt;A common question with self-hosted AI is about the hardware required. The application is designed to be flexible, and performance will scale with your machine’s capabilities. The main requirement is sufficient RAM to load the language models.&lt;/p&gt;

&lt;p&gt;To give you a real-world idea, here are a few setups I’ve tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good (Accessible Start):&lt;/strong&gt; For users with more modest hardware (e.g., a laptop with 8–16GB of RAM), the application runs well with a smaller, efficient model like &lt;code&gt;gemma3:4b&lt;/code&gt;. The results are surprisingly good for most document Q&amp;amp;A tasks, making this a great starting point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better (Smooth Personal Use):&lt;/strong&gt; On my MacBook with 24GB of RAM, it handles a 12-billion parameter model (eg.&lt;code&gt;gemma3:12b&lt;/code&gt;) very smoothly for all my personal documents and projects. This offers a noticeable boost in the quality of the AI’s responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best (Team Performance):&lt;/strong&gt; At my office, we have it on a server with an NVIDIA A6000 GPU. Our whole team of 6 uses it with the much larger &lt;code&gt;gemma3:27b&lt;/code&gt; model without any issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This demonstrates that the application can effectively scale from a standard laptop for individual use to a dedicated server for team collaboration, depending on your needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deep Dive: The System Architecture
&lt;/h3&gt;

&lt;p&gt;The application is broken down into three main Python components: the web server (&lt;code&gt;main_app.py&lt;/code&gt;), the authentication system (&lt;code&gt;auth.py&lt;/code&gt;), and the RAG core (&lt;code&gt;local_rag_chroma.py&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvuxtr3n2iocy5txa2a5r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvuxtr3n2iocy5txa2a5r.png" alt="Secure login and user admin dashboard"&gt;&lt;/a&gt;The application includes a secure login portal and a dashboard for user administration. These features allow for multi-user support, making the app suitable for shared use by a project team, a small business, or a family, with each user having their own secure access. (image by author)  &lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  1. The RAG Core: &lt;code&gt;local_rag_chroma.py&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;This is the heart of the application. I designed a KnowledgeBaseManager class to handle the entire lifecycle of document processing and retrieval.&lt;/p&gt;

&lt;h5&gt;
  
  
  Intelligent Document Synchronization
&lt;/h5&gt;

&lt;p&gt;To avoid redundant processing, I implemented a synchronization function that compares the state of files on disk with the metadata stored in ChromaDB.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;_synchronize_documents&lt;/code&gt; method works in a few steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It scans the document directories and creates a dictionary of all current files and their last-modified timestamps.&lt;/li&gt;
&lt;li&gt;It queries the ChromaDB collection to get a list of already-indexed files and their modification times stored in the metadata.&lt;/li&gt;
&lt;li&gt;By comparing these two lists, it identifies:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;New files to be added.&lt;/li&gt;
&lt;li&gt;Modified files that need to be deleted and re-indexed.&lt;/li&gt;
&lt;li&gt;Deleted files whose chunks must be removed from the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This ensures that only necessary changes are processed, making startup and resyncing incredibly fast.&lt;/p&gt;

&lt;h5&gt;
  
  
  Document Loading and Splitting
&lt;/h5&gt;

&lt;p&gt;The system uses LangChain’s document loaders to handle various file types (&lt;code&gt;.pdf&lt;/code&gt;, &lt;code&gt;.docx&lt;/code&gt;, &lt;code&gt;.md&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;,etc.). Each document is then split into manageable chunks using the &lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A snippet from _load_and_split_document in local_rag_chroma.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_load_and_split_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# ... logic to select the correct loader based on file extension ...
&lt;/span&gt;    &lt;span class="n"&gt;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PyPDFLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Example for PDF
&lt;/span&gt;    &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="c1"&gt;# Add file metadata for synchronization
&lt;/span&gt;    &lt;span class="n"&gt;file_mod_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;last_modified&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file_mod_time&lt;/span&gt;
        &lt;span class="n"&gt;text_splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CHUNK_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
                &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CHUNK_OVERLAP&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;split_docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text_splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;split_docs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h5&gt;
  
  
  Embedding and Storage
&lt;/h5&gt;

&lt;p&gt;The chunks are then passed to a locally running embedding model via &lt;code&gt;OllamaEmbeddings&lt;/code&gt;. Each resulting vector is stored in a persistent ChromaDB collection, creating a searchable index for that specific knowledge base. The application creates separate, isolated collections for each team's knowledge base and each user's personal documents, ensuring data segregation.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzdgrk96xigfnodluwumj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzdgrk96xigfnodluwumj.png" alt="User Interface"&gt;&lt;/a&gt;Here, the RAG system is demonstrated on a private, personal note. The model is able to extract and synthesize information directly from the user’s content. Because all processing is handled locally, it’s possible to run queries on sensitive information without the data ever leaving the machine. (image by author)  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmxag63008i1ec412yoi4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmxag63008i1ec412yoi4.png" alt="User Interface"&gt;&lt;/a&gt;This example demonstrates the system’s ability to query complex, structured documents. The model extracts specific data points from a financial report in response to a direct question. This showcases its utility for analyzing dense information, whether it’s technical documentation, financial data, or academic papers. (image by author)  &lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The Multi-User System: &lt;code&gt;auth.py&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;A robust application needs a solid authentication and authorization layer. I built one from scratch using a simple SQLite database to store user and session information.&lt;/p&gt;

&lt;h5&gt;
  
  
  Database and Authentication
&lt;/h5&gt;

&lt;p&gt;The &lt;code&gt;DatabaseManager&lt;/code&gt; class sets up tables for users, sessions, and analytics. User passwords are never stored in plain text; instead, I use &lt;code&gt;werkzeug.security&lt;/code&gt; to store salted and hashed passwords.&lt;/p&gt;

&lt;p&gt;When a user logs in, the system verifies their credentials and generates a JSON Web Token (JWT) that is used to authenticate subsequent API requests.&lt;/p&gt;

&lt;h5&gt;
  
  
  Securing Endpoints with Decorators
&lt;/h5&gt;

&lt;p&gt;To protect the API, I implemented custom decorators. The &lt;code&gt;@require_auth&lt;/code&gt; decorator checks for a valid session or JWT, while &lt;code&gt;@require_role('admin')&lt;/code&gt; restricts access to admin-only endpoints, like the analytics dashboard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A snippet from auth.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;required_role&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Decorator to require specific user role.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decorator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nd"&gt;@wraps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nd"&gt;@require_auth&lt;/span&gt;
        &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decorated_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;user_role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c1"&gt;# ... logic to check if user has sufficient privileges ...
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decorated_function&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decorator&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This design makes it easy to secure new API routes as the application grows.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faul03ilqduemql6wavoz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faul03ilqduemql6wavoz.png" alt="Admin Dashboard"&gt;&lt;/a&gt;User management using the admin dashboard (image by author)  &lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  3. The Web Server: &lt;code&gt;main_app.py&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;The Flask application ties everything together. It defines the API endpoints for the frontend to interact with. The most important one is &lt;code&gt;/api/ask&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A simplified view of the /api/ask endpoint in main_app.py
&lt;/span&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/api/ask&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nd"&gt;@require_auth&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_question_api&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_or_create_session_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;kb_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;knowledge_base_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user_personal&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Get or create a RAG instance for this session and knowledge base
&lt;/span&gt;    &lt;span class="n"&gt;rag_chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retriever&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_or_create_rag_instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rag_chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RAG service not ready.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;
    &lt;span class="c1"&gt;# Invoke the RAG chain and return the response
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rag_chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;# ... format and return the answer and sources ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application maintains a thread-safe dictionary (&lt;code&gt;rag_instances&lt;/code&gt;) to manage separate RAG chains and conversation histories for each user session and selected knowledge base, preventing memory leaks and ensuring that conversations are isolated.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Result: Your Private AI Knowledge Base for Work and Life
&lt;/h4&gt;

&lt;p&gt;The final result is a powerful, production-ready application that turns any collection of documents into an interactive AI assistant. It’s the perfect tool for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small Businesses&lt;/strong&gt; wanting a secure, internal knowledge base for their team without the high cost and privacy risks of SaaS products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developers &amp;amp; Freelancers&lt;/strong&gt; who need a powerful, private RAG system for their own documents or as a foundation for client projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal Users &amp;amp; Families&lt;/strong&gt; who want to securely organize and ask questions about their private files, from financial records to research papers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s the best of both worlds: the power of modern AI without sacrificing control over your data.&lt;/p&gt;

&lt;h4&gt;
  
  
  Want to Run It Yourself?
&lt;/h4&gt;

&lt;p&gt;This project takes the core principles of local processing from the AI Meeting Note Taker and expands them into a robust, multi-user platform ready for any use case.&lt;/p&gt;

&lt;p&gt;I’ve packaged the entire source code and made it available for a one-time purchase. It’s a fantastic way to get a powerful, private AI tool up and running in minutes and serves as a solid foundation for your own customizations.&lt;/p&gt;

&lt;p&gt;For a limited time, use the code &lt;code&gt;MEDIUM25&lt;/code&gt; for a 25% discount.&lt;/p&gt;

&lt;p&gt;You can get the complete RAG Application project &lt;a href="https://monjurulkarim.gumroad.com/l/self-hosted-rag" rel="noopener noreferrer"&gt;by clicking here&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading! I hope this deep-dive inspires you to take control of your data and explore the incredible potential of self-hosted AI.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>selfhosted</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
