You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perfeito — vamos criar um endpoint em Laravel que entende pedidos em linguagem natural tipo “faturas abertas”, “faturas atrasadas”, “faturas abertas dos últimos 3 meses” e retorna a lista certa para um cliente identificado por customer_id ou CPF/CNPJ.
Abaixo está um pacote completo e plug-and-play: models + (opcional) migrations + service de parsing + controller + rota, com exemplos de uso.
0) (Opcional) Modelo de dados
Se você já tem as tabelas, pule esta parte. Caso não, um esqueleto funcional:
Para criar um projeto em Docker que possibilite rodar localmente, executar consultas e adicionar dados, vamos seguir os seguintes passos:
Passos do Projeto:
Criação de um Dockerfile: Para configurar o ambiente com todas as dependências necessárias (Python, bibliotecas de aprendizado de máquina, etc.).
Configuração de um script Python para treinamento e consultas.
Configuração de um banco de dados (SQLite, PostgreSQL, etc.) para armazenamento de dados.
Criação de um README.md com as instruções de como usar o projeto.
Gerar um arquivo .zip com todos os arquivos necessários para você executar localmente.
Vou dividir o processo em partes:
1. Dockerfile
Este é o arquivo que vai configurar o ambiente de contêiner do Docker com todas as dependências necessárias para rodar o modelo de linguagem.
# Usando uma imagem base do PythonFROM python:3.9-slim
# Setando o diretório de trabalhoWORKDIR /app
# Instalando as dependências do sistemaRUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Instalando as dependências do PythonCOPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiando o código do projetoCOPY . /app
# Expondo a porta do Flask (se for usar uma API web)EXPOSE 5000
# Comando para rodar o servidor ou o script de inferênciaCMD ["python", "app.py"]
2. requirements.txt
Este arquivo contém as dependências do Python necessárias para o funcionamento do modelo, como as bibliotecas do Hugging Face para Transformers e Flask para uma API, caso queira interagir com o modelo via HTTP.
transformers==4.27.0
torch==1.11.0
flask==2.1.2
psycopg2==2.9.3 # Para conectar com banco de dados PostgreSQL (caso queira usar)
3. Script Python: app.py
Aqui vai um exemplo básico de como configurar um serviço com Flask para fazer consultas ao modelo treinado.
fromflaskimportFlask, request, jsonifyfromtransformersimportGPT2LMHeadModel, GPT2Tokenizer# Inicializando o Flaskapp=Flask(__name__)
# Carregar o modelo e o tokenizermodel_name="gpt2"model=GPT2LMHeadModel.from_pretrained(model_name)
tokenizer=GPT2Tokenizer.from_pretrained(model_name)
# Rota para consulta@app.route("/generate", methods=["POST"])defgenerate_text():
input_data=request.json.get("input", "")
# Tokenizando a entradainputs=tokenizer(input_data, return_tensors="pt")
# Gerando respostaoutputs=model.generate(inputs["input_ids"], max_length=50)
# Decodificando a respostagenerated_text=tokenizer.decode(outputs[0], skip_special_tokens=True)
returnjsonify({"generated_text": generated_text})
if__name__=="__main__":
app.run(debug=True, host="0.0.0.0", port=5000)
4. Banco de Dados
Vamos configurar um banco de dados PostgreSQL básico para armazenar os dados. Se quiser usar outro banco (como SQLite), basta substituir a string de conexão e a configuração no Python.
Aqui está um exemplo de como adicionar dados ao banco de dados via script Python:
importpsycopg2frompsycopg2importsql# Configuração da conexão ao banco de dadosconn=psycopg2.connect(
dbname="db_name",
user="db_user",
password="db_password",
host="db_host",
port="5432"
)
cursor=conn.cursor()
# Exemplo de inserção de dadoscursor.execute(
sql.SQL("INSERT INTO text_data (content) VALUES (%s)"),
["Texto de exemplo"]
)
conn.commit()
cursor.close()
conn.close()
5. Criar README.md
O README.md vai conter instruções claras de como rodar o projeto.
# Projeto LLM Local
Este é um projeto para rodar um modelo de linguagem (LLM) localmente usando Docker. O modelo é treinado com a arquitetura GPT-2 da Hugging Face. A aplicação oferece uma API para consultas e permite adicionar dados ao banco de dados.
## Requisitos
Antes de executar o projeto, é necessário ter o Docker instalado em sua máquina.
## Instruções para uso1.**Clone o repositório**:
``` git clone <URL_do_repositório> cd nome_do_repositório ```2. **Construa a imagem Docker**: ``` docker build -t llm-local . ```3. **Execute o container**: ``` docker run -p 5000:5000 llm-local ```4. **Consultar o modelo**: Para gerar texto, faça uma requisição POST para `http://localhost:5000/generate` com o seguinte corpo JSON: ```json { "input": "Exemplo de entrada para o modelo" } ```5. **Adicionar dados ao banco de dados**: O banco de dados PostgreSQL pode ser acessado com o seguinte comando: ``` docker exec -it <nome_do_container> psql -U <usuario> -d <nome_do_banco> ``` Para adicionar dados ao banco, use o script Python fornecido em `add_data.py`.## Tecnologias utilizadas- Docker- Python- Flask- Hugging Face Transformers- PostgreSQL
6. Gerar o ZIP
Agora, vamos compactar o projeto em um arquivo .zip.
Pontos mais importantes de IA, MCP (Model Context Protocol) e RAG (Retrieval-Augmented Generation).
A ideia é ser didático, mas sem perder a profundidade, e vou usar exemplos em PHP, Node e Nuxt quando fizer sentido. 🚀
🌐 1. O que é IA (Inteligência Artificial)?
De forma avançada, mas clara:
IA tradicional: algoritmos fixos → exemplo: árvore de decisão, regressão linear, k-means.
IA moderna (deep learning e LLMs): modelos treinados em grandes volumes de dados, capazes de gerar respostas novas (não apenas repetir).
👉 O que você precisa entender:
Dados + Modelo + Contexto = resposta inteligente.
O "contexto" é o que você fornece como entrada (prompt, histórico, documentos, etc.).
Os LLMs (como GPT, LLaMA, Mistral) não "sabem tudo" de forma absoluta, mas são capazes de generalizar padrões.
🛠️ 2. MCP (Model Context Protocol)
É uma tecnologia nova (2024+) que resolve um problema chave: como conectar um LLM às suas ferramentas e dados externos de forma padronizada.
Pensa nele como um “driver universal” entre modelos de IA e suas aplicações.
📌 Conceito:
Antes, cada integração com IA precisava de API específica.
Com o MCP, existe um protocolo aberto que permite o LLM acessar:
Banco de dados,
APIs de terceiros,
Arquivos locais,
Ferramentas customizadas.
👉 Isso torna o LLM mais útil, porque ele consegue agir e não só responder.
📊 Exemplo prático:
Imagina que você tem um sistema em Laravel e quer que a IA consulte o banco via Eloquent.
Sem MCP:
// Você teria que criar uma rota e expor a query manualmente
Route::get('/users', function () {
return User::all();
});
Com MCP:
Você define uma “ferramenta” MCP que expõe User.findAll.
O modelo pode chamar isso diretamente, sem você precisar reinventar o padrão.
No Node (TypeORM/Prisma) seria algo como expor o User.findMany() como ferramenta.
No Nuxt (com Prisma ou Drizzle), mesma ideia: o modelo chama a ferramenta padronizada.
Resumindo: MCP = “o USB das IAs”.
Conecta qualquer modelo a qualquer sistema sem precisar reescrever tudo de novo.
📚 3. RAG (Retrieval-Augmented Generation)
É a técnica que resolve a limitação da memória de um LLM.
📌 Problema:
Um modelo não sabe seus dados privados (ex: contratos, produtos no banco).
Ele só sabe o que foi treinado até certa data.
📌 Solução (RAG):
Você cria uma base vetorial (ex: Pinecone, Weaviate, Qdrant, ou até banco SQL com embeddings).
Quando o usuário faz uma pergunta, você:
Gera embeddings da query (transforma em vetor numérico).
Busca documentos similares na base.
Injeta esse conteúdo como contexto no prompt.
O modelo gera a resposta baseada nesses documentos.
⚙️ Exemplo prático (Laravel com Composer)
Imagine que você tem contratos no banco e quer que a IA responda sobre eles.
No Node (Prisma) seria quase igual, usando libs como @openai + banco vetorial.
No Nuxt, você usaria o mesmo fluxo mas com endpoints de API que chamam a IA.
🎯 4. Diferença MCP vs RAG
MCP: Conectar IA a ferramentas externas (bancos, APIs, sistemas).
RAG: Conectar IA a dados contextuais via busca semântica.
Eles podem trabalhar juntos:
MCP dá ferramentas de ação.
RAG dá conhecimento atualizado.
Exemplo: um chatbot jurídico pode:
Usar RAG para responder com base em contratos.
Usar MCP para baixar PDFs, salvar logs ou criar registros no banco.
📌 Resumindo em 1 frase:
MCP = “tomadas de energia da IA” (conectar ferramentas).
RAG = “memória de curto prazo” (recuperar dados relevantes).
Implementação no Laravel e Node (com Prisma) + Postgres via extensão PGVECTOR
perfeito! vamos montar um mini-projeto de RAG de ponta a ponta e, quando você quiser, a gente pluga MCP depois. vou te dar 3 variações (Laravel/PHP, Node+Prisma e Nuxt como front). a lógica é a mesma em todos:
ingestão: quebrar documentos em “chunks” → gerar embeddings → salvar no banco (com vetor).
busca semântica: transformar a pergunta em embedding → ORDER BY cosine_distance → pegar top-k trechos.
Geração: montar um prompt com as passagens relevantes → chamar o LLM → responder citando as fontes.
Banco recomendado: PostgreSQL + extensão pgvector (estável e popular).
Modelo de embedding sugerido:text-embedding-3-small (barato e bom para busca).
Top-k e janela: k=4 a 8; limite ~3–6 mil tokens de contexto total.
composer require guzzlehttp/guzzle
# no Postgres (uma vez por DB):
-- CREATE EXTENSION IF NOT EXISTS vector;
1) Migration (tabelas de documentos e embeddings)
Crie uma migration (ex.: database/migrations/2025_09_04_000000_create_rag_tables.php):
publicfunctionup(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
Schema::create('documents', function (Blueprint$table) {
$table->id();
$table->string('source'); // caminho do arquivo ou URL$table->text('text'); // texto completo (opcional)$table->timestamps();
});
// chunks com vetor (dimensão 1536 para text-embedding-3-small)DB::statement(" CREATE TABLE chunks ( id BIGSERIAL PRIMARY KEY, document_id BIGINT REFERENCES documents(id) ON DELETE CASCADE, chunk_index INT NOT NULL, content TEXT NOT NULL, embedding VECTOR(1536) );");
// índice aproximado (HNSW) opcional para acelerarDB::statement("CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON chunks USING hnsw (embedding vector_l2_ops)");
}
publicfunctiondown(): void
{
Schema::dropIfExists('documents');
DB::statement("DROP TABLE IF EXISTS chunks");
}
useIlluminate\Support\Facades\Route;
useIlluminate\Support\Facades\DB;
useApp\Services\Embeddings;
Route::post('/rag/query', function (\Illuminate\Http\Request$req) {
$q = $req->input('q');
abort_unless($q, 400, 'q é obrigatório');
$embedder = newEmbeddings(env('OPENAI_API_KEY'));
$qVec = $embedder->embed($q);
$qVecSql = '{'.implode(',', $qVec).'}';
// top-k por similaridade (menor distância L2 = melhor)$k = (int)($req->input('k', 6));
$rows = DB::select(" SELECT id, document_id, chunk_index, content, l2_distance(embedding, ?) AS dist FROM chunks ORDER BY embedding <-> ? LIMIT $k", [$qVecSql, $qVecSql]);
// monta contexto (limite simples por comprimento)$context = '';
$tokensAprox = 0;
foreach ($rowsas$r) {
$snippet = "Fonte #{$r->document_id}/chunk{$r->chunk_index}: {$r->content}\n";
if ($tokensAprox + strlen($snippet) > 6000) break;
$context .= $snippet;
$tokensAprox += strlen($snippet);
}
// prompt de geração (use seu cliente de chat preferido aqui)$prompt = <<<TXTVocê é um assistente que responde somente com base no CONTEXTO abaixo.Se não houver evidência suficiente, diga "Não encontrei no material".Cite as fontes como [docId/chunk].CONTEXTO:$contextPergunta do usuário: "$q"Responda de forma objetiva e cite as fontes entre colchetes.TXT;
// Exemplo de chamada ao chat (use seu wrapper preferido)// ... chame seu LLM aqui (OpenAI, etc.) ...// $answer = chat($prompt);returnresponse()->json([
'matches' => $rows,
'prompt' => $prompt,
// 'answer' => $answer,
]);
});
Observação: no ORDER BY embedding <-> ? usamos o operador pgvector de distância por L2. Você pode trocar por cosine_distance(embedding, ?) se preferir (e ajustar o índice para vector_cosine_ops).
🟦 Parte B — Node.js + Prisma + PostgreSQL (pgvector)
CREATEINDEXIF NOT EXISTS idx_chunks_embedding
ON"Chunk" USING hnsw (embedding vector_l2_ops);
2) Ingest (chunk + embed)
src/ingest.ts
importfsfrom'node:fs';import'dotenv/config';import{PrismaClient}from'@prisma/client';importOpenAIfrom'openai';constprisma=newPrismaClient();constopenai=newOpenAI({apiKey: process.env.OPENAI_API_KEY!});functionchunk(text: string,size=2200,overlap=300){constclean=text.replace(/\s+/g,' ').trim();constparts: string[]=[];for(leti=0;i<clean.length;i+=(size-overlap)){parts.push(clean.slice(i,i+size));if(i+size>=clean.length)break;}returnparts;}asyncfunctionmain(path: string){consttext=fs.readFileSync(path,'utf8');constdoc=awaitprisma.document.create({data: {source: path, text }});constparts=chunk(text);for(leti=0;i<parts.length;i++){constr=awaitopenai.embeddings.create({model: 'text-embedding-3-small',input: parts[i],});constvec=r.data[0].embedding;// transforma em literal de vetor: '{v1,v2,...}'constvecLit=`{${vec.join(',')}}`;awaitprisma.$executeRawUnsafe(`INSERT INTO "Chunk" ("documentId","chunkIndex","content","embedding") VALUES ($1,$2,$3,$4::vector)`,doc.id,i,parts[i],vecLit);}console.log(`OK: doc ${doc.id}, chunks ${parts.length}`);}main(process.argv[2]).catch(console.error).finally(()=>prisma.$disconnect());
3) Query (RAG)
src/query.ts
import'dotenv/config';importexpressfrom'express';import{PrismaClient}from'@prisma/client';importOpenAIfrom'openai';constprisma=newPrismaClient();constopenai=newOpenAI({apiKey: process.env.OPENAI_API_KEY!});constapp=express();app.use(express.json());app.post('/rag/query',async(req,res)=>{constq: string=req.body.q;constk=Number(req.body.k??6);constemb=awaitopenai.embeddings.create({model: 'text-embedding-3-small',input: q,});constqVec=`{${emb.data[0].embedding.join(',')}}`;constrows: any[]=awaitprisma.$queryRawUnsafe(` SELECT id, "documentId", "chunkIndex", content, l2_distance(embedding, $1::vector) AS dist FROM "Chunk" ORDER BY embedding <-> $1::vector LIMIT ${k} `,qVec);letcontext='';for(constrofrows){constpiece=`Fonte [${r.documentId}/${r.chunkIndex}]: ${r.content}\n`;if((context.length+piece.length)>6000)break;context+=piece;}constprompt=`Responda APENAS com base no CONTEXTO, citando fontes no formato [doc/chunk].Se não houver evidência, diga que não encontrou.CONTEXTO:${context}Pergunta: "${q}" `.trim();// const chat = await openai.chat.completions.create({ ... });// res.json({ matches: rows, prompt, answer: chat.choices[0].message });res.json({matches: rows, prompt });});app.listen(3000,()=>console.log('RAG API on :3000'));
🟩 Parte C — Nuxt (Front-end) consumindo a API RAG
Nuxt 3 (TS opcional) + simples página de busca que chama seu backend (Laravel ou Node).
Em produção, crie um endpoint /rag/answer no backend que realmente chama o LLM com o prompt retornado e devolve answer renderizada. Aqui deixei explícito para você ver a montagem do contexto.
🎛️ Boas práticas que fazem diferença (nível avançado)
Normalização e poda
Remova boilerplate, tabelas HTML, menus.
Prefira chunks por semântica (títulos/seções) + overlap pequeno (10–20%).
Re-ranker (opcional e poderoso)
Depois do top-k por vetor, refine a ordem com um re-ranker (ex.: cross-encoder) para qualidade máxima.
Janela controlada
Limite contexto por tokens (não só caracteres). Evite estourar o contexto do modelo.
Citações confiáveis
Sempre anexe [docId/chunk]. Se possível, retorne também offsets para destacar trechos no front.
Atualização incremental
Mantenha updated_at e reingira só o que mudou.
HNSW + filtros
Índice HNSW acelera. Adicione filtros por document_id, tag, language (pré-filtragem) antes do ranking vetorial.
Guardrails
Prompt com instruções de não alucinar + fallback “não encontrado”.
✅ O que você já consegue fazer com isso
Ingerir qualquer .txt/.md (ou PDF após extrair texto).
Perguntar em linguagem natural e obter trechos relevantes + prompt pronto.
Integrar um LLM para resposta final com citações.
Plugar Nuxt como UI.
Se quiser, eu já acrescento:
/rag/answer chamando o chat (OpenAI ou outro),
ingestão de PDF (com extração de texto),
re-ranking,
filtros por tags/projetos,
e a integração MCP (ex.: ferramenta “BuscarContratoPorId” para o modelo chamar).
Continuando implementação com o Laravel
perfeito — vamos finalizar a pilha em Laravel com o endpoint que chama o LLM e já devolve resposta pronta com citações. Vou manter compatível com o que montamos (Guzzle + pgvector), bem direto ao ponto.
1) Service para Chat (LLM)
app/Services/ChatLLM.php
<?phpnamespaceApp\Services;
useGuzzleHttp\Client;
class ChatLLM
{
privateClient$http;
publicfunction__construct(privatestring$apiKey)
{
$this->http = newClient([
'base_uri' => 'https://api.openai.com/v1/',
'timeout' => 60,
]);
}
/** * Gera resposta com base em um prompt “single-turn”. * Troque o $model conforme seu custo/performance. */publicfunctioncomplete(string$prompt, string$model = 'gpt-4o-mini'): string
{
$resp = $this->http->post('chat/completions', [
'headers' => [
'Authorization' => "Bearer {$this->apiKey}",
'Content-Type' => 'application/json',
],
'json' => [
'model' => $model,
'messages' => [
['role' => 'system', 'content' => 'Você é um assistente que só responde com base no CONTEXTO fornecido. Se faltar evidência, diga claramente. Sempre cite as fontes no formato [docId/chunk].'],
['role' => 'user', 'content' => $prompt],
],
'temperature' => 0.2,
],
]);
$data = json_decode($resp->getBody()->getContents(), true);
return$data['choices'][0]['message']['content'] ?? '';
}
}
Dica: para baratear, mantenha gpt-4o-mini. Se quiser mais qualidade, troque para gpt-4o (ou o modelo que preferir).
2) Funções utilitárias de RAG (montagem de contexto)
Criaremos um helper para buscar top-k e montar o prompt (reusável nos endpoints).
app/Services/RagHelper.php
<?phpnamespaceApp\Services;
useIlluminate\Support\Facades\DB;
class RagHelper
{
publicstaticfunctionsearchTopK(string$queryEmbeddingLiteral, int$k = 6): array
{
// usa L2; se você preferir cosseno: ORDER BY cosine_distance(embedding, $1::vector)$rows = DB::select(" SELECT id, document_id, chunk_index, content, l2_distance(embedding, $1::vector) AS dist FROM chunks ORDER BY embedding <-> $1::vector LIMIT $k", [$queryEmbeddingLiteral]);
return$rows;
}
publicstaticfunctionbuildPrompt(string$userQuestion, array$rows, int$maxChars = 6500): string
{
$context = '';
foreach ($rowsas$r) {
$piece = "Fonte [{$r->document_id}/{$r->chunk_index}]: {$r->content}\n";
if (strlen($context) + strlen($piece) > $maxChars) break;
$context .= $piece;
}
$prompt = <<<TXTResponda APENAS com base no CONTEXTO abaixo.Se não houver evidência suficiente, responda: "Não encontrei no material."Cite as fontes SEMPRE no formato [docId/chunk] logo após cada afirmação importante.CONTEXTO:$contextPergunta: "$userQuestion"Agora, escreva uma resposta objetiva, estruturada, com bullets quando útil, e com citações entre colchetes no corpo do texto.TXT;
return$prompt;
}
}
curl -X POST http://localhost:8000/api/rag/answer \
-H 'Content-Type: application/json' \
-d '{"q":"Quais cláusulas tratam de rescisão e multa?", "k":6}'
Resposta esperada (trecho):
{
"query": "Quais cláusulas tratam de rescisão e multa?",
"matches": [ ... ],
"prompt": "Responda APENAS com base ...",
"answer": "- A rescisão pode ser solicitada com aviso prévio de 30 dias [12/3].\n- Multa de 10% do saldo em caso de quebra unilateral [12/4]..."
}
6) Boas práticas (RAG em produção)
Operador de distância: escolha cosine para textos longos e normalize embeddings.
Índice: CREATE INDEX ... USING hnsw (embedding vector_cosine_ops);
Query: ORDER BY embedding <-> $1::vector continua ok, pois o operador troca conforme o ops do índice.
Limite por tokens: hoje usamos maxChars. Para mais precisão, troque por contagem de tokens (ex.: cl100k_base tokenizer).
Pós-rank (re-ranker): use um cross-encoder para reordenar os top-k (melhora muito qualidade).
Citações “clicáveis”: além de [docId/chunk], retorne document_id, chunk_index e offsets para destacar no front.
Atualização incremental: reingira apenas arquivos alterados (hash de conteúdo).
Guardrails: mantenha temperature baixa e mensagem de “não encontrado” clara.
7) Integração Nuxt (usar resposta pronta)
No seu pages/index.vue, troque a chamada para o endpoint de answer:
Exiba answer normalmente (ela já vem com as citações no corpo do texto).
Extras (filtros por tags e streaming SSE)
filtros por tags (coluna tag em documents + WHERE tag = ? antes do ranking),
streaming SSE para a resposta,
massa! vamos implementar (1) filtro por tags e (2) streaming SSE no seu projeto Laravel do RAG. Mantive tudo bem direto, plug-and-play com o que já fizemos.
1) Filtro por tags
1.1 Migration: adicionar tag em documents
Crie uma migration (ex.: 2025_09_04_120000_add_tag_to_documents.php):
constes=newEventSource('http://localhost:8000/api/rag/answer/stream',{withCredentials: false})// Para POST em SSE, abra via fetch p/ token e depois redirecione, ou use um proxy.// Alternativa prática: abrir via GET com querystring: ?q=...&tag=...es.onmessage=(ev)=>{constmsg=JSON.parse(ev.data)// {type:'chunk'|'end', delta?: string}if(msg.type==='chunk'){// append msg.delta ao campo de resposta}elseif(msg.type==='end'){es.close()}}
Observação: o SSE é GET por padrão. Se quiser manter POST com corpo JSON, você pode:
Criar um endpoint GET que recebe q, tag, tags[] por querystring; ou
Abrir conexão com fetch + ReadableStream (sem SSE).
Acima, deixei como POST SSE para ilustrar o fluxo no Laravel; na prática, recomendo criar também uma versão GET para simplificar o consumo clássico de EventSource.
Teste rápido (com querystring – versão GET simples)
Se preferir criar uma rota GET irmã (mais fácil de testar no navegador):
Route::get('/rag/answer/stream', function (Request$req) {
// leia $q, $k, $tag, $tags[] de querystring// copie a mesma lógica do POST de cima
});
Se trocar a métrica para cosseno, ajuste o índice:
CREATEINDEXIF NOT EXISTS idx_chunks_embedding_cos
ON chunks USING hnsw (embedding vector_cosine_ops);
…e mude o ORDER BY/l2_distance conforme necessário.
Em produção, habilite CORS no Laravel para o domínio do seu front (Nuxt) e verifique se o Nginx/Apache está com buffering desativado para SSE (proxy_buffering off; no Nginx).
Você pode enviar, junto dos chunks SSE, os matches (fontes) no início como um evento separado:
Assim o front já renderiza as fontes enquanto o texto vai chegando.
Exemplo de componente Nuxt consumindo SSE e anexando os matches antes do texto:
Começamos com a rota GET (SSE) no Laravel e um componente/página Nuxt consumindo o stream, já mostrando as fontes (matches) antes do texto chegar. Tudo plug-and-play com o que fizemos.
✅ Laravel — rota GET com SSE e meta + chunk + end
Requisitos já atendidos: Embeddings, ChatLLM, RagHelper, pgvector, etc.
routes/api.php
useIlluminate\Support\Facades\Route;
useIlluminate\Support\Facades\DB;
useIlluminate\Http\Request;
useSymfony\Component\HttpFoundation\StreamedResponse;
useApp\Services\Embeddings;
useApp\Services\ChatLLM;
useApp\Services\RagHelper;
Route::get('/rag/answer/stream', function (Request$req) {
$q = (string) $req->query('q', '');
$k = (int) ($req->query('k', 6));
$model = (string) $req->query('model', 'gpt-4o-mini');
$tag = $req->query('tag'); // string única$tags = $req->query('tags'); // pode vir como array (tags[]=a&tags[]=b) ou csvif (!$q) {
returnresponse('q é obrigatório', 400);
}
// normaliza $tags (aceita "tags=a,b" OU "tags[]=a&tags[]=b")if (is_string($tags) && str_contains($tags, ',')) {
$tags = array_values(array_filter(array_map('trim', explode(',', $tags))));
} elseif (is_string($tags) && $tags !== '') {
$tags = [$tags];
} elseif (!is_array($tags)) {
$tags = null;
}
// 1) embedding da pergunta$embedder = newEmbeddings(env('OPENAI_API_KEY'));
$qVec = $embedder->embed($q);
$qVecSql = '{'.implode(',', $qVec).'}';
// 2) busca top-k com possíveis filtros de tag$rows = RagHelper::searchTopK($qVecSql, $k, $tag, $tags);
// 3) prompt$prompt = RagHelper::buildPrompt($q, $rows);
// 4) stream SSE$response = newStreamedResponse(function () use ($rows, $prompt, $model) {
$send = function (array$payload) {
// uma linha SSE por eventoecho'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
ob_flush(); flush();
};
// envia metadados (matches + prompt) ANTES do texto$send([
'type' => 'meta',
'matches' => $rows,
'prompt' => $prompt,
]);
// (opcional) heartbeatecho":ok\n\n"; @ob_flush(); @flush();
// stream do LLM$chat = newChatLLM(env('OPENAI_API_KEY'));
$buffer = '';
$chat->stream($prompt, $model, function ($delta, $done) use (&$buffer, $send) {
if ($done) {
if ($buffer !== '') {
$send(['type' => 'chunk', 'delta' => $buffer]);
$buffer = '';
}
$send(['type' => 'end']);
return;
}
if ($delta !== null && $delta !== '') {
$buffer .= $delta;
if (mb_strlen($buffer) >= 400) {
$send(['type' => 'chunk', 'delta' => $buffer]);
$buffer = '';
}
}
});
});
// headers SSE$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache, no-transform');
$response->headers->set('X-Accel-Buffering', 'no');
$response->headers->set('Connection', 'keep-alive');
$response->headers->set('Access-Control-Allow-Origin', '*'); // ajuste para seu domínio em prodreturn$response;
});
Se quiser cosine em vez de L2, ajuste o índice e a query no RagHelper.
🟩 Nuxt 3 — página consumindo SSE, exibindo matches e o stream
Crie uma página simples (ou componente). Aqui uso pages/index.vue como exemplo.
Não é para responder. Isso é apenas para memória.
Para os novos códigos em PHP, quero que leve em conta os seguintes itens:
não usar supressão de erro com '@'. Se precisar capturar um erro (ideal), faça com try/catch;
use o conceito de early return e elseless sempre que possível visando deixar o código mais legível;
se o código for Laravel, não use as variáveis em outro lugar que não seja em uma config.
Por exemplo, se precisar usar uma variável exemplo 'OPENAI_MODEL' em uma classe, use uma config por exemplo 'config/openai.php'
e nela retorne um array que tenha o item 'model' => env('OPENAI_MODEL') e na classe use config('openai.model')
procure usar classes, funções, variáveis e constantes em inglês salvo se o item referido for algo local como CPF, CNPJ, Pix etc
use boas práticas da tecnologia usada e aplique as PSRs quando possível