Construyendo un Cerebro Digital (Parte 5): La Memoria Vectorial con ChromaDB
Implementamos la memoria vectorial del sistema: indexación en ChromaDB, embeddings generados con Ollama y un script sincronizador que convierte texto plano en conocimiento vivo consultable.
Construyendo un Cerebro Digital (Parte 5): La Memoria Vectorial con ChromaDB
En la última entrega dejamos a nuestro Orquestador creando .json rebosantes de conocimiento.
Pero una montaña de notas sin una forma rápida e inteligente de consultarlas es como una biblioteca sin catálogo.
👉 Toca darle memoria de largo plazo a nuestro sistema.
📚 Del texto plano al conocimiento vivo
Hoy convertimos archivos en conocimiento semántico accionable con dos piezas:
- ChromaDB 🧠: base de datos vectorial que no guarda texto, sino significado.
- Modelo de embeddings (vía Ollama en local): traduce texto a vectores.
El protagonista es el script indexar_contenido.py, nuestro bibliotecario digital que lee, entiende y cataloga.
📚 ¿Por qué una base de datos vectorial?
Imagina preguntarle al bibliotecario:
“Dame libros sobre ética de la IA”
Y que te traiga todo lo relevante, aunque no contenga esas palabras exactas.
Eso hace ChromaDB: busca por proximidad semántica, no por keywords.
Para lograrlo, guardamos embeddings: vectores que capturan el significado.
⚙️ Instalación exprés
Sin servidores raros. Solo:
pip install chromadb
ChromaDB guardará datos en tu carpeta chroma_db. Todo autocontenido y bajo tu control.
🧑🏫 El bibliotecario: indexar_contenido.py
Este script mantiene la memoria al día:
- Conecta con ChromaDB.
- Compara
.jsonlocales con lo ya indexado. - Elimina lo obsoleto.
- Añade lo nuevo creando embeddings.
A continuación, el código completo:
# indexar_contenido.py
# ------------------------------------------------------------
# Sincroniza JSONs de conocimiento con ChromaDB (vector DB),
# generando embeddings locales con Ollama.
# ------------------------------------------------------------
from pathlib import Path
from typing import Dict, List, Set
import json
import chromadb
import ollama
# --- CONFIGURACIÓN GLOBAL ---
RUTA_BASE_PROYECTO = Path(__file__).parent
RUTA_CONOCIMIENTO_EXTRAIDO = RUTA_BASE_PROYECTO / "textos_extraidos"
RUTA_BASE_DE_DATOS = str(RUTA_BASE_PROYECTO / "chroma_db")
MODELO_EMBEDDINGS = "mxbai-embed-large"
NOMBRE_COLECCION_DB = "documentacion_general"
# Opciones de chunking (aprox. por caracteres)
CHUNK_MAX_CHARS = 1000 # tamaño máximo de cada fragmento
CHUNK_MIN_CHARS = 200 # intentamos evitar chunks ultracortos
BATCH_EMBEDDINGS = 100 # tamaño del lote para add/embeddings
class SincronizadorConocimiento:
"""
Gestiona la sincronización entre los archivos JSON de conocimiento
y la base de datos vectorial ChromaDB, incluyendo sus metadatos.
"""
def __init__(self, ruta_conocimiento: Path, ruta_db: str,
nombre_coleccion: str, modelo_embeddings: str) -> None:
self.ruta_conocimiento = ruta_conocimiento
self.modelo_embeddings = modelo_embeddings
try:
cliente = chromadb.PersistentClient(path=ruta_db)
self.coleccion = cliente.get_or_create_collection(name=nombre_coleccion)
print(f"Conexión con ChromaDB establecida. Colección: '{nombre_coleccion}'")
except Exception as e:
print(f"!!! ERROR al conectar con ChromaDB: {e}")
raise
# ----------------- Utilidades de inventario -----------------
def obtener_archivos_locales(self) -> Set[str]:
"""Devuelve el conjunto de nombres de archivo .json existentes en la carpeta de conocimiento extraído."""
if not self.ruta_conocimiento.exists():
print(f"Carpeta '{self.ruta_conocimiento}' no existe. Creando...")
self.ruta_conocimiento.mkdir(parents=True, exist_ok=True)
return {p.name for p in self.ruta_conocimiento.glob("*.json")}
def obtener_fuentes_en_db(self) -> Dict[str, List[str]]:
"""Devuelve un diccionario: nombre_archivo_json -> [ids_chunk_en_db]."""
fuentes: Dict[str, List[str]] = {}
total = self.coleccion.count()
if total == 0:
return fuentes
offset = 0
page_size = 500
while offset < total:
batch = self.coleccion.get(
include=["metadatas", "ids"],
limit=page_size,
offset=offset
)
ids = batch.get("ids", []) or []
metas = batch.get("metadatas", []) or []
for id_, meta in zip(ids, metas):
nombre = (meta or {}).get("nombre_archivo_json")
if nombre:
fuentes.setdefault(nombre, []).append(id_)
offset += len(ids)
return fuentes
def _eliminar_documento_de_db(self, nombre_fuente_json: str, ids: List[str]) -> None:
"""Elimina de la colección todos los chunks asociados a una fuente."""
try:
print(f" -> Eliminando documento obsoleto '{nombre_fuente_json}' ({len(ids)} chunks)")
self.coleccion.delete(ids=ids)
except Exception as e:
print(f" !!! ERROR eliminando '{nombre_fuente_json}': {e}")
# ----------------- Chunking y Embeddings -----------------
def _trocear_texto(self, texto: str) -> List[str]:
"""Divide el texto por párrafos y los reagrupa para aproximarse a CHUNK_MAX_CHARS."""
paras = [p.strip() for p in texto.split("\n\n") if p.strip()]
if not paras:
return []
chunks: List[str] = []
actual = ""
for p in paras:
if not actual:
actual = p
continue
if len(actual) + 2 + len(p) <= CHUNK_MAX_CHARS:
actual += "\n\n" + p
else:
if len(actual) < CHUNK_MIN_CHARS and len(p) < CHUNK_MAX_CHARS:
actual += "\n\n" + p
else:
chunks.append(actual)
actual = p
if actual:
chunks.append(actual)
return chunks
def _generar_embeddings(self, fragmentos: List[str]) -> List[List[float]]:
"""Llama a Ollama para generar un embedding por fragmento."""
embeddings = []
for frag in fragmentos:
emb = ollama.embeddings(model=self.modelo_embeddings, prompt=frag)["embedding"]
embeddings.append(emb)
return embeddings
def _indexar_archivo(self, ruta_archivo_json: Path) -> None:
"""Lee un archivo JSON, lo trocea, vectoriza y añade a la base de datos."""
nombre_fuente_json = ruta_archivo_json.name
print(f"\n -> Indexando nuevo documento: {nombre_fuente_json}")
try:
with open(ruta_archivo_json, "r", encoding="utf-8") as f:
datos = json.load(f)
texto = datos.get("texto", "") or ""
metadatos_originales = datos.get("metadatos", {}) or {}
metadatos_originales["nombre_archivo_json"] = nombre_fuente_json
if not texto.strip():
print(" -> Documento sin texto. Omitiendo.")
return
fragmentos = self._trocear_texto(texto)
print(f" -> Documento dividido en {len(fragmentos)} fragmentos.")
for i in range(0, len(fragmentos), BATCH_EMBEDDINGS):
lote = fragmentos[i : i + BATCH_EMBEDDINGS]
ids = [f"{nombre_fuente_json}_{i + j}" for j in range(len(lote))]
print(f" -> Generando embeddings para lote {i // BATCH_EMBEDDINGS + 1}...")
embeddings = self._generar_embeddings(lote)
self.coleccion.add(
ids=ids,
embeddings=embeddings,
documents=lote,
metadatas=[metadatos_originales] * len(lote),
)
print(f" -> Contenido de '{nombre_fuente_json}' añadido a la base de datos.")
except Exception as e:
print(f" !!! ERROR al procesar '{nombre_fuente_json}': {e}")
def sincronizar(self) -> None:
"""
Orquesta el proceso completo de sincronización:
- Elimina de DB fuentes que ya no existen localmente.
- Indexa nuevos JSONs detectados.
"""
print("\n--- Iniciando proceso de sincronización de conocimiento ---")
archivos_locales_set = self.obtener_archivos_locales()
fuentes_en_db_dict = self.obtener_fuentes_en_db()
fuentes_a_eliminar = set(fuentes_en_db_dict.keys()) - archivos_locales_set
for nombre_fuente_json in fuentes_a_eliminar:
self._eliminar_documento_de_db(nombre_fuente_json, fuentes_en_db_dict[nombre_fuente_json])
archivos_a_indexar = archivos_locales_set - set(fuentes_en_db_dict.keys())
for nombre_fuente_json in archivos_a_indexar:
self._indexar_archivo(self.ruta_conocimiento / nombre_fuente_json)
print("\n--- Sincronización finalizada. ---")
print(f"Total de fragmentos (chunks) en la colección: {self.coleccion.count()}")
def ejecutar_sincronizacion() -> None:
"""Función principal que instancia y ejecuta el sincronizador."""
try:
sincronizador = SincronizadorConocimiento(
ruta_conocimiento=RUTA_CONOCIMIENTO_EXTRAIDO,
ruta_db=RUTA_BASE_DE_DATOS,
nombre_coleccion=NOMBRE_COLECCION_DB,
modelo_embeddings=MODELO_EMBEDDINGS,
)
sincronizador.sincronizar()
except Exception as e:
print(f"\nHa ocurrido un error crítico durante la inicialización: {e}")
if __name__ == "__main__":
ejecutar_sincronizacion()
🔍 El proceso (en cristiano)
- Chunking → Los LLMs tienen ventana limitada. Partimos por párrafos y reagrupamos hasta ~1000 caracteres para mantener coherencia sin “romper” ideas.
- Embeddings → Con
mxbai-embed-large(vía Ollama en local) convertimos cada fragmento en un vector: su huella semántica. - Almacenamiento → Insertamos en ChromaDB cada chunk con:
- ID único
- Embedding
- Texto original
- Metadatos (archivo de origen, fechas, etc.)
🚀 Conclusión
Al ejecutar:
python3 indexar_contenido.py
Tu bibliotecario digital se pone manos a la obra.
Lo que era una carpeta de textos ahora es memoria viva por conceptos.
La mesa está servida:
✅ Información extraída y
✅ Memoria vectorial
👉 Siguiente parada: Ollama y los modelos que harán de cerebro conversacional para hablar con tus documentos. 🔥