前回、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と組み合わせも可能で、更に広がりをもたせるなど工夫もできるでしょう。




