# 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.