Skip to content

Commit 2ebef42

Browse files
funsaizedclaude
andcommitted
feat(search): add Serper as a keyed fallback behind SearXNG (ADR-002)
Stage 2. SearXNG stays primary and the fallback engages only when a query returns nothing -- which is what a fully blocked engine pool looks like -- so the private path remains the default and the paid path is insurance. Serper was chosen because it returns organic URLs and snippets and nothing else. This stack already owns crawling, so providers bundling page content (Tavily, Exa, Firecrawl) would be paid for output it discards. Brave remains rejected pending a licensing question: its terms restrict storing API results, and this system exists to build a persistent corpus. Inert without SERPER_API_KEY, so acquisition behaves exactly as before until a key is present. Never raises: a quota error or transport failure returns no results, exactly as SearXNG does, so a dead fallback degrades acquisition rather than failing a job -- and unlike a silent CAPTCHA, a quota error is logged legibly. The key is excluded from the config repr, matching the judge key, and a test asserts it cannot reach a log that way. Fallback URLs go through the unchanged source policy and SSRF vetting, so a new provider cannot bypass either. Job progress records which provider served each query. 339 tests green in-container, covering result mapping, inert-without-key, quota errors, transport failure, entries without a link, and repr exclusion. ruff clean, compose valid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6193b59 commit 2ebef42

6 files changed

Lines changed: 198 additions & 3 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
SEARXNG_ENGINES=duckduckgo,bing,brave,startpage,mojeek,qwant
1010
# Delay between a plan's searches. Bursts are what trigger CAPTCHAs.
1111
SEARCH_PACING_SECONDS=2.0
12+
# Optional keyed search fallback, used only when every SearXNG engine returns
13+
# nothing. Leave blank to run on SearXNG alone. Never commit a real key.
14+
SERPER_API_KEY=
1215
SEARXNG_SECRET=
1316
# Shared bearer token between research-hub/research-worker and the Crawl4AI API.
1417
CRAWL4AI_API_TOKEN=

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ services:
225225
- SEARXNG_URL=http://hub-searxng:8080
226226
- SEARXNG_ENGINES=${SEARXNG_ENGINES:-duckduckgo,bing,brave,startpage,mojeek,qwant}
227227
- SEARCH_PACING_SECONDS=${SEARCH_PACING_SECONDS:-2.0}
228+
# Optional keyed fallback; unset means SearXNG only (ADR-002).
229+
- SERPER_API_KEY=${SERPER_API_KEY:-}
228230
- CRAWL4AI_URL=http://hub-crawl4ai:11235
229231
- CRAWL4AI_TOKEN=${CRAWL4AI_API_TOKEN:?Set CRAWL4AI_API_TOKEN in .env — generate with openssl rand -hex 32}
230232
- LOG_LEVEL=info
@@ -301,6 +303,8 @@ services:
301303
- SEARXNG_URL=http://hub-searxng:8080
302304
- SEARXNG_ENGINES=${SEARXNG_ENGINES:-duckduckgo,bing,brave,startpage,mojeek,qwant}
303305
- SEARCH_PACING_SECONDS=${SEARCH_PACING_SECONDS:-2.0}
306+
# Optional keyed fallback; unset means SearXNG only (ADR-002).
307+
- SERPER_API_KEY=${SERPER_API_KEY:-}
304308
- CRAWL4AI_URL=http://hub-crawl4ai:11235
305309
- CRAWL4AI_TOKEN=${CRAWL4AI_API_TOKEN:?Set CRAWL4AI_API_TOKEN in .env — generate with openssl rand -hex 32}
306310
- WORKER_LEASE_SECONDS=${WORKER_LEASE_SECONDS:-60}

research-hub/app/clients.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,73 @@ async def search(self, query: str, max_results: int = 20, language: str = "en")
424424
return []
425425

426426

427+
class SerperClient:
428+
"""Keyed Google-results fallback for when every SearXNG engine is blocked.
429+
430+
Deliberately a fallback, not a replacement: SearXNG stays the default so
431+
the private path is the normal one and the paid path is insurance
432+
(ADR-002). Returns URLs and snippets only -- this stack does its own
433+
crawling, so a provider bundling page content would be paid for output it
434+
discards.
435+
436+
Never raises: a failure returns no results, exactly as SearXNG does, so a
437+
dead fallback degrades acquisition rather than failing the job.
438+
"""
439+
440+
def __init__(self, api_key: str, base_url: str = "https://google.serper.dev",
441+
timeout: float = 30.0):
442+
self._api_key = api_key
443+
self._configured = bool(api_key)
444+
self._client = httpx.AsyncClient(base_url=base_url.rstrip("/"),
445+
timeout=timeout)
446+
447+
@property
448+
def configured(self) -> bool:
449+
return self._configured
450+
451+
async def close(self):
452+
await self._client.aclose()
453+
454+
async def search(self, query: str, max_results: int = 20,
455+
language: str = "en") -> list[dict]:
456+
if not self._configured:
457+
return []
458+
try:
459+
response = await self._client.post(
460+
"/search",
461+
headers={"X-API-KEY": self._api_key,
462+
"Content-Type": "application/json"},
463+
json={"q": query, "hl": language,
464+
"num": max(1, min(int(max_results), 100))},
465+
)
466+
if response.status_code != 200:
467+
# A quota error is a documented, legible failure -- unlike the
468+
# silent CAPTCHA this exists to survive.
469+
logger.warning("serper_search_failed", extra={"diagnostic": {
470+
"status_code": response.status_code,
471+
}})
472+
return []
473+
payload = response.json()
474+
except Exception as exc:
475+
logger.warning("serper_search_failed", extra={"diagnostic": {
476+
"failure_type": type(exc).__name__,
477+
}})
478+
return []
479+
480+
results = []
481+
for item in payload.get("organic") or []:
482+
url = item.get("link")
483+
if not url:
484+
continue
485+
results.append({
486+
"url": url,
487+
"title": item.get("title", ""),
488+
"snippet": item.get("snippet", ""),
489+
"published_at": item.get("date"),
490+
})
491+
return results
492+
493+
427494
def crawl_markdown_text(value: Any) -> str:
428495
"""Normalize Crawl4AI markdown across string and structured responses."""
429496
if isinstance(value, str):

research-hub/app/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ class Config:
2525
# succession is what triggers a CAPTCHA; SearXNG's own guidance is to back
2626
# off. Seconds per job against a ~180s job is a rounding error (ADR-002).
2727
search_pacing_seconds: float = 2.0
28+
# Keyed fallback for when every SearXNG engine is blocked (ADR-002 stage
29+
# 2). SearXNG stays primary: this engages only when a query returns
30+
# nothing, so the private path remains the default and the paid path is
31+
# insurance. Unset means disabled, and acquisition behaves exactly as
32+
# before. Excluded from repr so the key can never reach a log.
33+
serper_base_url: str = "https://google.serper.dev"
34+
serper_api_key: str = field(default="", repr=False)
2835
qdrant_collection: str = "research_corpus"
2936
embedding_dimension: int = 768
3037
chunk_size: int = 800
@@ -153,6 +160,9 @@ def load_config() -> Config:
153160
llm_model=os.environ.get("LLM_MODEL", "qwen3.5:9b"),
154161
embedding_model=os.environ.get("EMBEDDING_MODEL", "nomic-embed-text"),
155162
searxng_url=os.environ.get("SEARXNG_URL", "http://localhost:8080"),
163+
serper_base_url=os.environ.get("SERPER_BASE_URL",
164+
"https://google.serper.dev"),
165+
serper_api_key=os.environ.get("SERPER_API_KEY", ""),
156166
search_pacing_seconds=float(
157167
os.environ.get("SEARCH_PACING_SECONDS", "2.0")
158168
),

research-hub/app/research.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import redis.asyncio as redis_async
1818

1919
from .config import Config
20-
from .clients import OllamaClient, QdrantClient, SearXNGClient, Crawl4AIClient
20+
from .clients import (
21+
Crawl4AIClient, OllamaClient, QdrantClient, SearXNGClient, SerperClient,
22+
)
2123
from .context import classify_and_sanitize
2224
from .models import JobStatus, ResearchRequest
2325
from .document_store import DocumentStore
@@ -316,6 +318,10 @@ def __init__(self, cfg: Config):
316318
self.searxng = SearXNGClient(
317319
cfg.searxng_url, getattr(cfg, "searxng_engines", None)
318320
)
321+
self.serper = SerperClient(
322+
getattr(cfg, "serper_api_key", ""),
323+
getattr(cfg, "serper_base_url", "https://google.serper.dev"),
324+
)
319325
self.crawl4ai = Crawl4AIClient(cfg.crawl4ai_url, cfg.crawl4ai_token or None)
320326
self.documents = DocumentStore(cfg.document_store_path)
321327
self.retrieval = ScopedRetrievalService(
@@ -337,6 +343,7 @@ async def init(self):
337343
async def close(self):
338344
await self.ollama.close()
339345
await self.searxng.close()
346+
await self.serper.close()
340347
await self.crawl4ai.close()
341348
await self.claim_verifier.close()
342349
if self._redis:
@@ -665,6 +672,7 @@ async def run_job(self, job_id: str):
665672
issued_vectors = list(plan.vectors)
666673
search_pacing = getattr(self.cfg, "search_pacing_seconds", 0.0)
667674
snippet_ranking: dict | None = None
675+
search_providers: Counter = Counter()
668676
query_results: dict[str, set[str]] = {}
669677
covered_facets = 0
670678
issued_facets = 0
@@ -754,9 +762,30 @@ async def crawl_one(url: str, idx: int, round_selected: list[dict]):
754762
for index, query in enumerate(round_queries):
755763
if index and search_pacing > 0:
756764
await asyncio.sleep(search_pacing)
757-
results_by_facet.append(await self.searxng.search(
765+
facet_results = await self.searxng.search(
758766
query, max_results=max_sources, language=language
759-
))
767+
)
768+
# SearXNG stays primary. The keyed fallback engages
769+
# only when a query returns nothing -- which is what a
770+
# fully blocked engine pool looks like -- so the
771+
# private path remains the default and the paid path
772+
# is insurance (ADR-002 stage 2).
773+
if not facet_results and self.serper.configured:
774+
facet_results = await self.serper.search(
775+
query, max_results=max_sources, language=language
776+
)
777+
search_providers["serper" if facet_results
778+
else "none"] += 1
779+
if facet_results:
780+
logger.info("search_fallback_used", extra={
781+
"job_id": job_id, "phase": "search",
782+
"diagnostic": {"provider": "serper",
783+
"results": len(facet_results)},
784+
})
785+
else:
786+
search_providers[
787+
"searxng" if facet_results else "none"] += 1
788+
results_by_facet.append(facet_results)
760789
# Spend the crawl cap on the most relevant candidates rather
761790
# than on whatever the engine happened to rank first.
762791
if planning_enabled:
@@ -1083,6 +1112,7 @@ async def crawl_one(url: str, idx: int, round_selected: list[dict]):
10831112
"robots_respected": respect_robots,
10841113
"source_screening": source_screening,
10851114
"snippet_ranking": snippet_ranking,
1115+
"search_providers": dict(search_providers),
10861116
"query_plan": acquisition_provenance(
10871117
plan, issued_queries=issued_queries,
10881118
decisions=plan_decisions, rounds=rounds,

research-hub/tests/test_query_plan.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
import asyncio
13+
import json
1314

1415
import pytest
1516

@@ -431,6 +432,15 @@ def save(self, *_args, **_kwargs):
431432
reply_queue=reply_queue,
432433
vector_queue=vector_queue)
433434
orchestrator.searxng = Searx()
435+
436+
class NoFallback:
437+
"""The keyed fallback is unset in tests: SearXNG-only behaviour."""
438+
configured = False
439+
440+
async def search(self, *_args, **_kwargs):
441+
return []
442+
443+
orchestrator.serper = NoFallback()
434444
orchestrator.crawl4ai = Crawler()
435445
orchestrator.documents = Documents()
436446

@@ -1213,3 +1223,74 @@ def test_search_pacing_default_is_set_and_bounded():
12131223
_config(search_pacing_seconds=-1.0)
12141224
with pytest.raises(ValueError):
12151225
_config(search_pacing_seconds=99.0)
1226+
1227+
1228+
# --- ADR-002 stage 2: the keyed search fallback -----------------------------
1229+
1230+
def _serper(handler, key="test-serper-key"):
1231+
import httpx
1232+
from app.clients import SerperClient
1233+
subject = SerperClient(key)
1234+
subject._client = httpx.AsyncClient(
1235+
base_url="https://google.serper.dev",
1236+
transport=httpx.MockTransport(handler),
1237+
)
1238+
return subject
1239+
1240+
1241+
def test_serper_maps_organic_results_to_the_shared_shape():
1242+
import httpx
1243+
1244+
def handler(request):
1245+
assert request.headers["X-API-KEY"] == "test-serper-key"
1246+
body = json.loads(request.content)
1247+
assert body["q"] == "a query"
1248+
return httpx.Response(200, json={"organic": [
1249+
{"title": "T", "link": "https://a.example", "snippet": "S",
1250+
"date": "2026-01-02", "position": 1},
1251+
{"title": "U", "link": "https://b.example", "snippet": "V"},
1252+
]})
1253+
1254+
results = run(_serper(handler).search("a query", max_results=10))
1255+
assert results == [
1256+
{"url": "https://a.example", "title": "T", "snippet": "S",
1257+
"published_at": "2026-01-02"},
1258+
{"url": "https://b.example", "title": "U", "snippet": "V",
1259+
"published_at": None},
1260+
]
1261+
1262+
1263+
def test_serper_is_inert_without_a_key():
1264+
"""Unset means acquisition behaves exactly as it did before."""
1265+
from app.clients import SerperClient
1266+
subject = SerperClient("")
1267+
assert subject.configured is False
1268+
assert run(subject.search("a query")) == []
1269+
1270+
1271+
def test_serper_quota_error_returns_no_results_rather_than_raising():
1272+
import httpx
1273+
subject = _serper(lambda _r: httpx.Response(429, json={"message": "quota"}))
1274+
assert run(subject.search("a query")) == []
1275+
1276+
1277+
def test_serper_transport_failure_returns_no_results():
1278+
import httpx
1279+
1280+
def handler(request):
1281+
raise httpx.ConnectError("down", request=request)
1282+
1283+
assert run(_serper(handler).search("a query")) == []
1284+
1285+
1286+
def test_serper_drops_entries_without_a_link():
1287+
import httpx
1288+
subject = _serper(lambda _r: httpx.Response(200, json={"organic": [
1289+
{"title": "no link"}, {"title": "ok", "link": "https://c.example"},
1290+
]}))
1291+
assert [r["url"] for r in run(subject.search("q"))] == ["https://c.example"]
1292+
1293+
1294+
def test_serper_key_is_excluded_from_config_repr():
1295+
"""The key must never reach a log through a Config repr."""
1296+
assert "shhh" not in repr(_config(serper_api_key="shhh"))

0 commit comments

Comments
 (0)