Skip to content

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 fetch with a ReadableStream rather than EventSource. The SSE stream is a one-shot response — EventSource would 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 200 with an SSE error event carrying the rate-limit message (the JSON endpoint does not yet enforce rate limiting; rate limiting applies to the SSE path)
  • Retry-After header
  • X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers

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 platform
  • InvalidIntelligentSearchResponseException — malformed payload (missing fields, invalid SSE events, etc.)
  • \InvalidArgumentExceptionstream flag 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 documents
  • search.php — Non-streaming search
  • stream.php — Streaming search