Dev.to AI 🤖 Ai 👁 0 📖 3 min read

PrivaSearch: private AI search with SearXNG and a local LLM

I wanted AI-synthesized search answers without sending my queries to anyone. PrivaSearch is the result: SearXNG for metasearch, a local LLM served by Ollama for cited answers, and a small Node/Express API in between. Her

I wanted AI-synthesized search answers without sending my queries to anyone. PrivaSearch is the result: SearXNG for metasearch, a local LLM served by Ollama for cited answers, and a small Node/Express API in between. Here's how it's put together and what it does and doesn't guarantee.

The architecture is the privacy model

Four containers on one internal Docker network (privanet):

  • frontend: nginx serving a React/Vite UI and proxying /api
  • api: Node + Express orchestration, streaming over SSE
  • searxng: the metasearch backend, with a JSON API
  • ollama: the local LLM

Only the frontend publishes a host port (8080 by default). SearXNG, Ollama and the API have no ports: mapping, so they're unreachable from outside Docker. The only routine outbound traffic is SearXNG querying upstream search engines, which is the fundamental job of a metasearch engine.

Search and answer are two separate calls

The browser first asks GET /api/results for paginated SearXNG results, then calls POST /api/answer with the top N of those results to stream a RAG answer. Splitting them means you can change how many sources the model considers and regenerate the answer without re-querying the web.

The answer handler is short. It takes the caller's sources, builds the prompt, and streams tokens as they arrive from Ollama:

for await (const token of streamChat(messages, { model: MODEL, temperature: TEMPERATURE, signal: ac.signal })) {
  send('token', { text: token });
}

Ollama's /api/chat returns newline-delimited JSON when stream: true, and ollama.js parses it line by line. If the client disconnects, an AbortController cancels the generation.

Prompt discipline instead of hope

The system prompt (in prompt.js) constrains the model to the numbered results: write one direct answer in your own words, don't summarize sources one by one, cite each claim with [n], never add outside knowledge, and if the results don't contain enough information, reply with exactly "Insufficient information in the results." Each source is reduced to one line, [index] title — snippet, with no URLs, because the model doesn't need them and echoing them encourages source-by-source listing. The default temperature is 0.2 for factual answers. These prompt builders have unit tests that run in CI.

If the LLM is down or the model isn't pulled, the raw SearXNG results still render with a notice. If there are no results, you get an explicit "no results" message rather than a generated answer.

Few dependencies on purpose

The API's only runtime dependency is Express. Node's built-in fetch covers HTTP. Even the SOCKS5 client used to discover the Tor exit IP is a small dependency-free module (socks.js, about 60 lines). For a privacy project, fewer dependencies means less attack surface and less to audit.

Optional: hide from the engines too

By default, upstream engines see your real IP. Two optional overrides route SearXNG through a tunnel:

  • Tor (npm run up-tor): SearXNG goes through Tor's SOCKS proxy using socks5h://, so DNS also resolves through Tor. The trade-off is that it's slower, and some engines block Tor exit nodes.
  • VPN (npm run up-vpn): SearXNG runs inside Gluetun's network namespace. Gluetun's kill switch is on by default, so if the tunnel drops, searches fail closed rather than leaking your real IP.

What touches disk

Per the privacy statement in the README: your query lives only in API and SearXNG process memory, search results are held only for the request, and generated answers are streamed to your browser and not stored. What is persisted is the model weights (the ollama-models volume) and the SearXNG config. SearXNG is hardened with query logging off, no Redis attached, autocomplete off (no per-keystroke leakage) and remote images proxied. Thumbnails go through /api/image_proxy, so your browser never contacts remote image hosts, and the API refuses to proxy anything that isn't an image.

The honest exceptions

Two opt-in features make extra requests, and both can be disabled: Summarize fetches the one page you click (ALLOW_PAGE_FETCH=false turns it off), and the exit-location widget does one cached geo-IP lookup (SHOW_LOCATION=false turns it off). Both are disclosed in the app's privacy panel.

Performance reality

The default model is qwen2.5:1.5b (about 1 GB), chosen for CPU: it loads in seconds and the README reports about 8 s per warm answer. qwen2.5:0.5b is faster but weaker at citations, and llama3.1:8b gives better answers but is slow on CPU. The model is kept resident for 30 minutes (OLLAMA_KEEP_ALIVE) so you only pay the load cost once.

Try it

npm run setup
npm run pull-model
docker compose up -d

Then open http://localhost:8080. Source: https://github.com/HaidarESBER/privasearch

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.