ChromaDB na prática: entendendo bancos vetoriais além do RAG
Quando começamos a estudar aplicações baseadas em Inteligência Artificial Generativa, é comum encontrarmos bancos de vetores associados quase imediatamente a arquiteturas de RAG — Retrieval-Augmented Generation.
A associação faz sentido.
Em uma solução RAG, precisamos recuperar informações relevantes antes de fornecer contexto a um modelo de linguagem. Bancos vetoriais são uma das alternativas para realizar essa recuperação.
Mas há um detalhe importante:
Não precisamos de um RAG para utilizar um banco de vetores.
Na verdade, neste artigo vamos construir um pequeno sistema de recuperação de informação usando ChromaDB sem utilizar nenhum modelo generativo.
Vamos armazenar documentos, gerar embeddings, recuperar informações semanticamente semelhantes, aplicar filtros, atualizar dados, calcular distâncias e persistir informações.
Ou seja, estaremos trabalhando com um problema clássico de Information Retrieval, utilizando representações vetoriais.
E é justamente aí que a discussão começa a ficar interessante do ponto de vista da arquitetura.
O que vamos construir
Nosso exemplo será propositalmente simples.
Teremos uma pequena base contendo documentos relacionados a tecnologia, Inteligência Artificial, bancos de dados, Python e embeddings. Pode ver o código inteiro aqui: https://github.com/gomesrocha/DataVectorization/blob/main/pratica_chromadb.ipynb
Cada documento possuirá:
- um identificador;
- o conteúdo textual;
- metadados;
- uma representação vetorial.
A partir dela poderemos fazer perguntas como:
“Como representar textos com números?”
O sistema deverá encontrar documentos semanticamente próximos da pergunta, mesmo que eles não contenham exatamente as mesmas palavras.
É isso que diferencia uma busca semântica de uma busca puramente lexical.
1. Preparando o ambiente
O primeiro passo é instalar o ChromaDB.
!pip install chromadb -q
import chromadb
print(f"ChromaDB versão: {chromadb.__version__}")
No notebook utilizado neste artigo, a execução foi realizada com:
ChromaDB versão: 1.5.9
Agora podemos criar nosso primeiro cliente.
client = chromadb.Client()
Nesse primeiro momento estamos trabalhando com um cliente em memória.
Isso é bastante útil para:
- prototipação;
- experimentos;
- notebooks;
- testes automatizados;
- estudo de algoritmos de recuperação.
Ainda não estamos preocupados com persistência.
E essa diferença será importante mais adiante.
2. Criando uma coleção
No ChromaDB, os dados são organizados em collections.
Podemos pensar, inicialmente, em uma coleção como um agrupamento lógico de documentos e de seus respectivos embeddings.
Vamos criar uma chamada base_conhecimento.
collection = client.create_collection(
name="base_conhecimento",
metadata={
"descricao": "Base de conhecimento sobre tecnologia"
}
)
Agora temos uma estrutura para armazenar nossos documentos.
Mas ainda falta a parte mais importante:
Os dados.
3. Inserindo documentos e metadados
Vamos criar uma pequena base.
documentos = [
"ChromaDB é um banco de dados vetorial open-source focado em IA e aplicações de busca semântica.",
"Python é uma linguagem de programação versátil usada em data science, web e automação.",
"Bancos de dados relacionais usam SQL e armazenam dados em tabelas com relacionamentos.",
"Embeddings são representações numéricas de texto que capturam significado semântico em um espaço vetorial.",
"O Google Colab permite executar código Python no navegador sem necessidade de instalação local.",
"Machine Learning é um subcampo da IA que permite aos computadores aprender com dados.",
"Vetores de alta dimensão são usados para representar dados complexos em espaços matemáticos.",
"Busca semântica encontra documentos com significado similar, não apenas correspondências exatas.",
"A distância euclidiana mede a proximidade entre dois pontos em um espaço vetorial.",
"Sentence Transformers é uma biblioteca que gera embeddings de alta qualidade para frases e documentos."
]
Além do texto, adicionaremos metadados.
metadados = [
{"categoria": "banco de dados", "tipo": "vetorial", "ano": 2026},
{"categoria": "linguagem de programação", "tipo": "multiparadigma", "ano": 1991},
{"categoria": "banco de dados", "tipo": "relacional", "ano": 1977},
{"categoria": "IA", "tipo": "embeddings", "ano": 2019},
{"categoria": "ferramenta", "tipo": "colab", "ano": 2020},
{"categoria": "IA", "tipo": "aprendizado de máquina", "ano": 1959},
{"categoria": "IA", "tipo": "vetores", "ano": 1959},
{"categoria": "IA", "tipo": "busca semântica", "ano": 2020},
{"categoria": "IA", "tipo": "distância euclidiana", "ano": 1959},
{"categoria": "IA", "tipo": "sentence transformers", "ano": 2022}
]
Criamos também identificadores únicos.
ids = [f"doc_{i+1}" for i in range(len(documentos))]
E finalmente armazenamos tudo:
collection.add(
documents=documentos,
metadatas=metadados,
ids=ids
)
O resultado:
10 documentos adicionados à coleção!
Até aqui parece apenas uma operação de banco de dados.
Mas há algo importante acontecendo nos bastidores.
4. Onde estão os vetores?
Nós não criamos manualmente nenhum embedding.
Mesmo assim, podemos recuperar a representação vetorial de um documento.
resultado = collection.get(
ids=["doc_1"],
include=["embeddings"]
)
embedding = resultado["embeddings"][0]
print(len(embedding))
O resultado no nosso experimento é:
384
Ou seja:
Um pequeno texto agora é representado por um vetor com 384 valores numéricos.
O Chroma possui uma função de embedding padrão e pode gerar embeddings automaticamente quando trabalhamos diretamente com textos. A documentação também descreve o uso do modelo all-MiniLM-L6-v2 como implementação padrão dessa função.
Conceitualmente, fizemos algo semelhante a:
Documento
↓
Modelo de embeddings
↓
Vetor
↓
[0.014, -0.012, -0.094, ..., 0.023]
É justamente essa representação que permitirá realizar buscas baseadas na proximidade semântica.
E aqui aparece a primeira questão arquitetural importante:
O embedding também faz parte dos nossos dados agora.
Se o documento mudar, devemos considerar o seu embedding.
Se o modelo mudar, devemos considerar os embeddings já armazenados.
Se a dimensão mudar, devemos pensar na estrutura que a armazena.
Começamos a sair do domínio puramente de IA e a entrar em arquitetura de dados.
5. Fazendo nossa primeira busca semântica
Agora chegamos ao principal experimento.
Vamos fazer uma pergunta que não corresponde literalmente a nenhum dos documentos:
pergunta = "Como representar textos com números?"
Executamos então:
resultado = collection.query(
query_texts=[pergunta],
n_results=3
)
Os primeiros resultados obtidos no notebook foram:
1. Embeddings são representações numéricas de texto que capturam
significado semântico em um espaço vetorial.
2. Vetores de alta dimensão são usados para representar dados
complexos em espaços matemáticos.
3. Busca semântica encontra documentos com significado similar,
não apenas correspondências exatas.
Observe o que aconteceu.
Não procuramos por:
texto = "Como representar textos com números?"
Estamos procurando documentos cuja representação vetorial esteja próxima da nossa consulta.
Simplificando:
Pergunta
↓
Embedding
↓
Vetor da consulta
↓
Comparação com os vetores armazenados
↓
K vizinhos mais próximos
↓
Documentos mais relevantes
Nenhum LLM apareceu nesse fluxo.
Nenhuma resposta foi gerada.
Estamos apenas recuperando informação.
Isso é importante porque demonstra que a busca vetorial não é uma característica exclusiva de RAG.
RAG utiliza recuperação.
Mas a recuperação vetorial tem aplicações muito além do RAG.
6. Distância não é apenas um detalhe matemático
O resultado de uma consulta também apresenta uma distância.
No experimento:
Embeddings... distância = 0.7509
Vetores... distância = 0.9237
Busca semântica... distância = 1.1323
Quanto menor a distância naquele espaço de busca, mais próximo está o vetor encontrado da consulta.
Podemos perceber isso ao comparar duas perguntas muito diferentes.
resultado1 = collection.query(
query_texts=["Banco de dados vetorial para IA"],
n_results=1
)
resultado2 = collection.query(
query_texts=["Receita de bolo de chocolate"],
n_results=1
)
No experimento obtivemos aproximadamente:
Banco de dados vetorial para IA
Distância: 0.8543
Receita de bolo de chocolate
Distância: 1.2655
A segunda consulta está claramente fora do escopo da nossa pequena base.
Esse exemplo, apesar de simples, levanta uma questão extremamente importante para sistemas reais:
Até que distância ainda consideramos um resultado relevante?
Não basta simplesmente solicitar os cinco vetores mais próximos.
Sempre existirão cinco vetores mais próximos se houver pelo menos cinco candidatos.
Isso não significa que sejam bons resultados.
Em sistemas reais podemos precisar definir:
- thresholds;
- estratégias de ranking;
- avaliação de relevância;
- métricas como precision e recall;
Testes com conjuntos conhecidos de perguntas e respostas.
É aqui que um pequeno exemplo de busca vetorial começa a se transformar em um problema real de engenharia.
7. Busca vetorial + metadados
Outro recurso importante surge quando combinamos a similaridade vetorial com filtros estruturados.
Podemos perguntar por:
pergunta = "Tecnologia e dados"
Mas limitar os resultados apenas à categoria IA.
resultados = collection.query(
query_texts=[pergunta],
n_results=3,
where={
"categoria": {"$eq": "IA"}
}
)
Agora temos duas condições trabalhando juntas.
Semanticamente queremos documentos relacionados a:
Tecnologia e dados
Mas estruturalmente exigimos:
categoria = IA
Isso é particularmente interessante porque mostra que aplicações reais raramente dependem apenas da proximidade vetorial.
Imagine uma base corporativa.
Podemos querer:
semanticamente parecido com "contrato de prestação de serviços"
mas somente onde:
cliente = "Empresa X"
ano >= 2025
tipo_documento = "contrato"
status = "ativo"
A recuperação passa a combinar:
similaridade vetorial
+
metadados
+
regras da aplicação
E essa combinação será bastante importante quando compararmos posteriormente ChromaDB, pgvector e OpenSearch.
8. Filtros mais complexos
Também podemos utilizar operadores lógicos.
Por exemplo:
where={
"$and": [
{"categoria": {"$eq": "IA"}},
{"ano": {"$gte": 1958}}
]
}
Ou:
where={
"$or": [
{"categoria": "banco de dados"},
{"categoria": "IA"}
]
}
A discussão deixa então de ser simplesmente:
“Consigo armazenar um embedding?”
A pergunta começa a ser:
“Consigo recuperar eficientemente os dados que minha aplicação realmente precisa?”
Essa mudança, aparentemente pequena, é importante.
Porque as aplicações corporativas normalmente possuem contexto, autorização, tenant, categoria, período, origem do documento, versão, produto ou domínio.
O vetor dificilmente vive sozinho.
9. E quando o documento muda?
Até aqui adicionamos documentos.
Mas dados reais mudam.
Vamos recuperar o documento doc_2.
antes = collection.get(ids=["doc_2"])
Seu conteúdo original era:
Python é uma linguagem de programação versátil usada em
data science, web e automação.
Agora vamos modificá-lo.
collection.update(
ids=["doc_2"],
documents=[
"Python é uma linguagem de programação versátil usada em "
"data science, desenvolvimento de web api, automação "
"e análise de dados."
],
metadatas=[{
"categoria": "linguagem de programação",
"tipo": "multiparadigma",
"ano": 1991,
"atualizado": True
}]
)
Do ponto de vista de CRUD, é uma simples atualização.
Do ponto de vista vetorial, entretanto, existe uma consequência:
Se o conteúdo mudou, sua representação também precisa ser coerente com o novo conteúdo.
Esse é um detalhe arquitetural fácil de ignorar em uma demonstração e muito difícil de ignorar em produção.
Imagine agora milhões de documentos.
E imagine que decidimos trocar nosso modelo de embeddings.
Por exemplo:
modelo_embedding_v1
↓
10 milhões de embeddings
Algum tempo depois:
modelo_embedding_v2
O que faremos com os dez milhões de vetores anteriores?
Esse problema nos leva naturalmente para conceitos como:
- versionamento;
- reindexação;
- migração;
- processamento em lote;
- dual-write;
- compatibilidade de dimensões;
Controle do modelo responsável pela geração do vetor.
Ou seja: novamente estamos falando de engenharia de dados.
10. UPSERT: uma operação simples com implicações importantes
Também podemos utilizar upsert.
collection.upsert(
ids=["doc_11"],
documents=["Novo documento adicionado via UPSERT"],
metadatas=[{
"categoria": "teste",
"tipo": "novo"
}]
)
Se o identificador ainda não existir, inserimos.
Depois podemos executar:
collection.upsert(
ids=["doc_11"],
documents=["Documento atualizado via UPSERT"],
metadatas=[{
"categoria": "teste",
"tipo": "atualizado"
}]
)
O identificador permanece o mesmo, mas o conteúdo é atualizado.
Esse tipo de operação é particularmente interessante quando pensamos em pipelines de ingestão:
Fonte de dados
↓
Extração
↓
Transformação
↓
Chunking
↓
Embedding
↓
Upsert
↓
Índice vetorial
Nesse cenário, devemos também decidir qual será nosso identificador.
Ele representa:
- o documento?
- um chunk?
- uma versão?
- um parágrafo?
- uma combinação de documento e versão?
Esse é outro exemplo de decisão que parece ser apenas implementação, mas rapidamente se transforma em modelagem de dados.
11. Persistindo os dados
Até agora nosso primeiro cliente estava em memória.
Podemos mudar isso usando:
client_persistente = chromadb.PersistentClient(
path="./chroma_persistente"
)
Depois criamos a coleção:
collection_persistente = client_persistente.create_collection(
name="base_conhecimento",
metadata={
"descricao": "Base de conhecimento sobre tecnologia"
}
)
E inserimos nossos documentos.
collection_persistente.add(
ids=["persistido_1", "persistido_2"],
documents=[
"Este documento será salvo em disco",
"Os dados são persistidos no armazenamento configurado"
]
)
Agora chegamos a uma mudança importante.
Antes tínhamos:
processo
↓
memória
Agora temos:
processo
↓
ChromaDB
↓
filesystem
Isso muda completamente algumas preocupações arquiteturais.
Passamos a precisar pensar em:
backup, recuperação, espaço em disco, concorrência, ciclo de vida, disponibilidade e estratégia de implantação.
Há ainda uma observação importante para quem executar este exemplo no Google Colab: PersistentClient Persiste no sistema de arquivos utilizado pelo processo, mas as máquinas virtuais do Colab têm ciclo de vida limitado. Portanto, para a persistência entre diferentes ambientes de execução, é necessário utilizar armazenamento permanente ou executar o Chroma em uma infraestrutura persistente.
12. Transformando o exemplo em um pequeno mecanismo de busca
Podemos finalmente encapsular a consulta.
def busca_documentos(
colecao,
pergunta,
categoria=None,
n_resultados=3
):
where_filter = None
if categoria:
where_filter = {
"categoria": {"$eq": categoria}
}
resultados = colecao.query(
query_texts=[pergunta],
n_results=n_resultados,
where=where_filter
)
return resultados
Agora podemos consultar:
resultado = busca_documentos(
collection,
"Inteligencia artificial",
n_resultados=2
)
Ou combinar recuperação semântica com filtros:
resultado = busca_documentos(
collection,
"dados",
categoria="IA",
n_resultados=2
)
Temos, efetivamente, um pequeno sistema de recuperação semântica de informação.
E ainda não utilizamos um LLM.
Então onde entraria o RAG?
Agora podemos visualizar melhor a relação.
O que construímos foi:
Pergunta
↓
Embedding
↓
Busca vetorial
↓
Documentos relevantes
Uma arquitetura RAG poderia continuar a partir daí:
Pergunta
↓
Embedding
↓
Busca vetorial
↓
Documentos relevantes
↓
Construção de contexto
↓
LLM
↓
Resposta
Portanto, o banco vetorial é um possível componente da etapa de recuperação.
Mas a recuperação vetorial possui vida própria.
Podemos utilizá-la em mecanismos de busca, recomendação, comparação de documentos, detecção de similaridade, recuperação de código, deduplicação, clustering, classificação assistida e diferentes tipos de sistemas de recuperação de informação.
RAG é apenas um dos contextos possíveis.
O experimento simples esconde problemas arquiteturais importantes
Nosso notebook possui apenas dez documentos.
Com dez documentos, quase tudo parece fácil.
Agora imagine:
10 documentos
↓
10 mil
↓
1 milhão
↓
100 milhões
E acrescente:
múltiplos usuários
múltiplos tenants
documentos atualizados
diferentes modelos
controle de acesso
backup
observabilidade
alta disponibilidade
latência
custos
reindexação
Nesse momento a escolha de uma tecnologia vetorial deixa de ser apenas:
“Qual biblioteca é mais fácil de usar?”
Passamos a discutir:
qual arquitetura de dados é adequada para as características do meu sistema?
Esse é o ponto central desta série.
O que aprendemos com o ChromaDB
O ChromaDB torna relativamente simples iniciar experimentos de recuperação vetorial.
Em poucas linhas, conseguimos passar de textos a um sistema capaz de realizar buscas semânticas.
Mas o aspecto mais interessante do experimento não é apenas a simplicidade da API.
É perceber o que acontece quando o vetor passa a fazer parte do nosso modelo de dados.
Temos agora algo como:
Documento
+
Metadados
+
Embedding
+
Modelo que produziu o embedding
+
Estratégia de recuperação
E todos esses elementos têm um ciclo de vida.
É aí que bancos vetoriais encontram outro campo muito importante da computação:
Sistemas de uso intensivo de dados.
Próximo passo: ChromaDB é suficiente?
Este artigo começou com uma solução intencionalmente simples.
No próximo estágio da série, quero aumentar gradualmente a complexidade e comparar diferentes alternativas.
Depois do ChromaDB, vamos explorar pgvector, trazendo os vetores para dentro do PostgreSQL e analisando o que muda quando dados relacionais e vetoriais convivem no mesmo sistema.
Em seguida, vamos olhar para OpenSearch, em que a discussão passa a envolver também mecanismos distribuídos de busca e indexação.
A intenção não será simplesmente responder:
ChromaDB, pgvector ou OpenSearch: qual é o melhor?
Essa pergunta, isoladamente, provavelmente não tem uma boa resposta.
A pergunta que me interessa é outra:
Quais características do problema fazem uma arquitetura ser mais adequada do que outra?
Porque armazenar vetores é relativamente simples.
Projetar um sistema de recuperação de informação que continue funcionando à medida que dados, usuários, modelos e requisitos crescem é outra história.


Publicar comentário