El problema: enseñar lo que aún no has enseñado
Tengo un curso de programación con 47 clases. Cada clase tiene notes (donde explico cosas) y labs (donde el alumno practica). Y tengo un problema: a veces uso conceptos en los labs que todavía no he explicado en las notes.
«Vale, en este ejercicio usa map para transformar la lista.»
¿El problema? No he explicado qué coño es map hasta tres clases después.
Esto pasa más de lo que crees. Tienes el material en la cabeza, saltas de un sitio a otro, y sin darte cuenta asumes que el alumno sabe cosas que todavía no le has contado. El resultado: frustración, confusión, y alumnos que piensan que son tontos cuando el tonto eres tú.
La solución manual sería revisar cada lab, anotar qué conceptos usa, y verificar que se hayan explicado antes. Pero tengo 47 clases con varios notebooks cada una. Va a ser que no.
La solución: búsqueda semántica con ChromaDB
La idea es simple:
- Extraer los conceptos de cada notebook (qué se enseña, qué se usa)
- Guardarlos en una base de datos que entienda significado, no solo texto
- Para cada concepto usado en un lab, verificar que exista en notes anteriores
Eso de «entender significado» es clave. Si en las notes digo «función de orden superior» y en el lab uso «higher-order function», un grep no encontrará nada. Pero semánticamente son lo mismo.
Aquí es donde entra ChromaDB: una base de datos de vectores que convierte texto en embeddings y permite buscar por similitud. Dicho en cristiano: guardas texto, y luego puedes preguntar «¿hay algo parecido a esto?» y te devuelve los más similares.
ChromaDB en 5 minutos
ChromaDB es como SQLite pero para embeddings. Un solo archivo (o carpeta), sin servidor, sin configuración. Instalas, usas, y a correr.
pip install chromadb
# O si usas uv:
uv add chromadb
El concepto básico
En una base de datos normal guardas filas con columnas. En ChromaDB guardas documentos con embeddings:
import chromadb
# Crear cliente (persistente en disco)
client = chromadb.PersistentClient(path="./mi_db")
# Crear una "colección" (como una tabla)
collection = client.get_or_create_collection(
name="conceptos",
metadata={"hnsw:space": "cosine"} # Distancia coseno
)
# Guardar documentos
collection.add(
ids=["c1", "c2", "c3"],
documents=["función pura", "bucle for", "recursión"],
metadatas=[
{"clase": "class_010", "tipo": "notes"},
{"clase": "class_015", "tipo": "notes"},
{"clase": "class_020", "tipo": "notes"},
]
)
Eso es todo. ChromaDB automáticamente:
1. Genera embeddings de los documentos (por defecto usa all-MiniLM-L6-v2)
2. Los indexa para búsqueda rápida
3. Los persiste en disco
Buscar por similitud
results = collection.query(
query_texts=["higher-order function"],
n_results=3
)
print(results["documents"])
# [['función de orden superior', 'función pura', 'recursión']]
print(results["distances"])
# [[0.23, 0.45, 0.67]] # Menor = más similar
¿Ves? Busqué «higher-order function» y encontró «función de orden superior» aunque el texto es completamente diferente. Eso es la magia de los embeddings.
El sistema completo: validación curricular
Ahora vamos a construir el sistema que valida que no meta la pata. El código real está en mi proyecto, pero aquí te doy la versión simplificada para que entiendas el concepto.
Paso 1: Extraer conceptos de notebooks
Primero necesitamos sacar los conceptos de cada notebook. Esto lo hago con un LLM (Gemini Flash via OpenRouter), pero podrías hacerlo con regexes si eres valiente:
def extract_concepts_from_notebook(notebook_path: Path) -> list[dict]:
"""
Extrae conceptos de un notebook Jupyter.
Returns:
Lista de {"name": "concepto", "category": "introduces|uses"}
"""
content = get_notebook_content(notebook_path)
# Llamar al LLM para extraer conceptos
response = llm.chat(
messages=[
{"role": "system", "content": EXTRACTION_PROMPT},
{"role": "user", "content": content}
]
)
return json.loads(response)
El LLM clasifica cada concepto como: – introduces: Se enseña con explicación – uses: Se usa asumiendo conocimiento previo
Paso 2: Guardar en ChromaDB
Ahora guardamos los conceptos con sus metadatos:
from chromadb.utils import embedding_functions
# Usar modelo multilingüe (español + 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 conceptos
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 o labs
}]
)
Paso 3: Validar la progresión
Aquí viene lo interesante. Para cada concepto «usado» en un lab, verificamos que exista algo similar en notes anteriores:
def validate_curriculum(course_concepts: dict) -> list[str]:
"""
Valida que los labs no usen conceptos no enseñados.
Returns:
Lista de errores encontrados
"""
errors = []
known_concepts = set()
# Procesar clases en orden
for class_id in sorted(course_concepts.keys()):
class_data = course_concepts[class_id]
# Añadir conceptos introducidos en notes a los conocidos
for c in class_data:
if c["source_type"] == "notes" and c["category"] == "introduces":
known_concepts.add(c["name"].lower())
# Validar conceptos usados en 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 sin enseñar"
)
return errors
La función is_concept_known es donde entra ChromaDB. No hacemos match exacto, hacemos búsqueda semántica:
def is_concept_known(concept: str, known_concepts: set) -> bool:
"""Verifica si un concepto es conocido (exacto o semántico)."""
# 1. Match exacto
if concept.lower() in known_concepts:
return True
# 2. Búsqueda semántica
results = collection.query(
query_texts=[concept],
n_results=3,
where={"category": "introduces"} # Solo buscar en "introduces"
)
# Si hay algo muy similar (distancia < 0.3), lo consideramos conocido
if results["distances"][0] and results["distances"][0][0] < 0.3:
return True
return False
Paso 4: El informe
Ejecutando la validación sobre mi curso, obtengo un bonito informe:
# Informe de Validación Curricular
Se encontraron 17 problemas:
## class_006_abstraciones_abstraccion_funcion
- **refactorizar**: Concepto 'refactorizar' usado en lab pero no enseñado
- Archivo: `labs/0.funciones_basicas.ipynb`
## class_020_secuencias_while
- **memoización**: Concepto 'memoización' usado en lab pero no enseñado
- Archivo: `labs/2.generar_secuencias.ipynb`
## class_026_funciones_orden_superior
- **map**: Concepto 'map' usado en lab pero no enseñado
- Archivo: `labs/0.ejercicios_aplicadores_listas.ipynb`
Ahora sé exactamente qué tengo que arreglar.
Detalles que importan
Modelo de embeddings multilingüe
Si tu contenido está en español, usa un modelo multilingüe:
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="paraphrase-multilingual-MiniLM-L12-v2"
)
El modelo por defecto (all-MiniLM-L6-v2) está entrenado principalmente en inglés y puede dar resultados raros con español.
Distancia coseno vs euclidiana
Para texto, usa distancia coseno:
collection = client.get_or_create_collection(
name="concepts",
metadata={"hnsw:space": "cosine"} # ← Esto
)
La distancia coseno mide el ángulo entre vectores, ignorando la magnitud. Esto es lo que quieres para similitud semántica.
Convertir distancia a similitud
ChromaDB devuelve distancia (menor = más similar). Si quieres similitud (mayor = más similar):
similarity = 1 - distance
Con distancia coseno, el rango es [0, 2], así que la similitud queda en [-1, 1]. En la práctica, para textos similares suele estar en [0.5, 1].
Persistencia
ChromaDB tiene dos modos:
# En memoria (se pierde al cerrar)
client = chromadb.Client()
# Persistente (se guarda en disco)
client = chromadb.PersistentClient(path="./data/chroma")
Para un sistema de validación que vas a ejecutar repetidamente, usa persistente. Así no recalculas embeddings cada vez.
Filtros con where
Puedes filtrar resultados por metadatos:
# Solo buscar en notes
results = collection.query(
query_texts=["función"],
where={"source_type": "notes"}
)
# Solo buscar en clases anteriores a la 020
results = collection.query(
query_texts=["función"],
where={"class_num": {"$lt": 20}}
)
Esto es crucial para la validación: solo queremos buscar en conceptos que ya se han enseñado.
Alternativas a ChromaDB
ChromaDB no es la única opción. Aquí tienes otras:
| Herramienta | Pros | Contras |
|---|---|---|
| ChromaDB | Simple, sin servidor, buena documentación | Limitado a millones de vectores |
| Pinecone | Escalable, managed | De pago, vendor lock-in |
| Weaviate | Potente, GraphQL API | Más complejo de configurar |
| Qdrant | Rápido, Rust | Menos conocido |
| pgvector | Si ya usas PostgreSQL | Requiere PostgreSQL |
Para un proyecto como este (miles de conceptos, no millones), ChromaDB es perfecto. Si necesitas escalar a billones de vectores o alta disponibilidad, mira las alternativas.
El hook de pre-commit
Para que esto sea útil de verdad, lo integré en el workflow de git:
#!/bin/bash
# .git/hooks/pre-commit
echo "🔍 Validando progresión curricular..."
if python bin/concept_index.py validate; then
echo "✓ Validación exitosa"
exit 0
else
echo "❌ Hay conceptos usados sin enseñar"
echo " Ejecuta: make concept-validate-report"
exit 1
fi
Ahora, cada vez que intento hacer commit, el sistema verifica que no estoy metiendo la pata. Si hay violaciones, el commit se bloquea y me dice qué arreglar.
Conclusión
ChromaDB es una de esas herramientas que cuando la descubres piensas «¿cómo he vivido sin esto?». Es SQLite para embeddings: simple, local, y funciona.
El caso de uso que te he mostrado (validación curricular) es solo un ejemplo. Las bases de datos vectoriales sirven para:
- Búsqueda semántica en documentos
- RAG (Retrieval-Augmented Generation) para LLMs
- Detección de duplicados semánticos
- Recomendaciones basadas en similitud
- Clustering de contenido
Y lo mejor: la barrera de entrada es mínima. Instalas, guardas documentos, buscas. Nada de configurar servidores, schemas, o índices complicados.
Si tienes un problema donde necesitas encontrar «cosas parecidas a esto», prueba ChromaDB. Lo peor que puede pasar es que funcione demasiado bien y te preguntes por qué no lo usaste antes.
TL;DR: ChromaDB es una base de datos vectorial local y simple. La usamos para verificar que un curso de programación no use conceptos antes de enseñarlos, usando búsqueda semántica para detectar conceptos similares aunque el texto sea diferente.
Read this article in English.



