querrio Search API v1 — hybrid retrieval with LLM reranking via REST
Per-request pipeline: intent parsing → hybrid retrieval (Postgres full-text + pgvector) → LLM reranking when score margins are tight. The response is JSON with top_hits, results, groups, suggestions and a click_url per hit. Authenticate with a browser key (pk_…, origin-bound) or a server key (sk_…). Language is resolved against the project languages. Indexing and ranking tuning run server-side.
1 · Widget (drop-in)
One script tag, renders in Shadow DOM with no CSS conflicts. Configure in the dashboard, override with custom CSS.
<script src="https://cdn.querrio.com/widget.js"
data-key="pk_your_browser_key" defer></script>2 · JS SDK (headless)
~2 KB, zero dependencies, type definitions at /sdk.d.ts. Includes debouncing, session ID, suggest request abort and tracking via sendBeacon.
<script src="https://cdn.querrio.com/sdk.js"></script>
<script>
const client = querrio.createClient({ key: 'pk_your_browser_key' });
const res = await client.search('example query');
// res.top_hits → 1–2 hits for teaser display
// res.results → remaining hits as a list
// res.groups → categories with count and hit IDs
// res.suggestions → "popular searches" as tags
// res.intent → detected intent, period, audience
client.trackClick(res.results[0], 1);
</script>TypeScript types
The complete types (incl. categories, tier weighting, intent and debug object) are available at /sdk.d.ts. Drop the file in or fetch it with curl — autocompletion and type checking then work for the SDK and REST responses.
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_your_browser_key' });
const res: querrio.SearchResponse = await client.search('example query');
res.top_hits[0].tier; // "top" | "list"
res.groups[0].category; // "event" | "article" | "product" | …
res.intent.date_from; // "2026-04-19" or null
client.trackClick(res.results[0], 1);Before the first keystroke: topic tags & popular searches
Everything shown before the first keystroke comes from GET /api/public/v1/schema. The autopilot maintains the lists automatically (or your team manually in the dashboard) – per website language.
| Field | Content | Where to show |
|---|---|---|
| widget.topic_tags_by_lang | Short topics (1–3 words, max. 8), e.g. “Theatre”, “Concerts” | As tags directly below the search field, always visible |
| widget.quick_chips_by_lang | Search phrases (max. 6), e.g. “Renew subscription” – from real searches, AI suggestions during the start phase | In the empty search field under “Popular searches” |
| entry_points | Most important target pages (url, title, page_kind) | Optionally as quick entries in the empty search field |
| widget.config.placeholders | Placeholder text per language | placeholder of the search field |
- Language: Always use the list for the current page language. If there is none, show nothing – never another language. topic_tags/quick_chips without _by_lang contain only the primary language.
- Click: Send the term as a search to /v1/search and report it as suggest_click – otherwise the autopilot can't learn.
- Caching: The lists change at most daily; a 1-hour browser cache is fine.
// 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"], // primary language (compatibility)
"quick_chips": ["Abonnement verlängern"] // primary language (compatibility)
}
}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] ?? []; // never show another language
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", { // click counts for the autopilot
method: "POST", headers: { "content-type": "text/plain" },
body: JSON.stringify({ key: KEY, kind: "suggest_click" }), // the term itself arrives via the search
});
});
// In the empty search field (focused, nothing typed yet): 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>
);
}Reference implementations: vanilla JS, React, server
Complete headless integration: suggest input, top-hit rendering, facet filters via groups, quick chips, zero-result state and click tracking. Replace the key and run.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Custom search with 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="What are you looking for?" autocomplete="off" />
<ul id="suggest" class="suggest" hidden></ul>
<div id="filters" class="chips"></div>
<div id="out"></div>
<p><small>Search by querrio</small></p>
<script>
const client = querrio.createClient({ key: "pk_your_browser_key" });
const $ = (id) => document.getElementById(id);
let last = null; // last response
let activeCat = null; // selected category filter
let timer;
// Suggestions while typing
$("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>Searching …</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];
// No hits: offer suggested terms
if (!all.length) {
$("filters").innerHTML = "";
$("out").innerHTML = `<p>Nothing matching yet. Try:</p>
<div class="chips">${r.suggestions.map((s) => `<button data-q="${s}">${s}</button>`).join("")}</div>`;
return;
}
// Category filters from the groups
$("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>Period: ${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>Popular searches</small></p><div class="chips">${r.suggestions
.map((s) => `<button data-q="${s}">${s}</button>`).join("")}</div>`
: "");
}
// Filter and keyword clicks
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); }
});
// Click tracking: analytics + autopilot learn from it
$("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>Go-live checklist
- Use only the browser key (pk_…) in the browser and allow your domain in the dashboard under “Widget & API”.
- Use the server key only on your server – never in browser code.
- Add click tracking – without it analytics and the autopilot can't learn.
- Keep the “Search by querrio” notice visible as required by your plan.
3 · REST API
Server key in the header x-querrio-key. Browser keys are restricted to the allowed origins.
curl -X POST https://api.querrio.com/api/public/v1/search \
-H 'content-type: application/json' \
-H 'x-querrio-key: sk_your_server_key' \
-d '{ "query": "example query", "limit": 10 }'Tracking with API integration (required for the autopilot)
querrio counts searches and shown results automatically when /v1/search is called. With your own UI, querrio cannot see clicks and feedback by itself – they must be reported. Otherwise click rates stay empty. The autopilot will then never demote anything, but it also cannot learn.
Visitor searches → POST /v1/search → search + shown results are counted
response contains query_id and per hit id + click_url
Visitor clicks → Option A: link via hit.click_url (counted automatically)
→ Option B: POST /v1/track { kind: "click", query_id, document_id, position }Option A · click_url (recommended)
Every hit includes click_url via go.querrio.com. The link counts the click and instantly redirects to the stored page URL. A click only counts if the hit was shown in exactly that search. No cookies, bots are not counted. url stays unchanged if you prefer direct links.
Zero-Click Actions · hit.action
For transactional query intent (book, tickets, buy, offer, enquire, reserve) the top matching hit carries action { kind, label, target_url, action_url }. Intent detection is deterministic + LLM; page actions are extracted at crawl time from schema.org potentialAction, CTA anchors and URL patterns (confidence ≥ 0.6). Ranking boost max +0.15, only when base score ≥ 0.3. action_url records action_click and 302-redirects to the crawl-validated target_url — no open redirect. label is localized; render it as a CTA on the hit.
<a href="${hit.click_url}" rel="nofollow">${hit.title}</a>Option B · Report events via API
// SDK
client.trackClick(hit, position); // click on a hit
client.trackSuggestClick(text, hit?.id); // click on a suggestion, topic tag or "popular searches"
// REST (text/plain so that sendBeacon works cross-origin)
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" }));Fields of POST /api/public/v1/track
| Field | Required | Content |
|---|---|---|
| key | yes* | Browser or server key (*alternatively header x-querrio-key) |
| kind | yes | Event type, see below |
| query_id | recommended | From the search response – links the event to the search |
| document_id | for clicks | hit.id of the clicked hit |
| position | for clicks | Position in the displayed list, starting at 1 |
| dwell_ms | for dwell | Time spent on the target page in milliseconds |
| session_id | optional | Random ID per browser tab (no cookie). Detects reformulations and abandons; send the same ID with /v1/search |
Event types (kind)
click– Click on a hit (most important signal for the autopilot)suggest_click– Click on a suggestion, topic tag or popular searchhelpful / not_helpful– Answer to “Was this helpful?”dwell– Time spent after the clickconversion– Goal reached (e.g. booking, purchase, sign-up)reformulation / abandon– Search rephrased, or left without a click
Responses
200 ok– stored400– kind missing or unknown401 / 403– invalid key, or domain not allowed429– too many events (120 per minute and visitor), respect the Retry-After header
Excluding tests & privacy
- Send test calls (search and tracking) with the server key and the header x-querrio-test: 1 – they then count neither in analytics nor towards your quota. Calls from the querrio dashboard and from tools like curl using a browser key are detected as tests automatically.
- No cookies, no fingerprinting; IP addresses are not stored. Tracking needs no consent banner.
- Whether clicks arrive is shown in the dashboard under “Setup” (click reporting yes/no).
Auto-Suggest
GET /api/public/v1/suggest?q= returns suggestions from successful past searches and from titles and categories in the index — so it works right after the first crawl. Activation, minimum characters, count and direct hits are set in the dashboard under “Widget & API”; if auto-suggest is off, the API responds with enabled: false and empty lists.
// Typeahead: suggestions while typing
const client = querrio.createClient({ key: "pk_your_browser_key" });
input.addEventListener("input", async () => {
const res = await client.suggest(input.value); // older requests are cancelled automatically
if (!res.enabled) return; // disabled in the dashboard
render(res.items); // [{ text: "example term", type: "popular" | "content" }]
render(res.hits); // direct hits: id, url, title, category, image_url
showDate(res.date_label); // detected time reference, e.g. "tomorrow"
});
// Report a click on a suggestion (feeds analytics and autopilot):
client.trackSuggestClick(item.text);
client.trackSuggestClick(hit.title, hit.id);Response structure
{
"query": "example query",
"intent": { "topic": "example topic", "audience": null },
"date_from": null, "date_to": null, "date_label": null,
"top_hits": [
{ "id": "…", "url": "https://…", "title": "Example page title",
"snippet": "…", "image_url": "https://…", "category": "article",
"tags": ["example"], "score": 0.91, "tier": "top",
"is_event": false, "event_start": null,
"event_location": null }
],
"results": [ … ],
"groups": [ { "category": "article", "label": "Articles", "count": 6, "hit_ids": [ … ] } ],
"suggestions": ["related search term", "another search term"],
"total": 7, "took_ms": 240, "query_id": "…"
}Debug mode
"debug": true (or ?debug=1) adds a debug object to the response: parsed intent, resolved date/audience with source, active filters and ranking weights, retrieval candidates before reranking, order before/after, zero-result rescue, teaser thresholds, per-hit score breakdown and stage timings. Server keys: always allowed. Browser keys: must be enabled under “Widget & API”, otherwise header x-asky-debug: denied and no debug object.
curl -X POST https://api.querrio.com/api/public/v1/search \
-H 'content-type: application/json' \
-H 'x-querrio-key: sk_your_server_key' \
-d '{ "query": "example query", "debug": true }'"debug": {
"version": 1,
"query": { "raw": "example query",
"normalized": "example query",
"language": "de", "limit": 10, "candidate_limit": 30 },
"intent": { "topic": "example topic", "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": "Example page title", "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": []
}Endpoints
- POST
/api/public/v1/searchIntent search. Returns top hits, list, categories and suggestions. - GET
/api/public/v1/suggest?q=Typeahead: suggestions and instant hits. - GET
/api/public/v1/schemaInitial state: topic tags, popular searches, entry pages, placeholders, categories and languages. - POST
/api/public/v1/trackReport clicks, dwell time and feedback. - GET
/api/public/v1/openapi.jsonOpenAPI 3.1 specification for codegen.
Limits & notes
- Rate limit: 120 requests/min per project, plus 30/min per IP with browser keys. When exceeded: 429 with retry-after header.
- Error codes: 400 missing key, 401 invalid key, 403 origin not allowed or bot, 500 internal.
- Click and feedback events feed the ranking as learning signals (autopilot).
- All endpoints send CORS headers, no proxy required.