O problema: ensinar o que você ainda não ensinou

Tenho um curso de programação com 47 aulas. Cada aula tem notes (onde explico as coisas) e labs (onde o aluno pratica). E tenho um problema: às vezes uso conceitos nos labs que ainda não expliquei nas notes.

“Beleza, neste exercício usa map para transformar a lista.”

O problema? Não expliquei o que diabos é map até três aulas depois.

Isso acontece mais do que você pensa. Você tem o material na cabeça, pula de um lugar para outro, e sem perceber assume que o aluno sabe coisas que você ainda não contou para ele. O resultado: frustração, confusão, e alunos que pensam que são burros quando o burro é você.

A solução manual seria revisar cada lab, anotar que conceitos usa, e verificar se foram explicados antes. Mas tenho 47 aulas com vários notebooks cada uma. Nem pensar.

A solução: busca semântica com ChromaDB

A ideia é simples:

  1. Extrair os conceitos de cada notebook (o que se ensina, o que se usa)
  2. Guardá-los em um banco de dados que entenda significado, não só texto
  3. Para cada conceito usado em um lab, verificar se existe em notes anteriores

Isso de “entender significado” é crucial. Se nas notes digo “função de ordem superior” e no lab uso “higher-order function”, um grep não vai encontrar nada. Mas semanticamente são a mesma coisa.

Aqui que entra o ChromaDB: um banco de dados de vetores que converte texto em embeddings e permite buscar por similaridade. Dito em português: você guarda texto, e depois pode perguntar “tem algo parecido com isso?” e ele te devolve os mais similares.

ChromaDB em 5 minutos

ChromaDB é como SQLite mas para embeddings. Um único arquivo (ou pasta), sem servidor, sem configuração. Instala, usa e vai embora.

1
2
3
pip install chromadb
# Ou se usa uv:
uv add chromadb

O conceito básico

Num banco normal você guarda linhas com colunas. No ChromaDB você guarda documentos com embeddings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import chromadb

# Criar cliente (persistente em disco)
client = chromadb.PersistentClient(path="./meu_db")

# Criar uma "coleção" (como uma tabela)
collection = client.get_or_create_collection(
    name="conceitos",
    metadata={"hnsw:space": "cosine"}  # Distância cosseno
)

# Guardar documentos
collection.add(
    ids=["c1", "c2", "c3"],
    documents=["função pura", "loop for", "recursão"],
    metadatas=[
        {"classe": "class_010", "tipo": "notes"},
        {"classe": "class_015", "tipo": "notes"},
        {"classe": "class_020", "tipo": "notes"},
    ]
)

É isso. ChromaDB automaticamente:

  1. Gera embeddings dos documentos (por padrão usa all-MiniLM-L6-v2)
  2. Indexa para busca rápida
  3. Persiste no disco

Buscar por similaridade

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
results = collection.query(
    query_texts=["higher-order function"],
    n_results=3
)

print(results["documents"])
# [['função de ordem superior', 'função pura', 'recursão']]

print(results["distances"])
# [[0.23, 0.45, 0.67]]  # Menor = mais similar

Viu? Busquei “higher-order function” e encontrou “função de ordem superior” mesmo que o texto seja completamente diferente. Essa é a mágica dos embeddings.

O sistema completo: validação curricular

Agora vamos construir o sistema que valida pra não fazer besteira. O código real está no meu projeto, mas aqui te dou a versão simplificada pra você entender o conceito.

Passo 1: Extrair conceitos de notebooks

Primeiro precisamos tirar os conceitos de cada notebook. Isso faço com um LLM (Gemini Flash via OpenRouter), mas você poderia fazer com regexes se for corajoso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def extract_concepts_from_notebook(notebook_path: Path) -> list[dict]:
    """
    Extrai conceitos de um notebook Jupyter.

    Returns:
        Lista de {"name": "conceito", "category": "introduces|uses"}
    """
    content = get_notebook_content(notebook_path)

    # Chamar o LLM para extrair conceitos
    response = llm.chat(
        messages=[
            {"role": "system", "content": EXTRACTION_PROMPT},
            {"role": "user", "content": content}
        ]
    )

    return json.loads(response)

O LLM classifica cada conceito como:

  • introduces: Se ensina com explicação
  • uses: Se usa assumindo conhecimento prévio

Passo 2: Guardar no ChromaDB

Agora guardamos os conceitos com seus metadados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from chromadb.utils import embedding_functions

# Usar modelo multilíngue (português + inglês)
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="paraphrase-multilingual-MiniLM-L12-v2"
)

collection = client.get_or_create_collection(
    name="course_concepts",
    embedding_function=embedding_fn,
    metadata={"hnsw:space": "cosine"}
)

# Guardar conceitos
for class_id, concepts in course_concepts.items():
    for concept in concepts:
        collection.add(
            ids=[f"{class_id}:{concept['name']}"],
            documents=[concept["name"]],
            metadatas=[{
                "class_id": class_id,
                "category": concept["category"],
                "source_type": concept["source_type"],  # notes ou labs
            }]
        )

Passo 3: Validar a progressão

Aqui vem o interessante. Para cada conceito “usado” num lab, verificamos se existe algo similar em notes anteriores:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def validate_curriculum(course_concepts: dict) -> list[str]:
    """
    Valida que os labs não usem conceitos não ensinados.

    Returns:
        Lista de erros encontrados
    """
    errors = []
    known_concepts = set()

    # Processar classes em ordem
    for class_id in sorted(course_concepts.keys()):
        class_data = course_concepts[class_id]

        # Adicionar conceitos introduzidos em notes aos conhecidos
        for c in class_data:
            if c["source_type"] == "notes" and c["category"] == "introduces":
                known_concepts.add(c["name"].lower())

        # Validar conceitos usados em labs
        for c in class_data:
            if c["source_type"] == "labs" and c["category"] == "uses":
                if not is_concept_known(c["name"], known_concepts):
                    errors.append(
                        f"{class_id}: '{c['name']}' usado sem ensinar"
                    )

    return errors

A função is_concept_known é onde entra o ChromaDB. Não fazemos match exato, fazemos busca semântica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def is_concept_known(concept: str, known_concepts: set) -> bool:
    """Verifica se um conceito é conhecido (exato ou semântico)."""

    # 1. Match exato
    if concept.lower() in known_concepts:
        return True

    # 2. Busca semântica
    results = collection.query(
        query_texts=[concept],
        n_results=3,
        where={"category": "introduces"}  # Só buscar em "introduces"
    )

    # Se há algo muito similar (distância < 0.3), consideramos conhecido
    if results["distances"][0] and results["distances"][0][0] < 0.3:
        return True

    return False

Passo 4: O relatório

Executando a validação sobre meu curso, obtenho um belo relatório:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Relatório de Validação Curricular

Foram encontrados 17 problemas:

## class_006_abstrações_abstração_função

- **refatorar**: Conceito 'refatorar' usado em lab mas não ensinado
  - Arquivo: `labs/0.funcoes_basicas.ipynb`

## class_020_sequencias_while

- **memoização**: Conceito 'memoização' usado em lab mas não ensinado
  - Arquivo: `labs/2.gerar_sequencias.ipynb`

## class_026_funcoes_ordem_superior

- **map**: Conceito 'map' usado em lab mas não ensinado
  - Arquivo: `labs/0.exercicios_aplicadores_listas.ipynb`

Agora sei exatamente o que tenho que arrumar.

Detalhes que importam

Modelo de embeddings multilíngue

Se seu conteúdo está em português, use um modelo multilíngue:

1
2
3
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="paraphrase-multilingual-MiniLM-L12-v2"
)

O modelo padrão (all-MiniLM-L6-v2) está treinado principalmente em inglês e pode dar resultados estranhos com português.

Distância cosseno vs euclidiana

Para texto, use distância cosseno:

1
2
3
4
collection = client.get_or_create_collection(
    name="concepts",
    metadata={"hnsw:space": "cosine"}  # ← Isso
)

A distância cosseno mede o ângulo entre vetores, ignorando a magnitude. Isso é o que você quer para similaridade semântica.

Converter distância em similaridade

ChromaDB retorna distância (menor = mais similar). Se quiser similaridade (maior = mais similar):

1
similarity = 1 - distance

Com distância cosseno, o range é [0, 2], então a similaridade fica em [-1, 1]. Na prática, para textos similares costuma estar em [0.5, 1].

Persistência

ChromaDB tem dois modos:

1
2
3
4
5
# Em memória (se perde ao fechar)
client = chromadb.Client()

# Persistente (salva no disco)
client = chromadb.PersistentClient(path="./data/chroma")

Para um sistema de validação que você vai executar repetidamente, use persistente. Assim não recalcula embeddings toda vez.

Filtros com where

Pode filtrar resultados por metadados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Só buscar em notes
results = collection.query(
    query_texts=["função"],
    where={"source_type": "notes"}
)

# Só buscar em classes anteriores à 020
results = collection.query(
    query_texts=["função"],
    where={"class_num": {"$lt": 20}}
)

Isso é crucial para a validação: só queremos buscar em conceitos que já foram ensinados.

Alternativas ao ChromaDB

ChromaDB não é a única opção. Aqui tem outras:

FerramentaPrósContras
ChromaDBSimples, sem servidor, boa documentaçãoLimitado a milhões de vetores
PineconeEscalável, managedPago, vendor lock-in
WeaviatePoderoso, GraphQL APIMais complexo de configurar
QdrantRápido, RustMenos conhecido
pgvectorSe já usa PostgreSQLRequer PostgreSQL

Para um projeto como este (milhares de conceitos, não milhões), ChromaDB é perfeito. Se precisar escalar para bilhões de vetores ou alta disponibilidade, veja as alternativas.

O hook de pre-commit

Para isso ser útil de verdade, integrei no workflow do git:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash
# .git/hooks/pre-commit

echo "🔍 Validando progressão curricular..."

if python bin/concept_index.py validate; then
    echo "✓ Validação bem-sucedida"
    exit 0
else
    echo "❌ Há conceitos usados sem ensinar"
    echo "   Execute: make concept-validate-report"
    exit 1
fi

Agora, toda vez que tento fazer commit, o sistema verifica se não estou fazendo besteira. Se há violações, o commit é bloqueado e me diz o que arrumar.

Conclusão

ChromaDB é uma dessas ferramentas que quando você descobre pensa “como vivi sem isso?”. É SQLite para embeddings: simples, local, e funciona.

O caso de uso que te mostrei (validação curricular) é só um exemplo. Bancos de dados vetoriais servem para:

  • Busca semântica em documentos
  • RAG (Retrieval-Augmented Generation) para LLMs
  • Detecção de duplicados semânticos
  • Recomendações baseadas em similaridade
  • Clustering de conteúdo

E o melhor: a barreira de entrada é mínima. Instala, guarda documentos, busca. Nada de configurar servidores, schemas, ou índices complicados.

Se você tem um problema onde precisa encontrar “coisas parecidas com isso”, experimenta ChromaDB. O pior que pode acontecer é funcionar bem demais e você se perguntar por que não usou antes.


TL;DR: ChromaDB é um banco de dados vetorial local e simples. Usamos para verificar que um curso de programação não use conceitos antes de ensiná-los, usando busca semântica para detectar conceitos similares mesmo que o texto seja diferente.