0%
josehenriquedev

Como Implementei o Backend do Meu Site Pessoal: RAG Local com Bun, Elysia e pgvector

Serve para qualquer desenvolvedor que deseja transformar um portfólio estático em uma experiência interativa com IA, sem depender de serviços externos caros para embeddings e mantendo o controle total da arquitetura.

Quando decidi reformular meu site pessoal, eu não queria apenas listar projetos e links para o GitHub em páginas estáticas. Como desenvolvedor Full Stack focado em IA, eu queria que quem visitasse o site pudesse conversar diretamente com um assistente virtual que realmente me representasse — respondendo sobre minhas experiências, decisões técnicas e stack, sem alucinar informações. A primeira alternativa óbvia seria plugar uma API comercial para tudo: gerar embeddings na OpenAI, salvar em um vector database gerenciado na nuvem e chamar um modelo proprietário. Mas isso me levantou algumas perguntas: precisamos mesmo pagar por cada chamada de embedding de documentos que raramente mudam? Como manter essa esteira leve, rápida e rodando de ponta a ponta em TypeScript?

Para resolver isso, decidi construir o personal-rag: uma API desenvolvida com Bun, Elysia.js, Drizzle ORM com pgvector, embeddings locais via @huggingface/transformers e inferência ultrarrápida via Groq. Neste artigo, vou mostrar como essa arquitetura foi desenhada e como ela funciona por debaixo dos panos. ──────

A Escolha da Stack: Velocidade e Simplicidade em TypeScript

Manter uma esteira de RAG (Retrieval-Augmented Generation) muitas vezes empurra a arquitetura para Python por costume de ecossistema. No entanto, para um backend de site pessoal, manter tudo no mesmo ecossistema do front-end simplifica drasticamente manutenção, tipagem e deploy. A stack foi definida com:

• Bun + Elysia.js: inicialização quase instantânea e alta vazão para endpoints HTTP com tipagem estrita; • PostgreSQL + pgvector: extensão vetorial nativa dentro do próprio banco relacional, eliminando a necessidade de gerenciar outro serviço de vector store; • Drizzle ORM: consultas SQL type-safe, permitindo cálculo direto de distância por cosseno; • @huggingface/transformers: execução local do modelo Xenova/multilingual-e5-small diretamente na CPU via ONNX Runtime; • Groq SDK (openai/gpt-oss-120b): inferência de LLM com latência mínima para responder ao usuário. ──────

1. Processamento e Chunking de Conhecimento

Toda a base de conhecimento sobre minha carreira reside em arquivos Markdown versionados no repositório (profile.md, experience.md, projects.md). Cada arquivo possui um frontmatter com metadados estruturados.

Para transformar esses arquivos em blocos pesquisáveis sem perder o contexto semântico, utilizei o RecursiveCharacterTextSplitter do LangChain integrado ao gray-matter:

const splitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
    chunkSize: 800,
    chunkOverlap: 50,
});

Mas o texto puro muitas vezes não traz pistas suficientes de como alguém faria uma pergunta sobre ele. Para contornar isso, adicionei uma camada de Enriquecimento de Contexto: const questionsText = Array.isArray(data.probable_questions) ? Perguntas Frequentes Relacionadas:\n- ${data.probable_questions.join("\n- ")}\n\n : "";

const textToEmbbed = `Documento: ${data.title}\nTipo: ${data.type}\nLocale:

${itemLocale}\n${questionsText}Conteúdo:\n${cleanContent}`; Ao embutir perguntas prováveis junto ao conteúdo original no vetor, aproximamos a distância vetorial entre as dúvidas do visitante e os dados reais da minha trajetória. ──────

2. Embeddings Locais: Adeus APIs Pagas para Vetorização

Em vez de consumir endpoints de terceiros para cada fragmento de texto, utilizei o modelo Xenova/multilingual-e5-small rodando em memória via Hugging Face Transformers para Node/Bun:

export class EmbbedingService implements IEmbbed {
    private extractor!: FeatureExtractionPipeline;

    async initialize() {
        this.extractor = await pipeline(
            "feature-extraction",
            "Xenova/multilingual-e5-small",
            { device: "cpu" }
        );
    }

    async embbed(text: string): Promise<number[]> {
        const output = await this.extractor(text, {
            pooling: "mean",
            normalize: true,
        });

        return Array.from(output.data);
    }
}

Um detalhe crucial desse modelo: ele exige prefixos específicos de tarefa.

• Para documentos armazenados: passage: <conteúdo> • Para termos de busca do usuário: query: Ignorar esses prefixos degrada severamente a acurácia semântica. Adicionar o prefixo correto no momento da vetorização garante que consultas em português ou inglês convirjam corretamente para o mesmo espaço latente de 384 dimensões. ──────

3. Busca Vetorial com Drizzle ORM e pgvector

Com os vetores gerados, o armazenamento e a busca acontecem direto no PostgreSQL. A tabela foi estruturada utilizando tipos vetoriais nativos do pgvector:

export const professionalProfileTable = pgTable("profile", {
    id: serial().primaryKey(),
    title: text(),
    content: text(),
    type: text({ enum: ["project", "experience", "profile"] }),
    locale: text(),
    metadata: json(),
    embedding: vector("embedding", { dimensions: 384 }).notNull()
});

Na etapa de recuperação, calculamos a distância por cosseno utilizando o operador <=> do pgvector diretamente através do Drizzle:

const similarity = sql<number>`(${professionalProfileTable.embedding} <=> ${JSON.

stringify(embbeding)}::vector)`;

let query = db.select({
    id: professionalProfileTable.id,
    title: professionalProfileTable.title,
    content: professionalProfileTable.content,
    similarity: similarity
})
.from(professionalProfileTable)
.orderBy(similarity)
.limit(5);

Ou seja — a busca semântica acontece dentro da mesma infraestrutura que guarda os dados estruturados, sem latência de rede adicional para outro serviço externo. ──────

4. Orquestração e Geração com Limiar de Similaridade

Um dos problemas mais comuns em RAG é enviar contexto irrelevante para a LLM, o que encarece o prompt e induz alucinações. Para evitar isso, o serviço de orquestração faz uma triagem rigorosa com base no score de similaridade:

const gatherKnowledge = await this.retrieve.exec(input, locale);

// Filtra apenas chunks com distância menor que 0.35
const relevantChunks = gatherKnowledge?.filter(k => (k.similarity as number) < 0.35) || [];

Se nenhum chunk atinge essa relevância, a API não injeta dados incorretos: ela aciona um prompt básico com instruções claras para não inventar fatos sobre mim.

Caso encontre correspondências confiáveis, ela monta o contexto enriquecido e despacha a requisição para o modelo servido via Groq, garantindo tempo de resposta quase instantâneo. ──────

Conclusão

Construir o backend do meu portfólio dessa forma me permitiu unir três objetivos práticos: custo operacional zero com geração de embeddings, baixa latência de resposta e total autonomia sobre o pipeline de recuperação semântica.

Entender como o processo de RAG se desdobra por debaixo dos panos — desde o parsing de Markdown e a adição de prefixos de tarefas em modelos e5, até o cálculo vetorial com pgvector — nos ajuda a projetar aplicações de IA mais conscientes, previsíveis e econômicas.