前回の記事ではqueue分離・capability routing・モデル追加を実装した。今回はそれを土台に「Web検索」機能を追加した。完全ローカルの検索エンジンSearXNGをmarsに立て、AI-CoreにOllamaを追加し、Web検索Workerを動かすところまでを記録する。
今回やったこと
- marsにDocker + SearXNGを構築(内部LAN限定・JSON API有効)
- AI-CoreにOllamaを追加(ドメイン分類LLMの修正も兼ねる)
- Web検索Worker(
worker_web.py)を実装・動作確認 - CoordinatorにWeb検索ルーティングを追加(
tasks:webキュー・task_typeフィールド)
なぜWeb検索が必要か
現在の基盤の最大の弱点は「最新情報を知らない」ことだ。ローカルLLMはトレーニングデータのカットオフ以降の情報を持たない。「今日の天気は?」「最新のPythonリリースは?」といった質問に正確に答えられない。
フロンティアモデルはWeb検索を組み込んでいるが、完全ローカル・プライバシー重視という設計思想を崩したくない。SearXNGはオープンソースのメタ検索エンジンで、自前でホストすればWeb検索を完全ローカルで完結させられる。
marsはすでに専用回線を持つUbuntuサーバーで、WordPressを動かしている。メモリに余裕があり、SearXNGとの同居が合理的だった。
SearXNGの構築
marsにはDockerが入っていなかったので docker.io をインストールするところから始めた。Ubuntu 22.04では docker-compose も別パッケージで、sudo apt install docker-compose が必要だった(docker compose サブコマンドは使えない)。
marsのカーネルはIPv6が完全に無効化されており、SearXNGの最新版が使うWSGIサーバー(granian)がデフォルトでIPv6ソケットを開こうとして起動に失敗した。解決策は環境変数を1つ追加するだけだった。
version: '3.8'
services:
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "192.168.0.1:8888:8080" # 内部LANのみバインド
volumes:
- ./searxng:/etc/searxng:rw
environment:
- SEARXNG_BASE_URL=http://192.168.0.1:8888/
- GRANIAN_HOST=0.0.0.0 # IPv6無効カーネル環境では必須
dns:
- 8.8.8.8
- 1.1.1.1
次にJSON APIを有効化する。デフォルトはHTMLのみで、Worker側がAPIとして使うには json フォーマットを追加する必要がある。
# searxng/settings.yml
search:
formats:
- html
- json # ← これを追加
設定後、AI-Coreから動作確認。
curl -s "http://192.168.0.1:8888/search?q=Python&format=json" \
| python3 -m json.tool | head -10
25件の検索結果がJSONで返ってきた。
AI-CoreにOllamaを追加
ここで一つ問題が発覚した。
Coordinatorのドメイン分類LLM(キーワードマッチで判定できなかったときのフォールバック)は LLM_HOST=http://localhost:11434 がデフォルトになっている。しかしAI-CoreにはOllamaが入っていなかった。systemdのサービスファイルにも環境変数の上書きがなく、LLMフォールバックは毎回失敗してgeneralを返していたことになる。
過去の履歴を確認すると、初期はP50ホスト(192.168.0.2)のOllamaを使っていたが、のちに「不要」として削除していた。
AI-CoreにOllamaを入れることにした。メモリは6.7GB available、ディスクは110GB空きで問題なし。
curl -fsSL https://ollama.com/install.sh | sh
ollama pull hf.co/LiquidAI/LFM2.5-1.2B-JP-GGUF:Q4_K_M # ドメイン分類用
ollama pull llama3.2:3b # 要約用(後述)
systemdのoverride設定でLLM_HOSTを明示する。
# /etc/systemd/system/coordinator.service.d/override.conf
[Service]
Environment=LLM_HOST=http://localhost:11434
これでドメイン分類のLLMフォールバックが正しく動くようになった。
Web検索Workerの実装
Web検索Workerのやることはシンプルだ。
tasks:webキューを監視してタスクを受け取る- SearXNG JSON APIで検索(上位5件)
- 検索結果をLLMで要約してプロンプトへの回答を生成
- 結果をPostgreSQLに保存
既存の worker_base.py は使わず、Web検索専用の worker_web.py として独立して実装した。Coordinator登録・Heartbeat・Redisキュー監視などの骨格は同じだが、Ollamaへの推論呼び出しの代わりにSearXNG検索+LLM要約が入る。
Workerの supports は ["web_search"] で、models は空リスト(推論モデルを持たない)にした。
def process_web_search(task: dict):
prompt = task["prompt"]
# 1. SearXNGで検索
results = search(prompt)
# 2. LLMで要約
response = summarize(prompt, results)
# 3. DBに保存
update_task(task["task_id"], "done", response)
CPU推論のタイムアウト問題と対処
最初は要約モデルに llama3.2:3b を使った。短いプロンプトなら4秒程度で応答するが、検索結果5件を含む長いプロンプトだと120秒のタイムアウトに引っかかった。AI-CoreはCPU専用(GPUなし)なので3Bモデルでも重い。
対処は2つ。
- 要約モデルを
LFM2.5-1.2B-JP-GGUF(1Bモデル)に変更 - 検索結果のcontentを100文字に切り詰めてプロンプト長を削減
変更後は検索→要約→DB保存まで約30秒で完了するようになった。
CoordinatorにWeb検索ルーティングを追加
coordinator_api.pyに3箇所変更を加えた。
# 1. キュー定数を追加
QUEUE_WEB = "tasks:web"
# 2. RouteRequestにtask_typeフィールドを追加
class RouteRequest(BaseModel):
prompt: str
models: Optional[list[str]] = None
domain: Optional[str] = None
task_type: Optional[str] = None # "web_search" でtasks:webへ
# 3. /routeにweb_search分岐を追加
if req.task_type == "web_search":
# tasks:webキューに積んで即返す
r.lpush(QUEUE_WEB, json.dumps({...}))
return {"task_id": task_id, "queue": QUEUE_WEB, ...}
呼び出し側は task_type: "web_search" を指定するだけでよい。
curl -s -X POST http://localhost:8000/route \
-H "Content-Type: application/json" \
-d '{"prompt": "今日の東京の天気は?", "task_type": "web_search"}'
動作確認
Web検索Workerを起動してタスクを投入した。
# Workerのログ
Web Search Worker starting. ID=web-worker-aicore SearXNG=http://192.168.0.1:8888 LLM=http://localhost:11434
Redis OK
SearXNG OK
Ollama OK
[register] worker_id=web-worker-aicore supports=['web_search']
Waiting on queues ['tasks:web'] ...
# タスク投入後
[search] query='今日の東京の天気は?' hits=5
[summarize] model=hf.co/LiquidAI/LFM2.5-1.2B-JP-GGUF:Q4_K_M length=88
[web_search] done task_id=...
結果を確認すると、熱中症情報と天気の確認先について日本語で回答が返ってきた。SearXNGの検索結果をLLMが要約した形だ。
現在の構成
今回の追加でキューとWorkerの対応が以下のように整理された。
tasks:gpu ← rtx3070tiのみ監視(GPU専用モデル用)
tasks:cpu ← moon・rtx3070ti両方監視(CPUモデル用)
tasks:web ← web-worker-aicore監視(SearXNG検索用)
tasks:embedding ← 将来: RAGWorker(未実装)
capability routingの設計が先にあったおかげで、新しい種類のWorkerを追加するとき既存コードへの影響がほぼゼロだった。supports: ["web_search"] を宣言して tasks:web を監視するWorkerを書いて登録するだけ。
残っている課題
今回動作確認できたが、まだ暫定の状態が残っている。
worker_web.pyが手動起動のまま。systemdサービス化が次の作業になる。serviceファイルはすでに用意してあり、sudo systemctl enable web-worker.service で完了する。
Web検索の自動判定が未実装。現在は task_type: "web_search" を明示指定しないとWeb検索にならない。「今日」「最新」「ニュース」などのキーワードで自動的にWeb検索Workerへ振る仕組みを追加する予定だ。ドメイン分類の2段階方式(キーワードマッチ → LLMフォールバック)を使えばそのまま拡張できる。
web-worker-aicoreはAI-Coreに同居している暫定構成。将来のRAG基盤と合わせてP50上の別VMに移設する。
まとめ
SearXNG + Web検索Workerの追加で、ローカルLLMの最大の弱点だった「最新情報を知らない問題」に対処できるようになった。完全ローカル・プライバシー保護という設計思想を崩さずにWeb検索を組み込めた点が大きい。
次回はworker_web.pyのsystemd化と、Web検索の自動判定(キーワードによる自動ルーティング)を実装する予定だ。