# Como fazer o Claude citar as fontes nas respostas do RAG no Laravel com a Citations API

> Passo a passo para usar a Citations API do Claude no Laravel: chunks como documentos, parse das citações, notas numeradas, validação do trecho citado e testes.

Por Lucas Roberto · IA · publicado em 2026-10-24 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/como-fazer-o-claude-citar-as-fontes-nas-respostas-do-rag-no-laravel-com-a-citations-api

Para fazer o Claude citar as fontes no seu RAG, envie cada trecho recuperado como um bloco `document` com `citations.enabled` ligado. A Messages API devolve a resposta em blocos de texto, e cada bloco traz o trecho exato que o sustenta e o índice do documento de origem. No Laravel, basta mapear esse índice de volta para o chunk do seu banco, renderizar notas numeradas e recusar respostas que não citam nada.

Eu cheguei nisso pela dor de sempre. O usuário pergunta algo para a central de ajuda com IA, o modelo responde bonito e ninguém sabe dizer se aquilo saiu da base de conhecimento ou da imaginação do LLM. Pedir no prompt "cite as fontes entre colchetes" funciona até o dia em que o modelo inventa um `[3]` para um documento que não existe. A Citations API resolve isso na origem: a citação vem estruturada e o texto citado é recortado do documento que você mandou.

## O que você vai ter funcionando no final

Um endpoint `POST /api/ajuda/perguntar` que:

1. Recebe a pergunta e busca os chunks relevantes no pgvector (a parte de retrieval você já tem).
2. Envia os chunks ao Claude como documentos citáveis.
3. Devolve a resposta com marcadores `[1]`, `[2]` e a lista de fontes, com título, URL e trecho citado.
4. Confere se cada trecho citado existe no chunk original.
5. Responde "não encontrei" quando o modelo não consegue citar nada.

**Pré-requisitos:**

- PHP 8.2+ e Laravel 11 ou 12.
- Chave da API da Anthropic (`ANTHROPIC_API_KEY`).
- Um retrieval funcionando, que devolva os chunks com id, título, URL e conteúdo. Se ainda não tem, comece pelo post sobre [RAG com pgvector no Laravel](/blog/como-fazer-rag-com-pgvector-no-laravel-embeddings-busca-vetorial-e-respostas-com-claude).
- Pest para os testes (PHPUnit também serve).

Não vou usar SDK. A chamada vai direto pelo `Http::` do Laravel, então dá para ver exatamente o JSON que sai e o que volta.

## 1. Como configurar a chave e o modelo do Claude no Laravel?

No `.env`:

```dotenv
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_TIMEOUT=60
```

Em `config/services.php`:

```php
'anthropic' => [
    'key' => env('ANTHROPIC_API_KEY'),
    'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'),
    'timeout' => (int) env('ANTHROPIC_TIMEOUT', 60),
],
```

O DTO que o retrieval devolve é este. Se o seu já tem outro formato, adapte só o builder do passo 2:

```php
<?php

namespace App\Support\Rag;

final readonly class RetrievedChunk
{
    public function __construct(
        public int $id,
        public int $articleId,
        public string $title,
        public string $url,
        public string $content,
        public string $updatedAt,
    ) {}
}
```

## 2. Como transformar os chunks do RAG em documentos citáveis?

Na Messages API, um **bloco `document`** é um item de conteúdo da mensagem do usuário que o modelo trata como fonte, separado da instrução. Com `source.type = text`, o Claude quebra o texto em frases sozinho, e cada citação aponta para uma faixa de caracteres dentro daquele documento.

```php
<?php

namespace App\Support\Rag;

final class CitationDocumentBuilder
{
    /**
     * @param  list<RetrievedChunk>  $chunks
     * @return list<array<string, mixed>>
     */
    public function build(array $chunks): array
    {
        return array_map(fn (RetrievedChunk $chunk) => [
            'type' => 'document',
            'source' => [
                'type' => 'text',
                'media_type' => 'text/plain',
                'data' => $chunk->content,
            ],
            'title' => $chunk->title,
            'context' => "Artigo #{$chunk->articleId}, atualizado em {$chunk->updatedAt}",
            'citations' => ['enabled' => true],
        ], array_values($chunks));
    }
}
```

Três decisões aqui:

- **Um documento por chunk.** O `document_index` da citação vira um ponteiro direto para o chunk. Juntar vários chunks num documento só obriga você a converter offset de caractere em chunk depois, e não vale a dor.
- **`array_values` não é enfeite.** A ordem do array é a ordem dos índices. Se o retrieval devolver um array com chaves esparsas (depois de um `filter`, por exemplo), o mapeamento quebra em silêncio.
- **`context` não é citável.** Uso esse campo para metadados que ajudam o modelo, como data de atualização, sem que ele possa "citar" um metadado como se fosse conteúdo.

O tamanho do chunk influencia direto a qualidade da citação. Chunk enorme gera citações vagas e chunk picado demais perde contexto. Escrevi sobre isso em [chunking para RAG no Laravel](/blog/chunking-para-rag-no-laravel-como-dividir-documentos-sem-perder-o-contexto).

## 3. Como chamar a Messages API com citations habilitado?

```php
<?php

namespace App\Services\Anthropic;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;

final class CitationsClient
{
    private const SYSTEM = <<<'TXT'
    Você é o assistente da central de ajuda. Responda em português do Brasil,
    usando apenas as informações dos documentos fornecidos. Se os documentos
    não cobrem a pergunta, diga exatamente: "Não encontrei isso na central de ajuda."
    Não use conhecimento externo. Seja direto.
    TXT;

    /**
     * @param  list<array<string, mixed>>  $documents
     * @return list<array<string, mixed>>  blocos de content da resposta
     */
    public function ask(string $question, array $documents): array
    {
        $response = Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
        ])
            ->acceptJson()
            ->timeout(config('services.anthropic.timeout'))
            ->retry(2, 1000, function (Throwable $e) {
                return $e instanceof ConnectionException
                    || ($e instanceof RequestException
                        && in_array($e->response->status(), [429, 500, 529], true));
            })
            ->post('https://api.anthropic.com/v1/messages', [
                'model' => config('services.anthropic.model'),
                'max_tokens' => 1024,
                'system' => self::SYSTEM,
                'messages' => [[
                    'role' => 'user',
                    'content' => [
                        ...$documents,
                        ['type' => 'text', 'text' => $question],
                    ],
                ]],
            ]);

        return $response->throw()->json('content', []);
    }
}
```

Os documentos vão **antes** da pergunta, no mesmo `content`. Não precisa de header beta: citations é recurso estável da Messages API.

Uma resposta típica fica assim (texto de exemplo):

```json
{
  "content": [
    { "type": "text", "text": "De acordo com a central de ajuda, " },
    {
      "type": "text",
      "text": "a antecipação pode ser solicitada pelo app na tela de recebimentos",
      "citations": [
        {
          "type": "char_location",
          "cited_text": "A antecipação pode ser solicitada pelo app, na tela Recebimentos.",
          "document_index": 0,
          "document_title": "Como antecipar recebíveis",
          "start_char_index": 0,
          "end_char_index": 66
        }
      ]
    },
    { "type": "text", "text": "." }
  ]
}
```

A resposta vem fatiada em vários blocos `text`. Os que carregam afirmações sustentadas trazem o array `citations`. Os conectivos vêm sem citação.

## 4. Como mapear as citações de volta para os chunks do banco?

Este é o coração do tutorial. O parser concatena o texto, numera as fontes na ordem em que aparecem e confere se o `cited_text` existe mesmo no chunk.

```php
<?php

namespace App\Support\Rag;

final class CitedAnswerParser
{
    /**
     * @param  list<array<string, mixed>>  $content
     * @param  list<RetrievedChunk>  $chunks  mesma ordem enviada ao builder
     */
    public function parse(array $content, array $chunks): CitedAnswer
    {
        $chunks = array_values($chunks);
        $text = '';
        $numbers = [];   // chunkId => número da nota
        $sources = [];   // número => dados da fonte
        $invalid = 0;

        foreach ($content as $block) {
            if (($block['type'] ?? null) !== 'text') {
                continue;
            }

            $text .= $block['text'];
            $markers = [];

            foreach ($block['citations'] ?? [] as $citation) {
                $chunk = $chunks[$citation['document_index'] ?? -1] ?? null;
                $quote = (string) ($citation['cited_text'] ?? '');

                if ($chunk === null || ! $this->quoteExists($quote, $chunk->content)) {
                    $invalid++;
                    continue;
                }

                $n = $numbers[$chunk->id] ??= count($numbers) + 1;

                $sources[$n] ??= [
                    'chunk_id' => $chunk->id,
                    'title' => $chunk->title,
                    'url' => $chunk->url,
                    'quotes' => [],
                ];
                $sources[$n]['quotes'][] = trim($quote);
                $markers[$n] = "[{$n}]";
            }

            if ($markers !== []) {
                $text .= ' '.implode('', $markers);
            }
        }

        foreach ($sources as $n => $source) {
            $sources[$n]['quotes'] = array_values(array_unique($source['quotes']));
        }

        return new CitedAnswer(trim($text), array_values($sources), $invalid);
    }

    private function quoteExists(string $quote, string $document): bool
    {
        $normalize = fn (string $s) => preg_replace('/\s+/u', ' ', trim($s));

        return $quote !== ''
            && str_contains($normalize($document), $normalize($quote));
    }
}
```

```php
<?php

namespace App\Support\Rag;

final readonly class CitedAnswer
{
    public function __construct(
        public string $text,
        public array $sources,
        public int $invalidCitations,
    ) {}

    public function hasCitations(): bool
    {
        return $this->sources !== [];
    }
}
```

Por que conferir o `cited_text` se a API já garante que ele sai do documento? Porque o mapeamento `document_index → chunk` é **seu**, não da API. Se alguém mexer na ordem dos chunks entre o builder e o parser, a citação continua válida para a Anthropic e errada para você. A checagem custa microssegundos e pega esse bug no primeiro request.

Eu comparo por `str_contains` com espaços normalizados, e não pelos índices `start_char_index`/`end_char_index`, porque não quero depender de como a contagem de caracteres bate com o `mb_substr` do PHP em textos com acento e emoji.

## 5. Como bloquear respostas sem fonte no endpoint?

Uma resposta sem nenhuma citação é, na prática, uma resposta que o modelo não conseguiu ancorar nos documentos. Em central de ajuda de fintech eu prefiro um "não sei" honesto a uma taxa inventada.

```php
<?php

namespace App\Http\Controllers;

use App\Services\Anthropic\CitationsClient;
use App\Support\Rag\CitationDocumentBuilder;
use App\Support\Rag\CitedAnswerParser;
use App\Support\Rag\HelpCenterSearch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

final class HelpQuestionController
{
    private const FALLBACK = 'Não encontrei isso na central de ajuda. Quer falar com o suporte?';

    public function __invoke(
        Request $request,
        HelpCenterSearch $search,
        CitationDocumentBuilder $builder,
        CitationsClient $client,
        CitedAnswerParser $parser,
    ): JsonResponse {
        $question = $request->validate([
            'question' => ['required', 'string', 'max:1000'],
        ])['question'];

        $chunks = $search->topK($question, k: 6);

        if ($chunks === []) {
            return response()->json(['answer' => self::FALLBACK, 'sources' => []]);
        }

        $content = $client->ask($question, $builder->build($chunks));
        $answer = $parser->parse($content, $chunks);

        if ($answer->invalidCitations > 0) {
            Log::warning('rag.citation_mismatch', [
                'invalid' => $answer->invalidCitations,
                'chunk_ids' => array_map(fn ($c) => $c->id, $chunks),
            ]);
        }

        if (! $answer->hasCitations()) {
            return response()->json(['answer' => self::FALLBACK, 'sources' => []]);
        }

        return response()->json([
            'answer' => $answer->text,
            'sources' => $answer->sources,
        ]);
    }
}
```

Em `routes/api.php`:

```php
use App\Http\Controllers\HelpQuestionController;

Route::post('/ajuda/perguntar', HelpQuestionController::class)
    ->middleware('throttle:20,1');
```

Para testar na mão:

```bash
curl -X POST http://localhost:8000/api/ajuda/perguntar \
  -H 'Content-Type: application/json' \
  -d '{"question": "Como eu antecipo meus recebíveis?"}'
```

No front, os marcadores `[1]` viram links para `sources[0].url` e o trecho citado aparece num tooltip. É esse tooltip que faz o usuário confiar, porque ele vê de onde a frase saiu.

## 6. Como testar a integração com Http::fake?

O teste fixa duas garantias: o request sai com citations ligado em todos os documentos, e o parser numera e valida certo.

```php
<?php

use App\Support\Rag\HelpCenterSearch;
use App\Support\Rag\RetrievedChunk;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

it('responde com fontes numeradas a partir das citações', function () {
    $chunk = new RetrievedChunk(
        id: 42, articleId: 7, title: 'Como antecipar recebíveis',
        url: '/ajuda/antecipacao',
        content: 'A antecipação pode ser solicitada pelo app, na tela Recebimentos.',
        updatedAt: '2025-01-10',
    );

    $this->mock(HelpCenterSearch::class)
        ->shouldReceive('topK')->andReturn([$chunk]);

    Http::fake([
        'api.anthropic.com/*' => Http::response(['content' => [
            ['type' => 'text', 'text' => 'Você pode antecipar pelo app'],
            ['type' => 'text', 'text' => ', na tela Recebimentos', 'citations' => [[
                'type' => 'char_location',
                'cited_text' => 'A antecipação pode ser solicitada pelo app, na tela Recebimentos.',
                'document_index' => 0,
                'document_title' => 'Como antecipar recebíveis',
                'start_char_index' => 0,
                'end_char_index' => 66,
            ]]],
        ]]),
    ]);

    $this->postJson('/api/ajuda/perguntar', ['question' => 'Como antecipo?'])
        ->assertOk()
        ->assertJsonPath('answer', 'Você pode antecipar pelo app, na tela Recebimentos [1]')
        ->assertJsonPath('sources.0.chunk_id', 42);

    Http::assertSent(function (Request $request) {
        $docs = collect($request['messages'][0]['content'])->where('type', 'document');

        return $docs->isNotEmpty()
            && $docs->every(fn ($d) => $d['citations']['enabled'] === true);
    });
});

it('cai no fallback quando a resposta não tem citação', function () {
    $this->mock(HelpCenterSearch::class)->shouldReceive('topK')->andReturn([
        new RetrievedChunk(1, 1, 'X', '/x', 'Texto qualquer.', '2025-01-01'),
    ]);

    Http::fake(['api.anthropic.com/*' => Http::response(['content' => [
        ['type' => 'text', 'text' => 'A taxa é de 1%.'],
    ]])]);

    $this->postJson('/api/ajuda/perguntar', ['question' => 'Qual a taxa?'])
        ->assertJsonPath('sources', [])
        ->assertJsonPath('answer', fn ($a) => str_starts_with($a, 'Não encontrei'));
});
```

O segundo teste é o mais importante. Ele documenta a regra de negócio: afirmação sem fonte não chega ao usuário.

## Quanto custa usar citations?

Os documentos entram como tokens de entrada, como qualquer contexto de RAG. O `cited_text` que volta na resposta não conta como token de saída, segundo a documentação da Anthropic. Na prática, o custo é praticamente o mesmo de um RAG sem citações. A diferença costuma vir do texto do sistema um pouco maior e de respostas ligeiramente mais longas por causa da fragmentação. Para estimar o mensal com o seu volume de chunks por pergunta, a [calculadora de custo de IA](/ferramentas/calculadora-custo-ia) faz a conta por modelo, com e sem prompt caching.

## Erros comuns

1. **Erro 400 ao misturar documentos com e sem citations.** A API exige que citations esteja habilitado em todos os documentos do request ou em nenhum. Se você anexa um documento extra (um regulamento, por exemplo) por outro caminho, passe tudo pelo mesmo builder.
2. **Texto colado sem espaço.** Os blocos `text` já trazem os espaços e a pontuação certos. Se você der `trim()` em cada bloco antes de concatenar, sai "pelo app,na tela". Faça `trim` só no texto final.
3. **Citação apontando para o chunk errado.** Quase sempre o array de chunks foi reordenado ou filtrado entre o envio e o parse, ou há histórico de conversa com documentos de turnos anteriores. O `document_index` conta todos os documentos do request, em ordem. Monte o mapa a partir da mesma lista que foi enviada. A checagem `quoteExists` do passo 4 serve justamente para denunciar isso no log.
4. **Tentar receber a resposta em JSON com citações dentro.** Citations não é compatível com structured outputs. Se você precisa de JSON e fontes, deixe o Claude responder com citações nativas e monte o JSON no parser, como fizemos aqui.
5. **Citações enormes ou vagas.** Com `source.type = text`, a granularidade é a frase. Tabela em markdown, lista sem pontuação ou FAQ sem ponto final viram uma "frase" gigante. Resolva no chunking ou use `source.type = content` com blocos de texto que você mesmo define. Nesse caso a citação vem como `content_block_location`, com índices de bloco em vez de caracteres.
6. **Streaming sem as citações.** Via SSE, as citações chegam como eventos `content_block_delta` com `delta.type = citations_delta`, separados do `text_delta`. Se o seu handler só trata texto, elas somem. Ajuste o consumidor descrito no post de [streaming do Claude com Server-Sent Events](/blog/como-fazer-streaming-de-respostas-do-claude-no-laravel-com-server-sent-events) para acumular os dois tipos por índice de bloco.

## Perguntas frequentes

### A Citations API impede que o Claude alucine?

Não impede, mas deixa a alucinação visível. Trecho com citação está ancorado num documento seu. Trecho sem citação é suspeito. Por isso o gate do passo 5 bloqueia a resposta inteira quando não há nenhuma fonte.

### Dá para usar com PDF em vez de texto?

Sim. Envie o documento com `source.type = base64` e `media_type = application/pdf`. As citações voltam como `page_location`, com `start_page_number` e `end_page_number`. Para RAG eu prefiro texto já extraído e chunkado, porque o retrieval fica sob controle.

### Funciona com OpenAI ou outros LLMs?

Não com esse mesmo contrato de API. Com outros provedores, o caminho é pedir marcadores `[n]` no prompt e validar no parser se cada `n` existe e se o trecho bate. É mais frágil, e a checagem de `quoteExists` vira obrigatória.

### Isso substitui a avaliação do retrieval?

Não. Citations garante que a resposta usa os chunks enviados, não que os chunks certos foram enviados. Se o retrieval traz o artigo errado, o Claude vai citar o artigo errado com toda a confiança. A busca continua precisando de golden set e métricas.

### Posso usar prompt caching junto?

Pode. Blocos `document` aceitam `cache_control` como os outros blocos de conteúdo. Compensa quando os mesmos documentos se repetem entre perguntas, como num regulamento fixo. Em RAG com chunks diferentes a cada pergunta, o ganho é pequeno.

## Conclusão

Se você já tem RAG em produção, adotar citations é uma mudança de umas duzentas linhas: o builder troca a concatenação de chunks no prompt por blocos `document`, o parser substitui o `$response['content'][0]['text']` e o controller ganha a regra de bloquear resposta sem fonte. Comece por essa regra. Ela vale mais que qualquer ajuste de prompt, porque transforma "o modelo pode inventar" em "o modelo pode dizer que não sabe". Depois acompanhe no log os `rag.citation_mismatch` e a taxa de fallback. A primeira métrica mostra bug no seu mapeamento. A segunda mostra buraco na base de conhecimento, e esse buraco é trabalho para quem escreve os artigos, não para o LLM.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
