# JonasSearch Suchmaschine (`search/`) Die eigentliche Websuchmaschine über der PostgreSQL-Datenbank `jonassearch`: Volltextsuche auf dem vorhandenen `tsvector`-Index (`pages.search_vector`, GIN-Index `idx_pages_search`) mit mehrstufigem Ranking, Filtern, Snippets, Bildersuche (Tab „Bilder“), Weboberfläche, HTTP-API und Kommandozeile. | Baustein | Zweck | Port | |---|---|---| | `search/api.py` | HTTP-API + Weboberfläche | **8083** | | `search/cli.py` | Kommandozeile (ohne Webserver) | – | | `search/engine.py` | Suchlogik (von api/cli genutzt) | – | | `search/jonassearch-engine.service` | systemd-Dienst | 8083 | Bestehende Dienste: Statserver 8081 (`web/app.py`), Vorschlagsverfeinerer 8082 (`priorityweb/app.py`), Suchmaschine **8083**. ## Wie gesucht wird 1. **Retrieval:** Die Anfrage wird per `websearch_to_tsquery` geparst und per `@@` gegen `pages.search_vector` über den GIN-Index ausgewertet. Nutzersyntax: `wort1 wort2` (UND), `"exakte phrase"`, `OR`, `-ausschluss`. Da die Suchvektoren mit der Textsuch-Konfiguration `simple` gebaut werden (DB-Trigger), matched die Suche ohne Stemming – exakte Lexeme, Groß-/Kleinschreibung egal. 2. **Ranking (in SQL):** ```text score = ts_rank_cd(Gewichte, search_vector, tsquery, Norm=8) – Titel=A, Überschriften=B, Inhalt=C * (1 + 0.25 · exp(−Alter_in_Tagen / 180)) – Aktualität (crawled_at/discovered_at) ``` Die Gewichte sind über `JONASSEARCH_RANK_WEIGHTS` einstellbar (`A=1.0,B=0.6,C=0.3,D=0.1`). Die ts_rank_cd-Normalisierung (Bitmaske 8 = Rang / (1 + log Dokumentlänge), `JONASSEARCH_RANK_NORMALIZATION`) verhindert, dass riesige Seiten kurze, präzise Treffer dominieren. Nicht in die Formel eingeflossen (noch): Link-Autorität aus `pages.links`/`source_url` – dafür liegt mit dem gespeicherten Webgraph die Grundlage bereit. **Fallback bei sehr häufigen Begriffen:** Ist das volle Ranking über alle Kandidaten nach `JONASSEARCH_STATEMENT_TIMEOUT_MS` nicht fertig (z. B. Einzelwort mit zigtausend Treffern), wird automatisch ein zweiter Versuch über die ersten `JONASSEARCH_MAX_RANK_CANDIDATES` (Default 5000) Kandidaten gefahren – die Antwort kommt dann garantiert, die Antwortmarke `ranking_capped: true` zeigt die Einschränkung an. 3. **Filter** (kombinierbar): `site=` (Domain inkl. Subdomains), `tld=`, `after=`/`before=` (ISO-Datum oder `24h`/`7d`/`30d`/`365d`), nur `http_status = 200` und nur HTML-Inhalt (jeweils abschaltbar). 4. **Snippets:** `ts_headline` wird nur für die Treffer der angeforderten Seite berechnet – `content` (TOAST-lastig) wird nie für die ganze Kandidatenmenge gelesen. Markierungen im Snippet: `⟦…⟧`. ## Bildersuche Der Tab **Bilder** (`GET /api/images`) nutzt denselben Suchvektor, dieselben Filter und dieselbe Relevanz-/Frischeformel wie die Websuche. Die Bild-URLs stammen aus `pages.pictures`, das der Crawler füllt (absolute http(s)-URLs aus ``, `srcset`, ``, `og:image`, `twitter:image`, `link rel="image_src"`; Migration: `add_pages_pictures.sql` im Projektstamm). Kandidatenseiten werden also anhand von Titel/Überschriften/Inhalt gefunden, nicht anhand des Bildinhalts. Pro Quellseite werden höchstens `JONASSEARCH_IMAGES_PER_SOURCE_PAGE` Bilder geliefert (Default 4), damit Seiten mit vielen Bildern die Ergebnisse nicht dominieren. Die Gesamtzahl ist eine Schätzung (`SUMME(LEAST(cardinality(pictures), Limit))`; `total: null` bei Timeout). Bei einem Ranking-Timeout greift derselbe begrenzte Kandidaten-Fallback wie bei der Websuche. Die Bilder werden direkt von der Original-URL geladen – kein Proxy, kein Download, keine lokale Bildspeicherung. ## Schutz der Produktivdatenbank (~10 GB) - Jede Operation nutzt eine eigene Verbindung mit `statement_timeout` (Default 10 s); Zeitüberschreitung → HTTP 504 bzw. CLI-Hinweis zum Eingrenzen, niemals eine blockierte Datenbank. - Die Gesamt-Trefferzahl läuft mit eigenem, kürzerem Timeout (4 s) und ist **best effort**: bei Überschreitung liefert die API `total: null` („viele Treffer“), die Ergebnisse bleiben unbeeinflusst. - `/api/stats` nutzt nur Schätzwerte aus `pg_class`/`pg_stats`, keine Full-Scans. ## HTTP-API (Port 8083) | Endpunkt | Beschreibung | |---|---| | `GET /` | Weboberfläche (Suchseite, Deutsch, Dark/Light) | | `GET /api/search` | Suche – Parameter siehe unten | | `GET /api/images` | Bildersuche – gleiche Filter wie `/api/search`, liefert `image_url`, `page_url`, `title`, `domain` | | `GET /api/suggest?q=begr` | Autocomplete (Präfix auf letztem Wort, `to_tsquery 'begr:*'`) | | `GET /api/page?id=42` oder `?url=…` | gespeicherte Seite (Inhalt gekürzt) | | `GET /api/stats` | Index-Statistik (Seiten, Domains, Queue, DB-Größe) | | `GET /health` | Healthcheck inkl. DB-Status | Parameter von `/api/search`: | Parameter | Bedeutung | Default | |---|---|---| | `q` | Suchbegriffe (Pflicht) | – | | `page`, `per_page` | Paginierung (max 50/Seite) | 1, 10 | | `site` | Domain-Filter, mehrfach möglich (`site=example.com`) | – | | `tld` | TLD-Filter, mehrfach möglich (`tld=de`) | – | | `after` / `before` | Zeitfilter: ISO oder `24h`/`7d`/`2w` | – | | `sort` | `relevance` (Default) oder `freshness` | relevance | | `include_errors=1` | auch Seiten ohne HTTP 200 | aus | | `include_non_html=1` | auch Nicht-HTML-Dokumente | aus | | `no_snippets=1` | Snippets weglassen (schneller) | aus | Beispiel: ```bash curl "http://localhost:8083/api/search?q=volltextsuche&site=postgresql.org&after=30d" ``` Antwort (Auszug): ```json { "query": "volltextsuche", "parsed_query": "'volltextsuche'", "page": 1, "per_page": 10, "total": 1234, "has_more": true, "results": [ { "id": 42, "url": "https://…", "title": "…", "domain": "…", "tld": ".de", "http_status": 200, "crawled_at": "2026-09-30T18:12:03+00:00", "score": 0.081203, "text_rank": 0.078341, "freshness": 1.074, "snippet": "… PostgreSQL ⟦Volltextsuche⟧ mit tsvector …" } ], "took_ms": 31.4 } ``` Parameter von `/api/images` sind `q` (Pflicht), `page`, `per_page` (Default 40, max 100), `site`, `tld`, `after`/`before` und `sort`. Antwort von `/api/images` (Auszug): ```json { "query": "katze", "page": 1, "per_page": 40, "total": 128, "has_more": true, "results": [ { "image_url": "https://…/katze.jpg", "page_url": "https://…/katzen", "title": "…", "domain": "…", "tld": ".de", "crawled_at": "2026-09-30T18:12:03+00:00", "score": 0.42, "text_rank": 0.40, "freshness": 1.05 } ] } ``` Fehler: `400` ungültige Parameter · `404` Seite unbekannt · `503` Datenbank nicht erreichbar · `504` Zeitlimit (bitte eingrenzen). ## Kommandozeile ```bash python3 search/cli.py "postgresql volltextsuche" python3 search/cli.py "crawler" --site example.com --sort freshness --limit 20 python3 search/cli.py "python" --after 7d python3 search/cli.py --suggest "suchmasch" python3 search/cli.py --stats python3 search/cli.py --page-id 42 python3 search/cli.py "test" --json ``` ## Installation als Dienst ```bash sudo cp /srv/serverjonas/search/search/jonassearch-engine.service \ /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now jonassearch-engine.service journalctl -u jonassearch-engine -f ``` Port-Auflösung: `--port` (manueller Start) > `JONASSEARCH_API_PORT` (`.env`) > **8083**. Der systemd-Dienst pinnt `JONASSEARCH_API_PORT=8083` *nach* dem EnvironmentFile und überschreibt damit einen eventuell abweichenden Wert aus `.env` – der Dienst läuft garantiert auf 8083. ## Konfiguration (.env) | Variable | Default | Bedeutung | |---|---|---| | `JONASSEARCH_DB_HOST/PORT/NAME/USER/PASSWORD` | wie crawler.py | Datenbankzugriff | | `JONASSEARCH_API_HOST` | `0.0.0.0` | Bind-Adresse | | `JONASSEARCH_API_PORT` | `8083` | Port (Dienst pinnt 8083) | | `JONASSEARCH_TS_CONFIG` | `simple` | Muss zur Trigger-Konfiguration des Suchvektors passen! | | `JONASSEARCH_VECTOR_COLUMN` | auto | tsvector-Spalte erzwingen (sonst automatische Erkennung) | | `JONASSEARCH_RANK_WEIGHTS` | `A=1.0,B=0.6,C=0.3,D=0.1` | ts_rank_cd-Gewichte | | `JONASSEARCH_FRESHNESS_WEIGHT` | `0.25` | Stärke des Aktualitätsbonus | | `JONASSEARCH_FRESHNESS_HALF_LIFE_DAYS` | `180` | Halbwertszeit der Aktualität | | `JONASSEARCH_RANK_NORMALIZATION` | `8` | ts_rank_cd-Normalisierung (8 = 1+log Länge, 0 = aus) | | `JONASSEARCH_MAX_RANK_CANDIDATES` | `5000` | Kandidaten-Fenster des Ranking-Fallbacks | | `JONASSEARCH_STATEMENT_TIMEOUT_MS` | `10000` | Timeout pro Suchstatement | | `JONASSEARCH_COUNT_TIMEOUT_MS` | `4000` | Timeout der Trefferzählung | | `JONASSEARCH_RESULTS_PER_PAGE` | `10` | Treffer pro Seite | | `JONASSEARCH_MAX_RESULTS_PER_PAGE` | `50` | Maximum pro Seite | | `JONASSEARCH_IMAGE_RESULTS_PER_PAGE` | `40` | Bildkacheln pro Ergebnisseite | | `JONASSEARCH_MAX_IMAGE_RESULTS_PER_PAGE` | `100` | Maximum Bildkacheln pro Seite | | `JONASSEARCH_IMAGES_PER_SOURCE_PAGE` | `4` | Höchstens so viele Bilder pro Quellseite | | `JONASSEARCH_HEADLINE_SOURCE_CHARS` | `100000` | `content`-Anteil für ts_headline | ## Tests ```bash python3 search/test_engine.py python3 search/test_api.py ``` Getestet werden die DB-freien Teile: Parameter-Normalisierung, Gewichte, Zeitfilter, SQL-Zusammensetzung (Web- und Bildersuche), Query-Parsing sowie Routing/Fehler-Mapping des `/api/images`-Endpunkts und die Einbindung des Bilder-Tabs. Alles ohne Datenbankzugriff.