diff --git a/crawler.py b/crawler.py index 2a92a09..169d704 100644 --- a/crawler.py +++ b/crawler.py @@ -125,7 +125,7 @@ WORKERS = int(os.getenv("JONASSEARCH_WORKERS", "3")) # Minimum delay between requests to the same host, per worker. # robots.txt can specify a larger crawl delay. -DEFAULT_DELAY = float(os.getenv("JONASSEARCH_DELAY", "3.0")) +DEFAULT_DELAY = float(os.getenv("JONASSEARCH_DELAY", "0.0")) ROBOTS_CACHE_SECONDS = int( os.getenv("JONASSEARCH_ROBOTS_CACHE_SECONDS", str(24 * 60 * 60)) diff --git a/search/api.py b/search/api.py index 41ef634..136abdc 100644 --- a/search/api.py +++ b/search/api.py @@ -443,8 +443,11 @@ async function run() { " · keine durchsuchbaren Begriffe gefunden"; const capHint = data.ranking_capped ? " · Ranking auf Top-Kandidaten eingegrenzt" : ""; + const degradedHint = data.ranking_degraded ? + " · Trefferreihenfolge vereinfacht (Suchbegriffe sehr häufig)" : ""; $("info").textContent = totalTxt + " · Seite " + data.page + - " · " + (data.took_ms / 1000).toFixed(2) + " s" + emptyHint + capHint; + " · " + (data.took_ms / 1000).toFixed(2) + " s" + emptyHint + + capHint + degradedHint; if (!data.results.length) { $("results").innerHTML = diff --git a/search/cli.py b/search/cli.py index 1fd3810..9e95aa9 100644 --- a/search/cli.py +++ b/search/cli.py @@ -244,8 +244,8 @@ def main(argv=None): print(f"Fehlerhafte Anfrage: {exc}", file=sys.stderr) return 2 except engine.SearchTimeout: - print("Zeitlimit überschritten – bitte eingrenzen (site=, nach=, " - "weitere Begriffe).", file=sys.stderr) + print("Die Suche dauert ungewöhnlich lange. Neuer Versuch oder ein " + "Filter (site=, nach=, weitere Begriffe) hilft.", file=sys.stderr) return 4 except engine.SearchEngineError as exc: print(f"Suchfehler: {exc}", file=sys.stderr) diff --git a/search/engine.py b/search/engine.py index 22e4472..64e079c 100644 --- a/search/engine.py +++ b/search/engine.py @@ -112,6 +112,19 @@ RANK_NORMALIZATION = int(os.getenv("JONASSEARCH_RANK_NORMALIZATION", "8")) # Ranking dann innerhalb dieses Fensters (Antwortflag ranking_capped). MAX_RANK_CANDIDATES = int(os.getenv("JONASSEARCH_MAX_RANK_CANDIDATES", "5000")) +# Notfall-Stufe: selbst das Kandidatenfenster ist zu groß (extrem häufige +# Begriffe). Dann wird in Etappen immer weiter reduziert, bis die Antwort +# innerhalb des Zeitlimits kommt. Der Nutzer bekommt trotzdem Ergebnis- +# seiten statt einer Fehlermeldung – bei reduzierter Trefferqualität +# (Antwortflag ranking_degraded). +RANK_CAP_RETRIES = int(os.getenv("JONASSEARCH_RANK_CAP_RETRIES", "3")) + +# Sortierung "relevance" fällt bei Überschreitung aller Ranking-Timeouts +# auf diese Basisreihenfolge zurück (kein ts_rank_cd nötig). +FALLBACK_SORT_SQL = ( + "COALESCE(p.crawled_at, p.discovered_at) DESC NULLS LAST, p.id ASC" +) + # ts_rank_cd-Gewichte. Reihenfolge im DB-Trigger: Titel = A, # Überschriften = B, Inhalt = C, (D = ungewichteter Rest). # Format: "A=1.0,B=0.6,C=0.3,D=0.1" @@ -572,6 +585,30 @@ def _headline_sql(): """ +def _emergency_search_sql(vector_col, filter_sql): + """ + Notfall-Query ganz ohne ts_rank_cd: Kandidaten werden in fester + Basisreihenfolge (neueste zuerst) geliefert. Auch bei extrem häufigen + Begriffen garantiert schnell, weil kein Ranking über die Treffermenge + nötig ist. + """ + return f""" + SELECT p.id, p.url, p.title, p.domain, p.tld, + p.http_status, p.content_type, + COALESCE(p.crawled_at, p.discovered_at) AS crawled_at, + 0.0 AS text_rank, + 1.0 AS freshness, + 0.0 AS score + FROM pages p + CROSS JOIN LATERAL ( + SELECT websearch_to_tsquery(%(ts_config)s, %(query)s) AS tsq + ) q + WHERE p.{vector_col} @@ q.tsq{filter_sql} + ORDER BY {FALLBACK_SORT_SQL} + LIMIT %(limit)s OFFSET %(offset)s + """ + + def _count_sql(vector_col, filter_sql): return f""" SELECT count(*) AS n @@ -600,6 +637,50 @@ def _round(value, digits=6): # ÖFFENTLICHE API # ============================================================ +def _fallback_search(cur, vector_col, sort, filter_sql, params): + """ + Mehrstufiger Fallback, wenn das volle Ranking das Zeitlimit + überschritten hat. Liefert so gut wie immer Ergebnis-Seiten statt + eines harten Timeout-Fehlers: + + 1. Kandidatenfenster MAX_RANK_CANDIDATES (gs. 5000): + normales, aber begrenztes Ranking. + 2. Danach in Etappen verkleinern (z. B. 2000 → 800 → 320 …), bis + die Antwort innerhalb des Zeitlimits kommt. + 3. Letzte Stufe: ganz ohne ts_rank_cd-Ranking – nur Index-Kandidaten + in definierter Reihenfolge (z. B. neueste zuerst). + + Rückgabe: (ranking_capped, ranking_degraded, rows). + """ + cap = int(params.get("rank_cap") or MAX_RANK_CANDIDATES) + capped = True + degraded = False + + # Stufe 2: Fenster in Etappen verkleinern. + for _ in range(max(0, RANK_CAP_RETRIES)): + try: + cur.execute(_search_sql(vector_col, sort, filter_sql, capped=True), + params) + return capped, degraded, cur.fetchall() + except psycopg2.errors.QueryCanceled: + cap = max(50, int(cap * 0.4)) + params["rank_cap"] = cap + degraded = True + + # Stufe 3: Notfall ohne Ranking – Kandidaten in Basisreihenfolge. + try: + cur.execute( + _emergency_search_sql(vector_col, filter_sql), params, + ) + return capped, True, cur.fetchall() + except psycopg2.errors.QueryCanceled: + raise SearchTimeout( + "Die Suche dauert ungewöhnlich lange – probiere es nochmal " + "oder nutze einen Filter wie site=, um schneller zu " + "Ergebnissen zu kommen." + ) + + def search(query, *, page=1, per_page=None, sites=None, tlds=None, after=None, before=None, sort="relevance", include_errors=False, include_non_html=False, @@ -665,6 +746,7 @@ def search(query, *, page=1, per_page=None, sites=None, tlds=None, snippets = {} total = None ranking_capped = False + ranking_degraded = False if not empty_tsq: _set_statement_timeout(cur, STATEMENT_TIMEOUT_MS) @@ -673,27 +755,15 @@ def search(query, *, page=1, per_page=None, sites=None, tlds=None, params) rows = cur.fetchall() except psycopg2.errors.QueryCanceled: - # Sehr häufiger Begriff: volles Ranking über alle - # Kandidaten ist zu langsam. Zweiter Versuch über - # ein begrenztes Kandidatenfenster (bounded latency). + ranking_capped, ranking_degraded, rows = _fallback_search( + cur, vector_col, sort, filter_sql, params, + ) log.info( - "Volles Ranking hat das Zeitlimit überschritten; " - "Fallback auf %d Kandidaten.", MAX_RANK_CANDIDATES + "Volles Ranking übersprungen (Timeout); " + "Fallback: %d Kandidaten%s.", + params["rank_cap"], + ", degradiert" if ranking_degraded else "", ) - ranking_capped = True - try: - cur.execute( - _search_sql(vector_col, sort, filter_sql, - capped=True), - params, - ) - rows = cur.fetchall() - except psycopg2.errors.QueryCanceled: - raise SearchTimeout( - "Suchanfrage hat das Zeitlimit überschritten – " - "bitte weiter eingrenzen (z. B. site=, after= " - "oder zusätzliche Begriffe)." - ) if with_snippets and rows: ids = [r["id"] for r in rows[:per_page]] @@ -744,6 +814,7 @@ def search(query, *, page=1, per_page=None, sites=None, tlds=None, "per_page": per_page, "sort": sort, "ranking_capped": ranking_capped, + "ranking_degraded": ranking_degraded, "total": total, "has_more": has_more, "results": results,