← Kembali ke Blog

Bikin Agent AI Punya Web Search Sendiri Pakai SearXNG

Agent kamu bisa manggil API, baca file, dan refactor satu modul tanpa keringetan. Tapi tanya soal rilis bulan lalu, dia jawab dari ingatan, dengan signature fungsi yang salah dan percaya diri penuh.

Tool search menyelesaikan itu. Kebanyakan orang langsung ambil API search berbayar: daftar, salin key, bayar per query. Jalan sih, sampai satu loop multi-agent nembak puluhan pencarian per task, atau key-nya kena rate limit pas demo.

SearXNG opsi yang di-host sendiri. Dia metasearch: query kamu dikirim ke Google, Bing, DuckDuckGo, Brave, dan sederet engine kecil, lalu hasilnya digabung, dibuang duplikatnya, dan diranking dalam bentuk JSON. Tanpa key, tanpa tagihan per query, dan query-nya nggak keluar dari infrastruktur yang kamu pegang.

Tapi bukan berarti dia API search plus-plus. SearXNG nggak crawl halaman, nggak bikin ringkasan, dan nggak merapikan snippet seperti Tavily. Yang kamu dapat: judul, URL, snippet, dan metadata engine. Ngubah itu jadi jawaban tetap kerjaan model.

Yang perlu disiapkan

  • Docker Engine plus plugin compose (cek pakai docker compose version)
  • Python 3.10+ buat kode client
  • httpx dan openai terpasang
  • Sepuluh menit

Langkah 1: jalankan compose resmi

SearXNG punya template compose dengan dua service: core buat aplikasinya dan valkey, yang baru kepakai kalau limiter dinyalakan.

mkdir -p ./searxng/core-config && cd ./searxng
curl -fsSL -O https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml
curl -fsSL -O https://raw.githubusercontent.com/searxng/searxng/master/container/.env.example
cp .env.example .env

Edit .env biar port-nya cuma bind ke loopback:

SEARXNG_HOST=127.0.0.1
SEARXNG_PORT=8080

Nyalakan sekali. Di boot pertama container menyalin template settings ke core-config/ dan bikin secret_key acak buat kamu:

docker compose up -d
docker compose logs --tail 10 core

Buka core-config/settings.yml. Isinya diawali use_default_settings: true, artinya file itu cuma nyimpen override dan sisanya ikut image. Tambahkan format JSON:

search:
  formats:
    - html
    - json

Restart aplikasinya biar file baru kebaca:

docker compose restart core

Output JSON mati secara default di SearXNG. Lewatin langkah ini dan tiap request API balas 403, bukan hasil kosong. Ini yang bikin orang sibuk nyari bug di client-nya.

Langkah 2: cek endpoint JSON

curl -s "http://127.0.0.1:8080/search?q=docker+multi-stage+build&format=json" | jq '.results[0] | {title, url, engine}'

Di instance baru hasilnya kira-kira begini:

{
  "title": "Multi-stage builds | Docker Docs",
  "url": "https://docs.docker.com/build/building/multi-stage/",
  "engine": "brave"
}

Response-nya bawa results, answers, infoboxes, suggestions, corrections, dan unresponsive_engines. Di image sekarang nggak ada field total hasil, jadi len(data["results"]) itu hitunganmu. Tiap result punya url, title, content (snippet), engine, engines (semua engine yang balikin URL itu), score, category, dan kadang publishedDate.

403 di sini cuma berarti satu hal: format-nya belum aktif. Cek search.formats dulu sebelum curiga ke tempat lain.

Langkah 3: parameter yang beneran ngaruh

q satu-satunya parameter wajib. Empat lainnya yang paling ngaruh buat agent:

  • categories: dipisah koma, misalnya general, news, it, science. Buat pertanyaan developer, it paling berguna: docs, Q&A, halaman package.
  • language: en, id, dan kode locale lain. Set eksplisit, kalau nggak, default instance yang nentuin.
  • time_range: day, month, atau year. Engine yang nggak dukung bakal mengabaikannya tanpa error.
  • pageno: nomor halaman kalau butuh hasil lebih banyak.

format=json wajib ada di tiap request. Format yang belum diaktifkan balas 403, dan itu perilaku resmi, bukan bug.

SearXNG menggabung URL duplikat antar engine lalu ranking hasil gabungannya. Array engines jadi sinyal relevansi murah: halaman docs yang muncul dari tiga engine biasanya lebih layak dipilih daripada satu post forum dari satu engine.

Langkah 4: client yang bisa dipanggil agent

Bagian pentingnya: timeout pendek, error dibalikin sebagai data bukan exception, dedupe per URL, dan snippet dibatasi biar satu halaman panjang nggak makan context window.

import os
import httpx

SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://127.0.0.1:8080")


def web_search(query: str, categories: str = "general", language: str = "en",
               time_range: str | None = None, limit: int = 8) -> list[dict]:
    params = {
        "q": query,
        "format": "json",
        "categories": categories,
        "language": language,
        "safesearch": 0,
    }
    if time_range:
        params["time_range"] = time_range

    try:
        resp = httpx.get(f"{SEARXNG_URL}/search", params=params, timeout=10.0)
    except httpx.RequestError as exc:
        return [{"error": f"search backend unreachable: {exc}"}]

    if resp.status_code == 403:
        return [{"error": "JSON format disabled: add json to search.formats"}]
    if resp.status_code == 429:
        return [{"error": "rate limited by the SearXNG limiter"}]
    resp.raise_for_status()

    seen, out = set(), []
    for item in resp.json().get("results", []):
        url = item.get("url")
        if not url or url in seen:
            continue
        seen.add(url)
        out.append({
            "title": item.get("title", "").strip(),
            "url": url,
            "snippet": (item.get("content") or "").strip()[:400],
            "engine": ",".join(item.get("engines") or [item.get("engine", "")]),
            "published": item.get("publishedDate"),
        })
        if len(out) >= limit:
            break
    return out

Jalanin dulu tanpa LLM buat mastiin jalurnya bener:

SEARXNG_URL=http://127.0.0.1:8080 python -c "from search_client import web_search; print(web_search('kubernetes pod disruption budget', limit=3))"

Balikin error sebagai dict kelihatan malas, tapi itu yang bikin agent tetap jalan. Model yang terima {"error": "JSON format disabled"} bisa retry dengan argumen lain atau ngasih tahu user. Stack trace cuma bikin run-nya berhenti.

Langkah 5: pasang sebagai tool

Client OpenAI-compatible apa pun bisa dipakai, termasuk model lokal di belakang Ollama atau vLLM. Schema tool sengaja dibuat kecil, karena tiap field tambahan jadi satu cara lagi buat model salah manggil.

import json
import os
from openai import OpenAI
from search_client import web_search

client = OpenAI()

tools = [{
    "type": "function",
    "function": {
        "name": "web_search",
        "description": "Search the web. Returns titles, URLs and snippets.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
                "categories": {"type": "string", "description": "general, news, it, science"},
                "time_range": {"type": "string", "enum": ["day", "month", "year"]},
            },
            "required": ["query"],
        },
    },
}]

model = os.environ.get("MODEL", "gpt-4.1-mini")
messages = [{"role": "user", "content": "What changed in Kubernetes 1.34 about sidecar containers?"}]

resp = client.chat.completions.create(model=model, messages=messages, tools=tools)
msg = resp.choices[0].message

if msg.tool_calls:
    messages.append(msg)
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(web_search(**args)),
        })
    resp = client.chat.completions.create(model=model, messages=messages, tools=tools)

print(resp.choices[0].message.content)

Begitu search jalan, tool berikutnya yang paling masuk akal adalah fetch: ambil URL teratas, tarik halamannya, buang HTML-nya, dan biarkan model mengutip dari sumber asli, bukan dari snippet.

Langkah 6: jaga tetap hidup

SearXNG nggak punya autentikasi. Simpan di loopback atau jaringan privat. Kalau harus diakses dari luar, taruh reverse proxy dengan auth di depannya.

Kalau instance-nya publik, nyalakan limiter. Limiter butuh Valkey, dan compose tadi sudah menjalankannya:

server:
  limiter: true

valkey:
  url: valkey://searxng-valkey:6379/0

Di belakang reverse proxy, limiter baca IP client dari X-Forwarded-For dan X-Real-IP. Kalau proxy nggak set header itu, semua request kelihatan dari satu alamat dan agent kamu sendiri yang kena throttle.

Engine juga bisa rusak. Google ngasih CAPTCHA, Bing rate limit, DuckDuckGo diem. SearXNG men-suspend engine setelah gagal, default sehari untuk access denied dan CAPTCHA, sejam untuk terlalu banyak request. Statusnya kelihatan per request di unresponsive_engines, dan bisa diatur di search.suspended_times.

Kalau satu engine terus gagal, matikan saja. Engine lebih sedikit berarti lebih jarang kena ban dan response lebih cepat:

engines:
  - name: google
    disabled: true

Update pakai dua perintah standar, setelah cek dulu perubahan di template compose-nya:

docker compose pull
docker compose up -d

Kapan pakai ini, kapan bayar API search

API hosted seperti Tavily, Brave, Exa, atau Serper ngasih uptime yang dikelola, snippet lebih rapi, dan endpoint tambahan seperti ekstraksi halaman atau generasi jawaban. Buat prototipe atau beban beberapa ribu query per bulan, langganannya lebih murah daripada satu sore ngoprek engine.

Scrape endpoint HTML DuckDuckGo sendiri itu opsi tanpa infrastruktur, dan rusak diam-diam begitu markup-nya berubah. Satu engine, tanpa dedupe, tanpa fallback waktu dia menganggap kamu bot.

SearXNG menang kalau volume search tinggi atau susah ditebak, kalau query nggak boleh keluar jaringan, dan kalau kamu mau beberapa engine di satu endpoint. Tukarnya jelas: uptime jadi tanggung jawabmu, dan engine bakal rewel.

Error yang kemungkinan besar kamu temui

  • 403 di format=json: json belum ada di search.formats. Restart core setelah edit.
  • HTTP 200 tapi nol hasil: query terlalu sempit, time_range terlalu ketat, atau filter bahasa menyingkirkan semuanya. Longgarkan salah satu.
  • unresponsive_engines terus nambah: engine memblokir IP instance. Cek docker compose logs core | grep -i captcha, kurangi jumlah kategori, dan tunggu masa suspend selesai.
  • Agent di container lain nggak bisa akses 127.0.0.1:8080: taruh keduanya di satu network compose dan pakai http://core:8080.
  • Edit settings nggak ngaruh: file harus ada di direktori core-config/ yang di-mount, dan service perlu restart. Error YAML muncul di log dan bikin container berhenti.
  • Limiter memblokir agent sendiri: limiter bekerja per IP client. Matikan di jaringan privat, atau atur trusted_proxies di limiter.toml.

Penutup

Dua perintah cukup buat sebagian besar setup: docker compose up -d dan satu curl dengan format=json. Sisanya tuning di sekitar dua itu.

Peningkatan berikutnya yang paling terasa adalah tool fetch, karena snippet cukup buat milih URL tapi nggak cukup buat mengutipnya dengan akurat.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis