1

Pitch: Construí um RAG local em TypeScript para transformar meu portfólio em um assistente de IA

Quando reformulei meu site pessoal, não queria criar apenas mais um portfólio estático com projetos, experiências e links para o GitHub.

Queria que o próprio site fosse capaz de responder perguntas sobre minha trajetória, experiência técnica e stack.

Foi daí que surgiu a ideia de construir um assistente de IA utilizando RAG (Retrieval-Augmented Generation).

Em vez de depender de uma API externa para cada etapa, construí o pipeline utilizando principalmente tecnologias do ecossistema TypeScript:

  • Bun + Elysia.js para o backend;
  • PostgreSQL + pgvector para armazenamento e busca vetorial;
  • Drizzle ORM para as consultas;
  • @huggingface/transformers para geração local de embeddings;
  • multilingual-e5-small rodando em CPU;
  • Groq para inferência do modelo de linguagem.

A arquitetura ficou aproximadamente assim:

Arquivos Markdown
       ↓
Parsing + Chunking
       ↓
Enriquecimento com perguntas
       ↓
Embeddings locais
       ↓
PostgreSQL + pgvector
       ↓
Busca por similaridade
       ↓
Filtro de relevância
       ↓
LLM via Groq
       ↓
Resposta

Enriquecendo os documentos antes do embedding

Minha base de conhecimento é formada por arquivos Markdown versionados no próprio projeto.

O primeiro passo é transformar esses arquivos em chunks utilizando RecursiveCharacterTextSplitter.

Porém, apenas dividir o conteúdo não era suficiente.

Uma pergunta feita pelo visitante nem sempre utiliza as mesmas palavras presentes no documento.

Por isso adicionei ao conteúdo de cada chunk algumas perguntas prováveis relacionadas àquele conhecimento.

Por exemplo, um documento que contém informações sobre minha stack pode carregar também perguntas como:

Quais tecnologias o José utiliza?
Quais linguagens de programação ele conhece?
Qual é a experiência dele com TypeScript?

Essas perguntas são incorporadas ao texto antes da geração do embedding.

A ideia é aproximar a representação vetorial dos documentos das formas como eles provavelmente serão consultados.

Embeddings sem uma API externa

Para gerar os embeddings utilizei o Xenova/multilingual-e5-small através do @huggingface/transformers.

O modelo é carregado em memória e executado localmente utilizando CPU.

Um detalhe importante do E5 é a utilização dos prefixos de tarefa:

passage: <conteúdo do documento>

para os documentos armazenados e:

query: <pergunta do usuário>

para as consultas.

Isso é importante porque o modelo foi treinado para diferenciar esses dois tipos de entrada.

Os embeddings resultantes possuem 384 dimensões e são armazenados diretamente no PostgreSQL.

Por que não usar um vector database separado?

Como o projeto já utilizava PostgreSQL, não vi necessidade de adicionar outro serviço apenas para armazenar vetores.

Com o pgvector, consigo armazenar os embeddings junto aos dados do próprio sistema e realizar a busca por similaridade diretamente no banco.

A distância por cosseno pode ser calculada utilizando o operador <=>:

embedding <=> query_embedding

E o Drizzle ORM permite incorporar essa operação às consultas de forma tipada.

Na prática, o fluxo de recuperação fica dentro da própria infraestrutura do PostgreSQL, sem precisar adicionar uma camada externa de armazenamento vetorial.

Controlando o contexto antes da LLM

Um dos pontos que mais me preocupava era a possibilidade de recuperar conteúdo pouco relevante e simplesmente entregá-lo para a LLM.

Isso pode aumentar o tamanho do prompt e, principalmente, fornecer informações inadequadas para a geração da resposta.

Por isso implementei um limiar de distância.

No meu caso, os chunks recuperados precisam ter uma distância por cosseno inferior a 0.35 para serem considerados relevantes:

const relevantChunks =
    gatherKnowledge?.filter(
        k => (k.similarity as number) < 0.35
    ) || [];

Se nenhum resultado atingir esse limite, o sistema não injeta um contexto arbitrário na LLM.

Em vez disso, utiliza um prompt básico orientando o modelo a não inventar informações sobre minha trajetória.

Isso não elimina completamente as alucinações — nenhum threshold faz isso sozinho — mas cria uma barreira importante entre a recuperação de documentos e a geração da resposta.

O resultado

No final, consegui transformar o conteúdo do meu próprio portfólio em uma base de conhecimento semanticamente pesquisável, utilizando uma arquitetura relativamente pequena:

TypeScript
   +
Bun / Elysia
   +
PostgreSQL / pgvector
   +
Embeddings locais
   +
Groq

O projeto também acabou servindo como uma forma prática de explorar o que acontece dentro de um pipeline de RAG.

Mais do que simplesmente chamar uma LLM, existem vários problemas interessantes antes da geração: como representar o conhecimento, como fazer o retrieval encontrar o conteúdo certo, como medir relevância e quando decidir que não existe contexto suficiente para responder.

Escrevi o artigo completo mostrando a implementação e os detalhes técnicos de cada etapa:

Leia o artigo completo no meu site

Carregando publicação patrocinada...