En 04-01 dejamos funcionando una búsqueda semántica de fuerza bruta con dos vergüenzas confesas: cada consulta compara la pregunta contra todos los vectores del corpus, y esos vectores viven en una lista de Python que se evapora al reiniciar el proceso, obligando a re-embeber (y re-pagar) toda la documentación en cada arranque. Para el mini-corpus de seis fragmentos daba igual; para las cientos de páginas de la wiki de Nubelia, no. Esta lección presenta la pieza de infraestructura que resuelve ambos problemas: la base de datos vectorial. Veremos qué aporta exactamente (persistencia, índices aproximados, metadatos con filtrado), montaremos ChromaDB en local paso a paso —será la base vectorial de DocuBot durante el resto del curso—, entenderemos a nivel conceptual el trade-off exactitud/velocidad de los índices ANN, y cerraremos con un mapa de alternativas para que sepas elegir con criterio cuando el proyecto no sea DocuBot.
Contenido
- Por qué la fuerza bruta no escala
- Qué aporta una base de datos vectorial
- Índices ANN: cambiar una pizca de exactitud por mucha velocidad
- ChromaDB paso a paso: la colección de DocuBot
- Metadatos y filtrado: buscar solo donde tiene sentido
- Panorama de alternativas: cómo elegir
Por qué la fuerza bruta no escala
Pongamos números al problema. Nuestra función buscar() de 04-01 hace, por cada consulta, una similitud coseno contra cada vector del corpus — coste O(n): crece linealmente con el número de fragmentos.
| Corpus | Vectores (tras chunking) | Comparaciones por consulta | ¿Viable en fuerza bruta? |
|---|---|---|---|
| Mini-demo de 04-01 | 6 | 6 | Sí, trivial |
| Wiki de Nubelia hoy | ~300 páginas → ~2.000 chunks | 2.000 | Sí, aún aguanta (milisegundos) |
| Nubelia + repos + histórico de tickets | ~50.000 chunks | 50.000 | Empieza a doler en latencia |
| Un corpus corporativo grande | millones | millones | No |
Y la latencia no es el único problema. Con la lista en memoria:
- Cada arranque re-embebe todo el corpus: minutos de espera y una factura de embeddings repetida sin necesidad — justo el tipo de gasto evitable que aprendimos a perseguir en 03-04.
- No hay actualización incremental: si Nubelia publica una página nueva, o hay que re-embeber todo o hay que inventarse una gestión manual de qué está embebido y qué no.
- No hay filtros: si el usuario pregunta por la versión 2026.2 del producto, la fuerza bruta le puede devolver alegremente documentación de la 2024, porque solo mira cercanía de significado.
- Un solo proceso: dos instancias de DocuBot no pueden compartir la lista.
Sonará familiar: son los mismos motivos por los que las aplicaciones no guardan sus datos en diccionarios de Python sino en una base de datos. Los vectores no son una excepción — solo necesitan una base de datos que sepa hacer la operación "dame los k vecinos más cercanos" de forma eficiente.
Qué aporta una base de datos vectorial
Una base de datos vectorial es un almacén especializado en vectores + su texto + sus metadatos, con tres superpoderes:
- Persistencia. Los vectores se guardan en disco. Embeber el corpus se paga una vez (en la ingesta, que construiremos en 04-03) y las consultas lo reutilizan para siempre. Reiniciar el servicio cuesta cero.
- Índices ANN (Approximate Nearest Neighbors, vecinos más cercanos aproximados). En lugar de comparar contra todo, el índice organiza los vectores para encontrar los más cercanos visitando solo una pequeña fracción — la búsqueda pasa de O(n) a algo cercano a logarítmico. La palabra "aproximado" tiene letra pequeña; la desgranamos en el siguiente apartado.
- Metadatos y filtrado. Cada vector lleva adjunto un diccionario de metadatos (categoría, versión, URL de origen, fecha...) y las consultas pueden combinar cercanía semántica con filtros exactos: "los 4 chunks más parecidos a esta pregunta que sean de la categoría
permisosy de la versión 2026.2". Para DocuBot esto conecta directamente con el clasificador de 6 categorías de 02-02: clasificar la pregunta primero permite buscar solo en la sección relevante de la documentación.
Además, según el producto: API de upsert (insertar-o-actualizar, clave para re-ingestas), borrado por id, réplicas, control de acceso... Lo esencial es entender que la base vectorial no calcula embeddings (eso sigue siendo trabajo de nuestro embeddings.py, aunque algunas ofrecen hacerlo por comodidad) ni genera respuestas (eso es el LLM): es la pieza intermedia que recuerda y encuentra.
Índices ANN: cambiar una pizca de exactitud por mucha velocidad
¿Cómo encuentra el vecino más cercano sin mirar todos los vectores? La idea general de los índices ANN es preorganizar los vectores en una estructura que permita descartar regiones enteras del espacio sin visitarlas.
El índice más popular hoy es HNSW (Hierarchical Navigable Small World), que usan Chroma, Qdrant, Weaviate o pgvector. Sin entrar en matemática, la intuición:
- Imagina los vectores como ciudades en el mapa de significados, conectadas con carreteras a sus vecinas cercanas.
- HNSW añade encima capas de autopistas: una capa superior con pocas ciudades muy separadas y conexiones de larga distancia, capas intermedias más densas, y abajo la red local completa.
- Una búsqueda entra por la autopista (saltos largos hacia la región correcta), va bajando de capa (saltos cada vez más finos) y termina explorando solo el vecindario local de la respuesta.
- Resultado: en vez de visitar los 50.000 vectores, visita unos cientos.
La letra pequeña es la palabra aproximado: el índice no garantiza encontrar exactamente los k vecinos más cercanos; garantiza encontrar casi siempre casi todos. La métrica que lo mide se llama recall del índice (porcentaje de los verdaderos k vecinos que el índice devuelve — no lo confundas todavía con el recall de recuperación de 04-05, que mide otra cosa a otro nivel).
| Configuración del índice | Velocidad | Exactitud (recall del índice) | Memoria |
|---|---|---|---|
| Índice más "denso" (más conexiones, más exploración) | Menor | Mayor (99%+) | Mayor |
| Índice más "ligero" (menos conexiones, menos exploración) | Mayor | Menor (90–95%) | Menor |
| Fuerza bruta (sin índice) | La peor | 100% exacta | Mínima |
Dos consecuencias prácticas para no obsesionarse:
- A la escala de DocuBot (miles de chunks), los valores por defecto de cualquier base vectorial dan recall altísimo con latencia de milisegundos. No toques los parámetros del índice hasta tener un problema medible.
- Perder ocasionalmente el 4º vecino más cercano casi nunca importa en RAG: si tu respuesta depende de recuperar exactamente ese chunk y ningún otro parecido, el problema real suele estar en el chunking o en la redundancia del corpus, no en el índice.
ChromaDB paso a paso: la colección de DocuBot
ChromaDB es una base vectorial open source que corre en local (embebida en tu proceso Python o como servidor), persiste en disco y tiene una API mínima — ideal para desarrollo y para proyectos del tamaño de DocuBot. Instalación:
Creamos el módulo que centralizará el acceso (los módulos 4, 5 y 6 lo importarán tal cual):
# vectordb.py — acceso a la base vectorial de DocuBot
import os
import chromadb
RUTA_DB = os.environ.get("DOCUBOT_VECTORDB", "./docubot_db")
def obtener_coleccion():
"""Devuelve la colección de documentación de Nubelia (la crea si no existe)."""
cliente = chromadb.PersistentClient(path=RUTA_DB)
return cliente.get_or_create_collection(
name="docs_nubelia",
metadata={"hnsw:space": "cosine"}, # medir con distancia coseno
)Desglose:
PersistentClient(path=...)guarda todo en el directorio indicado. Ahí está la persistencia: mata el proceso, vuelve a arrancar, y los vectores siguen. (Existe tambiénchromadb.Client()puramente en memoria, útil solo para tests.)get_or_create_collection: una colección es el equivalente a una tabla — un conjunto de vectores con el mismo modelo de embeddings detrás.docs_nubeliaserá nuestra colección canónica.{"hnsw:space": "cosine"}: le decimos al índice HNSW que mida con coseno, coherente con 04-01. Ojo a un matiz que causa confusión: Chroma devuelve distancias, no similitudes, y con espacio coseno la relación essimilitud = 1 - distancia. Distancia 0.2 ⇒ similitud 0.8.
Añadimos documentos. Un punto de diseño deliberado: calculamos los embeddings nosotros, con el embed() de 04-01, y se los pasamos ya hechos a Chroma. Chroma puede calcularlos por su cuenta con un modelo local por defecto, pero perderíamos el control sobre qué modelo se usa — y ya sabemos de 04-01 que mezclar modelos de embeddings es un error silencioso y letal.
# poblar_demo.py — carga manual de prueba (la ingesta real llega en 04-03)
from embeddings import embed
from vectordb import obtener_coleccion
coleccion = obtener_coleccion()
documentos = [
"Para restablecer tu contraseña, ve a Ajustes > Seguridad y pulsa 'Restablecer'.",
"Los informes pueden exportarse a CSV y Excel. La exportación a PDF no está soportada.",
"El plan Pro permite hasta 500 tareas por proyecto y 25 miembros por espacio.",
"La API de Nubelia usa autenticación por token Bearer, generado en Ajustes > Desarrolladores.",
]
metadatos = [
{"categoria": "permisos", "version": "2026.2", "url": "wiki/seguridad/restablecer-password"},
{"categoria": "otros", "version": "2026.2", "url": "wiki/informes/exportacion"},
{"categoria": "facturacion", "version": "2026.2", "url": "wiki/planes/limites-pro"},
{"categoria": "api", "version": "2026.2", "url": "wiki/api/autenticacion"},
]
ids = ["seguridad-password-01", "informes-export-01", "planes-pro-01", "api-auth-01"]
coleccion.upsert(
ids=ids, # id estable y único por chunk
documents=documentos, # el texto original (¡recuperable!)
embeddings=embed(documentos, tipo="document"),
metadatas=metadatos,
)
print(coleccion.count()) # 4Tres detalles importantes:
upserten lugar deadd: si el id ya existe, actualiza en vez de fallar o duplicar. Es lo que hará re-ejecutable la ingesta de 04-03.- Los
idsson responsabilidad nuestra y conviene que sean deterministas (derivados del origen del chunk), no aleatorios: así la misma página re-ingerida sobrescribe sus propios chunks. - Chroma guarda el texto junto al vector (
documents): al consultar recuperamos directamente el fragmento para el bloque<documentacion>, sin una segunda base de datos.
Y la consulta:
from embeddings import embed
from vectordb import obtener_coleccion
coleccion = obtener_coleccion()
resultado = coleccion.query(
query_embeddings=embed(["no puedo entrar en mi cuenta"], tipo="query"),
n_results=2,
)
for doc, meta, dist in zip(resultado["documents"][0],
resultado["metadatas"][0],
resultado["distances"][0]):
print(f"similitud={1 - dist:.3f} [{meta['categoria']}] {doc[:55]}...")similitud=0.781 [permisos] Para restablecer tu contraseña, ve a Ajustes > Segurida... similitud=0.412 [api] La API de Nubelia usa autenticación por token Bearer, g...
Fíjate en la forma del resultado: cada campo es una lista de listas (resultado["documents"][0]), porque query acepta varias consultas a la vez y devuelve una lista de resultados por consulta. Como enviamos una sola pregunta, siempre indexamos con [0]. Es el tropiezo número uno con la API de Chroma.
Metadatos y filtrado: buscar solo donde tiene sentido
El parámetro where combina la búsqueda semántica con filtros exactos sobre los metadatos:
# Solo documentación de la categoría 'permisos'
resultado = coleccion.query(
query_embeddings=embed(["no puedo entrar en mi cuenta"], tipo="query"),
n_results=2,
where={"categoria": "permisos"},
)
# Combinando condiciones: categoría 'api' Y versión actual del producto
resultado = coleccion.query(
query_embeddings=embed(["cómo me autentico contra la API"], tipo="query"),
n_results=4,
where={"$and": [
{"categoria": "api"},
{"version": "2026.2"},
]},
)¿Cuándo filtrar en DocuBot? Dos casos con recorrido:
- Por categoría, reutilizando el clasificador de 02-02: si
prompt_clasificacion()dice que la pregunta es defacturacion, buscar solo en esa categoría elimina de raíz que un chunk de la API "se cuele" por parecido superficial. (En 04-04 decidiremos cuándo compensa activar este filtro y cuándo es mejor buscar en todo.) - Por versión del producto: cuando conviven documentación de la versión actual y de versiones antiguas, el filtro evita responder con instrucciones obsoletas — un tipo de error especialmente dañino en soporte.
Una advertencia simétrica: cada filtro es una apuesta. Si el clasificador se equivoca de categoría, el filtro esconde justamente los chunks correctos y la recuperación fracasa sin ruido. Filtra cuando el metadato sea fiable (la versión lo es; una clasificación automática, solo a veces), y mide el efecto con la evaluación de 04-05 antes de dejarlo fijo.
Panorama de alternativas: cómo elegir
Chroma es nuestra elección didáctica y perfectamente válida para DocuBot, pero el ecosistema es amplio. Lo honesto no es un ranking sino un mapa de criterios:
| Opción | Tipo | Rasgo distintivo | Cuándo encaja bien |
|---|---|---|---|
| Chroma | Local (embebida o servidor), open source | API mínima, cero fricción para empezar | Prototipos, proyectos pequeños/medianos, desarrollo local |
| pgvector | Extensión de PostgreSQL | Los vectores viven junto a tus datos relacionales, con SQL, transacciones y backups de siempre | Equipos que ya operan PostgreSQL y no quieren una pieza nueva de infraestructura |
| Qdrant | Servidor dedicado, open source (con nube gestionada) | Filtrado sobre metadatos muy potente y buen rendimiento | Cargas medias/grandes con filtros complejos, control del despliegue |
| Weaviate | Servidor dedicado, open source (con nube gestionada) | Módulos integrados (embeddings, híbrida keyword+vector) | Quien quiere búsqueda híbrida y funcionalidad "con pilas incluidas" |
| Pinecone | Servicio gestionado (SaaS) | Cero operación: escala, réplicas y disponibilidad son problema del proveedor | Producción a escala sin equipo de infraestructura, si el dato puede salir a un tercero |
| FAISS | Biblioteca (no base de datos) | Índices vectoriales de altísimo rendimiento, sin persistencia ni metadatos "de serie" | Investigación, pipelines a medida donde tú construyes todo lo demás alrededor |
Los criterios de decisión, en orden razonable de importancia:
- ¿Local o gestionado? La misma disyuntiva de 01-03 con los LLMs: control y privacidad del dato frente a comodidad operativa. Para documentación interna, dónde viven los vectores importa (los embeddings no son cifrado: de un vector se puede reconstruir mucho del contenido).
- ¿Qué opera ya tu equipo? Si hay PostgreSQL en producción, pgvector suele ganar por simplicidad operativa aunque otra opción sea "mejor" sobre el papel.
- Escala real (no aspiracional): miles de vectores → cualquiera vale, empieza simple; decenas de millones → servidor dedicado o gestionado.
- Filtrado y funciones: si tus consultas dependen de filtros ricos o búsqueda híbrida, mira Qdrant/Weaviate antes.
La buena noticia: como nuestro código aísla el acceso en vectordb.py y los embeddings en embeddings.py, migrar de base vectorial más adelante es un cambio localizado, no una reescritura.
Errores Comunes y Consejos
- Confundir distancia con similitud. Chroma devuelve distancias; con espacio coseno,
similitud = 1 - distancia. Si tus "similitudes" salen mejores cuanto más bajas, estás leyendo distancias. Convierte en un solo sitio y trabaja siempre en similitud. - Olvidar
metadata={"hnsw:space": "cosine"}al crear la colección: el espacio por defecto puede no ser coseno, y los números dejarán de casar con los de 04-01. Se fija al crear la colección — cambiarlo después implica recrearla. - Dejar que la base calcule los embeddings con su modelo por defecto. Tendrás un corpus embebido con un modelo y preguntas embebidas con otro: resultados malos sin ningún error visible. Pasa siempre
embeddings=calculados con tuembed(). - Ids aleatorios (
uuid4) en la carga. Cada re-ingesta duplicará el corpus entero. Ids deterministas +upsertes la pareja correcta. - Olvidar el
[0]al leer los resultados dequery(son listas de listas, una por consulta). - Optimizar el índice ANN prematuramente. A escala DocuBot, los defaults sobran. Mide primero (04-05), ajusta después.
- Consejo: guarda en los metadatos todo lo que puedas necesitar para filtrar o citar (categoría, versión, URL, fecha). Añadir un metadato a posteriori significa re-procesar todo el corpus.
Ejercicios
- Persistencia demostrada. Ejecuta
poblar_demo.py, cierra el intérprete de Python, ábrelo de nuevo y, sin volver a añadir nada, ejecuta una consulta sobreobtener_coleccion(). Comprueba concoleccion.count()que los 4 documentos siguen ahí. ¿Qué habría pasado con la listaVECTORESde 04-01? - El filtro que ayuda y el filtro que estorba. Con los 4 documentos de la demo, consulta "¿cuántas tareas puedo tener en un proyecto?" primero sin filtro y luego con
where={"categoria": "api"}. Compara resultados y explica qué lección práctica se extrae sobre los filtros por clasificación automática. - Upsert idempotente. Ejecuta
poblar_demo.pytres veces seguidas y compruebacoleccion.count(). Después, cambia el texto del documentoinformes-export-01(por ejemplo, añade "La exportación a PDF está prevista para 2027."), re-ejecuta y consulta "¿puedo exportar a PDF?" para verificar que recuperas la versión nueva. ¿Qué dos decisiones del código hacen esto posible?
Soluciones
-
count()devuelve 4 y la consulta funciona:PersistentClientescribió los vectores en./docubot_dby los recarga del disco. La listaVECTORESde 04-01 habría desaparecido con el proceso, y reconstruirla habría exigido volver a llamar a la API de embeddings para todo el corpus — pagando en tiempo y dinero. Esa es exactamente la diferencia entre una estructura en memoria y una base de datos. -
Sin filtro, el chunk de "El plan Pro permite hasta 500 tareas por proyecto" sale primero con similitud claramente superior — recuperación correcta. Con
where={"categoria": "api"}, ese chunk queda excluido de raíz (su categoría esfacturacion) y la consulta devuelve el chunk de autenticación de la API, que es lo más parecido dentro del filtro pero irrelevante para la pregunta. Lección: un filtro basado en una clasificación equivocada no degrada la búsqueda — la sabotea, y además con similitudes bajas que parecen "resultados normales". Por eso en 04-04 trataremos el filtro por categoría como opcional y condicionado a la confianza del clasificador, y por eso el umbral mínimo de similitud del ejercicio 1 de 04-01 será nuestra red de seguridad. -
count()sigue siendo 4 tras tres ejecuciones:upsertcon los mismos ids sobrescribe en lugar de duplicar (si hubiéramos usadoaddcon ids nuevos cada vez, tendríamos 12). Al cambiar el texto deinformes-export-01, la re-ejecución recalcula su embedding y sobrescribe vector, texto y metadatos bajo el mismo id, así que la consulta recupera la versión actualizada. Las dos decisiones: ids deterministas (el documento "sabe" cuál es su id) y upsert. Son la base del script de ingesta re-ejecutable de la próxima lección — junto con un tercer paso que aquí hicimos mentalmente: invalidar los cachés de respuestas de 03-04/04-01, porque la documentación acaba de cambiar.
Conclusión
DocuBot ya tiene memoria de largo plazo: la colección docs_nubelia de ChromaDB persiste los vectores en disco (se embebe una vez, se consulta siempre), los encuentra en milisegundos gracias a un índice HNSW que cambia una pizca de exactitud por órdenes de magnitud de velocidad, y guarda junto a cada vector el texto original y unos metadatos (categoría, versión, URL) que permiten filtrar la búsqueda y que pronto alimentarán el campo fuentes del contrato JSON. Hemos encapsulado el acceso en vectordb.py::obtener_coleccion(), aprendido a convertir las distancias de Chroma en las similitudes de 04-01, y situado a Chroma en un mapa de alternativas (pgvector, Qdrant, Weaviate, Pinecone, FAISS) donde la elección correcta depende de criterios —dato local o gestionado, qué opera ya el equipo, escala real— y no de rankings. Pero nuestra colección sigue poblada con cuatro fragmentos escritos a mano en un script de demo. La documentación real de Nubelia son cientos de páginas markdown en la wiki y los repos, con encabezados, tablas y secciones de longitudes salvajes. Convertir ese material bruto en chunks bien cortados, con metadatos ricos y mediante un script de ingesta re-ejecutable es el oficio que aprendemos en la siguiente lección: ingesta y chunking de documentos (04-03).
Curso de IA Generativa y LLMs para Desarrolladores
Módulo 1: Fundamentos de la IA generativa
- Qué es la IA generativa y por qué importa a los desarrolladores
- Cómo funciona un LLM: tokens, embeddings y atención
- Panorama de modelos y proveedores
- Limitaciones y riesgos: alucinaciones, contexto y costes
Módulo 2: Prompt engineering
- Anatomía de un prompt: roles, instrucciones y contexto
- Técnicas de prompting: few-shot, cadena de razonamiento y plantillas
- Salidas estructuradas: JSON y control de formato
- Iteración y evaluación de prompts
Módulo 3: Integración de LLMs en aplicaciones
- Primera integración con la API de un LLM
- Streaming, errores y reintentos
- Conversaciones y gestión del contexto
- Costes, latencia y caching
Módulo 4: RAG - Generación aumentada por recuperación
- Embeddings y búsqueda semántica
- Bases de datos vectoriales
- Ingesta y chunking de documentos
- Construcción de un pipeline RAG completo
- Evaluación de sistemas RAG
Módulo 5: Function calling y agentes
- Function calling: conectar el LLM con tu código
- De LLM a agente: el bucle de razonamiento y acción
- Frameworks de orquestación
