Web search: SearXNG, the router, the search guard
Part of the developer notes; the index is in CLAUDE.md.
Web search
-
SearXNG: the
openphalanx-searxngcontainer is pinned by digest and runs on the privateopenphalanxDocker network with no published ports. Its settings live incrates/openphalanx-core/searxng/settings.ymland are written to~/.local/share/openphalanx/searxng/settings.yml(0644, read-only mount,FORCE_OWNERSHIP=false). The secret is passed asSEARXNG_SECRET, and--log-driver nonekeeps queries out of logs. The GUI starts it before the backend whensettings.web_searchis on (the default; toggle on the Server page). The backend getsSEARXNG_URL=http://openphalanx-searxng:8080. -
Gateway:
POST /v1/search {query, max_results}returns{results: [{title, url, snippet}]}.- A chat request with
X-Oppx-Web-Search: auto(sent byoppxunless--no-web) goes through the router, in two steps:- Decide: a
yes/nocall to the same model (gateway.classify: one regex-constrained token, or a short free answer on reasoning models; see ask/edit routing in theoppx-chat-frontendtopic). It reads the user’s whole latest turn, every user message since the last assistant message, minus any paragraph that repeats the system prompt: Aider appends its editing rules to each message, and the router read those instead of the question. The few-shot examples sit in a fixed system prompt, so they’re cached. Median about 50 ms (about 260 ms on gpt-oss); 16/16 correct on mixed phrasings for both. - Query: only on
yes, a call writes the search query (constrained JSON, or free text on reasoning models). It’s told today’s date, anddrop_invented_datesremoves dates and years the model added that the user didn’t write. gpt-oss stamped queries with “2024-10-04” or today’s date and found stale pages. - Results say when they were retrieved, that they beat training data for things that change (versions, releases), and that links can’t be opened. gpt-oss otherwise tried to browse and returned nothing.
- Decide: a
- Speculative routing: the answer starts at the same time as the router, and its response is held unread until the router decides. With no search, the router costs about 27 ms of time-to-first-token (68 ms against 39 ms measured). With a search, the speculative answer is closed (SGLang aborts it) and the request is resent with
<web_search_results>appended to the end of the latest message, with an “untrusted” note. - Native tool calling was tried and doesn’t work with Qwen2.5-Coder-14B.
-
Search guard (
docker/server/searchguard.py, used bygateway.web_search, so by/v1/searchand the router alike): public engines suspend an address that asks too often, and SearXNG then returns an empty list, which a client can’t tell from “nothing found”. A day of agent evals did exactly that (Brave and Google CSE “Suspended: too many requests”, DuckDuckGo timing out), and one agent turn then reworded an empty search 41 times.- Cache: the same query within 15 minutes is answered from memory, 256 queries at most; nothing on disk. “The same” ignores case, punctuation, word order and filler words, and from four words up also covers nearly the same words (
NEAR, 0.8 of all the words either query has): agents reword a search far more often than they ask something new. Shorter queries must match word for word (“vite latest release” is not “react latest release”). - Pace: a burst of 6 searches, then one every 20 s, for the whole server. Over that the search is refused with
503andretry-after, not queued. - Pause: engines refusing (no results and every engine in SearXNG’s
unresponsive_engines) twice in a row pauses search for 5 minutes, doubling up to an hour while the probe after each pause is refused too. During a pause nothing is sent, the503says why (“web search is paused for 4 min: the search engines are refusing requests (brave: Suspended: too many requests; …)”), and the router isn’t asked either. An empty answer from engines that did respond is a result, and is cached. - Settings:
SEARCH_CACHE_S,SEARCH_BURST,SEARCH_REFILL_S,SEARCH_TRIP_AFTER,SEARCH_PAUSE_S,SEARCH_PAUSE_MAX_S.GET /admin/statushas asearchblock (paused, for how long, the reason, sent, from cache, paced); the app doesn’t show it yet. - Client: a search with nearly the words of an earlier one in the session (0.6 of them shared;
Agent._searched, the latest 30) gets the earlier results back with a note, without a request. The agent stops offeringweb_searchuntil the server’sretry-afterhas passed, in this turn and the next ones (Agent._search_off_until); three searches in a row with no results, or 8 in one turn (MAX_SEARCHES), take the tool away for the turn.oppx searchprints the server’s reason. - Tested:
scripts/test_searchguard.py(7 tests, no dependencies, in CI), and a second gateway from the working tree on spare ports in front of a fake SearXNG: a repeat from the cache (1 query reached the engine for 2 requests), the 7th search in a burst refused withretry-after: 5, two refusals then a pause (2 queries reached the engine for 4 requests), a cached query still answered while paused, and search back after the pause. Then on the real 3090 in the rebuilt image (the lifecycle example, Qwen2.5-Coder-14B at 32k): after two searchesGET /admin/statusshowedpaused: truewith “brave: Suspended: too many requests; duckduckgo: CAPTCHA; google cse: Suspended: too many requests”.searchguard.pyis a new build file, so the image tag changes and every cluster member needs the same code; a split start on the new image hasn’t been run yet. - When testing, don’t search in a loop.
scripts/eval_app.pyruns without web search unless--webis given.
- Cache: the same query within 15 minutes is answered from memory, 256 queries at most; nothing on disk. “The same” ignores case, punctuation, word order and filler words, and from four words up also covers nearly the same words (
-
Client:
oppx,oppx aiderandoppx proxysend the header by default;--no-webopts out (--webis accepted as a hidden no-op).oppx searchcalls/v1/search. -
Manual run (without the GUI):
docker network create openphalanx docker run -d --name openphalanx-searxng --network openphalanx --log-driver none \ -e FORCE_OWNERSHIP=false -e SEARXNG_SECRET=$(openssl rand -hex 32) \ -v $PWD/crates/openphalanx-core/searxng/settings.yml:/etc/searxng/settings.yml:ro \ searxng/searxng@sha256:c642712fcedcdaa78fac44f71eada86aff510745826ba1bd1a368211fea2ce7f # then add to the backend's docker run: --network openphalanx -e SEARXNG_URL=http://openphalanx-searxng:8080