MYAI02 構築マニュアル

個人日記・Twitterアーカイブを知識源とするローカルRAG AIアシスタント
MacBook Pro M2(8GB)向け / 作成日:2026-03-30


概要

MYAI02は、自分の記録(日記・SNS投稿・プロフィール)をSQLite FTS5でインデックスし、ローカルLLM(Qwen3-4B-4bit via mlx_lm)で質問応答するパーソナルAIアシスタントです。

Streamlit UI(ブラウザ)
    ↓  質問
SQLite FTS5 + Janome  ←── 日記ファイル・Twitterアーカイブ・プロフィール
    ↓  参照文書(上位4件)
Qwen3-4B-4bit(mlx_lm / Apple Silicon)
    ↓  回答
チャット画面(セッション履歴付き)

ディレクトリ構成

~/MYAI02/
├── app.py               # Streamlit UI・RAG・推論(メインアプリ)
├── myai.py              # CUI版スタンドアロン
├── build_db.py          # SQLite FTS5 インデックス構築
├── watch_knowledge.py   # ファイル変更の自動検知・再インデックス
├── diary.db             # FTS5 データベース(自動生成)
├── chat_history.db      # チャットセッション履歴(自動生成)
└── venv/                # Python 仮想環境

~/diary/                 # 日記ファイル置き場
    sou_YYYYMMDD.txt     # 日記(命名規則に従うこと)

前提環境

項目詳細
ハードウェアMacBook Pro M2(8GB RAM)
macOSVentura 以降
Homebrewインストール済み
Python3.14(python3.14 -m venv が使える状態)
インターネット初回モデルダウンロード時のみ必要

STEP 1:Homebrew と Python の確認

# Homebrew が入っているか確認
brew --version

# Python 3.14 が入っているか確認
python3.14 --version
# なければインストール
brew install python@3.14

STEP 2:仮想環境の作成

mkdir ~/MYAI02
python3.14 -m venv ~/MYAI02/venv
source ~/MYAI02/venv/bin/activate

プロンプトが (venv) に変わればOKです。


STEP 3:必要パッケージのインストール

pip install streamlit mlx-lm janome watchdog
パッケージ用途
streamlitWeb UI フレームワーク
mlx-lmApple Silicon LLM 推論エンジン
janome日本語形態素解析(FTS5 トークナイザ)
watchdogファイル変更監視

STEP 4:モデルのダウンロード

初回のみ、Qwen3-4B-4bit(約2GB)をダウンロードします。

python -c "from mlx_lm import load; load('mlx-community/Qwen3-4B-4bit')"

ダウンロード先: ~/.cache/huggingface/hub/models--mlx-community--Qwen3-4B-4bit/

2回目以降はオフラインで起動できます。必要に応じて以下を app.py の先頭に追加してください。

import os
os.environ["HF_HUB_OFFLINE"] = "1"   # ネットワークアクセスを遮断

STEP 5:日記ファイルの準備

日記ファイルは以下の命名規則で ~/diary/ に置きます。

~/diary/sou_20260101.txt
~/diary/sou_20260102.txt
...

プロフィールファイルも用意します。

~/MYAI02/profile.txt   ← 自分のプロフィール(名前・職業・関心分野など)

STEP 6:build_db.py(インデックス構築スクリプト)

~/MYAI02/build_db.py を作成します。
このスクリプトはSQLite FTS5にJanomeで分かち書きしたテキストを登録します。

import sqlite3
import os
import re
from janome.tokenizer import Tokenizer

DIARY_DIR   = os.path.expanduser("~/diary")
PROFILE_PATH = os.path.expanduser("~/MYAI02/profile.txt")
DB_PATH     = os.path.expanduser("~/MYAI02/diary.db")

tok = Tokenizer()

def tokenize_ja(text):
    """Janome で分かち書きしてFTS5用の文字列を返す"""
    tokens = [t.surface for t in tok.tokenize(text)]
    return " ".join(tokens)

def build():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("DROP TABLE IF EXISTS diary_fts")
    conn.execute("""
        CREATE VIRTUAL TABLE diary_fts USING fts5(
            date, filename, raw, tokens,
            tokenize='unicode61'
        )
    """)

    inserted = 0

    # ── 日記ファイルを登録 ──────────────────────────────
    for fname in sorted(os.listdir(DIARY_DIR)):
        if not fname.endswith(".txt"):
            continue
        m = re.match(r"sou_(\d{4})(\d{2})(\d{2})\.txt", fname)
        date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" if m else "unknown"
        path = os.path.join(DIARY_DIR, fname)
        try:
            raw = open(path, encoding="utf-8").read()
        except UnicodeDecodeError:
            raw = open(path, encoding="cp932", errors="replace").read()
        tokens = tokenize_ja(raw)
        conn.execute(
            "INSERT INTO diary_fts(date, filename, raw, tokens) VALUES(?,?,?,?)",
            (date, fname, raw, tokens)
        )
        inserted += 1

    # ── プロフィールを登録 ──────────────────────────────
    if os.path.exists(PROFILE_PATH):
        raw = open(PROFILE_PATH, encoding="utf-8").read()
        tokens = tokenize_ja(raw)
        conn.execute(
            "INSERT INTO diary_fts(date, filename, raw, tokens) VALUES(?,?,?,?)",
            ("profile", "profile.txt", raw, tokens)
        )
        inserted += 1

    conn.commit()
    conn.close()
    print(f"✓ {inserted} 件を登録しました → {DB_PATH}")

if __name__ == "__main__":
    build()

実行:

source ~/MYAI02/venv/bin/activate
python ~/MYAI02/build_db.py

STEP 7:app.py(Streamlit メインアプリ)

~/MYAI02/app.py の核となる実装ポイントを示します。

7-1. モデルのキャッシュロード

from mlx_lm import load, generate
import streamlit as st

MODEL_ID = "mlx-community/Qwen3-4B-4bit"

@st.cache_resource
def load_model():
    return load(MODEL_ID)   # 起動時に一度だけロード

model, tokenizer = load_model()

@st.cache_resource を使うことで、Streamlit の再レンダリング時にモデルが二重ロードされるのを防ぎます。

7-2. システムプロンプトへのプロフィール注入

PROFILE_PATH = os.path.expanduser("~/MYAI02/profile.txt")

def load_profile():
    if os.path.exists(PROFILE_PATH):
        return open(PROFILE_PATH, encoding="utf-8").read()
    return ""

PROFILE = load_profile()

def build_system_prompt():
    return f"""あなたは和彦の個人記録アシスタントです。
以下のプロフィールを参照してください。

=== プロフィール ===
{PROFILE}

回答ルール:
1. 回答は必ず「参照記録」に書かれた内容だけを根拠にすること
2. 参照記録に書かれていないことは絶対に推測・創作しないこと
3. 記録にない情報は「その記録はありません」と明示すること
4. 日本語で、具体的なエピソードや日付を引用しながら答えること"""

7-3. FTS5 検索

import sqlite3
import re
from janome.tokenizer import Tokenizer

DB_PATH = os.path.expanduser("~/MYAI02/diary.db")
tok = Tokenizer()

def extract_keywords(query):
    stop = {'について', 'を', 'は', 'が', 'の', 'に', 'で', 'と', 'も'}
    words = []
    for t in tok.tokenize(query):
        if t.part_of_speech.startswith('名詞') and t.surface not in stop:
            words.append(t.surface)
    return words

def search(query, top_k=4, history=None):
    conn = sqlite3.connect(DB_PATH)

    # ① クエリに日付が含まれる場合(最優先)
    m = re.search(r'(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})', query)
    if m:
        date_str = f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}"
        rows = conn.execute(
            "SELECT date, filename, raw FROM diary_fts WHERE date=? LIMIT ?",
            (date_str, top_k)
        ).fetchall()
        if rows:
            conn.close()
            return rows

    # ② FTS5 全文検索
    keywords = extract_keywords(query)
    if keywords:
        q_tokens = " OR ".join(keywords)
        try:
            rows = conn.execute(
                "SELECT date, filename, raw FROM diary_fts WHERE tokens MATCH ? LIMIT ?",
                (q_tokens, top_k)
            ).fetchall()
            if rows:
                conn.close()
                return rows
        except sqlite3.OperationalError:
            pass

    # ③ LIKE フォールバック
    rows = []
    seen = set()
    for kw in keywords:
        hits = conn.execute(
            "SELECT date, filename, raw FROM diary_fts WHERE raw LIKE ? LIMIT ?",
            (f"%{kw}%", top_k)
        ).fetchall()
        for h in hits:
            if h[1] not in seen:
                rows.append(h)
                seen.add(h[1])
    conn.close()
    return rows[:top_k]

7-4. 推論(Qwen3 の思考タグを無効化)

def ask(query, history):
    hits = search(query, history=history)
    context = "\n\n".join([f"[{d} / {f}]\n{r[:600]}" for d, f, r in hits]) \
              if hits else "(関連する記録が見つかりませんでした)"

    messages = [
        {"role": "system", "content": build_system_prompt() + f"\n\n=== 参照記録 ===\n{context}"},
        {"role": "user",   "content": query},
    ]

    # Qwen3 は enable_thinking=False を指定しないと <think>...</think> タグが出力に混入する
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False,
    )

    answer = generate(model, tokenizer, prompt=prompt, max_tokens=600, verbose=False)
    sources = [[d, f, ""] for d, f, _ in hits]
    return answer, sources

7-5. チャット履歴(SQLite バックエンド)

チャット履歴は ~/MYAI02/chat_history.db に保存されます。

CHAT_DB_PATH = os.path.expanduser("~/MYAI02/chat_history.db")

def init_chat_db():
    conn = sqlite3.connect(CHAT_DB_PATH)
    conn.execute("""CREATE TABLE IF NOT EXISTS sessions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT, created_at TEXT)""")
    conn.execute("""CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id INTEGER, role TEXT, content TEXT,
        sources TEXT, created_at TEXT)""")
    conn.commit()
    conn.close()

7-6. フィードバックボタン(👍/👎)

回答が良質な場合、👍を押すと Q&A ペアが diary_fts に追記され、次回以降の検索精度が向上します。

def save_good_response(question, answer):
    date = datetime.now().strftime("%Y-%m-%d")
    fname = f"feedback_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
    content = f"Q: {question}\nA: {answer}"
    tokens = tokenize_ja(content)
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "INSERT INTO diary_fts(date, filename, raw, tokens) VALUES(?,?,?,?)",
        (date, fname, content, tokens)
    )
    conn.commit()
    conn.close()

STEP 8:watch_knowledge.py(自動再インデックス)

日記ファイルを追加・変更したとき、自動でDBを再構築します。

import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import subprocess, os

WATCH_DIR = os.path.expanduser("~/diary")

class DiaryHandler(FileSystemEventHandler):
    def on_modified(self, event):
        if event.src_path.endswith(".txt"):
            print(f"変更検知: {event.src_path} → 再インデックス")
            subprocess.run(["python", os.path.expanduser("~/MYAI02/build_db.py")])
    on_created = on_modified

if __name__ == "__main__":
    observer = Observer()
    observer.schedule(DiaryHandler(), WATCH_DIR, recursive=False)
    observer.start()
    print(f"監視開始: {WATCH_DIR}")
    try:
        while True:
            time.sleep(5)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

STEP 9:起動

# 仮想環境を有効化
source ~/MYAI02/venv/bin/activate

# DBが未作成の場合はビルド
python ~/MYAI02/build_db.py

# アプリ起動
streamlit run ~/MYAI02/app.py

ブラウザで http://localhost:8501 が開きます。


トラブルシューティング

症状原因対処
<think>...</think> が回答に出るenable_thinking 指定漏れapply_chat_templateenable_thinking=False を追加
モデルが毎回再ロードされる@st.cache_resource がないload_model()@st.cache_resource を付ける
FTS5 検索が OperationalErrorクエリに記号が混入clean_fts_query() で記号を除去してから渡す
別セッションで「フィードバック済」と表示feedback_done のキーがインデックス番号のみ(session_id, i) のタプルで管理する
モデルのダウンロードが毎回走るHF_HUB_OFFLINE 未設定os.environ["HF_HUB_OFFLINE"] = "1" を先頭に追加
Python 3.14 で SyntaxError: unterminated f-stringf-string 内の改行がPython 3.14で非対応f-string を変数に分けて結合する

モデル選択の目安(M2 8GB)

モデルサイズ速度推奨用途
Qwen3-1.7B-4bit≈0.9 GB速い(約180 tok/s)他作業と並行・プレビュー
Qwen3-4B-4bit≈2.0 GB標準(約120 tok/s)常用推奨
Qwen3-8B-4bit≈4.5 GB遅いメモリプレッシャーに注意

切り替えは app.pymyai.pyMODEL_ID 1行を変更し、Streamlit を再起動するだけです。


ファイル一覧まとめ

ファイル必須説明
app.pyStreamlit UI + RAG + 推論
myai.pyCUI版(ターミナルのみで動作)
build_db.pyFTS5 インデックス構築
watch_knowledge.py任意日記追加時の自動再インデックス
profile.txt推奨システムプロンプトに注入するプロフィール
diary.db自動生成FTS5 データベース
chat_history.db自動生成チャットセッション履歴
venv/Python 仮想環境

MYAI02 — 和顔愛語、息慮凝心