こんにちは、パレイド技術部の夏目です。ローカルLLMの一番の弱点は 知識カットオフ(学習した時点より新しい情報を知らないこと)です。手元で動く安心感と引き換えに、最新の話題には答えられません。
前回、SearXNG を Mac に Docker で立て、検索結果を JSON で取り出せるところまで整えました。今回はその SearXNG を Ollama から叩き、ローカルLLM が自分で最新情報を取りに行く構成を Python で実装します。
本記事は、ローカル LLM を Mac で動かす全体像をまとめたローカルLLM完全ガイドの「応用:Web検索」にあたる実装編です。導入やモデル選定からたどりたい場合は、そちらの目次を起点にしてください。
パレイドSearXNGをMacで構築する方法|DockerとJSON出力設定まで解説ChatGPTなどのクラウド型AIでは、Web検索はほとんど意識することなく統合されています。最新情報を取得し、文脈に沿って要約してくれる体験は、すで…
公式 Web Search API か、自前 SearXNG か
実はこの記事を最初に書いたあと、状況が一つ変わりました。Ollama に 公式の Web 検索機能(Web Search) が登場したのです(公式ドキュメント)。正式名称や無料枠・課金の扱いは流動的なので最新はドキュメントで確認してほしいのですが、まず読者の現在地として「公式ルートが増えた」ことは押さえておきます。
選択肢が二つに分かれたので、住み分けを先に示します。
| 構成 | 手軽さ | プライバシー | コスト | 向く場面 |
|---|---|---|---|---|
| 公式 Web Search API | ◎ 数行で呼べる | △ クエリが外部経由 | △ アカウント/APIキー要 | 最新情報でハルシネを減らしたい |
| 自前 SearXNG(本記事) | △ 自分で構築 | ◎ 完全ローカル | ◎ 無料 | クエリを外に出したくない・全部自分で制御したい |
手軽さなら公式 API が早いです。ただし検索クエリは外部サービスを経由します。この記事が扱うのは後者——検索まで含めてローカルで完結させ、何を検索したかすら外に出さない構成です。無料で、プライバシーが手元に残り、挙動を自分で握れる。そこに価値があると考えています。
仕組み — RAG と同じ原理でWeb検索をツール化する
実装の骨格は、以前の RAG(Retrieval-Augmented Generation、外部情報を検索して回答を補強する手法)と同じです。あのときは知識ベース(ChromaDB)を検索していましたが、今回はその検索先を Web に差し替えるだけです。
パレイドOllamaのFunction CallingでRAGを実装する方法(Python+Chroma DB)OllamaのEmbedding+ChromaDB構成を土台に、Function Callingを使ったRAG実装の考え方と最小構成を解説します。
Ollama の Function Calling(Tool Calling) を使い、web_search という関数を LLM に渡しておきます。LLM が「これは自分の知識では足りない」と判断したときだけ検索を呼び、結果を踏まえて回答する——いわば Web 検索対応の RAG です。Open WebUI などのフレームワーク経由でも実現できますが、ここでは仕組みを見通すために Ollama の API を直接叩く最小構成にします。
SearXNG 検索をツールにする(Python)
まず SearXNG を叩く web_search を用意します。SearXNG の Search API(/search に format=json)を呼び、LLM に渡しやすいよう title / url / snippet だけに整形して返します。
import os
import requests
from typing import Any, Dict
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://127.0.0.1:8080")
SEARXNG_LANG = os.getenv("SEARXNG_LANG", "ja")
SEARXNG_NUM = int(os.getenv("SEARXNG_NUM", "5"))
def web_search(query: str, num_results: int = SEARXNG_NUM) -> Dict[str, Any]:
# SearXNG の JSON API を叩く(事前に JSON 出力を有効化しておく)
params = {"q": query, "format": "json", "language": SEARXNG_LANG, "count": num_results}
r = requests.get(f"{SEARXNG_URL.rstrip('/')}/search", params=params, timeout=20)
r.raise_for_status()
# LLM に食わせやすいよう title/url/snippet に絞って整形
results = [
{"title": x.get("title"), "url": x.get("url"),
"snippet": x.get("content") or x.get("snippet")}
for x in r.json().get("results", [])[:num_results]
]
return {"query": query, "results": results}
SearXNG 側で JSON 出力を有効にしておくこと(前回記事の設定)が前提です。これを忘れると format=json が弾かれます。
Ollama にツールを渡してエージェント化する
次に、この関数を Ollama の tools として登録し、LLM が必要に応じて呼べるようにします。検証には Function Calling 対応モデル(llama3.1:8b や qwen3:8b など)が必要です。コードは ChatGPT に下書きを書かせ、エラーを見ながら詰めました。
import json, requests
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
TOOLS = [{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web via SearXNG. Use it when you need up-to-date facts.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}]
def ollama_chat(messages):
payload = {"model": MODEL, "messages": messages, "tools": TOOLS, "stream": False}
r = requests.post(f"{OLLAMA_HOST}/api/chat", json=payload, timeout=60, stream=True)
r.raise_for_status()
# 罠: stream:False でもサーバが NDJSON で返すことがある。最後の1件を採用
ctype = r.headers.get("Content-Type", "")
if "application/x-ndjson" in ctype or r.headers.get("Transfer-Encoding") == "chunked":
last = {}
for raw in r.iter_lines(decode_unicode=True):
if raw:
try: last = json.loads(raw)
except Exception: continue
return last
return r.json()
def run_agent(prompt: str, max_steps: int = 6) -> str:
messages = [
{"role": "system", "content": "最新情報や不確かな点は web_search を呼び、URL を引用すること。"},
{"role": "user", "content": prompt},
]
for _ in range(max_steps):
msg = ollama_chat(messages).get("message", {})
calls = msg.get("tool_calls") or []
if not calls: # tool 呼び出しが無ければ最終回答
return msg.get("content", "")
for tc in calls: # web_search を実行して結果を差し戻す
args = tc["function"].get("arguments") or {}
if isinstance(args, str): args = json.loads(args)
result = web_search(**args)
messages.append({"role": "tool", "name": "web_search",
"content": json.dumps(result, ensure_ascii=False)})
return "(max_steps に達しました。クエリを具体化してください)"
ここで一番ハマったのが NDJSON(Newline Delimited JSON、1行に1つの JSON を並べた形式) です。"stream": False を指定しても、サーバ側が逐次ストリーミングで返してくる場合があり、素直に .json() するとパースに失敗します。Content-Type を見て NDJSON なら行ごとに読み、最後のオブジェクトを最終応答として拾う——この一手で安定しました。最初はここに気づかず、タイムアウトのデバッグに時間を取られました。
動かしてみた — 最新情報を参照した回答
llama3.1:8b で「Ollama で自発的に Web 検索させたい。SearXNG を使う構成と注意点を要点だけ」と聞いた結果がこちらです(最終回答のみ抜粋)。
SearXNG と Ollama を組み合わせると、外部にクエリを出さずローカル完結のセキュアな
RAG 環境が作れます。要点は (1) SearXNG を Docker で起動し JSON 出力を有効化、
(2) Ollama 側で web_search を tool 登録、(3) 必要時のみ検索を呼ぶ、の3点です。
参照元:
- https://docs.ollama.com/capabilities/web-search
- https://searxng.readthedocs.io/en/latest/
検索結果を踏まえ、参照元 URL 付きで答えが返ってきました。正直な印象として、ローカル 8B モデルは 検索結果の要約と引用はこなす一方、回答の構成や「どこで止めるか」はまだ安定しません。同じ質問でも箇条書きが冗長に伸びることがあります。ここはクラウドの大型モデルにまだ譲る部分です。それでも、外部に何も出さずに知識カットオフを越えられる意味は大きいと感じます。
まとめ
完全ローカルのまま、ローカルLLM に最新情報を参照させることができました。手軽さを取るなら公式 Web Search API、ローカル完結とプライバシーを取るなら今回の自前 SearXNG——用途で選べばよいでしょう。検索先を ChromaDB と Web で切り替えれば、社内文書と最新情報を両取りする RAG にも広げられます。1年後、ローカルモデルの「止め際」がクラウドに追いつく頃には、この構成がそのまま実用ラインに乗るはずです。
パレイドローカルLLMのモデル選定とコンテキスト長|2026年版・Mac/Apple Silicon対応の選び方こんにちは、パレイド技術部の夏目です。 ローカル LLM を手元のマシンで実用レベルに仕上げるには、「どのモデルを選び」「コンテキスト長(文脈長…
パレイドRAG関連記事まとめ:基礎からOllama×ChromaDB実装、LangChain整理までpareido.jpのAIテキストカテゴリ内にあるRAG関連記事10本を、読む順番が迷わない形で整理しました。
