Quando uma equipe decide que os agentes dela precisam de uma base de conhecimento, a primeira pergunta costuma ser qual produto SaaS pagar. Nenhum. Rodando no mesmo Docker que você já tem: pasta de markdown para as notas, pgvector (extensão gratuita do Postgres) para busca semântica, e um modelo de embedding local que baixa uma vez e nunca mais faz chamada externa. Zero API hospedada, zero chave, zero custo por request. O editor, se quiser, é o Obsidian, que também é local e gratuito de usar, em casa e no trabalho. VS Code abre os mesmos arquivos.
Este é o setup que rodo contra o meu próprio vault, e o que eu colocaria na frente de uma equipe: acesso via retrieval-augmented a convenções, runbooks e decisões de arquitetura, sem SaaS, sem custo por assento e sem lock-in de fornecedor. O agente lê de um vault que humanos editam. Humanos editam porque a experiência de edição é agradável. Essa última parte é o truque inteiro.
O Que RAG Realmente É
Retrieval-Augmented Generation. Você não coloca sua base de conhecimento dentro do LLM. Você a coloca ao lado do LLM e, na hora da inferência, recupera os trechos relevantes e injeta no prompt. É assim que os agentes sabem de coisas que não estavam nos dados de treinamento: convenções de equipe, runbooks, decisões de arquitetura do último trimestre. Puro in-context, sem fine-tuning, sem fornecedor.
As peças que você precisa: algum lugar para armazenar o texto (arquivos markdown), algum lugar para armazenar os embeddings (pgvector) e um pipeline que mantém os dois em sincronia. Só isso. A complexidade vem de operar isso em produção, não de construir.
O Que É um Embedding e Por Que Você Precisa de Um
Busca por palavra-chave (SQL LIKE '%...%') não serve para RAG. Se uma nota fala de 'deploy em produção' e o agente pergunta 'como subir código no servidor', você quer que a nota apareça mesmo sem as palavras baterem. Isso é busca por significado, não por string. Embeddings resolvem isso: cada chunk de texto vira um vetor de números (por exemplo, 384 ou 1024 floats) que representa o significado do texto. Textos com significado parecido geram vetores parecidos. A busca vira geometria. Me dê os vetores mais próximos desse aqui. É isso que o pgvector faz.
Alguém precisa gerar esses vetores. Esse alguém é o modelo de embedding, e essa é a decisão onde a maioria dos tutoriais enterra você numa API paga sem justificativa. Existe opção 100% local que roda na sua própria stack Docker, ao lado do Postgres, sem API externa, sem chave, sem custo. Use isso como default. Só considere API hospedada se tiver uma razão concreta (volume de re-embedding, problemas de recall medidos, notas que o modelo local não consegue ler), e o post cobre as três no final.
Por Que Obsidian Especificamente
Eu uso Obsidian por padrão porque o vault é apenas uma pasta de arquivos markdown. Coloque o Obsidian em cima dessa pasta e você tem um editor ótimo com backlinks, tags, graph view e busca. Remova o Obsidian e você ainda tem uma pasta de arquivos markdown. Sem lock-in. O git versiona o vault. O agente lê os arquivos diretamente.
O Obsidian é um desktop app (Windows, Mac, Linux, mobile) que roda 100% na sua máquina. O vault nunca sai do seu disco. Os serviços pagos deles (Sync e Publish) são add-ons opcionais que você ignora completamente: sincronização entre dispositivos neste setup é com git. Usar no trabalho também é de graça. Desde fevereiro de 2025, o Obsidian é gratuito para uso comercial, e a Commercial license ($50 por usuário por ano) é apoio opcional, não exigência. Isso mudou um mês depois deste post ir ao ar pela primeira vez, quando empresas de duas pessoas ou mais ainda precisavam comprar a licença.
Se a equipe preferir não usar Obsidian, o setup funciona identicamente. O agente lê os arquivos .md. Ele não se importa se você editou no VS Code, no Obsidian ou no vim.
A Arquitetura
O Indexador
O indexador é um pequeno serviço em Python. Ele observa o diretório do vault, faz chunking das notas quando mudam, gera embedding de cada chunk com um modelo que roda dentro do próprio container e faz upsert no pgvector. Nenhuma chamada externa. Roda em Docker ao lado do resto da stack.
# libs/memory/indexer.py
import os
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from sentence_transformers import SentenceTransformer
import psycopg
VAULT_PATH = Path(os.environ["VAULT_PATH"])
DB_URL = os.environ["DATABASE_URL"]
# Local model. Downloads once (~90MB) to the container disk.
# Zero API, zero key, zero per-call cost.
# Resulting vector: 384 dimensions.
model = SentenceTransformer("all-MiniLM-L6-v2")
def chunk(text: str, max_chars: int = 1500) -> list[str]:
paragraphs = text.split("\n\n")
chunks, buf = [], ""
for p in paragraphs:
if len(buf) + len(p) > max_chars:
chunks.append(buf)
buf = p
else:
buf = f"{buf}\n\n{p}" if buf else p
if buf:
chunks.append(buf)
return chunks
def embed(text: str) -> list[float]:
return model.encode(text).tolist()
def reindex(file_path: Path):
text = file_path.read_text()
source = str(file_path.relative_to(VAULT_PATH))
chunks = chunk(text)
with psycopg.connect(DB_URL) as conn:
for i, c in enumerate(chunks):
emb = embed(c)
# The %s::vector cast is not optional. psycopg3 sends a Python
# list as float8[] and Postgres refuses to store that in a
# VECTOR column. The alternative is register_vector(conn) from
# pgvector.psycopg, which teaches psycopg the type once.
conn.execute(
"INSERT INTO knowledge (source, chunk_index, content, embedding) "
"VALUES (%s, %s, %s, %s::vector) "
"ON CONFLICT (source, chunk_index) DO UPDATE SET "
"content = EXCLUDED.content, "
"embedding = EXCLUDED.embedding, "
"updated_at = NOW()",
(source, i, c, emb)
)
# A note that got shorter leaves orphan chunks behind.
conn.execute(
"DELETE FROM knowledge WHERE source = %s AND chunk_index >= %s",
(source, len(chunks))
)
conn.commit()
class Handler(FileSystemEventHandler):
def on_modified(self, event):
if event.src_path.endswith(".md"):
reindex(Path(event.src_path))
if __name__ == "__main__":
# Full initial indexing
for md in VAULT_PATH.rglob("*.md"):
reindex(md)
# Watch for changes
observer = Observer()
observer.schedule(Handler(), str(VAULT_PATH), recursive=True)
observer.start()
observer.join()Esse é o indexador inteiro. Não é pseudo-código mínimo. É Python de verdade que roda. Ele cuida da indexação inicial, da detecção de mudança de arquivo e da re-geração de embedding quando há edição. O tamanho de chunk de 1500 caracteres é um meio-termo: pequeno o suficiente para recuperar snippets precisos, grande o suficiente para preservar contexto de parágrafo. Uma ressalva: o all-MiniLM-L6-v2 trunca qualquer entrada maior que 256 word pieces, algo como 1.000 caracteres de inglês, então o final de um chunk cheio de 1500 caracteres nunca chega ao embedding. Se suas notas forem densas, baixe o max_chars ou escolha um modelo com janela de entrada maior.
O Schema
CREATE EXTENSION IF NOT EXISTS vector;
-- VECTOR(384) matches all-MiniLM-L6-v2.
-- If you swap the embedding model, adjust the dimension and reindex.
CREATE TABLE knowledge (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding VECTOR(384) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (source, chunk_index)
);
CREATE INDEX idx_knowledge_embedding
ON knowledge USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_knowledge_source ON knowledge (source);Uma tabela. Texto do chunk, embedding, caminho de origem. O caminho de origem é o que importa quando o agente mostra o contexto recuperado para um humano: 'isto veio de conventions/testing.md', não 'isto veio do embedding ID 8d3f'.
Como o Agente Usa Isso
# libs/memory/retrieval.py
# Reuses the same model loaded in the indexer (one instance, CPU).
def retrieve(query: str, top_k: int = 5) -> list[dict]:
q_emb = model.encode(query).tolist()
with psycopg.connect(DB_URL) as conn:
rows = conn.execute(
# Inner query stays on the HNSW index. Outer query re-ranks
# the candidates and demotes anything older than 6 months.
"WITH candidates AS ("
" SELECT source, chunk_index, content, updated_at, "
" 1 - (embedding <=> %s::vector) AS similarity "
" FROM knowledge "
" ORDER BY embedding <=> %s::vector "
" LIMIT 20"
") "
"SELECT source, chunk_index, content, "
" similarity - CASE "
" WHEN updated_at < NOW() - INTERVAL '6 months' THEN 0.05 "
" ELSE 0 END AS score "
"FROM candidates "
"ORDER BY score DESC "
"LIMIT %s",
(q_emb, q_emb, top_k)
).fetchall()
return [
{"source": r[0], "chunk": r[1], "content": r[2], "score": r[3]}
for r in rows
]O agente monta uma query a partir do payload do trigger mais o raciocínio atual dele, chama retrieve e recebe de volta os top 5 chunks. Esses chunks vão para o system prompt com seus caminhos de origem. Se o agente citar a base de conhecimento na saída, ele cita o caminho de origem, não um ID opaco. Um humano pode abrir exatamente aquele arquivo e verificar. A query em dois passos é deliberada: você puxa um conjunto amplo de candidatos com o índice vetorial e depois re-ranqueia em SQL. Re-ranquear na query externa impede que a penalidade de frescor jogue o índice fora.
O Que Colocar no Vault
Não tudo. Seja deliberado sobre o que pertence a uma base de conhecimento de equipe que agentes leem.
A regra que eu uso: se a nota ainda for útil daqui a 12 meses, ela pertence ao vault. Se é um snapshot de um momento, vai para o Slack ou para um ticket.
Custo
Por padrão, este setup custa zero. O modelo de embedding baixa uma vez para o disco do container (em torno de 90MB) e roda na CPU do servidor que você já tem. O pgvector é uma extensão gratuita do Postgres. A única infra paga é o próprio servidor onde tudo roda, e se você já roda Postgres para qualquer outra coisa, isto é zero infraestrutura adicional.
Para um vault de 500 notas com média de 2 chunks cada, a indexação inicial com all-MiniLM-L6-v2 em CPU moderna termina em bem menos de um minuto. O re-embedding contínuo em edições é instantâneo do ponto de vista de quem está editando. Se você escalar para dezenas de milhares de notas e começar a sentir latência de indexação, aí faz sentido revisitar a decisão. Até lá, resista à tentação de 'melhorar' trocando por uma API paga antes de ter um número que justifique.
Os Erros Que Já Cometi
Primeiro erro: construí uma estratégia de chunking complicada com overlap e splitting semântico na primeira versão. Ela teve desempenho pior do que chunking baseado em parágrafo porque as fronteiras eram menos previsíveis. Comece simples. Chunks de parágrafo com tamanho máximo. Só ajuste quando a qualidade do retrieval te disser para ajustar.
Segundo erro: tratei a base de conhecimento como append-only e deixei conteúdo obsoleto acumular. Deixado assim, isso termina com o agente citando uma convenção depreciada de um runbook que ninguém tocou em um ano. A query de retrieval acima é a correção: ela puxa um conjunto de candidatos mais amplo e depois subtrai uma penalidade pequena de qualquer coisa cujo updated_at tenha mais de 6 meses. Uma nota velha ainda pode ganhar se nada mais fresco estiver perto, que é o comportamento que você quer.
Terceiro erro: dei ao agente acesso de escrita ao vault para que ele pudesse 'aprender' atualizando notas. Esse é o instinto errado. Uma execução de baixa confiança pode reescrever uma nota em algo sutilmente errado, nada no pipeline pega isso, e o retrieval seguinte trata o texto do próprio agente como material de origem. O vault é editado por humanos. A saída do agente vai para uma pasta separada agent_learnings/ que um humano lê antes de qualquer coisa ser promovida para o vault.
A base de conhecimento de onde seus agentes leem e a base de conhecimento que sua equipe edita deveriam ser a mesma pasta. Qualquer arquitetura que separa as duas faz as duas divergirem, e memória divergente é pior que memória nenhuma.
Upgrades Opcionais
Três situações onde o modelo local padrão deixa de ser a resposta certa. Só essas três.
Volume massivo. Se o vault passa de dezenas de milhares de notas e o re-embedding local começa a levar horas, uma API hospedada termina em minutos. Voyage AI voyage-3 é o upgrade mais direto aqui porque foi treinado especificamente para retrieval, e suporta input_type='query' vs 'document' para melhorar recall.
Qualidade de recall validada por benchmark. Se você mediu retrieval local e ele está recuperando chunks irrelevantes em cenários específicos, modelos hospedados maiores às vezes resolvem. Mas meça antes de trocar. Muitas vezes o problema é chunking ou ranking, não o modelo.
Entrada que o modelo local não consegue representar. O all-MiniLM-L6-v2 trunca em 256 word pieces e foi treinado em inglês. Se suas notas são longas ou multilíngues, a qualidade do retrieval cai por um motivo que nenhum ajuste resolve. Tente primeiro a opção local maior: o nomic-embed-text pelo Ollama aceita uma entrada bem mais longa, lida com mais idiomas, e continua de graça. Um modelo hospedado com janela de contexto longa é o passo depois disso, não antes.
Quando o número fecha, a troca é de poucas linhas:
# --- Local default (zero cost) ---
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2") # 384 dim
def embed(text: str) -> list[float]:
return model.encode(text).tolist()
# --- Upgrade to Voyage (pay per call) ---
import voyageai
vo = voyageai.Client() # reads VOYAGE_API_KEY from env
def embed_doc(text: str) -> list[float]:
result = vo.embed([text], model="voyage-3", input_type="document")
return result.embeddings[0]
def embed_query(text: str) -> list[float]:
result = vo.embed([text], model="voyage-3", input_type="query")
return result.embeddings[0]
# WARNING: voyage-3 returns 1024 dim. Adjust VECTOR(1024) in the schema
# and reindex the entire vault. The migration is not transparent.Referências
- sentence-transformers (embedding local, default deste post): https://www.sbert.net
- Ollama (modelos de embedding locais, ex: nomic-embed-text): https://ollama.com
- Obsidian: https://obsidian.md
- Obsidian, gratuito para o trabalho (fevereiro de 2025): https://obsidian.md/blog/free-for-work/
- pgvector: https://github.com/pgvector/pgvector
- Voyage AI (upgrade opcional hospedado): https://www.voyageai.com