ローカルLLMにWeb検索機能を実装する(Ollama+SearXNG構成)

AIテキスト

前回、SearXNGを導入して検索結果をJSON出力できる準備を整えました。今回はLLMがSearXNGを通じて最新情報を取得する方法をPythonで実装してみます。

OllamaのFunction CallingでWeb検索を呼び出す

LLMからの呼び出しにはOpen WebUIなどのフレームワークを利用する方法もありますが、本記事ではOllamaのAPIを直接使用し、PythonとFunction Callingによって検索処理を呼び出す構成を採用します。

この仕組みは、以前紹介したRAG(Retrieval-Augmented Generation)において、LLMが外部の知識ベースを検索して回答を補完する場合と同じ原理です。LLMが知識の不足を判断した際に検索処理を実行し、その結果をもとに回答を生成するローカルWeb検索対応RAGを構築します。

自発的にWeb検索を行うLLMのPython実装

本記事では、ChatGPTにコードを生成させて検証を行いました。モデルには、前回のRAG検証でも使用したllama3.1:8bを利用しています。今回は前回のコードを流用せず、新規に作成したほうが混乱が少ないと判断しました。

OllamaのAPIでFunction callingを使ってLLMが自発的にSearXNGで検索するコードを生成してください。モデルはllama3.1:8bを使用。SearXNGはdockerで導入指導環境で起動している。

初期に生成されたコードではタイムアウトが発生しデバッグが必要でしたが、エラー内容や出力結果を確認しながら修正を重ねた結果、NDJSONへの対応が不足していたことが原因と判明し、動作する状態となりました。RAGの検証時と同様に、Function Calling(Tool Calling)対応モデルでも応答の挙動が安定しない場合があるため、デバッグ目的でLLMの応答を表示する処理はそのまま残しています。

NDJSON(Newline Delimited JSON)は「1行ごとに1つの JSON オブジェクト」を並べた形式。ストリーミングはサーバが処理結果を逐次この形式で送り続けること。AIとのチャットでメッセージが少しずつ出力される形式と同じ。全体を待たず逐次受信できるメリットがあるが、今回のよう完全な JSON が必要な場合は中断行のバッファ処理が必要。

import os
import json
import requests
from typing import Any, Dict, List

# ========= 設定 =========
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://192.168.11.63:11434")
MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b")  # tool calling対応モデルに変更OK
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://127.0.0.1:8080")  # 例: http://127.0.0.1:8080
SEARXNG_LANG = os.getenv("SEARXNG_LANG", "ja")
SEARXNG_SAFESEARCH = int(os.getenv("SEARXNG_SAFESEARCH", "1"))  # 0/1/2
SEARXNG_NUM = int(os.getenv("SEARXNG_NUM", "5"))


# ========= SearXNG 検索ツール =========
def web_search(query: str, num_results: int = SEARXNG_NUM) -> Dict[str, Any]:
    """
    SearXNG Search API (JSON) を叩いて結果を返す
    返却は LLM に食わせやすいように title/url/snippet を中心に整形
    """
    params = {
        "q": query,
        "format": "json",
        "language": SEARXNG_LANG,
        "safesearch": SEARXNG_SAFESEARCH,
        "count": num_results,
    }
    r = requests.get(f"{SEARXNG_URL.rstrip('/')}/search", params=params, timeout=20)
    r.raise_for_status()
    data = r.json()

    results = []
    for item in data.get("results", [])[:num_results]:
        results.append(
            {
                "title": item.get("title"),
                "url": item.get("url"),
                "snippet": item.get("content") or item.get("snippet") or item.get("description"),
                "engine": item.get("engine"),
            }
        )

    return {
        "query": query,
        "results": results,
    }


# ========= Ollama Chat with Tools =========
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "web_search",
            "description": "Search the web via SearXNG and return top results (title/url/snippet). Use when you need up-to-date facts or citations.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Search query"},
                    "num_results": {"type": "integer", "description": "Number of results", "default": 5},
                },
                "required": ["query"],
            },
        },
    }
]

TOOL_IMPL = {
    "web_search": web_search,
}


def ollama_chat(messages: List[Dict[str, Any]], tools: List[Dict[str, Any]] = None) -> Dict[str, Any]:
    payload: Dict[str, Any] = {
        "model": MODEL,
        "messages": messages,
        "stream": False,
    }
    if tools:
        payload["tools"] = tools

    # Use stream=True to handle servers that return NDJSON streaming even when
    # "stream": False is requested. Fall back to .json() for normal responses.
    r = requests.post(f"{OLLAMA_HOST.rstrip('/')}/api/chat", json=payload, timeout=60, stream=True)
    r.raise_for_status()

    content_type = r.headers.get("Content-Type", "")
    if "application/x-ndjson" in content_type or r.headers.get("Transfer-Encoding", "") == "chunked":
        # Parse NDJSON streaming lines; keep last parsed object as final response
        last_obj = None
        for raw in r.iter_lines(decode_unicode=True):
            if not raw:
                continue
            try:
                obj = json.loads(raw)
            except Exception:
                # ignore non-json lines
                continue
            last_obj = obj
        return last_obj or {}
    else:
        return r.json()


def run_agent(user_prompt: str, max_steps: int = 6) -> str:
    """
    1) LLMに投げる
    2) tool_callが返ってきたら実行
    3) tool結果をmessagesに追加
    4) 最終回答が出るまで繰り返し
    """
    messages: List[Dict[str, Any]] = [
        {
            "role": "system",
            "content": (
                "You are a careful assistant.\n"
                "If the user asks for up-to-date information or anything uncertain, call web_search.\n"
                "After web_search, cite sources by listing URLs.\n"
                "If you don't need web_search, answer directly.\n"
            ),
        },
        {"role": "user", "content": user_prompt},
    ]

    for _ in range(max_steps):
        res = ollama_chat(messages, tools=TOOLS)
        msg = res.get("message", {})  # {"role": "assistant", "content": "...", "tool_calls": [...]}

        # デバッグ: LLMの生レスポンスを標準出力する
        print("=== LLM response ===")
        try:
            print(json.dumps(msg, ensure_ascii=False, indent=2))
        except Exception:
            print(msg)

        # 返答テキストがあるなら一旦積む
        if "content" in msg and msg["content"]:
            messages.append({"role": "assistant", "content": msg["content"]})

        tool_calls = msg.get("tool_calls") or []
        if not tool_calls:
            # tool callが無い => 最終回答とみなす
            return msg.get("content", "") or ""

        # tool_calls を順に実行
        for tc in tool_calls:
            fn = tc.get("function", {})
            name = fn.get("name")
            arguments = fn.get("arguments") or {}

            # arguments が文字列JSONで来る実装もあるので保険
            if isinstance(arguments, str):
                try:
                    arguments = json.loads(arguments)
                except json.JSONDecodeError:
                    arguments = {"query": arguments}

            if name not in TOOL_IMPL:
                tool_result = {"error": f"Unknown tool: {name}"}
            else:
                tool_result = TOOL_IMPL[name](**arguments)

            # デバッグ: 実行した tool の結果を標準出力
            print(f"=== Tool '{name}' result ===")
            try:
                print(json.dumps(tool_result, ensure_ascii=False, indent=2))
            except Exception:
                print(tool_result)

            # Ollama仕様に合わせて tool 返却を messages に入れる
            messages.append(
                {
                    "role": "tool",
                    "name": name,
                    "content": json.dumps(tool_result, ensure_ascii=False),
                }
            )

    return "(max_stepsに達しました。プロンプトを分割するか、検索クエリを具体化してください)"


if __name__ == "__main__":
    prompt = "OllamaでLLMに自発的なウェブ検索をさせたい。SearXNGを使う構成と注意点、必要な設定を要点だけ教えて。"
    print(run_agent(prompt))

検索結果を踏まえた最終回答例

llama3.1:8bを利用して実行した例です。最終回答のみ抜粋しています。

SearXNGとOllamaを組み合わせることで、完全無料かつインターネットにデータを流さないセキュアなRAG環境が構築できます。
ここでは、Docker Compose ひとつで一撃で立ち上げるハンズオン形式で紹介します。                                     
SearXNGはプライバシー重視のメタ検索エンジンであり、OllamaはLLM実行基盤です。この両者を組み合わせることで、最新情報
にも対応できる最強のローカルLLM環境が構築できます。                                                               
必要な設定としては、次のようなことが挙げられます。

*   Docker Composeのインストール
*   OllamaとOpen WebUIのセットアップ
*   SearXNGのセットアップ

また、SearXNGには以下の注意点があります。

*   SearXNGは分散型検索エンジンであり、データを流さないため、セキュアな環境が構築できます。
*   SearXNGにはオープンソースの特徴があり、コミュニティ主導のイノベーションです。

さらに、Ollamaではウェブ検索の「知能」を組み込むことが難しいと言われています。ただし、Home AssistantやOpen WebUIな
どのアプリケーション層で検索機能を「注入」することもできます。                                                    
Web searchはOllamaのREST APIとして提供されており、PythonおよびJavaScriptライブラリでの統合も可能です。これにより、
モデルごとに長時間の研究タスクが実行できるようになります。Web search APIへのアクセスにはAPIキーが必要であり、無料 のOllamaアカウントが必要です。                                                                                    
最終的な回答は次のようになっています。

SearXNGとOllamaを組み合わせることで、完全無料かつインターネットにデータを流さないセキュアなRAG環境が構築できます。
Docker Compose ひとつで一撃で立ち上げるハンズオン形式で構築します。また、Web search APIを使用することで、モデルご とに長時間の研究タスクが実行できるようになります。                                                                
参照元:

*   https://zenn.dev/shineos/articles/local-llm-rag-web-search-with-ollama
*   https://suzaku-tec.hatenadiary.jp/category/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0
*   https://www.glukhov.org/ja/post/2025/06/yacy-search-engine/
*   https://warokai.com/2026/01/30/ollama%E3%81%A7%E3%82%A6%E3%82%A7%E3%83%96%E6%A4%9C%E7%B4%A2%E3%82%92%E5%AE%9F%
E7%8F%BE%E3%81%99%E3%82%8B%E5%BE%B9%E5%BA%95%E8%A7%A3%E8%AA%AC%E3%81%A8%E3%81%9D%E3%81%9A%E3%81%AE%E9%99%90%E7%95%8C/                                                                                                               *   https://docs.ollama.com/capabilities/web-search

qwen3:8bの実行結果です。

OllamaでLLMにSearXNGを介した自発的なウェブ検索を実装するには以下が要点です:

1. **SearXNGの構築**  
   - Dockerでローカルに実行(例: `docker run -p 8888:8888 searxng/searxng`)  
   - インデックス設定:`/searxng/config/searxng.yaml`で検索対象のウェブドメイン/ファイルを指定  
   - HTTPS設定:`/searxng/config/searxng.yaml`でSSL証明書を設定(例: Let's Encrypt)

2. **Ollamaとの連携**  
   - LLMモデルにAPI呼び出し機能を追加(例: `ollama run`でカスタムモデルを構築)  
   - SearXNGのAPIエンドポイント(例: `https://localhost:8888/search`)を環境変数に設定  
   - モデルのpromptに検索ロジックを埋め込む(例: "必要ならSearXNGで検索: {query}")

3. **注意点**  
   - プライバシー:SearXNGのインデックスデータをローカルに保つ  
   - セキュリティ:API通信をHTTPSで暗号化  
   - パフォーマンス:並列検索を制限(`/searxng/config/searxng.yaml`で`max_threads`設定)  
   - ライセンス:SearXNGのMITライセンスに準拠

4. **代替案**  
   - Ollamaの`--model`引数で直接検索可能なモデル(例: `llama3`)を指定  
   - プロンプトに「インターネット情報を参照して答えよ」と明示的に指示

参考: [SearXNG公式ドキュメント](https://searxng.readthedocs.io/en/latest/) | [Ollamaモデルカスタマイズ](https://ollama.com/docs/models)

まとめ

今回は、SearXNGを利用してローカルLLMにWeb検索機能を追加する構成を実装しました。

これにより、ローカルLLMでも知識カットオフによる情報の制約を緩和し、最新情報を参照した回答が可能になります。

RAGと組み合わせも可能で、更に広がりをもたせるなど工夫もできるでしょう。

タイトルとURLをコピーしました