Skip to content

Instantly share code, notes, and snippets.

@tiagofrancafernandes
Last active September 4, 2025 19:24
Show Gist options
  • Save tiagofrancafernandes/f62f57ddd30632b270d03ac2dc43cfbd to your computer and use it in GitHub Desktop.
Save tiagofrancafernandes/f62f57ddd30632b270d03ac2dc43cfbd to your computer and use it in GitHub Desktop.
dev-IA Related

Consultando recursos internos (exemplo faturas)

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:

Migration (exemplo) database/migrations/2025_09_04_000010_create_customers_and_invoices.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        Schema::create('customers', function (Blueprint $table) {
            $table->id(); // customer_id
            $table->string('name')->nullable();
            $table->string('cpf_cnpj')->unique()->index(); // somente dígitos (limpo)
            $table->timestamps();
        });

        Schema::create('invoices', function (Blueprint $table) {
            $table->id();
            $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
            $table->date('issue_date')->nullable();
            $table->date('due_date');
            $table->decimal('amount', 14, 2);
            $table->timestamp('paid_at')->nullable();
            $table->timestamp('canceled_at')->nullable();
            $table->string('status')->default('open'); // open|paid|canceled (opcional, derivável)
            $table->timestamps();
            $table->index(['customer_id', 'due_date']);
        });
    }

    public function down(): void {
        Schema::dropIfExists('invoices');
        Schema::dropIfExists('customers');
    }
};

Models (simples) app/Models/Customer.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Customer extends Model
{
    protected $fillable = ['name', 'cpf_cnpj'];
    public function invoices(): HasMany { return $this->hasMany(Invoice::class); }
}

app/Models/Invoice.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;

class Invoice extends Model
{
    protected $fillable = [
        'customer_id', 'issue_date', 'due_date', 'amount',
        'paid_at', 'canceled_at', 'status'
    ];

    // Escopos úteis
    public function scopeOpen(Builder $q): Builder {
        return $q->whereNull('paid_at')->whereNull('canceled_at');
    }
    public function scopeOverdue(Builder $q, ?string $tz = 'America/Cuiaba'): Builder {
        $today = now($tz)->startOfDay();
        return $q->whereNull('paid_at')->whereNull('canceled_at')->where('due_date', '<', $today);
    }
}

1) Service de Intent Parsing (PT-BR)

Vamos interpretar frases como “abertas”, “atrasadas”, “dos últimos X meses”, etc.

app/Services/InvoiceQueryIntent.php

<?php

namespace App\Services;

class InvoiceQueryIntent
{
    /**
     * @return array{
     *   want:'open'|'overdue'|'open_or_overdue',
     *   months:int|null
     * }
     */
    public static function parse(string $text): array
    {
        $t = mb_strtolower(trim($text));

        // padrão de meses: "últimos 3 meses", "dos últimos 6 meses", "último mês"...
        $months = null;
        if (preg_match('/(últim[oa]s?\s+(\d+)\s+meses?)/u', $t, $m)) {
            $months = max(1, (int)$m[2]);
        } elseif (preg_match('/(últim[oa]\s+m[eê]s)/u', $t)) {
            $months = 1;
        } elseif (preg_match('/(30\s*dias)/u', $t)) {
            $months = 1; // aproximação simples
        } elseif (preg_match('/(90\s*dias)/u', $t)) {
            $months = 3;
        }

        // intenção: abertas, pendentes, em aberto
        $isOpen = preg_match('/\b(abert[ao]s?|pendentes?|em aberto)\b/u', $t);

        // intenção: atrasadas / vencidas
        $isOverdue = preg_match('/\b(atrasad[ao]s?|vencid[ao]s?)\b/u', $t);

        // fallback: se nada citado, tratamos como "abertas"
        $want = 'open';
        if ($isOverdue) {
            $want = 'overdue';
        } elseif ($isOpen && !$isOverdue) {
            $want = 'open';
        } elseif (!$isOpen && !$isOverdue) {
            // se veio algo genérico como "faturas", retornar abertas por padrão
            $want = 'open';
        }

        return ['want' => $want, 'months' => $months];
    }

    /**
     * Remove caracteres não numéricos de CPF/CNPJ e valida o comprimento.
     */
    public static function normalizeCpfCnpj(?string $doc): ?string
    {
        if (!$doc) return null;
        $digits = preg_replace('/\D+/', '', $doc);
        if ($digits === '') return null;
        // aceita 11 (CPF) ou 14 (CNPJ); se diferente, ainda assim devolve para permitir busca flexível
        return $digits;
    }
}

2) Controller: busca por customer_id ou CPF/CNPJ + filtros

app/Http/Controllers/InvoiceQueryController.php

<?php

namespace App\Http\Controllers;

use App\Models\Customer;
use App\Models\Invoice;
use App\Services\InvoiceQueryIntent;
use Illuminate\Http\Request;

class InvoiceQueryController extends Controller
{
    public function query(Request $req)
    {
        $message     = (string) $req->input('message', ''); // ex.: "faturas abertas dos últimos 3 meses"
        $customerId  = $req->input('customer_id');
        $cpfCnpjRaw  = $req->input('cpf_cnpj');
        $tz          = (string) ($req->input('tz') ?? 'America/Cuiaba');

        // 1) Entender intenção
        $intent  = InvoiceQueryIntent::parse($message);
        $want    = $intent['want'];   // 'open' | 'overdue'
        $months  = $intent['months']; // int|null

        // 2) Identificar cliente
        $customer = null;
        if ($customerId) {
            $customer = Customer::find($customerId);
        } else {
            $cpfCnpj = InvoiceQueryIntent::normalizeCpfCnpj($cpfCnpjRaw);
            if ($cpfCnpj) {
                $customer = Customer::where('cpf_cnpj', $cpfCnpj)->first();
            }
        }

        if (!$customer) {
            return response()->json([
                'needs_customer' => true,
                'message' => 'Informe customer_id ou cpf_cnpj para buscar faturas.',
                'hint' => [
                    'by_customer_id' => ['customer_id' => 123, 'message' => 'faturas abertas dos últimos 3 meses'],
                    'by_cpf_cnpj'    => ['cpf_cnpj'   => '11.222.333/0001-44', 'message' => 'faturas atrasadas'],
                ]
            ], 422);
        }

        // 3) Montar query base
        $q = Invoice::query()->where('customer_id', $customer->id);

        if ($want === 'overdue') {
            $q->overdue($tz);
        } else { // 'open'
            $q->open();
        }

        // 4) Filtro por janela temporal (últimos N meses) se requisitado
        if ($months !== null) {
            $from = now($tz)->startOfDay()->subMonths($months);
            // Você pode filtrar por due_date ou issue_date; aqui uso due_date
            $q->where('due_date', '>=', $from->toDateString());
        }

        // 5) Ordenação e retorno
        $items = $q->orderBy('due_date')->get([
            'id', 'customer_id', 'issue_date', 'due_date', 'amount', 'paid_at', 'canceled_at', 'status', 'created_at'
        ]);

        // 6) Totais úteis
        $totalAmount = $items->sum('amount');

        return response()->json([
            'customer' => [
                'id'       => $customer->id,
                'name'     => $customer->name,
                'cpf_cnpj' => $customer->cpf_cnpj,
            ],
            'filters' => [
                'intent' => $want,
                'months' => $months,
                'tz'     => $tz,
            ],
            'count'  => $items->count(),
            'total'  => (float) $totalAmount,
            'items'  => $items,
        ]);
    }
}

3) Rota

routes/api.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\InvoiceQueryController;

Route::post('/invoices/query', [InvoiceQueryController::class, 'query']);

4) Exemplos de uso

4.1 “Faturas abertas” por customer_id

curl -X POST http://localhost:8000/api/invoices/query \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "faturas abertas",
    "customer_id": 42
  }'

4.2 “Faturas atrasadas” por CPF/CNPJ

curl -X POST http://localhost:8000/api/invoices/query \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "faturas atrasadas",
    "cpf_cnpj": "12.345.678/0001-90"
  }'

4.3 “Faturas abertas dos últimos 3 meses” por CPF

curl -X POST http://localhost:8000/api/invoices/query \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "faturas abertas dos últimos 3 meses",
    "cpf_cnpj": "123.456.789-10"
  }'

4.4 Sem cliente informado (retorna 422 e pede o cliente)

curl -X POST http://localhost:8000/api/invoices/query \
  -H 'Content-Type: application/json' \
  -d '{"message": "faturas abertas"}'

Regras de negócio implementadas (resumo)

  • Abertas = paid_at IS NULL e canceled_at IS NULL.
  • Atrasadas = Abertas e due_date < hoje (timezone America/Cuiaba por padrão).
  • Janela temporal (últimos N meses) = filtra por due_date >= hoje - N meses.
  • Identificação do cliente: por customer_id ou cpf_cnpj (normalizado só dígitos).
  • Se não houver cliente → responde 422 com instruções de como informar.

Extensões fáceis

  • Mais intents: “da semana”, “do mês passado”, “últimos X dias” (basta ampliar os regex do InvoiceQueryIntent::parse).
  • Paginação: usar paginate() e retornar links.
  • Moeda: formatar amount como BRL no front.
  • Status derivado: se preferir, remova status e derive no getter (open/paid/canceled).
  • Autorização: adicione Policies/Guards para assegurar que um cliente só veja as próprias faturas.

Para criar um projeto em Docker que possibilite rodar localmente, executar consultas e adicionar dados, vamos seguir os seguintes passos:

Passos do Projeto:

  1. Criação de um Dockerfile: Para configurar o ambiente com todas as dependências necessárias (Python, bibliotecas de aprendizado de máquina, etc.).
  2. Configuração de um script Python para treinamento e consultas.
  3. Configuração de um banco de dados (SQLite, PostgreSQL, etc.) para armazenamento de dados.
  4. Criação de um README.md com as instruções de como usar o projeto.
  5. 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 Python
FROM python:3.9-slim

# Setando o diretório de trabalho
WORKDIR /app

# Instalando as dependências do sistema
RUN apt-get update && apt-get install -y \
    build-essential \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Instalando as dependências do Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copiando o código do projeto
COPY . /app

# Expondo a porta do Flask (se for usar uma API web)
EXPOSE 5000

# Comando para rodar o servidor ou o script de inferência
CMD ["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.

from flask import Flask, request, jsonify
from transformers import GPT2LMHeadModel, GPT2Tokenizer

# Inicializando o Flask
app = Flask(__name__)

# Carregar o modelo e o tokenizer
model_name = "gpt2"
model = GPT2LMHeadModel.from_pretrained(model_name)
tokenizer = GPT2Tokenizer.from_pretrained(model_name)

# Rota para consulta
@app.route("/generate", methods=["POST"])
def generate_text():
    input_data = request.json.get("input", "")

    # Tokenizando a entrada
    inputs = tokenizer(input_data, return_tensors="pt")

    # Gerando resposta
    outputs = model.generate(inputs["input_ids"], max_length=50)

    # Decodificando a resposta
    generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)

    return jsonify({"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:

import psycopg2
from psycopg2 import sql

# Configuração da conexão ao banco de dados
conn = psycopg2.connect(
    dbname="db_name",
    user="db_user",
    password="db_password",
    host="db_host",
    port="5432"
)
cursor = conn.cursor()

# Exemplo de inserção de dados
cursor.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 uso

1. **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.

Estrutura de Diretórios:

/meu_projeto_llm
|-- Dockerfile
|-- requirements.txt
|-- app.py
|-- add_data.py
|-- README.md

Após criar todos os arquivos e organizá-los, você pode usar um comando de terminal para compactar o diretório:

zip -r meu_projeto_llm.zip meu_projeto_llm/

7. Enviar o ZIP para Download

Você pode facilmente gerar o arquivo ZIP a partir da estrutura acima.

Quando você tiver o projeto em mãos, basta seguir as instruções do README.md para executar localmente.

MCP vs RAG

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:

  1. Dados + Modelo + Contexto = resposta inteligente.
  2. O "contexto" é o que você fornece como entrada (prompt, histórico, documentos, etc.).
  3. 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):

  1. Você cria uma base vetorial (ex: Pinecone, Weaviate, Qdrant, ou até banco SQL com embeddings).

  2. 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.
  3. 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.

  • Passo 1: Indexar os contratos
use OpenAI\Client as OpenAI;

$openai = new OpenAI($_ENV['OPENAI_API_KEY']);

foreach (Contract::all() as $contract) {
    $embedding = $openai->embeddings()->create([
        'model' => 'text-embedding-3-small',
        'input' => $contract->text,
    ]);

    // salvar em tabela `contract_embeddings`
    DB::table('contract_embeddings')->insert([
        'contract_id' => $contract->id,
        'vector' => json_encode($embedding['data'][0]['embedding']),
    ]);
}
  • Passo 2: Consulta RAG
$query = "Quais cláusulas falam sobre rescisão?";
$embedding = $openai->embeddings()->create([
    'model' => 'text-embedding-3-small',
    'input' => $query,
]);

// Calcular similaridade com embeddings do banco
$results = ContractEmbedding::orderByRaw("cosine_similarity(vector, ?)", [json_encode($embedding['data'][0]['embedding'])])->take(3)->get();

// Passar contratos similares ao LLM como contexto
$response = $openai->chat()->create([
    'model' => 'gpt-4o',
    'messages' => [[
        'role' => 'user',
        'content' => "Com base nesses contratos: " . $results->pluck('text')->join("\n") . " responda: $query"
    ]]
]);

👉 Isso é RAG: buscar + injetar + gerar.

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:

  1. ingestão: quebrar documentos em “chunks” → gerar embeddings → salvar no banco (com vetor).
  2. busca semântica: transformar a pergunta em embedding → ORDER BY cosine_distance → pegar top-k trechos.
  3. Geração: montar um prompt com as passagens relevantes → chamar o LLM → responder citando as fontes.

🧠 Arquitetura (resumo)

[PDF/TXT/HTML] --(chunk + embed)--> [DB com vetores]
             \                               |
              \--(pergunta)--> [embed] --> [busca top-k] --> [prompt] --> [LLM] --> resposta+citações

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.


🧩 Parte A — Laravel (PHP) + PostgreSQL (pgvector)

0) Pré-requisitos

  • PHP 8.2+, Composer
  • Laravel 10/11
  • PostgreSQL 15+ com CREATE EXTENSION vector;
  • Pacotes: guzzlehttp/guzzle (HTTP), vlucas/phpdotenv (se necessário)
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):

public function up(): 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 acelerar
    DB::statement("CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON chunks USING hnsw (embedding vector_l2_ops)");
}

public function down(): void
{
    Schema::dropIfExists('documents');
    DB::statement("DROP TABLE IF EXISTS chunks");
}

2) Helper de chunking (simples e eficaz)

app/Services/TextChunker.php

<?php

namespace App\Services;

class TextChunker
{
    /**
     * Divide texto em blocos ~700-900 tokens-equivalentes (aqui por caracteres) com overlap.
     */
    public static function chunk(string $text, int $size = 2200, int $overlap = 300): array
    {
        $text = trim(preg_replace('/\s+/', ' ', $text));
        $chunks = [];
        $start = 0;
        $len = strlen($text);

        while ($start < $len) {
            $end = min($start + $size, $len);
            $slice = substr($text, $start, $end - $start);
            $chunks[] = $slice;
            if ($end === $len) break;
            $start = max(0, $end - $overlap);
        }
        return $chunks;
    }
}

3) Cliente de Embedding

app/Services/Embeddings.php

<?php

namespace App\Services;

use GuzzleHttp\Client;

class Embeddings
{
    private Client $http;

    public function __construct(private string $apiKey)
    {
        $this->http = new Client([
            'base_uri' => 'https://api.openai.com/v1/',
            'timeout'  => 30,
        ]);
    }

    public function embed(string $text, string $model = 'text-embedding-3-small'): array
    {
        $resp = $this->http->post('embeddings', [
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
                'Content-Type'  => 'application/json',
            ],
            'json' => [
                'model' => $model,
                'input' => $text,
            ],
        ]);

        $data = json_decode($resp->getBody()->getContents(), true);
        return $data['data'][0]['embedding'];
    }
}

4) Comando de ingestão

app/Console/Commands/RagIngest.php

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\TextChunker;
use App\Services\Embeddings;
use Illuminate\Support\Facades\DB;

class RagIngest extends Command
{
    protected $signature = 'rag:ingest {path : Caminho de um .txt ou .md}';
    protected $description = 'Ingere documento: chunk -> embedding -> salva';

    public function handle()
    {
        $path = $this->argument('path');
        if (!file_exists($path)) {
            $this->error("Arquivo não encontrado: $path");
            return Command::FAILURE;
        }

        $text = file_get_contents($path);
        $docId = DB::table('documents')->insertGetId([
            'source' => $path,
            'text'   => $text,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $chunks = TextChunker::chunk($text);
        $embedder = new Embeddings(env('OPENAI_API_KEY'));

        foreach ($chunks as $i => $content) {
            $vec = $embedder->embed($content);
            DB::insert(
                "INSERT INTO chunks (document_id, chunk_index, content, embedding) VALUES (?, ?, ?, ?)",
                [$docId, $i, $content, '{'.implode(',', $vec).'}']
            );
        }

        $this->info("Ingestão concluída. Doc #$docId, chunks: ".count($chunks));
        return Command::SUCCESS;
    }
}

5) Endpoint de pergunta (RAG Query)

routes/api.php

use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use App\Services\Embeddings;

Route::post('/rag/query', function (\Illuminate\Http\Request $req) {
    $q = $req->input('q');
    abort_unless($q, 400, 'q é obrigatório');

    $embedder = new Embeddings(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 ($rows as $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 = <<<TXT
Você é 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:
$context

Pergunta 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);

    return response()->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)

0) Setup

npm init -y
npm i prisma @prisma/client pg dotenv openai
npx prisma init

No Postgres:

CREATE EXTENSION IF NOT EXISTS vector;

1) Prisma schema (com campo vetorial via Unsupported)

prisma/schema.prisma

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Document {
  id        BigInt   @id @default(autoincrement())
  source    String
  text      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  chunks    Chunk[]
}

model Chunk {
  id         BigInt  @id @default(autoincrement())
  documentId BigInt
  chunkIndex Int
  content    String
  // vetor pgvector 1536 dimensões
  embedding  Unsupported("vector")

  document   Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

  @@index([embedding], type: Brin) // índice genérico; crie HNSW via SQL manualmente
}

Depois:

npx prisma migrate dev -n init

Crie o índice HNSW manual (opcional):

CREATE INDEX IF NOT EXISTS idx_chunks_embedding
ON "Chunk" USING hnsw (embedding vector_l2_ops);

2) Ingest (chunk + embed)

src/ingest.ts

import fs from 'node:fs';
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import OpenAI from 'openai';

const prisma = new PrismaClient();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

function chunk(text: string, size = 2200, overlap = 300) {
  const clean = text.replace(/\s+/g, ' ').trim();
  const parts: string[] = [];
  for (let i = 0; i < clean.length; i += (size - overlap)) {
    parts.push(clean.slice(i, i + size));
    if (i + size >= clean.length) break;
  }
  return parts;
}

async function main(path: string) {
  const text = fs.readFileSync(path, 'utf8');
  const doc = await prisma.document.create({ data: { source: path, text } });
  const parts = chunk(text);

  for (let i = 0; i < parts.length; i++) {
    const r = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: parts[i],
    });
    const vec = r.data[0].embedding;
    // transforma em literal de vetor: '{v1,v2,...}'
    const vecLit = `{${vec.join(',')}}`;

    await prisma.$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';
import express from 'express';
import { PrismaClient } from '@prisma/client';
import OpenAI from 'openai';

const prisma = new PrismaClient();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const app = express();
app.use(express.json());

app.post('/rag/query', async (req, res) => {
  const q: string = req.body.q;
  const k = Number(req.body.k ?? 6);

  const emb = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: q,
  });
  const qVec = `{${emb.data[0].embedding.join(',')}}`;

  const rows: any[] = await prisma.$queryRawUnsafe(`
    SELECT id, "documentId", "chunkIndex", content,
           l2_distance(embedding, $1::vector) AS dist
    FROM "Chunk"
    ORDER BY embedding <-> $1::vector
    LIMIT ${k}
  `, qVec);

  let context = '';
  for (const r of rows) {
    const piece = `Fonte [${r.documentId}/${r.chunkIndex}]: ${r.content}\n`;
    if ((context.length + piece.length) > 6000) break;
    context += piece;
  }

  const prompt = `
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).

npx nuxi init nuxt-rag-ui
cd nuxt-rag-ui
npm i

pages/index.vue

<script setup lang="ts">
import { ref } from 'vue'

const q = ref('')
const loading = ref(false)
const answer = ref<string | null>(null)
const matches = ref<any[]>([])
const prompt = ref<string>('')

async function ask() {
  loading.value = true
  answer.value = null
  matches.value = []
  prompt.value = ''

  // troque a URL abaixo para o seu backend (Laravel ou Node)
  const r = await $fetch('http://localhost:3000/rag/query', {
    method: 'POST',
    body: { q: q.value, k: 6 }
  })

  matches.value = (r as any).matches
  prompt.value = (r as any).prompt

  // aqui você chamaria um endpoint /rag/answer que executa o LLM com o prompt
  // por simplicidade, mostramos o prompt + snippets
  loading.value = false
}
</script>

<template>
  <div class="min-h-screen p-6 max-w-3xl mx-auto">
    <h1 class="text-2xl font-bold mb-4">RAG Demo</h1>

    <div class="flex gap-2 mb-4">
      <input v-model="q" type="text" placeholder="Pergunte algo..."
             class="flex-1 border rounded px-3 py-2" />
      <button @click="ask" class="px-4 py-2 rounded border" :disabled="loading">
        {{ loading ? 'Buscando...' : 'Perguntar' }}
      </button>
    </div>

    <div v-if="prompt" class="mb-6">
      <h2 class="font-semibold mb-2">Prompt (debug):</h2>
      <pre class="whitespace-pre-wrap text-sm bg-gray-50 p-3 rounded">{{ prompt }}</pre>
    </div>

    <div v-if="matches.length">
      <h2 class="font-semibold mb-2">Trechos recuperados:</h2>
      <ul class="space-y-3">
        <li v-for="m in matches" :key="m.id" class="p-3 border rounded">
          <div class="text-xs text-gray-600 mb-1">[{{ m.documentId }}/{{ m.chunkIndex }}] — dist={{ m.dist?.toFixed?.(3) }}</div>
          <div class="text-sm">{{ m.content }}</div>
        </li>
      </ul>
    </div>

    <div v-if="answer" class="mt-6 p-4 border rounded">
      <h2 class="font-semibold mb-2">Resposta</h2>
      <div class="prose" v-html="answer"></div>
    </div>
  </div>
</template>

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)

  1. Normalização e poda

    • Remova boilerplate, tabelas HTML, menus.
    • Prefira chunks por semântica (títulos/seções) + overlap pequeno (10–20%).
  2. 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.
  3. Janela controlada

    • Limite contexto por tokens (não só caracteres). Evite estourar o contexto do modelo.
  4. Citações confiáveis

    • Sempre anexe [docId/chunk]. Se possível, retorne também offsets para destacar trechos no front.
  5. Atualização incremental

    • Mantenha updated_at e reingira só o que mudou.
  6. HNSW + filtros

    • Índice HNSW acelera. Adicione filtros por document_id, tag, language (pré-filtragem) antes do ranking vetorial.
  7. 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

<?php

namespace App\Services;

use GuzzleHttp\Client;

class ChatLLM
{
    private Client $http;

    public function __construct(private string $apiKey)
    {
        $this->http = new Client([
            '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.
     */
    public function complete(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

<?php

namespace App\Services;

use Illuminate\Support\Facades\DB;

class RagHelper
{
    public static function searchTopK(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;
    }

    public static function buildPrompt(string $userQuestion, array $rows, int $maxChars = 6500): string
    {
        $context = '';
        foreach ($rows as $r) {
            $piece = "Fonte [{$r->document_id}/{$r->chunk_index}]: {$r->content}\n";
            if (strlen($context) + strlen($piece) > $maxChars) break;
            $context .= $piece;
        }

        $prompt = <<<TXT
Responda 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:
$context

Pergunta: "$userQuestion"

Agora, escreva uma resposta objetiva, estruturada, com bullets quando útil, e com citações entre colchetes no corpo do texto.
TXT;

        return $prompt;
    }
}

3) Endpoint /rag/answer (faz tudo: embed → buscar → gerar)

routes/api.php

use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use App\Services\Embeddings;
use App\Services\ChatLLM;
use App\Services\RagHelper;

Route::post('/rag/answer', function (Request $req) {
    $q = $req->input('q');
    $k = (int)($req->input('k', 6));
    abort_unless($q, 400, 'q é obrigatório');

    // 1) embedding da pergunta
    $embedder = new Embeddings(env('OPENAI_API_KEY'));
    $qVec = $embedder->embed($q);
    $qVecSql = '{'.implode(',', $qVec).'}';

    // 2) busca top-k
    $rows = RagHelper::searchTopK($qVecSql, $k);

    // 3) monta prompt
    $prompt = RagHelper::buildPrompt($q, $rows);

    // 4) chama LLM
    $chat = new ChatLLM(env('OPENAI_API_KEY'));
    $answer = $chat->complete($prompt, model: $req->input('model', 'gpt-4o-mini'));

    return response()->json([
        'query'   => $q,
        'matches' => $rows,   // para exibir os trechos e/ou criar âncoras de citação
        'prompt'  => $prompt, // útil para debug
        'answer'  => $answer, // resposta final com citações [docId/chunk]
    ]);
});

Se quiser streaming mais tarde, dá pra trocar para Server-Sent Events (SSE) e usar stream: true na API de chat — mas vamos manter simples agora.


4) (Opcional) Endpoint /rag/query de debug

Se você ainda não criou, segue a versão “debug” (busca + prompt, sem gerar resposta):

use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use App\Services\Embeddings;
use App\Services\RagHelper;

Route::post('/rag/query', function (Request $req) {
    $q = $req->input('q');
    $k = (int)($req->input('k', 6));
    abort_unless($q, 400, 'q é obrigatório');

    $embedder = new Embeddings(env('OPENAI_API_KEY'));
    $qVec = $embedder->embed($q);
    $qVecSql = '{'.implode(',', $qVec).'}';

    $rows = RagHelper::searchTopK($qVecSql, $k);
    $prompt = RagHelper::buildPrompt($q, $rows);

    return response()->json([
        'query'   => $q,
        'matches' => $rows,
        'prompt'  => $prompt,
    ]);
});

5) Testes rápidos

Artisan (se já criou o comando de ingestão rag:ingest):

php artisan rag:ingest storage/app/exemplos/contratos.md

cURL – resposta final:

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:

const r = await $fetch('http://localhost:8000/api/rag/answer', {
  method: 'POST',
  body: { q: q.value, k: 6, model: 'gpt-4o-mini' }
})

matches.value = (r as any).matches
prompt.value  = (r as any).prompt
answer.value  = (r as any).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):

public function up(): void
{
    Schema::table('documents', function (Blueprint $table) {
        $table->string('tag')->nullable()->index()->after('source');
    });
}

public function down(): void
{
    Schema::table('documents', function (Blueprint $table) {
        $table->dropIndex(['tag']);
        $table->dropColumn('tag');
    });
}

Rode: php artisan migrate

1.2 Ajuste no comando de ingestão: aceitar --tag=...

Em app/Console/Commands/RagIngest.php altere o $signature e o insert:

protected $signature = 'rag:ingest {path : Caminho de um .txt ou .md} {--tag=}';
$docId = DB::table('documents')->insertGetId([
    'source'     => $path,
    'tag'        => $this->option('tag'),   // << aqui
    'text'       => $text,
    'created_at' => now(),
    'updated_at' => now(),
]);

Uso:

php artisan rag:ingest storage/app/contratos.md --tag=juridico

1.3 Helper RAG: pré-filtro por tag

Atualize app/Services/RagHelper.php para aceitar tag única ou múltiplas:

public static function searchTopK(string $queryEmbeddingLiteral, int $k = 6, ?string $tag = null, ?array $tags = null): array
{
    // Se houver tag(s), usamos JOIN com documents
    if ($tag || ($tags && count($tags))) {
        $where = '';
        $params = [$queryEmbeddingLiteral, $queryEmbeddingLiteral];

        if ($tag) {
            $where = 'WHERE d.tag = $3';
            $params[] = $tag;
        } else {
            // múltiplas tags: WHERE d.tag IN (...)
            $in = [];
            $i  = 3;
            foreach ($tags as $t) { $in[] = '$'.$i; $params[] = $t; $i++; }
            $where = 'WHERE d.tag IN ('.implode(',', $in).')';
        }

        $sql = "
            SELECT c.id, c.document_id, c.chunk_index, c.content,
                   l2_distance(c.embedding, $1::vector) AS dist
            FROM chunks c
            JOIN documents d ON d.id = c.document_id
            $where
            ORDER BY c.embedding <-> $2::vector
            LIMIT $k
        ";

        return DB::select($sql, $params);
    }

    // Sem filtro de tag
    return 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]);
}

1.4 Endpoints: aceitar tag/tags

Em /rag/query e /rag/answer, leia tag e tags e passe adiante:

$tag  = $req->input('tag');              // string única
$tags = $req->input('tags');             // array opcional

$rows = RagHelper::searchTopK($qVecSql, $k, $tag, $tags);

Agora você pode chamar:

# tag única
curl -X POST http://localhost:8000/api/rag/answer \
  -H 'Content-Type: application/json' \
  -d '{"q":"multas de rescisão","k":6,"tag":"juridico"}'

# múltiplas tags
curl -X POST http://localhost:8000/api/rag/answer \
  -H 'Content-Type: application/json' \
  -d '{"q":"prazos","k":6,"tags":["juridico","comercial"]}'

2) Streaming SSE (Server-Sent Events)

Vamos criar um endpoint /rag/answer/stream que:

  1. faz embed da pergunta,
  2. busca top-k com filtro de tag (se houver),
  3. streama a resposta do LLM em tempo real como SSE.

2.1 Service: ChatLLM::stream()

Crie um método de streaming usando Guzzle + stream => true:

// app/Services/ChatLLM.php
public function stream(string $prompt, string $model = 'gpt-4o-mini', callable $onDelta = null): void
{
    $resp = $this->http->post('chat/completions', [
        'headers' => [
            'Authorization' => "Bearer {$this->apiKey}",
            'Content-Type'  => 'application/json',
        ],
        'stream' => true,
        'json' => [
            'model' => $model,
            'stream' => true,
            'messages' => [
                ['role' => 'system', 'content' => 'Você responde somente com base no CONTEXTO e cita fontes [doc/chunk].'],
                ['role' => 'user',   'content' => $prompt],
            ],
            'temperature' => 0.2,
        ],
    ]);

    $body = $resp->getBody();
    while (!$body->eof()) {
        $line = trim($body->read(8192)); // lê em blocos
        if ($line === '') { continue; }

        // A API envia linhas "data: {...}" e "data: [DONE]"
        foreach (explode("\n", $line) as $l) {
            $l = trim($l);
            if (stripos($l, 'data: ') === 0) {
                $payload = substr($l, 6);
                if ($payload === '[DONE]') {
                    if ($onDelta) $onDelta(null, true);
                    return;
                }
                $json = json_decode($payload, true);
                $delta = $json['choices'][0]['delta']['content'] ?? '';
                if ($delta !== '' && $onDelta) {
                    $onDelta($delta, false);
                }
            }
        }
    }
}

2.2 Route SSE

Crie o endpoint que seta os headers de SSE e flush contínuo:

// routes/api.php
use Symfony\Component\HttpFoundation\StreamedResponse;

Route::post('/rag/answer/stream', function (Request $req) {
    $q     = $req->input('q');
    $k     = (int)($req->input('k', 6));
    $model = $req->input('model', 'gpt-4o-mini');
    $tag   = $req->input('tag');           // string
    $tags  = $req->input('tags');          // array

    abort_unless($q, 400, 'q é obrigatório');

    $embedder = new Embeddings(env('OPENAI_API_KEY'));
    $qVec = $embedder->embed($q);
    $qVecSql = '{'.implode(',', $qVec).'}';

    $rows = RagHelper::searchTopK($qVecSql, $k, $tag, $tags);
    $prompt = RagHelper::buildPrompt($q, $rows);

    $response = new StreamedResponse(function () use ($prompt, $model) {
        // helper p/ enviar evento SSE
        $send = function (string $data) {
            echo "data: " . str_replace(["\r", "\n"], ['\r', '\n'], $data) . "\n\n";
            @ob_flush(); @flush();
        };

        // heartbeat a cada 15s (não essencial aqui, mas útil)
        echo ":ok\n\n"; @ob_flush(); @flush();

        $chat = new ChatLLM(env('OPENAI_API_KEY'));
        $buffer = '';

        $chat->stream($prompt, $model, function ($delta, $done) use (&$buffer, $send) {
            if ($done) {
                if ($buffer !== '') {
                    $send(json_encode(['type' => 'chunk', 'delta' => $buffer]));
                    $buffer = '';
                }
                $send(json_encode(['type' => 'end']));
                return;
            }
            if ($delta !== null) {
                // Estratégia simples: envia chunk por chunk (ou bufferize se preferir)
                $buffer .= $delta;

                // envie em pedaços pequenos para UX mais fluida
                if (mb_strlen($buffer) >= 400) {
                    $send(json_encode(['type' => 'chunk', 'delta' => $buffer]));
                    $buffer = '';
                }
            }
        });
    });

    $response->headers->set('Content-Type', 'text/event-stream');
    $response->headers->set('Cache-Control', 'no-cache, no-transform');
    $response->headers->set('X-Accel-Buffering', 'no'); // Nginx: desativa buffer
    $response->headers->set('Access-Control-Allow-Origin', '*');

    return $response;
});

Como consumir no front (Nuxt, React, etc.)

const es = new EventSource('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) => {
  const msg = JSON.parse(ev.data) // {type:'chunk'|'end', delta?: string}
  if (msg.type === 'chunk') {
    // append msg.delta ao campo de resposta
  } else if (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
});

Depois abra no navegador:

http://localhost:8000/api/rag/answer/stream?q=quero%20prazos&k=6&tag=juridico

Dicas finais

  • Se trocar a métrica para cosseno, ajuste o índice:

    CREATE INDEX IF 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:

    $send(json_encode(['type'=>'meta','matches'=>$rows]));

    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

use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Services\Embeddings;
use App\Services\ChatLLM;
use App\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 csv

    if (!$q) {
        return response('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 = new Embeddings(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 = new StreamedResponse(function () use ($rows, $prompt, $model) {
        $send = function (array $payload) {
            // uma linha SSE por evento
            echo '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) heartbeat
        echo ":ok\n\n"; @ob_flush(); @flush();

        // stream do LLM
        $chat = new ChatLLM(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 prod

    return $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.

pages/index.vue

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const q = ref('')
const tag = ref<string | null>(null)        // exemplo de filtro único
const tags = ref<string>('')                // csv opcional: "juridico,comercial"
const k = ref(6)
const model = ref('gpt-4o-mini')

const loading = ref(false)
const answer = ref('')                      // texto streamado
const matches = ref<any[]>([])              // fontes
const prompt = ref<string>('')              // opcional: debug
let es: EventSource | null = null

function buildStreamURL() {
  const base = 'http://localhost:8000/api/rag/answer/stream'
  const params = new URLSearchParams()
  params.set('q', q.value)
  params.set('k', String(k.value))
  params.set('model', model.value)
  if (tag.value) params.set('tag', tag.value)
  if (tags.value.trim()) params.set('tags', tags.value.trim()) // aceita csv
  return `${base}?${params.toString()}`
}

function startStream() {
  // encerra stream anterior
  if (es) {
    es.close()
    es = null
  }

  answer.value = ''
  matches.value = []
  prompt.value = ''
  loading.value = true

  const url = buildStreamURL()
  es = new EventSource(url, { withCredentials: false })

  es.onmessage = (ev) => {
    try {
      const payload = JSON.parse(ev.data)
      if (payload.type === 'meta') {
        matches.value = payload.matches ?? []
        prompt.value  = payload.prompt ?? ''
      } else if (payload.type === 'chunk') {
        answer.value += payload.delta
      } else if (payload.type === 'end') {
        loading.value = false
        es?.close()
        es = null
      }
    } catch (e) {
      // eventos de comentário/heartbeat (linhas que começam com ":")
      // ou payload inesperado — pode ignorar
    }
  }

  es.onerror = (err) => {
    loading.value = false
    es?.close()
    es = null
    console.error('SSE error:', err)
  }
}

onBeforeUnmount(() => {
  if (es) es.close()
})
</script>

<template>
  <div class="min-h-screen p-6 max-w-3xl mx-auto">
    <h1 class="text-2xl font-bold mb-4">RAG com Streaming (Laravel + Nuxt)</h1>

    <div class="grid gap-3 grid-cols-1 md:grid-cols-2 mb-4">
      <input v-model="q" type="text" placeholder="Pergunte algo…" class="border rounded px-3 py-2" />
      <div class="flex gap-2">
        <input v-model="tag" type="text" placeholder="tag única (opcional)" class="border rounded px-3 py-2 w-full" />
        <input v-model="tags" type="text" placeholder="tags CSV (opcional)" class="border rounded px-3 py-2 w-full" />
      </div>
      <select v-model="model" class="border rounded px-3 py-2">
        <option value="gpt-4o-mini">gpt-4o-mini</option>
        <option value="gpt-4o">gpt-4o</option>
      </select>
      <div class="flex items-center gap-2">
        <label class="text-sm">Top-K</label>
        <input v-model.number="k" type="number" min="1" max="12" class="border rounded px-2 py-1 w-20" />
      </div>
    </div>

    <button @click="startStream" class="px-4 py-2 rounded border" :disabled="loading || !q">
      {{ loading ? 'Respondendo…' : 'Perguntar (SSE)' }}
    </button>

    <div v-if="matches.length" class="mt-6">
      <h2 class="font-semibold mb-2">Fontes (matches):</h2>
      <ul class="space-y-3">
        <li v-for="m in matches" :key="m.id" class="p-3 border rounded">
          <div class="text-xs text-gray-600 mb-1">
            [{{ m.document_id }}/{{ m.chunk_index }}] — dist={{ Number(m.dist ?? 0).toFixed(3) }}
          </div>
          <div class="text-sm">{{ m.content }}</div>
        </li>
      </ul>
    </div>

    <div v-if="prompt" class="mt-6">
      <h2 class="font-semibold mb-2">Prompt (debug):</h2>
      <pre class="whitespace-pre-wrap text-sm bg-gray-50 p-3 rounded">{{ prompt }}</pre>
    </div>

    <div class="mt-6 p-4 border rounded min-h-[120px]">
      <h2 class="font-semibold mb-2">Resposta (stream)</h2>
      <div class="whitespace-pre-wrap">{{ answer }}</div>
    </div>
  </div>
</template>

Observações importantes no Nuxt:

  • O EventSource roda no cliente; use essa página fora do SSR (o código acima já roda bem porque é acionado por um clique).
  • Troque a URL base para o seu host/porta.
  • Se front e API estiverem em domínios diferentes, habilite CORS no Laravel.

🌐 CORS rápido no Laravel (se front ≠ API)

No Laravel 11+, o CORS já vem por padrão. Ajuste config/cors.php:

'paths' => ['api/*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000', 'http://127.0.0.1:3000'], // seu Nuxt
'allowed_headers' => ['*'],
'exposed_headers' => ['*'],
'supports_credentials' => false,

Depois: php artisan config:clear

No Nginx, para SSE:

location /api/ {
    proxy_buffering off; # importante p/ stream
}

🧪 Como testar

  1. Ingestão (com tag):
php artisan rag:ingest storage/app/contratos.md --tag=juridico
php artisan rag:ingest storage/app/comercial.md --tag=comercial
  1. GET no navegador (teste simples):
http://localhost:8000/api/rag/answer/stream?q=multas%20de%20rescisao&k=6&tag=juridico

Você verá um stream SSE bruto (linhas data: {...}).

  1. Nuxt: abra http://localhost:3000/, pergunte, ajuste tag/tags se quiser, e veja:
  • Fontes aparecerem primeiro (evento meta)
  • Texto chegar em tempo real (eventos chunk)
  • Evento end finalizar a resposta.

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
  • indente os códigos com 4 espaços
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment