0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChatGPTに書類をアップしたくない人のために、ローカルLLMで動くドキュメント管理アプリをスクラッチで作った

0
Posted at

はじめに

「このPDF、どこに保存したっけ?」

営業資料、契約書、領収書、スキャンした手書きメモ……。デジタル化が進めば進むほど、ファイルの山はむしろ増えていく。

既存のソリューションを試してみると、どれも一長一短だった。

  • Notion / Google Drive → クラウドに書類を上げるのが不安(守秘義務のある文書は特に)
  • ChatGPT → 高精度だが月額費用がかかるし、業務文書を外部サーバーに送りたくない
  • ローカルの全文検索ツール → AI要約がない。タグ管理もない

100% ローカル動作の、AI付き書類管理アプリを自分で作るしかない」

それが ArcShield の始まりだ。


アプリのコア機能

ArcShield の価値を一言で表すと、「外部に何も漏らさず、AI がドキュメントを整理・検索・要約する」に尽きる。

1. FTS5 による高速全文検索

SQLite の FTS5 (Full-Text Search 5) を活用し、PDF・Word・テキストを横断検索する。インデックスは INSERT/UPDATE/DELETE トリガーで自動同期される。

-- Python バックエンド (src-python/main.py)
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
    filename,
    content,
    path      UNINDEXED,
    file_type UNINDEXED,
    size      UNINDEXED,
    tokenize  = 'unicode61 remove_diacritics 1',  -- 日本語対応
    content   = 'documents',
    content_rowid = 'rowid'
);

-- INSERT トリガーで FTS インデックスを自動更新
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON documents BEGIN
    INSERT INTO docs_fts(rowid, filename, content)
    VALUES (new.rowid, new.filename, new.content);
END;

unicode61 remove_diacritics 1 を指定することで、日本語を含む Unicode テキストが正しくトークナイズされる。

2. ローカル LLM(Ollama)による AI 要約・チャット

AI 機能はすべて Ollama のローカル推論に委ねる。API キーも月額費用も不要。

# src-python/main.py — 要約エンドポイント
@app.post("/api/summarize")
async def summarize_document(body: SummarizeRequest):
    # default_system を「先頭」に置いて日本語指定を優先させる
    default_system = (
        "あなたは日本語専門のドキュメントアシスタントです。"
        "【必須】日本語のみで回答してください。英語・その他の言語は使用禁止です。"
        "入力文書の言語に関わらず、出力は必ず日本語にしてください。"
    )
    system = f"{default_system}\n\n{body.system_prompt}" if body.system_prompt else default_system

    payload = {
        "model":  body.model or OLLAMA_MODEL,
        "prompt": f"以下の文書を日本語で要約してください。\n\n{content}",
        "system": system,
        "stream": False,
    }
    async with httpx.AsyncClient(timeout=300) as client:
        resp = await client.post(f"{OLLAMA_URL}/api/generate", json=payload)
    return {"summary": resp.json()["response"]}

システムプロンプトの順番がポイント。 default_system(日本語強制)をユーザーのカスタムプロンプトよりに置くことで、LLM が英語で回答するのを防げる。最初この順番を逆にしていて、英語で要約が返ってきて頭を悩ませた。

3. OCR テキスト抽出

画像のみの PDF(スキャン書類)は pdf2image + pytesseract で処理する。

def try_ocr_extraction(file_path: Path) -> str:
    OCR_MAX_PAGES = 3   # 全ページ処理すると重すぎるので制限
    OCR_DPI       = 200 # 300 は精度高いが速度犠牲

    images = convert_from_path(str(file_path), dpi=OCR_DPI,
                               first_page=1, last_page=OCR_MAX_PAGES)
    texts = []
    for img in images:
        text = pytesseract.image_to_string(img, lang="jpn+eng",
                                           config="--psm 3")
        texts.append(text)
    return "\n".join(texts)

技術スタック・アーキテクチャ

┌─────────────────────────────────────────────────────┐
│                   Tauri v2 Shell                     │
│  ┌────────────────────┐  ┌───────────────────────┐  │
│  │  React 18 + Vite   │  │   Rust (arc-shield)   │  │
│  │  TypeScript 5      │◄─►│   tauri-plugin-shell  │  │
│  │  react-window      │  │   notify (fs watcher)  │  │
│  │  react-joyride     │  │   aes-gcm / pbkdf2     │  │
│  └────────────────────┘  └──────────┬────────────┘  │
│              IPC (invoke / emit)     │               │
└──────────────────────────────────────┼───────────────┘
                                       │ HTTP (動的ポート)
                              ┌────────▼───────────┐
                              │  FastAPI (Python)   │
                              │  SQLite + FTS5      │
                              │  pytesseract        │
                              │  pdf2image / pypdf  │
                              │  httpx → Ollama     │
                              └────────────────────┘
                                       │
                              ┌────────▼───────────┐
                              │  Ollama (localhost) │
                              │  gemma3 / qwen2.5  │
                              └────────────────────┘

フロントエンド(React + Tauri)

技術 採用理由
Tauri v2 Electron より軽量(約 5〜15 MB)、Rust でシステム操作
React 18 + Vite HMR が速く開発体験が良い
react-window 数千件のドキュメントリストを仮想スクロール
TypeScript 5 @tauri-apps/api の型定義が充実している

バックエンド(FastAPI Sidecar)

Python の FastAPI プロセスを PyInstaller で単一 EXE に固め、Tauri の External Binary (Sidecar) として同梱する。

src-tauri/
  binaries/
    arcshield-backend-x86_64-pc-windows-msvc.exe  ← PyInstaller 成果物

tauri.conf.json に登録すると、アプリ起動時に Tauri が自動で sidecar を起動・終了してくれる:

{
  "bundle": {
    "externalBin": ["binaries/arcshield-backend"]
  }
}

Rust 側から起動するコードはシンプルだ:

// src-tauri/src/lib.rs
match app.shell().sidecar("arcshield-backend") {
    Ok(cmd) => match cmd.spawn() {
        Ok((_rx, child)) => {
            eprintln!("[sidecar] arcshield-backend を起動しました");
            *sidecar_child().lock().unwrap() = Some(child);
        }
        Err(e) => {
            // リリースビルドで sidecar が起動できない場合は
            // Python フォールバック(開発時用)へ
            eprintln!("[sidecar] 起動失敗 ({}), Python フォールバックへ", e);
            if let Some(child) = spawn_python_backend() {
                *python_child().lock().unwrap() = Some(child);
            }
        }
    },
    // ...
}

E2EE 暗号化同期(AES-256-GCM)

Google Drive 等への同期データを Rust 側で暗号化する。pbkdf2 で鍵導出し、aes-gcm で暗号化する。

// src-tauri/src/lib.rs
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
use aes_gcm::aead::Aead;

fn encrypt_with_key(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
    let mut nonce_arr = [0u8; NONCE_LEN];
    rand::thread_rng().fill_bytes(&mut nonce_arr);

    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
    let nonce  = Nonce::from_slice(&nonce_arr);
    let ciphertext = cipher.encrypt(nonce, plaintext)
        .map_err(|e| format!("暗号化失敗: {e}"))?;

    // [MAGIC(4)][NONCE(12)][ciphertext] の形式でシリアライズ
    let mut out = Vec::with_capacity(MAGIC.len() + NONCE_LEN + ciphertext.len());
    out.extend_from_slice(MAGIC);
    out.extend_from_slice(&nonce_arr);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

開発で苦労した点・工夫した点

1. 完全 E2EE 同期を Rust でスクラッチ実装——PBKDF2 × AES-256-GCM × デュアルフォーマット設計

Google Drive などへの同期機能を実装するにあたり、「ローカル AI アプリを名乗るなら暗号化の設計も理解して実装しなければ」と思い、E2EE(エンドツーエンド暗号化)を Rust でフルスクラッチ設計した。

最も悩んだのが**「PBKDF2 コストをどこで払うか」**という問題だ。

PBKDF2-HMAC-SHA256 を 200,000 ラウンド(NIST 推奨水準)実行するとシングルスレッドで 100〜200ms かかる。数百ファイルを同期する際にファイルごとに PBKDF2 を回すと数分かかって使い物にならない。かといって、ラウンド数を下げると総当たり耐性が下がる。

解決策はマニフェストファイルだけ SALT を埋め込み、データファイルはセッション鍵を共用するデュアルフォーマットだ:

ファイル種別 バイナリ形式 役割
マニフェスト [MAGIC][SALT(16)][NONCE(12)][ciphertext] SALT を保持し、鍵を再導出可能にする
データファイル [MAGIC][NONCE(12)][ciphertext] SALT なし。セッション鍵を再利用

PBKDF2 はセッション冒頭の 1 回分だけ支払えば、何千ファイルあっても追加コストゼロ。復元時もマニフェストから SALT を抽出して 1 回だけ鍵を再導出すれば全ファイルを復号できる:

// src-tauri/src/lib.rs
const PBKDF2_ROUNDS: u32 = 200_000;

/// マニフェスト専用: SALT を埋め込んで鍵再導出を可能にする
fn encrypt_manifest(plaintext: &[u8], key: &[u8; 32], salt: &[u8; 16]) -> Result<Vec<u8>, String> {
    let mut nonce_arr = [0u8; NONCE_LEN];
    rand::thread_rng().fill_bytes(&mut nonce_arr);
    let cipher     = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
    let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce_arr), plaintext)
        .map_err(|e| format!("暗号化失敗: {e}"))?;
    let mut out = Vec::new();
    out.extend_from_slice(MAGIC);
    out.extend_from_slice(salt);       // ← SALT あり
    out.extend_from_slice(&nonce_arr);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

/// データファイル: 共通鍵を再利用し SALT を省略
fn encrypt_with_key(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
    let mut nonce_arr = [0u8; NONCE_LEN];
    rand::thread_rng().fill_bytes(&mut nonce_arr);
    let cipher     = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
    let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce_arr), plaintext)
        .map_err(|e| format!("暗号化失敗: {e}"))?;
    let mut out = Vec::new();
    out.extend_from_slice(MAGIC);
    // SALT なし — セッション鍵を再利用
    out.extend_from_slice(&nonce_arr);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

また、暗号化後のファイル名には元パスの SHA256 先頭 8 バイト(16 hex 文字) を使う。決定論的なので復元時にファイル名の対応表がいらず、かつ 2^64 の衝突空間を持つ:

fn path_to_enc_name(path: &str) -> String {
    use sha2::Digest;
    let hash = sha2::Sha256::digest(path.as_bytes());
    hash[..8].iter().map(|b| format!("{:02x}", b)).collect::<String>() + ".enc"
}

2. Tokio を詰まらせない——PBKDF2 の spawn_blocking 設計

PBKDF2 の 200,000 ラウンドは純粋な CPU バウンド処理で、Tokio の async タスク上でそのまま実行すると、そのスレッドが 100〜200ms 専有されて他の Future が進めなくなる(Executor 枯渇)。

解決策は tokio::task::spawn_blocking でブロッキング専用スレッドプールに追い出すことだ:

// src-tauri/src/lib.rs
async fn derive_key_async(password: String, salt: Vec<u8>) -> [u8; 32] {
    tokio::task::spawn_blocking(move || {
        let mut key = [0u8; 32];
        pbkdf2::pbkdf2_hmac::<sha2::Sha256>(
            password.as_bytes(),
            &salt,
            PBKDF2_ROUNDS,   // 200,000 rounds
            &mut key,
        );
        key
    })
    .await
    .expect("PBKDF2 task panicked")
}

spawn_blocking は Tokio が別途確保しているブロッキング用スレッドプールでタスクを動かし、完了すると await で呼び出し元の async コンテキストに戻ってくる。「CPU バウンドは spawn_blocking、I/O は await」は Tokio の基本だが、暗号ライブラリを async コードに組み込む際にうっかり忘れがちなポイントで、実際に最初の実装ではサイドバー UI がフリーズする問題が出た。

3. FTS5 コンテンツテーブル + 3 トリガー構成 + ALTER TABLE マイグレーション

SQLite の FTS5 を「コンテンツテーブルモード」(content = 'documents')で使うと、元テーブルの内容を FTS が所有せずインデックスだけを管理するため、アプリ側が INSERT / UPDATE / DELETE のタイミングで手動で同期する責任を持つ。

FTS5 の削除には通常の SQL とは異なる影テーブル構文docs_fts テーブル自体に INSERT で 'delete' 命令を渡す)が必要で、UPDATE トリガーは「旧エントリ削除 → 新エントリ追加」の 2 ステップが必須だ:

-- src-python/main.py
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
    filename, content,
    path UNINDEXED, file_type UNINDEXED, size UNINDEXED,
    tokenize      = 'unicode61 remove_diacritics 1',  -- 日本語対応
    content       = 'documents',                       -- コンテンツテーブルモード
    content_rowid = 'rowid'
);

-- INSERT: 新エントリを追加
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON documents BEGIN
    INSERT INTO docs_fts(rowid, filename, content)
    VALUES (new.rowid, new.filename, new.content);
END;

-- UPDATE: 旧エントリを影テーブル構文で削除してから新エントリを追加
CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON documents BEGIN
    INSERT INTO docs_fts(docs_fts, rowid, filename, content)  -- 影テーブル構文で削除
    VALUES ('delete', old.rowid, old.filename, old.content);
    INSERT INTO docs_fts(rowid, filename, content)
    VALUES (new.rowid, new.filename, new.content);
END;

-- DELETE: 影テーブル構文のみ
CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON documents BEGIN
    INSERT INTO docs_fts(docs_fts, rowid, filename, content)
    VALUES ('delete', old.rowid, old.filename, old.content);
END;

既存ユーザーのデータを壊さないカラム追加マイグレーションには PRAGMA table_info で現状を確認してから ALTER TABLE ADD COLUMN する冪等パターンを採用した。SQLite は DROP COLUMN のサポートが限定的なため、「あれば何もしない・なければ追加する」のみ:

cols = {r[1] for r in conn.execute("PRAGMA table_info(documents)").fetchall()}
for col, definition in [
    ("is_favorite", "INTEGER NOT NULL DEFAULT 0"),
    ("is_archived", "INTEGER NOT NULL DEFAULT 0"),
    ("notes",       "TEXT"),
    ("rating",      "INTEGER"),
    ("due_date",    "TEXT"),
]:
    if col not in cols:
        conn.execute(f"ALTER TABLE documents ADD COLUMN {col} {definition}")

4. ML ライブラリなし——純 Python で TF-IDF コサイン類似度をスクラッチ実装

重複ドキュメント検出と類似文書サジェスト機能には「文書間の意味的な近さ」の計算が必要だった。scikit-learn の TfidfVectorizer を使えば 1 行で済むが、PyInstaller でパッケージすると numpy + scikit-learn だけで バイナリが 100MB 超え になり、動的インポートのトラブルも増える。

標準ライブラリだけで TF ベースのコサイン類似度を実装した:

# src-python/main.py
import re, math
from collections import Counter

def _tokenize(text: str) -> list:
    # ASCII + 日本語(CJK ブロック)を両方拾う Unicode 正規表現
    # トークン上限 3000 でメモリを抑制
    return re.findall(r'[a-zA-Z0-9぀-鿿一-鿿]+', text.lower())[:3000]

def _tf_vector(tokens: list) -> dict:
    if not tokens:
        return {}
    c = Counter(tokens)
    total = len(tokens)
    return {t: cnt / total for t, cnt in c.items()}  # TF = 出現回数 / 総トークン数

def _cosine(v1: dict, v2: dict) -> float:
    common = set(v1) & set(v2)   # 共通トークンだけを走査
    if not common:
        return 0.0
    dot  = sum(v1[k] * v2[k] for k in common)
    mag1 = math.sqrt(sum(x * x for x in v1.values()))
    mag2 = math.sqrt(sum(x * x for x in v2.values()))
    return dot / (mag1 * mag2) if mag1 > 0 and mag2 > 0 else 0.0

common = set(v1) & set(v2) で共通トークンだけ走査するのがポイント。両文書に出現しないトークンへの内積はゼロなので省略でき、辞書をスパースベクトルとして扱うことで計算量が O(|共通語彙|) に落ちる。コーパス全体の IDF が不要な TF ベースの近似でも、同一ドキュメントの検出と類似度ランキングには実用上十分だった。

5. OnceLock<Mutex<Option<>>> でグローバルプロセスを安全に管理 + 起動フォールバック設計

Rust でアプリ終了時に子プロセスを kill する要件は意外と難しい。on_window_event ハンドラーは AppHandle だけ受け取り、Tauri の State を経由せずにプロセスハンドルにアクセスする必要があった。

解決策は OnceLock<Mutex<Option<>>> のトリプルラッピングだ:

// src-tauri/src/lib.rs
static SIDECAR_CHILD:  OnceLock<Mutex<Option<CommandChild>>>        = OnceLock::new();
static PYTHON_CHILD:   OnceLock<Mutex<Option<Child>>>               = OnceLock::new();
static FOLDER_WATCHER: OnceLock<Mutex<Option<RecommendedWatcher>>>  = OnceLock::new();

fn sidecar_child()  -> &'static Mutex<Option<CommandChild>>       { SIDECAR_CHILD.get_or_init(|| Mutex::new(None)) }
fn python_child()   -> &'static Mutex<Option<Child>>              { PYTHON_CHILD.get_or_init(|| Mutex::new(None)) }
fn folder_watcher() -> &'static Mutex<Option<RecommendedWatcher>> { FOLDER_WATCHER.get_or_init(|| Mutex::new(None)) }
  • OnceLock: 一度だけ初期化。複数スレッドから競合しない
  • Mutex: 複数スレッドからのアクセスを直列化
  • Option: take() で所有権を取り出し None に置き換えてから kill() できる(二重 kill を防ぐ)

さらにリリースビルド(sidecar EXE)と開発ビルド(Python スクリプト直接)を透過的に切り替えるフォールバック設計を組み込んだ。externalBin に登録した sidecar が起動できなければ自動的に src-python/main.py を直接実行する:

match app.shell().sidecar("arcshield-backend") {
    Ok(cmd) => match cmd.spawn() {
        Ok((_rx, child)) => {
            *sidecar_child().lock().unwrap() = Some(child);
        }
        Err(_) => {
            // リリース EXE がない開発環境では Python スクリプトを直接起動
            if let Some(child) = spawn_python_backend() {
                *python_child().lock().unwrap() = Some(child);
            }
        }
    },
    Err(_) => { /* ... */ }
}

開発中はコードを変更するだけで両方の起動経路が自動選択され、cargo run でも tauri dev でも同じように動く。

6. tokio BufReader で Ollama pull を行ストリーミング——プロセス出力をリアルタイムにフロントエンドへ伝播

ollama pull gemma3 は数 GB のモデルをダウンロードしながら stdout に進捗テキストを逐次出力する。output() で完了を待つ実装では全ダウンロードが終わるまで UI が無反応になるため、非同期ラインストリーミングを実装した。

tokio::process::Command で stdout を piped() し、tokio::io::BufReader で非同期にライン読み取りして Tauri イベントとして emit する:

// src-tauri/src/lib.rs
#[tauri::command]
async fn pull_ollama_model(model: String, app: AppHandle) -> Result<(), String> {
    use tokio::io::{AsyncBufReadExt, BufReader};

    let mut child = async_cmd_hidden("ollama")
        .args(["pull", &model])
        .stdout(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| format!("ollama 起動失敗: {e}"))?;

    let stdout = child.stdout.take().ok_or("stdout を取得できません")?;
    let mut lines = BufReader::new(stdout).lines();

    // stdout の各行をそのまま Tauri イベントとして emit
    while let Ok(Some(line)) = lines.next_line().await {
        if !line.trim().is_empty() {
            let _ = app.emit("ollama-pull-progress", PullProgress {
                model: model.clone(), text: line, done: false, error: false,
            });
        }
    }

    let status = child.wait().await.map_err(|e| format!("待機エラー: {e}"))?;
    let _ = app.emit("ollama-pull-progress", PullProgress {
        model:  model.clone(),
        text:   if status.success() { "✅ ダウンロード完了" } else { "❌ 失敗" }.into(),
        done:   true,
        error:  !status.success(),
    });
    if status.success() { Ok(()) } else { Err(format!("exit: {:?}", status.code())) }
}

フロントエンドは listen("ollama-pull-progress", ...) でリアルタイムにプログレスバーへ反映する。「Rust async コマンド → Tauri イベント → React state」の連鎖は、スキャン進捗やバックアップ進捗など長時間処理の全般に応用できるパターンで、アプリ全体の UX の柱になっている。


今後のロードマップと BOOTH での格安頒布について

ベータ版先行公開中

現在 ArcShield は BOOTH にて格安(ワンコイン程度)で先行配布中 です。

🔗 BOOTH販売ページ(https://konoshin.booth.pm/items/8528645)

ベータ期間中の購入者は、正式版(v1.0.0)に無料アップデートできます。現時点では「動いてくれれば御の字」レベルの完成度ですが、エンジニアの目線でのフィードバックをいただきながら品質を上げていきたいと考えています。

バグ出し・要望出しを手伝ってくれるアーリーアダプター歓迎です。

まとめ

レイヤー 技術
GUI フレームワーク Tauri v2 + React 18 + TypeScript + Vite
バックエンド FastAPI (Python 3.12) + SQLite FTS5
AI Ollama(ローカル LLM 実行環境)
OCR Tesseract + pdf2image + pytesseract
暗号化 AES-256-GCM (Rust aes-gcm crate)
FS 監視 notify crate(Rust)
テスト Vitest 144件 + pytest 97件
配布形式 NSIS インストーラー(Tauri bundle)

「クラウドに書類を渡したくない」「月額費用を払いたくない」「でも AI には使いたい」——そのわがままな要件を全部満たすために、Tauri × FastAPI × Ollama の組み合わせは思った以上によく機能した。

Windows でのプロセス管理(CREATE_NO_WINDOW)や CSP の動的ポート対応など、ドキュメントが少なくて手探りな部分も多かったが、その分この記事が誰かの役に立てば嬉しい。

ベータ版への星・フォーク・フィードバック、お待ちしています!


この記事は Tauri v2 (2.x) / FastAPI 0.115.x / Python 3.12 / React 18 の環境で執筆しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?