Features

RAG Site-Search Tools

Two function-calling tools that ground the model in the site's own public content, reading whichever TYPO3 search index is installed with a database fallback.

Why it exists

Agent runs should answer questions about the website's own content with cited evidence instead of model world-knowledge. The retrieval source usually already exists in a TYPO3 install: EXT:solr, ke_search, or the core's indexed_search. What was missing is a controlled retrieval layer that uses whichever index is present, degrades gracefully when none is, and hands the model a curated evidence package with resolvable source URLs rather than raw search hits (ADR-049).

A generic per-engine tool list (solr_search, ke_search_query, …) was rejected: the model would need to know what is installed, every engine would leak its own result shape into prompts, and the tool count would grow per engine. Instead nr_llm ships one retrieval core with many backends behind a single tool group. Grounding a model in the site's own content is treated as a cross-cutting primitive on the same footing as the content and introspection tools — nr_llm may read indexes others own and keep fresh, but it never owns a persistent index it has to keep synchronised (ADR-050). Vector/semantic RAG stays in the sibling extension nr_ai_search.

When to use it

  • An agent must answer questions about the site's own public content and cite where the answer came from, rather than rely on the model's training data.
  • You run any of EXT:solr, ke_search, or indexed_search — the tools read whichever is installed, in priority order, first-available-wins.
  • You run none of them: the always-available pages/tt_content database fallback still answers, labelled as such in the evidence header.
  • Evidence must be scoped to what an anonymous visitor could read — index-level filtering is always public-only (fail-closed without a backend user).
  • Use site_rag_query to get a curated evidence package, then site_fetch_source(source_id) to read a single source's full indexed text (capped).
  • For persistent vector store / chunking / reindex-on-change semantic retrieval, install nr_ai_search on top instead — that is deliberately out of scope here (ADR-050).

How it works

One retrieval core, many backends. A Service/Retrieval layer defines SearchBackendInterface (getIdentifier, getPriority, isAvailable, search, fetchSource), with four implementations collected via the nr_llm.retrieval_backend DI tag. RetrievalService asks the backends in priority order and uses the first available one — there is no cross-engine score merging, because Solr relevance, MySQL fulltext scores and LIKE hits are not comparable. A backend that throws is treated as unavailable and the cascade continues with a note; an empty result from an available backend is final by design. The answering backend is always named in the result so the model knows the evidence quality.

Two tools form one new group, rag: site_rag_query (question → evidence package of source_id · title · url plus a match excerpt per source) and site_fetch_source (source_id → the indexed full text, capped). These are 2 of the 41 built-in read-only tools across 8 toggleable groups. Tool arguments are model-chosen and untrusted, so length caps, source-id grammar validation and result caps apply.

Access is fail-closed and public-only: index-level filtering is always fe_group ''/0, gr_list 0,-1, and the Solr access filter {!typo3access}0,-1 — RAG evidence is exactly what the anonymous visitor could read. Because that content is public, no per-user page narrowing applies; the tools still require a backend user like every builtin.

SolrSearchBackend

Talks to the EXT:solr server over the documented HTTP select API (site-config solr_*_read keys, per-language read cores) instead of EXT:solr's @internal PHP classes; carries the {!typo3access}0,-1 public filter and adds no composer dependency on EXT:solr.

KeSearchBackend

Reads tx_kesearch_index directly — MATCH … AGAINST on MySQL/MariaDB, LIKE elsewhere; matches title/content only, never hidden_content.

IndexedSearchBackend

Reads the core index_* tables directly (word-hash join with the md5 computed in PHP; LIKE over index_fulltext when the word tables are empty).

DatabaseSearchBackend

Always-available fallback (priority 0): LIKE across pages/tt_content search fields, grouped per page — works on a bare instance with no search extension.

RetrievalService

The cascade: sorts backends by priority, asks the first available one, deduplicates same-URL hits, caps the source count, and labels the evidence with the answering backend.

SiteRagQueryTool / SiteFetchSourceTool

The two builtin tools of the rag group; clamp model-supplied arguments and format the evidence list into a citable text block for the model.

For developers

The retrieval core is extensible via a single DI tag. A niche search engine's adapter registers through nr_llm.retrieval_backend without a core release; the two rag tools consume RetrievalService and need no change.

Implement a backend against the interface

Every backend implements SearchBackendInterface. The tag is auto-applied via AutoconfigureTag, so implementing the interface is enough to enter the cascade. isAvailable() must be cheap and must not throw; keep every reference to an optional extension's classes behind availability checks so the class can always be instantiated.

php
#[AutoconfigureTag(name: self::TAG_NAME)]
interface SearchBackendInterface
{
    public const TAG_NAME = 'nr_llm.retrieval_backend';

    public function getIdentifier(): string;

    public function getPriority(): int;

    public function isAvailable(): bool;

    public function search(RetrievalQuery $query, AccessContext $context): EvidenceList;

    public function fetchSource(SourceReference $reference, AccessContext $context): ?string;
}

Understand the first-available-wins cascade

RetrievalService sorts backends by descending priority, skips unavailable ones, treats a throwing backend as unavailable (adding a note and continuing), and returns the first available backend's result — deduplicated by URL and capped to maxSources. There is no cross-backend score merging.

php
foreach ($this->prioritized() as $backend) {
    if (!$this->available($backend)) {
        continue;
    }

    try {
        $result = $backend->search($query, $context);
    } catch (Throwable) {
        $notes[] = sprintf('Backend "%s" failed and was skipped.', $backend->getIdentifier());
        continue;
    }

    return $this->deduplicate($result, $query->maxSources)->withNotes($notes);
}

return new EvidenceList('none', [], [...$notes, 'No search backend available.']);

The rag group is defined per tool

A builtin tool joins the rag group by returning it from getGroup(). SiteRagQueryTool clamps the model's question length and source count, runs RetrievalService with a public-only AccessContext for the acting backend user, and formats the evidence into a citable text block.

php
public function getGroup(): string
{
    return 'rag';
}

public function requiresAdmin(): bool
{
    // Evidence is public website content; the backend-user gate above
    // (fail-closed) is the only narrowing needed.
    return false;
}

Fuse rankings when you add your own dense arm (optional)

The cascade itself never merges backends, but hybrid consumers that pair their own embedding arm with nr_llm's keyword facade can reuse the hosted Reciprocal Rank Fusion utility (ADR-074). It is a pure, newable final class — no DI, no interface — with a signature identical to the nr_ai_search original.

php
$fused = (new ReciprocalRankFusion())->fuse(
    $rankedKeyLists,   // one ranked list of ids per retrieval arm
    k: 60,
    weights: [],
);

Gotchas

Public-only, always

Index-level filtering is fixed to public content (fe_group ''/0, gr_list 0,-1, Solr {!typo3access}0,-1). Evidence is what an anonymous visitor could read; there is no per-user page narrowing. AccessContext travels through the core so a future frontend endpoint could widen filtering per fe_group, but that is not consumed beyond public-only in this iteration.

First available backend wins — empty is final

An empty result from an available backend is not a reason to fall through to the next engine; falling through would silently mix engines of different quality, and 'the site's search finds nothing' is itself honest evidence. Only an unavailable or throwing backend advances the cascade.

Direct table access can drift on future majors

KeSearchBackend and IndexedSearchBackend read tx_kesearch_index and index_* directly, trading API stability for decoupling. The schemas are verified against currently supported versions (ke_search v6.6/v7, core 13.4/14.x) and pinned by functional tests, but a future major could drift; isAvailable() checks table presence.

Solr uses per-language cores, not a language filter

EXT:solr separates languages by per-language cores (core_de, core_en, …) whose schema has no 'language' field. An earlier fq=language:<id> filtered on a non-existent field and returned zero results for every query; ADR-067 dropped it — language is handled by per-language read-core selection, access by {!typo3access}0,-1 only.

Stale indexes cite stale content

ke_search incremental runs never delete and indexed_search only updates on render, so search-index RAG can cite content that has since changed. This is a known property of index-backed retrieval, documented for editors — the answering backend is always named so the evidence quality is visible.