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) |
| macOS | Ventura 以降 |
| Homebrew | インストール済み |
| Python | 3.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
| パッケージ | 用途 |
|---|---|
streamlit | Web UI フレームワーク |
mlx-lm | Apple 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_template に enable_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-string | f-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.py と myai.py の MODEL_ID 1行を変更し、Streamlit を再起動するだけです。
ファイル一覧まとめ
| ファイル | 必須 | 説明 |
|---|---|---|
app.py | ✅ | Streamlit UI + RAG + 推論 |
myai.py | ✅ | CUI版(ターミナルのみで動作) |
build_db.py | ✅ | FTS5 インデックス構築 |
watch_knowledge.py | 任意 | 日記追加時の自動再インデックス |
profile.txt | 推奨 | システムプロンプトに注入するプロフィール |
diary.db | 自動生成 | FTS5 データベース |
chat_history.db | 自動生成 | チャットセッション履歴 |
venv/ | ✅ | Python 仮想環境 |
MYAI02 — 和顔愛語、息慮凝心