querrio Search API v1 — Hybrid Retrieval mit LLM-Reranking als REST-Endpoint
Pipeline pro Request: Intent-Parsing → Hybrid Retrieval (Postgres-Volltext + pgvector) → LLM-Reranking bei knappem Score-Abstand. Die Antwort ist JSON mit top_hits, results, groups, suggestions und click_url je Treffer. Auth per Browser-Key (pk_…, Origin-gebunden) oder Server-Key (sk_…). Die Sprache wird gegen die Projektsprachen aufgelöst. Indexierung und Ranking-Tuning laufen serverseitig.
1 · Widget (Drop-in)
Ein Script-Tag, rendert in Shadow DOM ohne CSS-Konflikte. Konfiguration im Dashboard, Overrides per Custom CSS.
<script src="https://cdn.querrio.com/widget.js"
data-key="pk_dein_browser_key" defer></script>2 · JS-SDK (headless)
~2 KB, keine Dependencies, Typdefinitionen unter /sdk.d.ts. Enthält Debouncing, Session-ID, Abort von Suggest-Requests und Tracking per sendBeacon.
<script src="https://cdn.querrio.com/sdk.js"></script>
<script>
const client = querrio.createClient({ key: 'pk_dein_browser_key' });
const res = await client.search('beispiel suchanfrage');
// res.top_hits → 1–2 Treffer für die Teaser-Darstellung
// res.results → restliche Treffer als Liste
// res.groups → Kategorien mit Anzahl und Treffer-IDs
// res.suggestions → "Häufig gesucht" als Tags
// res.intent → erkannte Absicht, Zeitraum, Zielgruppe
client.trackClick(res.results[0], 1);
</script>TypeScript-Typen
Die vollständigen Typen (inkl. Kategorien, Tier-Gewichtung, Intent und Debug-Objekt) liegen unter /sdk.d.ts. Datei ablegen oder per curl ins Projekt ziehen — danach greifen Autocompletion und Typprüfung für SDK und REST-Antworten.
curl -o types/querrio.d.ts https://cdn.querrio.com/sdk.d.ts
// app.ts
/// <reference path="./types/querrio.d.ts" />
const client = window.Querrio.createClient({ key: 'pk_dein_browser_key' });
const res: querrio.SearchResponse = await client.search('beispiel suchanfrage');
res.top_hits[0].tier; // "top" | "list"
res.groups[0].category; // "event" | "article" | "product" | …
res.intent.date_from; // "2026-04-19" oder null
client.trackClick(res.results[0], 1);Anzeige vor der ersten Eingabe: Themen-Tags & Häufig gesucht
Alles, was vor der ersten Eingabe angezeigt wird, liefert GET /api/public/v1/schema. Die Listen pflegt der Autopilot automatisch (oder dein Team manuell im Backend) – pro Website-Sprache.
| Feld | Inhalt | Wo anzeigen |
|---|---|---|
| widget.topic_tags_by_lang | Kurze Themen (1–3 Wörter, max. 8), z. B. „Theater“, „Konzerte“ | Als Tags direkt unter dem Suchfeld, immer sichtbar |
| widget.quick_chips_by_lang | Suchphrasen (max. 6), z. B. „Abonnement verlängern“ – aus echten Suchen, in der Startphase KI-Vorschläge | Im leeren Suchfeld unter „Häufig gesucht“ |
| entry_points | Wichtigste Zielseiten (url, title, page_kind) | Optional als Schnelleinstiege im leeren Suchfeld |
| widget.config.placeholders | Platzhaltertext je Sprache | placeholder des Suchfelds |
- Sprache: Immer die Liste der aktuellen Seitensprache nehmen. Gibt es keine, nichts anzeigen – nie eine andere Sprache. topic_tags/quick_chips ohne _by_lang enthalten nur die Hauptsprache.
- Klick: Begriff als Suche an /v1/search senden und als suggest_click melden – sonst lernt der Autopilot nicht mit.
- Caching: Die Listen ändern sich höchstens täglich; 1 Stunde Cache im Browser ist unproblematisch.
// GET /api/public/v1/schema (Header: x-querrio-key: pk_…)
{
"languages": ["de", "en"],
"entry_points": [{ "url": "https://…/abo", "title": "Abonnements", "page_kind": "page" }],
"widget": {
"primary_language": "de",
"config": { "placeholders": { "de": "Stellen Sie eine Frage", "en": "Ask a question" } },
"topic_tags_by_lang": { "de": ["Kulturfahrten", "Theater", "Abonnements", "Konzerte"], "en": ["Theatre", "Concerts"] },
"quick_chips_by_lang": { "de": ["Abonnement verlängern", "Veranstaltungen für Kinder"], "en": ["Renew subscription"] },
"topic_tags": ["Kulturfahrten", "Theater"], // Hauptsprache (Kompatibilität)
"quick_chips": ["Abonnement verlängern"] // Hauptsprache (Kompatibilität)
}
}const KEY = "pk_…", API = "https://api.querrio.com";
const lang = (document.documentElement.lang || "de").slice(0, 2);
const s = await fetch(API + "/api/public/v1/schema", { headers: { "x-querrio-key": KEY } }).then(r => r.json());
const pick = (byLang) => byLang?.[lang] ?? []; // nie eine fremde Sprache anzeigen
input.placeholder = s.widget.config.placeholders?.[lang] ?? input.placeholder;
tagRow.innerHTML = pick(s.widget.topic_tags_by_lang)
.map(t => `<button class="tag">${t}</button>`).join("");
tagRow.addEventListener("click", (e) => {
const t = e.target.closest(".tag"); if (!t) return;
input.value = t.textContent;
runSearch(input.value); // POST /api/public/v1/search
fetch(API + "/api/public/v1/track", { // Klick zählt für Autopilot
method: "POST", headers: { "content-type": "text/plain" },
body: JSON.stringify({ key: KEY, kind: "suggest_click" }), // Begriff selbst kommt über die Suche
});
});
// Im leeren Suchfeld (Fokus, noch nichts getippt): pick(s.widget.quick_chips_by_lang)function SearchStart({ schema, lang, onSearch }) {
const tags = schema.widget.topic_tags_by_lang?.[lang] ?? [];
return (
<div className="tags">
{tags.map(t => (
<button key={t} onClick={() => { onSearch(t); client.trackSuggestClick(t); }}>{t}</button>
))}
</div>
);
}Referenz-Implementierungen: Vanilla JS, React, Server
Vollständige Headless-Integration: Suggest-Input, Top-Hit-Rendering, Facetten-Filter über groups, Quick-Chips, Zero-Result-State und Klick-Tracking. Key ersetzen, lauffähig.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>Eigene Suche mit querrio</title>
<script src="https://cdn.querrio.com/sdk.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
input { width: 100%; padding: 12px 16px; border: 1px solid #ccc; border-radius: 999px; font-size: 16px; }
.suggest { list-style: none; padding: 0; margin: 4px 0; border: 1px solid #eee; border-radius: 12px; }
.suggest li { padding: 8px 14px; cursor: pointer; }
.suggest li:hover { background: #f4f4f5; }
.chips button { margin: 4px 4px 0 0; padding: 4px 12px; border: 1px solid #ddd; border-radius: 999px; background: #fff; cursor: pointer; }
.chips button.active { background: #111; color: #fff; }
.hit { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #eee; text-decoration: none; color: inherit; }
.hit img { width: 96px; height: 64px; object-fit: cover; border-radius: 8px; }
.top { background: #f7f7f8; border-radius: 12px; padding: 12px; }
small { color: #666; }
</style>
</head>
<body>
<input id="q" type="search" placeholder="Wonach suchst du?" autocomplete="off" />
<ul id="suggest" class="suggest" hidden></ul>
<div id="filters" class="chips"></div>
<div id="out"></div>
<p><small>Suche von querrio</small></p>
<script>
const client = querrio.createClient({ key: "pk_dein_browser_key" });
const $ = (id) => document.getElementById(id);
let last = null; // letzte Antwort
let activeCat = null; // gewählter Kategorie-Filter
let timer;
// Vorschläge beim Tippen
$("q").addEventListener("input", () => {
clearTimeout(timer);
const q = $("q").value.trim();
if (q.length < 2) { $("suggest").hidden = true; return; }
timer = setTimeout(async () => {
const s = await client.suggest(q);
if (!s.enabled || !s.items.length) { $("suggest").hidden = true; return; }
$("suggest").innerHTML = s.items
.map((i) => `<li data-text="${i.text}">${i.text}</li>`).join("");
$("suggest").hidden = false;
}, 150);
});
$("suggest").addEventListener("click", (e) => {
const text = e.target.dataset.text;
if (!text) return;
client.trackSuggestClick(text);
$("q").value = text;
$("suggest").hidden = true;
run(text);
});
$("q").addEventListener("keydown", (e) => {
if (e.key === "Enter") { $("suggest").hidden = true; run($("q").value); }
});
async function run(query) {
if (!query.trim()) return;
$("out").innerHTML = "<small>Suche läuft …</small>";
activeCat = null;
last = await client.search(query);
render();
}
function hitHtml(h, pos, top) {
return `<a class="hit ${top ? "top" : ""}" href="${h.url}" data-pos="${pos}" data-id="${h.id}">
${h.image_url ? `<img src="${h.image_url}" alt="" />` : ""}
<div><strong>${h.title}</strong><br /><small>${h.snippet ?? ""}</small></div>
</a>`;
}
function render() {
const r = last;
const all = [...r.top_hits, ...r.results];
// Keine Treffer: vorgeschlagene Begriffe anbieten
if (!all.length) {
$("filters").innerHTML = "";
$("out").innerHTML = `<p>Noch nichts Passendes gefunden. Versuch es mit:</p>
<div class="chips">${r.suggestions.map((s) => `<button data-q="${s}">${s}</button>`).join("")}</div>`;
return;
}
// Kategorie-Filter aus den Gruppen
$("filters").innerHTML = r.groups.map((g) =>
`<button data-cat="${g.category}" class="${g.category === activeCat ? "active" : ""}">${g.label} (${g.count})</button>`
).join("");
const visible = activeCat
? all.filter((h) => r.groups.find((g) => g.category === activeCat)?.hit_ids.includes(h.id))
: null;
$("out").innerHTML =
(r.date_label ? `<small>Zeitraum: ${r.date_label}</small>` : "") +
(visible
? visible.map((h, i) => hitHtml(h, i + 1, false)).join("")
: r.top_hits.map((h, i) => hitHtml(h, i + 1, true)).join("") +
r.results.map((h, i) => hitHtml(h, r.top_hits.length + i + 1, false)).join("")) +
(r.suggestions.length
? `<p><small>Häufig gesucht</small></p><div class="chips">${r.suggestions
.map((s) => `<button data-q="${s}">${s}</button>`).join("")}</div>`
: "");
}
// Filter- und Stichwort-Klicks
document.addEventListener("click", (e) => {
const t = e.target.closest("button");
if (!t) return;
if (t.dataset.cat) { activeCat = activeCat === t.dataset.cat ? null : t.dataset.cat; render(); }
if (t.dataset.q) { $("q").value = t.dataset.q; run(t.dataset.q); }
});
// Klick-Auswertung: Analytics + Autopilot lernen mit
$("out").addEventListener("click", (e) => {
const a = e.target.closest("a.hit");
if (!a || !last) return;
const hit = [...last.top_hits, ...last.results].find((h) => h.id === a.dataset.id);
if (hit) client.trackClick(hit, Number(a.dataset.pos));
});
</script>
</body>
</html>Checkliste vor dem Go-live
- Im Browser nur den Browser-Schlüssel (pk_…) verwenden und deine Domain im Backend unter „API & Widget“ freigeben.
- Server-Schlüssel ausschließlich auf deinem Server nutzen – nie im Browser-Code.
- Klick-Auswertung einbauen – ohne sie lernen Analytics und Autopilot nicht mit.
- Den Hinweis „Suche von querrio“ gemäß deinem Abo sichtbar lassen.
3 · REST-API
Server-Key im Header x-querrio-key. Browser-Keys sind auf die erlaubten Origins beschränkt.
curl -X POST https://api.querrio.com/api/public/v1/search \
-H 'content-type: application/json' \
-H 'x-querrio-key: sk_dein_server_key' \
-d '{ "query": "beispiel suchanfrage", "limit": 10 }'Tracking bei API-Einbindung (Voraussetzung für den Autopilot)
Suchen und angezeigte Treffer zählt querrio automatisch beim Aufruf von /v1/search. Klicks und Feedback sieht querrio bei einer eigenen Oberfläche nicht von selbst – sie müssen gemeldet werden. Sonst bleiben Klickraten leer. Der Autopilot stuft dann zwar nichts ab, lernt aber auch nicht dazu.
Besucher sucht → POST /v1/search → Suche + angezeigte Treffer werden gezählt
Antwort enthält query_id und je Treffer id + click_url
Besucher klickt → Option A: Link über hit.click_url (zählt automatisch)
→ Option B: POST /v1/track { kind: "click", query_id, document_id, position }Option A · click_url (empfohlen)
Jeder Treffer enthält click_url über go.querrio.com. Der Link zählt den Klick und leitet sofort auf die gespeicherte Seiten-URL weiter. Gezählt wird nur, wenn der Treffer in genau dieser Suche angezeigt wurde. Keine Cookies, Bots zählen nicht. url bleibt unverändert, falls du direkte Links bevorzugst.
Zero-Click Actions · hit.action
Bei transaktionaler Query-Intent-Klasse (book, tickets, buy, offer, enquire, reserve) erhält der oberste passende Treffer ein Objekt action { kind, label, target_url, action_url }. Intent-Detection deterministisch + LLM, Page-Action beim Crawl aus schema.org potentialAction, CTA-Ankern und URL-Mustern (Confidence ≥ 0.6). Ranking-Bonus max. +0.15, nur bei Basis-Score ≥ 0.3. action_url zählt action_click und leitet per 302 auf das beim Crawl validierte target_url — keine offene Weiterleitung. label ist lokalisiert; rendere es als CTA am Treffer.
<a href="${hit.click_url}" rel="nofollow">${hit.title}</a>Option B · Ereignisse per API melden
// SDK
client.trackClick(hit, position); // Klick auf einen Treffer
client.trackSuggestClick(text, hit?.id); // Klick auf Vorschlag, Tag oder "Häufig gesucht"
// REST (text/plain, damit sendBeacon cross-origin funktioniert)
navigator.sendBeacon("https://api.querrio.com/api/public/v1/track",
new Blob([JSON.stringify({ key, kind: "click", query_id, document_id: hit.id, position })],
{ type: "text/plain" }));Felder von POST /api/public/v1/track
| Feld | Pflicht | Inhalt |
|---|---|---|
| key | ja* | Browser- oder Server-Schlüssel (*alternativ Header x-querrio-key) |
| kind | ja | Art des Ereignisses, siehe unten |
| query_id | empfohlen | Aus der Suchantwort – verknüpft das Ereignis mit der Suche |
| document_id | bei Klick | hit.id des Treffers |
| position | bei Klick | Position in der angezeigten Liste, ab 1 |
| dwell_ms | bei dwell | Verweildauer auf der Zielseite in Millisekunden |
| session_id | optional | Zufällige ID pro Browser-Tab (kein Cookie). Erkennt Umformulierungen und Abbrüche; dieselbe ID auch bei /v1/search mitsenden |
Ereignisarten (kind)
click– Klick auf einen Treffer (wichtigstes Signal für den Autopilot)suggest_click– Klick auf Vorschlag, Themen-Tag oder „Häufig gesucht“helpful / not_helpful– Antwort auf „War das hilfreich?“dwell– Verweildauer nach dem Klickconversion– Ziel erreicht (z. B. Buchung, Kauf, Anmeldung)reformulation / abandon– Suche umformuliert bzw. ohne Klick verlassen
Antworten
200 ok– gespeichert400– kind fehlt oder ist unbekannt401 / 403– Schlüssel ungültig bzw. Domain nicht freigegeben429– zu viele Meldungen (120 pro Minute und Besucher), Header Retry-After beachten
Tests nicht mitzählen & Datenschutz
- Testaufrufe (Suche und Tracking) mit dem Server-Schlüssel und dem Header x-querrio-test: 1 senden – sie zählen dann weder in Auswertungen noch im Kontingent. Aufrufe aus dem querrio-Dashboard und von Werkzeugen wie curl mit Browser-Schlüssel werden automatisch als Test erkannt.
- Keine Cookies, kein Fingerprinting; IP-Adressen werden nicht gespeichert. Für das Tracking ist kein Einwilligungsbanner nötig.
- Ob Klicks ankommen, zeigt das Backend unter „Einrichtung“ (Klick-Meldung ja/nein).
Auto-Suggest
GET /api/public/v1/suggest?q= liefert Vorschläge aus erfolgreichen früheren Suchen und aus Titeln und Kategorien des Index — also auch direkt nach dem ersten Crawl. Aktivierung, Mindestzeichen, Anzahl und Direkttreffer stellst du im Backend unter „API & Widget“ ein; ist Auto-Suggest aus, antwortet die API mit enabled: false und leeren Listen.
// Typeahead: Vorschläge während der Eingabe
const client = querrio.createClient({ key: "pk_dein_browser_key" });
input.addEventListener("input", async () => {
const res = await client.suggest(input.value); // ältere Anfragen brechen automatisch ab
if (!res.enabled) return; // im Backend deaktiviert
render(res.items); // [{ text: "beispielbegriff", type: "popular" | "content" }]
render(res.hits); // Direkttreffer: id, url, title, category, image_url
showDate(res.date_label); // erkannter Zeitbezug, z. B. "morgen"
});
// Klick auf einen Vorschlag melden (fließt in Analytics und Autopilot):
client.trackSuggestClick(item.text);
client.trackSuggestClick(hit.title, hit.id);Antwortstruktur
{
"query": "beispiel suchanfrage",
"intent": { "topic": "beispielthema", "audience": null },
"date_from": null, "date_to": null, "date_label": null,
"top_hits": [
{ "id": "…", "url": "https://…", "title": "Beispiel-Seitentitel",
"snippet": "…", "image_url": "https://…", "category": "article",
"tags": ["beispiel"], "score": 0.91, "tier": "top",
"is_event": false, "event_start": null,
"event_location": null }
],
"results": [ … ],
"groups": [ { "category": "article", "label": "Beiträge", "count": 6, "hit_ids": [ … ] } ],
"suggestions": ["verwandter suchbegriff", "weiterer suchbegriff"],
"total": 7, "took_ms": 240, "query_id": "…"
}Debug-Modus
"debug": true (oder ?debug=1) ergänzt die Antwort um ein debug-Objekt: geparster Intent, aufgelöstes Datum/Zielgruppe mit Quelle, aktive Filter und Ranking-Gewichte, Retrieval-Kandidaten vor dem Reranking, Reihenfolge vorher/nachher, Zero-Result-Rescue, Teaser-Schwellen, Score-Breakdown je Treffer und Stage-Timings. Server-Keys: immer erlaubt. Browser-Keys: Freischaltung unter „API & Widget“ nötig, sonst Header x-asky-debug: denied ohne debug-Objekt.
curl -X POST https://api.querrio.com/api/public/v1/search \
-H 'content-type: application/json' \
-H 'x-querrio-key: sk_dein_server_key' \
-d '{ "query": "beispiel suchanfrage", "debug": true }'"debug": {
"version": 1,
"query": { "raw": "beispiel suchanfrage",
"normalized": "beispiel suchanfrage",
"language": "de", "limit": 10, "candidate_limit": 30 },
"intent": { "topic": "beispielthema", "audience": null,
"entities": { … }, "prompt_override": false },
"resolved": { "date_from": null, "date_to": null,
"date_label": null, "date_source": "none",
"audience": null, "audience_source": "none",
"now": "2026-04-18T09:12:00Z" },
"filters": { "date_from": null, "date_to": null,
"audience": null, "category": null, "tags": [] },
"weights": { "vector": 0.6, "fts": 0.25, "quality": 0.1,
"date_boost": 0.15, "audience_boost": 0.1 },
"candidates": [ { "rank": 1, "id": "…", "url": "https://…",
"title": "Beispiel-Seitentitel", "score": 0.83,
"is_event": false, "event_start": null } ],
"rerank": { "applied": true, "model_used": true, "before": [ … ], "after": [ … ] },
"rescue": { "attempted": false, "from_cache": false, "candidates": [], "used": null },
"tiering": { "top_score": 0.91, "max_top_hits": 2,
"tier_threshold": 0.12, "top_hit_ids": [ … ] },
"scores": [ { "id": "…", "position": 1, "final_score": 0.91,
"relative_to_top": 1, "tier": "top", "category": "article" } ],
"timings": { "config_ms": 12, "intent_embed_ms": 180, "retrieval_ms": 41,
"rerank_ms": 260, "total_ms": 512 },
"notes": []
}Endpunkte
- POST
/api/public/v1/searchIntent-Suche. Liefert Top-Treffer, Liste, Kategorien und Vorschläge. - GET
/api/public/v1/suggest?q=Typeahead: Vorschläge und Instant-Treffer. - GET
/api/public/v1/schemaStartzustand: Themen-Tags, Häufig gesucht, Einstiegsseiten, Platzhalter, Kategorien und Sprachen. - POST
/api/public/v1/trackKlicks, Verweildauer und Feedback melden. - GET
/api/public/v1/openapi.jsonOpenAPI-3.1-Spezifikation für Codegen.
Limits & Hinweise
- Rate-Limit: 120 Requests/Min. pro Projekt, mit Browser-Key zusätzlich 30/Min. pro IP. Bei Überschreitung 429 mit Header retry-after.
- Fehlercodes: 400 fehlender Key, 401 ungültiger Key, 403 Origin nicht erlaubt bzw. Bot, 500 intern.
- Klick- und Feedback-Events fließen als Lernsignal in das Ranking (Autopilot).
- Alle Endpunkte senden CORS-Header, ein Proxy ist nicht nötig.