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:
- Recebe a pergunta e busca os chunks relevantes no pgvector (a parte de retrieval você já tem).
- Envia os chunks ao Claude como documentos citáveis.
- Devolve a resposta com marcadores
[1],[2]e a lista de fontes, com título, URL e trecho citado. - Confere se cada trecho citado existe no chunk original.
- 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.
- 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:
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5 ANTHROPIC_TIMEOUT=60
Em config/services.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 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 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_indexda 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_valuesnão é enfeite. A ordem do array é a ordem dos índices. Se o retrieval devolver um array com chaves esparsas (depois de umfilter, por exemplo), o mapeamento quebra em silêncio.contextnã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.
3. Como chamar a Messages API com citations habilitado?
<?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):
{ "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 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 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 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:
use App\Http\Controllers\HelpQuestionController; Route::post('/ajuda/perguntar', HelpQuestionController::class) ->middleware('throttle:20,1');
Para testar na mão:
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 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 faz a conta por modelo, com e sem prompt caching.
Erros comuns
- 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.
- Texto colado sem espaço. Os blocos
textjá trazem os espaços e a pontuação certos. Se você dertrim()em cada bloco antes de concatenar, sai "pelo app,na tela". Façatrimsó no texto final. - 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_indexconta todos os documentos do request, em ordem. Monte o mapa a partir da mesma lista que foi enviada. A checagemquoteExistsdo passo 4 serve justamente para denunciar isso no log. - 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.
- 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 usesource.type = contentcom blocos de texto que você mesmo define. Nesse caso a citação vem comocontent_block_location, com índices de bloco em vez de caracteres. - Streaming sem as citações. Via SSE, as citações chegam como eventos
content_block_deltacomdelta.type = citations_delta, separados dotext_delta. Se o seu handler só trata texto, elas somem. Ajuste o consumidor descrito no post de streaming do Claude 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.