家庭内分散AI基盤にWeb検索を追加:SearXNG構築とWeb検索Worker実装

前回の記事では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のやることはシンプルだ。

  1. tasks:web キューを監視してタスクを受け取る
  2. SearXNG JSON APIで検索(上位5件)
  3. 検索結果をLLMで要約してプロンプトへの回答を生成
  4. 結果を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検索の自動判定(キーワードによる自動ルーティング)を実装する予定だ。