ローカル LLM クラスタに RAG を載せた ― Qdrant・bge-m3・専用 VM で自分のブログを検索する

自宅クラスタに「自分だけのナレッジ検索」を追加した話です。今回は 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ステップで処理します。

  1. 埋め込み:クエリを bge-m3 でベクトル化(Ollama /api/embed)
  2. 検索:Qdrant の各コレクションを Cosine 類似度で検索(top-5)
  3. 合成:ヒットした抜粋を文脈に 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 にまとめることも検討しています。

クラスタに「記憶」が生まれた感じがします。