SearXNG as your private search API: from a Docker container to a Python script that answers its own questions

You already know SearXNG can give a local LLM private web search. This piece goes further: turn it into a general JSON API your Python scripts can call, learn why it is the wrong tool for your own company documents, and combine both into a hybrid research pipeline.

SearXNG as your private search API: from a Docker container to a Python script that answers its own questions
Everything runs on hardware you own, and nothing on it reports home.

Who should read it?

Data scientists and developers with a working Ollama stack who want to script against their own search engine

Prerequisites

Docker and Docker Compose installed; ideally the SearXNG container from the setup article is already running.

Intro

Most Searxng stories end where the interesting part starts. The usual tutorial gets the container running, points Open WebUI at it, tests a chat, and closes. That is a fine outcome for a chat window, but Searxng is more than a chat feature. Once it returns JSON, it is a search API on your own hardware: any script you write can query the web anonymously, parse the results, and decide what to do with them. And the most common follow-up question, "Can I point it at my company documents?", has an answer that surprises people.

We are going to cover 3 things:

  1. The Python integration, which is shorter than you expect.
  2. The internal-documents question, where the honest answer is "Do not use Searxng".
  3. A hybrid pattern that uses both, so a script checks your own files first and only searches the web when the answer is not at home.

The basic container setup, the docker-compose file, and the Open WebUI wiring are all covered in the earlier article Give your local LLM fresh eyes: private web search with Searxng and Ollama. We won't repeat them here.

Quick recap: the setup you need

If you already run Searxng for Open WebUI, you have almost nothing left to do. The container is up, and the one setting that matters for scripting is already in place or takes a minute to add.

Searxng by default serves HTML, which humans like and programs do not. Your script needs JSON. In the Searxng settings volume, edit settings.yml and make sure the formats list includes it:

search:
  formats:
    - html
    - json

Restart the container afterward (docker restart searxng), then verify with a browser or curl against http://localhost:3002/search?q=test&format=json. If you see a JSON array of results, the API is live. If you get a forbidden error instead, the formats line is the first place to look; this single omission is the most common failure in every Searxng integration, scripted or not.

If you have not set the container up at all yet, the companion article walks through the compose file, the shared Docker network, and the Open WebUI connection. Come back when Searxng answers curl with JSON.

A private search API in a few lines of Python

Because Searxng speaks standard HTTP with a format=json parameter, the "integration" is a GET request. No SDK, no key, no client library, and no request ever leaves your machine with your name attached. Searxng takes your query, does it fan-out to upstream engines without identifying you, aggregates the results, and hands them back in one clean JSON object.

Here is a complete working client:

import requests

SEARXNG_URL = "http://localhost:3002"  # the host port you mapped in compose

def web_search(query: str, max_results: int = 5) -> list[dict]:
    """Search the web anonymously through your local Searxng."""
    response = requests.get(
        f"{SEARXNG_URL}/search",
        params={"q": query, "format": "json"},
        timeout=10,
    )
    response.raise_for_status()
    results = response.json().get("results", [])
    return [
        {"title": r.get("title"), "url": r.get("url"), "snippet": r.get("content")}
        for r in results[:max_results]
    ]

for hit in web_search("searxng json api settings.yml"):
    print(hit["title"], "\n ", hit["url"], "\n ", hit["snippet"][:120], "\n")

The JSON object contains more than the snippet above uses: categories, engine names, published dates, and sometimes scores. Start with title, url, and content, and add fields only when a script needs them.

3 things worth knowing BEFORE you build on this:

  • The address depends on where your script runs. A script on the host machine talks to localhost:3002 (or whichever port you mapped). A script inside another Docker container on the same network uses the service name instead, http://searxng:8080/search, which is exactly the URL Open WebUI uses. Mixing the two up is the second most common failure after the JSON formats.
  • Every result still comes from the public web. Searxng hides "who" is asking, not "what" is asked. Your query text travels to the upstream engines, stripped of your identity and history. For scripted workloads that means 2 things: your query stays visible to those engines, and your request rate lands on their infrastructure, so be polite with how often you fan out.
  • The endpoint is also a rate-limit and auth story waiting to happen. A Searxng instance that only listens on localhost or inside a Docker network is private by construction. The moment you expose it beyond that, you are running a public proxy for the whole internet's searches, which is its own project; keep it inside the network and you skip that problem entirely.

That last point is where the GDPR article earns its keep. Its core rule about the search layer is that sovereignty is a property of the whole pipeline: a locally hosted Searxng closes the path where user input constructed from personal data would otherwise travel to external public APIs tied to your identity. A Python client pointed at your own container inherits exactly that guarantee, which is precisely what you want when a nightly batch job feeds customer questions into a local model.

The internal documents question: Searxng is the wrong tool, here are the right ones

"So how do I configure Searxng to search internal company documents?". You do not, and the sooner you hear it, the sooner you build the thing that actually works.

Searxng is a metasearch engine for the public web.

It has no crawlers for your file server, no connector for your wiki, and no concept of an intranet. Pointing it at file:// paths does nothing. The architecture that searches your documents is a different machine, and there are 3 versions of it, ordered by effort.

Option 1: Open WebUI's built-in RAG, zero code

Open WebUI ships with retrieval-augmented generation (RAG). Drag a PDF, a text file, or a CSV into a chat, and it chunks the text, embeds the chunks locally, and feeds the relevant pieces to the model with your question. Nothing leaves the machine. You could ask for example "What does this contract say about notice periods?".

Option 2: Your own semantic search in Python

When you want programmatic control, the same machinery is a few lines away with sentence-transformers and FAISS:

from sentence_transformers import SentenceTransformer
import faiss
import numpy as np

model = SentenceTransformer("google/embeddinggemma-300m")

# one-time: build the index from your documents
chunks = load_and_chunk_documents()   # your own loader, plain text in, strings out
embeddings = model.encode(chunks, normalize_embeddings=True)
index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(np.array(embeddings))

# at query time
def find_relevant(question: str, k: int = 4) -> list[str]:
    q = model.encode([question], normalize_embeddings=True)
    _, hits = index.search(np.array(q), k)
    return [chunks[i] for i in hits[0]]

The trick that makes this better than keyword search is the embedding: "vacation policy" and "PTO guidelines" land near each other in vector space despite sharing no words. The index is a plain file on disk, the whole thing runs offline, and the chunks you retrieve become the context you hand to your local model.

Option 3: ElasticSearch or OpenSearch, at the scale of a company

Individual files suit drag-&-drop, but it does not suit a company. When the corpus is code repositories, wikis, and years of reports, replace the index with Elasticsearch or OpenSearch and point your stack at its internal API instead of Searxng.

The pattern is "Method 1" (see above) run in reverse: instead of Open WebUI asking a private web engine what the internet says, it asks a private corporate index what the company knows. Same container discipline, same privacy story, different data source.

The decision rule

  • use RAG for: uploads and a chat window
  • use FAISS route for: scripts and control
  • use ElasticSearch / OpenSearch for: whole departments' worth of documents

Be aware: in none of these 3 cases does Searxng appear, because its job, aggregating the public web anonymously, is simply a different job.

The hybrid pattern: ask home first, then the web

Here is where the 2 halves stop being alternatives. A research script or a local assistant gets the best results when it checks the internal knowledge base first and treats the web as the fallback. The 2 searches you now have compose in about 10 lines:

def answer_with_context(question: str) -> dict:
    local_hits = find_relevant(question)          # FAISS over your documents
    if local_hits:
        return {
            "source": "internal",
            "context": local_hits,
            "question": question,
        }
    return {
        "source": "web",
        "context": web_search(question, max_results=5),
        "question": question,
    }

Feed the returned context plus the question into your local model through Ollama, and you have an assistant that answers internal questions from internal files, at zero data-exit risk, and admits it needs the outside world when the answer is not at home. The GDPR note adds a subtlety worth keeping in mind: the 2 paths have different privacy profiles. Internal retrieval keeps data on the device entirely. The web path hides your identity but still shows the query text to upstream engines, so anything containing personal data should be rephrased or handled before it reaches Searxng.

The earlier article's comparison table said these 2 are complements, not rivals. This section is what that sentence looks like in code.

Where to go from here

  1. If Searxng is not running yet, follow the setup article, then confirm format=json returns results.
  2. Drop the Python client into your scripts folder and replace the next hosted search call you were about to make.
  3. When a script needs your own files, start with Open WebUI's upload; go FAISS when you want control, Elasticsearch when the whole company shows up.
  4. Wire the hybrid pattern into anything that answers questions for humans, and keep the rephrase-before-web-search rule wherever personal data is in the query.

Key Takeaway

The stack you end up with is small: one container, one embedding model, one index file, and a script that knows which door to knock on first. Everything on that list runs on hardware you own, and nothing on it reports home.