Searching¶
Once content is ingested, you can query it through the public API endpoint, programmatically from your services, or via the CLI.
Public API Endpoint¶
The bundle exposes a public endpoint for integrating intelligent search into your website frontend.
Route: GET /api/intelligent-search
Query parameters:
| Parameter | Required | Description |
|---|---|---|
q |
yes | The question to ask |
locale |
yes | Search locale |
buckets |
no | Comma-separated list of buckets to filter by |
webspace |
no | Webspace key filter |
prefer_recent |
no | 1/true/yes/on to prioritize recently updated documents |
since |
no | ISO-8601 date — only include documents updated after this point |
format |
no | sse (default) or json |
Every search is persisted; the uuid returned to the client is what the feedback endpoint refers to.
SSE Mode (default)¶
Response: Server-Sent Events stream with these event types:
| Event | Data | Description |
|---|---|---|
metadata |
{"uuid": "..."} |
Search identifier used for feedback submission |
progress |
{"step": "...", "messageKey": "...", "message": "..."} |
Processing status update |
chunk |
JSON-encoded string | A piece of the answer text |
sources |
{"sources": [...]} |
Array of cited source objects |
done |
{"complete": true} |
Stream is complete |
error |
{"message": "...", "code": N} |
An error occurred (the response still has HTTP 200) |
JavaScript Example¶
Note: Use
fetchwith aReadableStreamrather thanEventSource. The SSE stream is a one-shot response —EventSourcewould automatically reconnect after the stream ends and trigger duplicate requests.
const params = new URLSearchParams({ q: 'How do I get started?', locale: 'en' });
let searchUuid = null;
fetch(`/api/intelligent-search?${params}`).then(async (response) => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
let currentEvent = null;
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith('data: ') && currentEvent) {
handleEvent(currentEvent, line.slice(6));
currentEvent = null;
}
}
}
});
function handleEvent(event, data) {
if (event === 'metadata') {
searchUuid = JSON.parse(data).uuid;
} else if (event === 'progress') {
console.log('Progress:', JSON.parse(data).message);
} else if (event === 'chunk') {
document.getElementById('answer').textContent += JSON.parse(data);
} else if (event === 'sources') {
JSON.parse(data).sources.forEach((source) => {
console.log(`[${source.citation_number}] ${source.title}: ${source.url}`);
});
} else if (event === 'done') {
console.log('Search complete. UUID for feedback:', searchUuid);
} else if (event === 'error') {
console.error('Search error:', JSON.parse(data).message);
}
}
JSON Mode¶
Use format=json for a non-streaming JSON response.
Response (success):
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"answer": "Use the getting started guide.",
"sources": [
{
"id": "doc-1",
"title": "Getting Started",
"url": "https://example.com/docs/getting-started",
"bucket": "documentation",
"citation_number": 1,
"updated_at": "2026-04-01T10:00:00+00:00",
"similarity": 0.82,
"score": 0.74
}
]
}
Response (error):
{
"success": false,
"error": {
"message": "Query parameter \"q\" is required",
"code": 400
}
}
The HTTP status mirrors error.code for JSON errors (400 for validation, 500 for upstream failures). Stream errors instead use HTTP 200 with an error SSE event so partially-rendered UIs can recover gracefully.
Rate Limiting¶
The public endpoint includes per-IP rate limiting (enabled by default). When the limit is exceeded the response contains:
- HTTP
200with an SSEerrorevent carrying the rate-limit message (the JSON endpoint does not yet enforce rate limiting; rate limiting applies to the SSE path) Retry-AfterheaderX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetheaders
Configure limits in sulu_ai_platform.intelligent_search.ip_rate_limit (see Configuration).
Feedback Submission¶
After a search the client can submit thumbs-up/down feedback via POST /api/intelligent-search/feedback. See Feedback for the full contract.
Example UI¶
The bundle includes a built-in demo interface for testing intelligent search during development.
Enable it:
sulu_ai_platform:
intelligent_search:
enabled: true
example_ui:
enabled: true
Access it at: GET /ai-search
The UI lists every available webspace, locale, and provider-derived bucket, lets you toggle between streaming and JSON mode, renders the citation list, and exposes the feedback buttons against the persisted search.
This is intended for development and demos. Build your own frontend for production use — the SSE event format above shows what to consume.
Customizing the styling¶
The demo template (@SuluAiPlatform/intelligent_search/example.html.twig) is structured with Twig blocks so you can override the look without copying the whole file. The available blocks are:
| Block | Wraps | Typical use |
|---|---|---|
html_lang |
<html lang="…"> |
Set the document language |
title |
<title> content |
Rename the browser tab |
head_assets |
Tailwind + Marked <script> tags |
Swap the CSS framework or asset CDN |
stylesheets |
The inline <style> block |
Main styling override point |
head_extra |
Empty hook before </head> |
Inject extra stylesheets, fonts, or a Tailwind config |
body_class |
<body> class attribute |
Change the page background/base theme |
body |
Everything inside <body> except the script |
Replace the full layout |
header |
Title + intro paragraph | Rebrand the heading |
content |
The search card | Replace the card markup |
search_form |
The <form> and its fields |
Customize inputs and selectors |
results |
The chat + sources containers | Restyle answers and citations |
javascripts |
The closing <script> |
Adjust client behaviour (keep the element IDs) |
To override styling, place a template at templates/bundles/SuluAiPlatformBundle/intelligent_search/example.html.twig in your project. Extend the bundle's original template with the @! prefix (this references the un-overridden bundle template, avoiding infinite recursion) and override only the blocks you need:
{# templates/bundles/SuluAiPlatformBundle/intelligent_search/example.html.twig #}
{% extends '@!SuluAiPlatform/intelligent_search/example.html.twig' %}
{% block title %}Acme Knowledge Search{% endblock %}
{% block body_class %}bg-gray-900 text-gray-100 antialiased{% endblock %}
{# Pull in the original CSS, then append your own overrides #}
{% block stylesheets %}
{{ parent() }}
<style>
h1 span { color: #22d3ee; }
.citation:hover { background: #22d3ee; color: #0f172a; }
.feedback-button:hover:not(:disabled) { border-color: #22d3ee; color: #22d3ee; background: #082f49; }
</style>
{% endblock %}
{# Or load your own compiled stylesheet instead of the Tailwind CDN #}
{% block head_extra %}
<link rel="stylesheet" href="{{ asset('build/intelligent-search.css') }}">
{% endblock %}
Use {{ parent() }} to keep the bundle's default markup/styles and add to them, or omit it to replace a block entirely. The interactive features depend on the element IDs (ai-search-form, question, mode, locale, webspace, buckets, chat, sources, …) and the prose-ai, citation, and feedback-button classes — preserve these if you override search_form, results, or javascripts.
Programmatic Usage¶
Inject IntelligentSearchClientInterface into your services:
Non-Streaming¶
<?php
namespace App\Service;
use Sulu\Bundle\AiPlatformBundle\IntelligentSearch\Client\IntelligentSearchClientInterface;
use Sulu\Bundle\AiPlatformBundle\IntelligentSearch\Query\IntelligentSearchQuery;
final readonly class SearchService
{
public function __construct(
private IntelligentSearchClientInterface $intelligentSearch,
) {
}
public function search(string $question, string $locale): string
{
$query = IntelligentSearchQuery::create($question, $locale);
$result = $this->intelligentSearch->search($query);
return $result->answer; // $result->sources contains the citations
}
}
Streaming¶
<?php
namespace App\Service;
use Sulu\Bundle\AiPlatformBundle\IntelligentSearch\Client\IntelligentSearchClientInterface;
use Sulu\Bundle\AiPlatformBundle\IntelligentSearch\Query\IntelligentSearchQuery;
final readonly class SearchStreamService
{
public function __construct(
private IntelligentSearchClientInterface $intelligentSearch,
) {
}
public function streamSearch(string $question, string $locale): string
{
$query = IntelligentSearchQuery::create($question, $locale)->withStream();
$streamResult = $this->intelligentSearch->searchStream($query);
// Optionally observe progress events as the stream is consumed.
foreach ($streamResult->getAnswerStream(function (array $progress): void {
// $progress: ['step' => ..., 'messageKey' => ..., 'message' => ...]
}) as $chunk) {
echo $chunk;
}
foreach ($streamResult->sources as $source) {
echo \sprintf("[%d] %s (%s)\n", $source->citationNumber, $source->title, $source->url);
}
return $streamResult->getAnswer();
}
}
Query Builder¶
IntelligentSearchQuery is an immutable value object with a fluent API:
$query = IntelligentSearchQuery::create('How do I get started?', 'en')
->withBuckets(['pages', 'articles'])
->withWebspaceKey('example')
->withPreferRecent()
->withSince('2026-01-01T00:00:00Z');
| Method | Description |
|---|---|
create(string $question, string $locale) |
Static factory (required parameters) |
withBuckets(list<string> $buckets) |
Filter results by content buckets |
withWebspaceKey(?string $key) |
Filter by Sulu webspace |
withPreferRecent(bool $prefer = true) |
Prioritize recently updated documents |
withSince(?string $isoDate) |
Only include documents updated after this ISO-8601 date |
withStream(bool $stream = true) |
Enable streaming mode (required before calling searchStream()) |
Calling search() with stream set to true, or searchStream() with stream set to false, throws InvalidArgumentException.
Result Objects¶
IntelligentSearchResult (non-streaming):
- string $answer — The generated answer
- list<IntelligentSearchSource> $sources — Cited sources
IntelligentSearchStreamResult (streaming):
- getAnswerStream(?callable $onProgress = null): Generator<string> — yields answer chunks; the optional callback is invoked once per progress event
- getAnswer(): string — returns the full answer (consumes the stream if it hasn't been consumed yet)
- list<IntelligentSearchSource> $sources — populated after the stream delivers them
IntelligentSearchSource:
| Property | Type | Notes |
|---|---|---|
id |
string |
Document identifier |
title |
string |
Document title |
url |
string |
Document URL |
bucket |
?string |
Content bucket |
citationNumber |
int |
1-based citation reference |
updatedAt |
?string |
ISO-8601 timestamp |
similarity |
?float |
Vector similarity |
score |
?float |
Combined relevance score |
Exceptions¶
Both search() and searchStream() may throw:
IntelligentSearchRequestFailedException— transport error or non-2xx response from the platformInvalidIntelligentSearchResponseException— malformed payload (missing fields, invalid SSE events, etc.)\InvalidArgumentException—streamflag mismatch (see above)
CLI Search Command¶
Use sulu:ai:intelligent-search:search to query from the terminal:
# Basic search (streams the answer to stdout)
php bin/console sulu:ai:intelligent-search:search "How do I create a page?" --locale=en
# Filter by bucket (repeatable)
php bin/console sulu:ai:intelligent-search:search "Pricing?" --locale=en --bucket=pages --bucket=articles
# Prioritize recent content
php bin/console sulu:ai:intelligent-search:search "Latest release notes" --locale=en --prefer-recent
# Only include content updated after a date
php bin/console sulu:ai:intelligent-search:search "Recent changes" --locale=en --since=2026-01-01T00:00:00Z
# Return the non-streaming JSON payload
php bin/console sulu:ai:intelligent-search:search "How do I create a page?" --locale=en --json
The streaming command prints the answer chunk-by-chunk to stdout and lists the cited sources at the end.
Options¶
| Option | Short | Description |
|---|---|---|
question |
— | The question to ask (required argument) |
--locale |
-l |
Search locale (required) |
--bucket |
-b |
Restrict to specific buckets (repeatable) |
--webspace-key |
— | Filter by webspace |
--prefer-recent |
— | Prioritize recently updated documents |
--since |
— | Only include documents updated after this ISO-8601 date |
--json |
— | Return the non-streaming JSON response |
Standalone Examples¶
For usage outside of a Symfony application, see the example scripts in examples/intelligent-search/:
index.php— Indexing fixture documentssearch.php— Non-streaming searchstream.php— Streaming search