自宅クラスタに「自分だけのナレッジ検索」を追加した話です。今回は Qdrant(ベクトルDB)+ bge-m3(埋め込みモデル)+ 専用VM(rag-tools) を組み合わせて、このブログの記事や運用資料を自然言語で検索できる RAG 基盤を構築しました。
「最新情報はWeb検索Workerに任せる」設計は前回までに完成しています。今回は自分の資産(ブログ・運用ドキュメント)を検索することに特化した経路を追加します。
なぜ今 RAG なのか
動機はシンプルです。記事数が15本を超え、「あの設定どこに書いたっけ」「ghost_trigger の実装は何回目のセッションだったか」という自己検索のニーズが出てきました。
Web検索は外部情報に強いですが、非公開の運用資料(引継ぎドキュメント・設計メモ)には手が届きません。そこで ブログ記事 + 引継ぎ資料をベクトル化してローカルに持ち、OpenWebUI から質問できる 構成を追加します。
設計判断:なぜ専用 VM を立てるのか
最初に「AI-Core に全部載せればよいのでは」と考えました。しかし AI-Core(8GB)には既に Coordinator API・Redis・PostgreSQL・分類用 Ollama・Web検索 Worker が同居しています。ここに bge-m3(埋め込みモデル / 1.2GB)を追加すると Ollama の枠(OLLAMA_MAX_LOADED_MODELS=3)を取り合い、過去に苦しめられたタイムアウト連鎖が再発します。
解決策は構造的な分離です。P50(仮想化ホスト / Core i7・32GB)には AI-Core VM のほかに余裕があります。ここに rag-tools VM(6GB・2vCPU)を新設し、RAG に必要なものをすべて自己完結させる。
P50(仮想化ホスト)
├── AI-Core VM(192.168.0.40 / 8GB) ← 既存・変更なし
│ Coordinator / Redis / PG / 分類Ollama / web-worker
└── rag-tools VM(192.168.0.42 / 6GB)← 新設・常時起動
├── Qdrant(localhost:6333)
├── Ollama(bge-m3 + llama3.2:3b)
└── worker_rag.py(tasks:rag 監視)
この構成の利点は3つです。①AI-Core の Ollama 枠を汚さない、②mars(公開 Web サーバー)を内部インフラから完全に分離できる、③将来 web-worker を MacBook Air M1 に移す際も rag-tools は独立しているため影響なし。
構成要素の選択
ベクトル DB:Qdrant
Rust 製の軽量ベクトルDB です。Docker 版もありますが、今回は 単体バイナリ + systemd で動かします。Docker デーモンを増やさず、systemd で管理できるのが家庭内クラスタには自然です。
QDRANT_VERSION=v1.17.1
curl -fsSL -o qdrant.tar.gz \
"https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-x86_64-unknown-linux-gnu.tar.gz"
tar -xzf qdrant.tar.gz
sudo install -m 0755 qdrant /usr/local/bin/qdrant
埋め込みモデル:bge-m3
BAAI の多言語埋め込みモデルです。1024 次元・日本語対応・Ollama で pull できる(約 1.2GB)という三拍子が揃っています。Ollama の /api/embed エンドポイントでバッチ埋め込みが可能です。
ollama pull bge-m3
ollama pull llama3.2:3b # 回答合成用
コレクション設計
Qdrant のコレクションは2つです。
| コレクション名 | ソース | チャンク |
|---|---|---|
| blog_articles | WordPress REST API(/wp-json/wp/v2/posts) | 800文字・150文字オーバーラップ |
| server_ops | ローカルの *.md(引継ぎ資料) | 同上 |
チャンクIDは sha1("blog::post_id::chunk_idx") の先頭 60bit を使います。同じ記事を再取り込みしても同じ ID になるため、upsert で冪等に上書きされます。記事更新後に再実行するだけで常に最新の内容が反映されます。
取り込みバッチ(rag_ingest.py)
WordPress REST API から全公開記事を取得し、HTML をテキストに変換してチャンク分割します。
# ブログ記事を取り込み
python rag_ingest.py blog
# 運用資料(*.md)を取り込み
python rag_ingest.py ops ~/rag/docs/
# コレクションの件数確認
python rag_ingest.py info
15記事・運用資料数本で数百チャンク程度です。bge-m3 の CPU 埋め込みは初回ロードに少し時間がかかりますが、2回目以降はメモリに乗っているので速くなります。
RAG Worker(worker_rag.py)の動作フロー
tasks:rag キューを監視し、タスクが来たら次の3ステップで処理します。
- 埋め込み:クエリを bge-m3 でベクトル化(Ollama /api/embed)
- 検索:Qdrant の各コレクションを Cosine 類似度で検索(top-5)
- 合成:ヒットした抜粋を文脈に llama3.2:3b で日本語回答を生成。末尾に出典(タイトル・URL)を付記
抜粋にない情報は推測しないよう、プロンプトで明示的に指示しています。LLM が「幻覚」で存在しない情報を返すリスクを下げるためです。
Coordinator v6.4.0:RAG ルーティングの追加
coordinator_api.py への変更は5箇所だけです。
# 1. キュー定数
QUEUE_RAG = "tasks:rag"
# 2. reaper 除外(単一Worker・代替先なし)
AND model NOT IN ('web_search', 'rag')
# 3. ルート分岐(/route の冒頭)
if req.task_type == "rag":
return route_to_rag(req, domain)
# 4. route_to_rag() 関数追加
# 5. version "6.4.0" に統一(root() のドリフトも解消)
RAG ルーティングは明示指定のみにしました。「自動でRAGに振る」設計も検討しましたが、過去に「architecture ドメイン誤判定」で泥沼に入った経験から、誤爆源になりやすい自動判定は入れない方針です。OpenWebUI で📚擬似モデルを選んだ質問だけが RAG に流れます。
OpenWebUI から使う
coordinator_pipe.py v6.2 で擬似モデルを3つ公開するよう更新しました。
def pipes(self) -> list[dict]:
return [
{"id": PIPE_AUTO, "name": "🧭 Coordinator (Auto Route)"},
{"id": PIPE_MOON, "name": "🌙 Coordinator → moon (指名・必要ならWoL起動)"},
{"id": PIPE_RAG, "name": "📚 Coordinator → RAG (社内ナレッジ検索)"},
]
「📚 Coordinator → RAG」を選んで質問すると、Coordinator が task_type="rag" を付けてルーティングし、rag-tools の worker_rag.py が Qdrant 検索→LLM 合成→出典付き回答を返します。
ハマりポイント3選
① Qdrant に Connection refused(IPv6 問題)
Python 3.14 + httpx の組み合わせでは localhost が IPv6(::1)に優先解決される場合があります。Qdrant の config.yaml を host: 127.0.0.1 にしていると IPv4 側しか待ち受けないため接続が拒否されます。
# config.yaml の修正
service:
host: 0.0.0.0 # 127.0.0.1 ではなく 0.0.0.0
http_port: 6333
VM 外には公開していないので、0.0.0.0 でバインドしても実害はありません。
② 埋め込みのタイムアウト(初回ロード)
bge-m3 の初回ロードは 60 秒以上かかることがあります。デフォルトの EMBED_TIMEOUT=60 で取り込みバッチを実行するとタイムアウトします。
# rag_common.py のデフォルト値
EMBED_TIMEOUT = int(os.getenv("EMBED_TIMEOUT", 300)) # 60 → 300
③ OpenWebUI のストリーミングタイムアウト
Web 検索 Worker の処理(SearXNG + LLM 要約)が 2 分を超えると、OpenWebUI 自体のタイムアウトで切断されます。Coordinator や Worker が正常でも「接続がタイムアウトしました」と表示されます。
起動バッチに環境変数を追加して解消しました。
@echo off
set AIOHTTP_CLIENT_TIMEOUT=600
set WEBUI_TASK_TIMEOUT=600
call open-webui serve
動作確認
OpenWebUI から「📚 Coordinator → RAG」を選んで試した質問と回答の例です。
質問:このクラスタの Reaper はどんな故障を検出する?
回答:Reaper は3種類の故障パターンを検出します。①dead_trigger(Worker が Redis から消えた状態でタスクが running のまま)、②zombie_trigger(タスクが running だが担当 Worker が死んでいる)、③ghost_trigger(Worker は Redis に登録されているが BRPOP ループがクラッシュして pending タスクが永久に詰まっている)。それぞれ別の worker_id や status の組み合わせで判定し、別 Worker へ自動再投入します。
出典:OpenWebUIから返事が来なくなった(mapleharp.jp/archives/127)
自分の記事を根拠に答えてくれています。ちゃんと動いています。
今後の展望
今のところ RAG はブログ記事と引継ぎ資料を対象にしています。server_ops コレクションに最新の引継ぎ資料を追加して「過去の設計判断を質問できる」状態にするのが直近の目標です。
また今回 web-worker は AI-Core に残しましたが、将来 MacBook Air M1 が届いたら移設する予定です。その際に web-worker と worker_rag を supports: ["web_search","rag"] の統合 Worker にまとめることも検討しています。
クラスタに「記憶」が生まれた感じがします。