A FastAPI service that implements a 3-stage web search pipeline using Pydantic AI agents and a pluggable search backend — SearXNG (default) or Staan Web for AI. Also exposes search as an MCP tool for integration with LLM-powered applications (e.g. Open WebUI).
flowchart TD
Req([SearchRequest<br/>query, context, no_cache])
Planner[Query Planner<br/>LLM · skipped for simple queries]
Search[Search Executor<br/>SearXNG · concurrent · deduped]
Fetch[Fetch & Extract<br/>optional · trafilatura]
Synth[Analyze + Synthesize<br/>LLM · cited summary]
Resp([SearchResult<br/>summary, sources])
Req --> Planner --> Search --> Fetch --> Synth --> Resp
subgraph Redis["Redis cache — fail-open · bypass via no_cache=true"]
direction TB
CP[("planner:v1<br/>key: normalized query + context + YYYY-MM-DD<br/>TTL 6h")]
CS[("searxng:v1<br/>key: normalized query + searxng_url<br/>TTL 5min")]
CF[("fetch:v1<br/>key: url + max_chars<br/>TTL 1h hit / 5min miss")]
end
Planner <-.-> CP
Search <-.-> CS
Fetch <-.-> CF
See the Caching section below for fail-open semantics, bypass behaviour, and backend choices.
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check. Pass ?deep=true to also verify SearXNG connectivity. |
/api/v1/search |
POST | Run the full search pipeline. Accepts {"query": "...", "context": "...", "no_cache": false}. Set no_cache: true to bypass Redis and force a fresh run. |
/ |
- | MCP Streamable HTTP transport. Exposes search_web tool (steps 1+2 only, no LLM synthesis). |
- Docker and Docker Compose
- Task (optional, for convenience commands)
- Access to an OpenAI-compatible LLM endpoint (Ollama, vLLM, OpenAI, etc.)
-
Copy the example env file and fill in your LLM settings:
cp .env.example .env
Required variables:
Variable Description SEARCH_AGENT_LLM_BASE_URLOpenAI-compatible API base URL SEARCH_AGENT_LLM_API_KEYAPI key for the LLM endpoint SEARCH_AGENT_LLM_MODELModel name to use -
Start the services:
task up
Or without Task:
docker compose up --detach --remove-orphans
This starts:
- agent (the search-agent service) on container port
8001(random host port unless overridden) - searxng on container port
8080(random host port unless overridden) - redis on container port
6379(random host port unless overridden) — shared cache for planner output, SearXNG results, and fetched page extracts
- agent (the search-agent service) on container port
-
Verify it's running:
# Find the mapped host port docker compose port agent 8001 # Check health curl http://localhost:<port>/health
All environment variables use the SEARCH_AGENT_ prefix (via pydantic-settings).
| Variable | Default | Description |
|---|---|---|
SEARCH_AGENT_DEBUG |
false |
Enables DEBUG-level logging across search_agent + pydantic_ai (full agent prompts, outputs, and the SearXNG engine list per query). Does not enable httpx DEBUG (which would leak Authorization headers). |
SEARCH_AGENT_LLM_BASE_URL |
http://localhost:11434/v1 |
OpenAI-compatible LLM endpoint |
SEARCH_AGENT_LLM_API_KEY |
not-needed |
API key for the LLM |
SEARCH_AGENT_LLM_MODEL |
llama3 |
Model name |
SEARCH_AGENT_LLM_STRICT_TOOLS |
true |
OpenAI strict tool definitions. Set to false for models that don't support it (e.g. Mistral). |
SEARCH_AGENT_LLM_TIMEOUT |
60 |
LLM request timeout (seconds) |
SEARCH_AGENT_SEARCH_PROVIDER |
searxng |
Search backend: searxng (self-hosted) or staan (Staan Web for AI) |
SEARCH_AGENT_SEARXNG_URL |
http://searxng:8080 |
SearXNG instance URL |
SEARCH_AGENT_SEARXNG_TIMEOUT |
15 |
SearXNG request timeout (seconds) |
SEARCH_AGENT_STAAN_API_KEY |
(empty) | Required when provider is staan — startup fails without it. Never logged. |
SEARCH_AGENT_STAAN_URL |
https://api.staan.ai |
Staan API base URL |
SEARCH_AGENT_STAAN_MARKET |
en-us |
Market/locale for Staan searches (e.g. da-dk) |
SEARCH_AGENT_STAAN_TIMEOUT |
10 |
Staan request timeout (seconds); docs recommend 8–10s with enrichment |
SEARCH_AGENT_STAAN_ENRICHMENT |
full_content |
full_content (full page body as markdown per result), extra_snippets (semantically scored chunks), or none (snippets only) |
SEARCH_AGENT_STAAN_MAX_SNIPPETS |
3 |
Max chunks per result (extra_snippets mode only, 1–10) |
SEARCH_AGENT_STAAN_MIN_SCORE |
0.1 |
Min chunk relevance (extra_snippets mode only, 0–1) |
SEARCH_AGENT_STAAN_CONTENT_MAX_CHARS |
5000 |
Per-result cap on enrichment text mapped into content |
SEARCH_AGENT_STAAN_CONTENT_MAX_RESULTS |
5 |
Only the top N (reranked) results keep content; the rest stay snippet-only. Enforced globally across all planner queries, so it bounds the whole synthesizer prompt (not each query). Together with the char cap this keeps the prompt inside the LLM context window. |
SEARCH_AGENT_STAAN_MAX_RESPONSE_BYTES |
10000000 |
Max bytes read from a Staan response before the read is aborted. Larger than the SearXNG cap because full_content returns whole page bodies per result. |
SEARCH_AGENT_SEARCH_PIPELINE_TIMEOUT |
90 |
Overall pipeline timeout (seconds) |
SEARCH_AGENT_DATETIME_TIMEZONE |
UTC |
Timezone for date/time in query planner prompts |
SEARCH_AGENT_DATETIME_FORMAT |
%A, %B %-d, %Y, %H:%M %Z |
Date format string |
SEARCH_AGENT_MCP_ALLOWED_HOSTS |
["search-agent:8001","localhost:8001"] |
Hosts allowed for MCP transport |
SEARCH_AGENT_SEARCH_SKIP_PLANNER_FOR_SIMPLE_QUERIES |
true |
Skip query planner for simple queries |
SEARCH_AGENT_SEARCH_MAX_QUERIES |
3 |
Cap on how many queries the planner's output is truncated to |
SEARCH_AGENT_SEARCH_MAX_RESULTS |
15 |
Max deduplicated results passed to the synthesizer / returned via MCP |
SEARCH_AGENT_SEARCH_SIMPLE_QUERY_MAX_WORDS |
15 |
Word-count threshold for the simple-query heuristic |
SEARCH_AGENT_SEARCH_SIMPLE_QUERY_MAX_QUESTIONS |
1 |
Max ? count before a query is considered multi-part |
SEARCH_AGENT_SEARCH_FETCH_PAGE_CONTENT |
false |
Opt-in: fetch and extract main text from result pages (via trafilatura) so the synthesizer sees a longer content field in addition to the SearXNG snippet. MCP path (search_web) is snippet-only regardless. |
SEARCH_AGENT_SEARCH_FETCH_MAX_PAGES |
5 |
When fetch is enabled, only the top N results are fetched |
SEARCH_AGENT_SEARCH_FETCH_TIMEOUT |
10 |
Per-page HTTP timeout (seconds) during fetch |
SEARCH_AGENT_SEARCH_FETCH_MAX_CHARS |
5000 |
Truncate extracted page text to this many characters |
SEARCH_AGENT_SEARCH_FETCH_MAX_BYTES |
2000000 |
Abort a page fetch if the response exceeds this many bytes |
SEARCH_AGENT_SEARCH_QUERY_PLANNER_PROMPT |
(built-in) | Override the query planner system prompt |
SEARCH_AGENT_SEARCH_ANALYZE_SYNTHESIZE_PROMPT |
(built-in) | Override the analyze+synthesize system prompt |
SEARCH_AGENT_CACHE_BACKEND |
redis |
Cache backend: redis (production — shared across pods), memory (tests/dev only, per-process), or disabled. |
SEARCH_AGENT_CACHE_REDIS_URL |
redis://redis:6379/0 |
Redis connection URL. Used only when CACHE_BACKEND=redis. |
SEARCH_AGENT_CACHE_FETCH_TTL |
3600 |
TTL (seconds) for successful page extracts. |
SEARCH_AGENT_CACHE_FETCH_NEGATIVE_TTL |
300 |
TTL (seconds) for failed/empty fetches (SSRF block, 4xx, byte-cap, empty extract) so transient failures recover quickly. |
SEARCH_AGENT_CACHE_SEARXNG_TTL |
300 |
TTL (seconds) for SearXNG result lists. Short by default because engine rankings shift quickly. |
SEARCH_AGENT_CACHE_STAAN_TTL |
300 |
TTL (seconds) for Staan result lists. |
SEARCH_AGENT_CACHE_PLANNER_TTL |
21600 |
TTL (seconds) for cached planner outputs. The cache key includes today's date (YYYY-MM-DD), so entries roll daily regardless of TTL. |
Three pipeline stages are cached in Redis to avoid repeating deterministic work across requests and pods:
| Stage | Cache key | Positive TTL | Negative TTL |
|---|---|---|---|
| Query planner | planner:v1:sha256(normalized_query + context + YYYY-MM-DD) |
CACHE_PLANNER_TTL (6h) |
not cached |
| SearXNG search | searxng:v1:sha256(normalized_query + searxng_url) |
CACHE_SEARXNG_TTL (5min) |
not cached (empty results allowed to retry) |
| Staan search | staan:v1:sha256(normalized_query + staan_url + market + enrichment + char_cap) |
CACHE_STAAN_TTL (5min) |
not cached (empty results allowed to retry) |
| Page fetch (per URL) | fetch:v1:sha256(url + max_chars) |
CACHE_FETCH_TTL (1h) |
CACHE_FETCH_NEGATIVE_TTL (5min) |
Fail-open: every cache operation is wrapped so that Redis errors or timeouts (300ms socket budget) degrade to a miss and the request continues unaffected. A dead or slow Redis will never break search.
Bypass: set "no_cache": true on a /api/v1/search request to force a fresh run. The flag only affects that request and doesn't clear existing entries.
Backend choice: production uses redis so state is shared across all pods. memory is intentionally limited to tests and local single-pod dev — it fragments per process and defeats the shared-hit-rate goal. disabled is useful for debugging freshness issues.
Namespace versioning: keys are prefixed with :v1: so a change to a cached value's shape (e.g. a new RawSearchResult field) can be invalidated by bumping the version in src/search_agent/cache.py instead of flushing Redis.
All Python commands run via Docker Compose -- never directly on the host.
task up # Start all services (required first)
task test # Run tests
task test -- tests/test_pipeline.py # Run a specific test file
task lint:check # Lint (src/ only)
task lint:format # Format (src/ only)
task lint # Lint + format check
task coding-standards:apply # Lint fix + formatHeads-up:
Taskfile.ymlcurrently setsSERVICE: search-agent, but the docker-compose service is namedagent. Until that variable is fixed, every task that runsdocker compose exec {{.SERVICE}}(task test,task lint,task format, etc.) will fail withservice "search-agent" is not running. Use the direct Docker Compose commands below in the meantime.
docker compose run --rm --no-deps agent uv run pytest
docker compose run --rm --no-deps agent uv run ruff check src tests
docker compose run --rm --no-deps agent uv run ruff format src testsTo run against the running containers (picks up the volume-mounted source):
docker compose exec agent uv run pytest
docker compose exec agent uv run ruff check src teststask build:image # Build and push with :latest tag
task build:image TAG=v1.0.0 # Build and push with custom tagImage is pushed to ghcr.io/aarhusai/search-agent.
The Dockerfile uses multi-stage builds with dev and prod targets. The build target is controlled by the ENV variable (defaults to dev).
- dev -- includes test/lint tools, source is volume-mounted for live reload
- prod -- runtime dependencies only (
uv sync --no-dev)
Services connect via a bridge network (app) and an external frontend network. SearXNG configuration lives in .docker/searxng/settings.yml. Redis data persists under .docker/data/redis/; the service runs with a 256MB maxmemory cap and allkeys-lru eviction so it can't grow unbounded.
- Python 3.12+
- FastAPI + Uvicorn
- Pydantic AI for LLM agents
- SearXNG for web search
- MCP (Model Context Protocol) for tool integration
- httpx for async HTTP
- trafilatura for optional main-content extraction from result pages
- Redis as the shared pipeline cache (via
redis-py) - uv for package management
- ruff for linting and formatting
- Task for task running