A 04-01 vam deixar funcionant una cerca semàntica de força bruta amb dues vergonyes confessades: cada consulta compara la pregunta contra tots els vectors del corpus, i aquests vectors viuen en una llista de Python que s'evapora en reiniciar el procés, obligant a re-embeddir (i re-pagar) tota la documentació a cada arrencada. Per al mini-corpus de sis fragments era igual; per als centenars de pàgines de la wiki de Nubelia, no. Aquesta lliçó presenta la peça d'infraestructura que resol tots dos problemes: la base de dades vectorial. Veurem què aporta exactament (persistència, índexs aproximats, metadades amb filtratge), muntarem ChromaDB en local pas a pas —serà la base vectorial de DocuBot durant la resta del curs—, entendrem a nivell conceptual el trade-off exactitud/velocitat dels índexs ANN, i tancarem amb un mapa d'alternatives perquè sàpigues triar amb criteri quan el projecte no sigui DocuBot.

Contingut

  1. Per què la força bruta no escala
  2. Què aporta una base de dades vectorial
  3. Índexs ANN: canviar una engruna d'exactitud per molta velocitat
  4. ChromaDB pas a pas: la col·lecció de DocuBot
  5. Metadades i filtratge: cercar només on té sentit
  6. Panorama d'alternatives: com triar

Per què la força bruta no escala

Posem números al problema. La nostra funció cercar() de 04-01 fa, per cada consulta, una similitud cosinus contra cada vector del corpus — cost O(n): creix linealment amb el nombre de fragments.

Corpus Vectors (després del chunking) Comparacions per consulta Viable en força bruta?
Mini-demo de 04-01 6 6 Sí, trivial
Wiki de Nubelia avui ~300 pàgines → ~2.000 chunks 2.000 Sí, encara aguanta (mil·lisegons)
Nubelia + repos + històric de tiquets ~50.000 chunks 50.000 Comença a fer mal en latència
Un corpus corporatiu gran milions milions No

I la latència no és l'únic problema. Amb la llista en memòria:

  • Cada arrencada re-embedeix tot el corpus: minuts d'espera i una factura d'embeddings repetida sense necessitat — just el tipus de despesa evitable que vam aprendre a perseguir a 03-04.
  • No hi ha actualització incremental: si Nubelia publica una pàgina nova, o cal re-embeddir-ho tot o cal inventar-se una gestió manual de què està embeddit i què no.
  • No hi ha filtres: si l'usuari pregunta per la versió 2026.2 del producte, la força bruta li pot retornar alegrement documentació de la 2024, perquè només mira proximitat de significat.
  • Un sol procés: dues instàncies de DocuBot no poden compartir la llista.

Sonarà familiar: són els mateixos motius pels quals les aplicacions no desen les seves dades en diccionaris de Python sinó en una base de dades. Els vectors no en són una excepció — només necessiten una base de dades que sàpiga fer l'operació "dona'm els k veïns més propers" de manera eficient.

Què aporta una base de dades vectorial

Una base de dades vectorial és un magatzem especialitzat en vectors + el seu text + les seves metadades, amb tres superpoders:

  1. Persistència. Els vectors es desen a disc. Embeddir el corpus es paga una vegada (a la ingesta, que construirem a 04-03) i les consultes ho reutilitzen per sempre. Reiniciar el servei costa zero.
  2. Índexs ANN (Approximate Nearest Neighbors, veïns més propers aproximats). En comptes de comparar contra tot, l'índex organitza els vectors per trobar els més propers visitant només una petita fracció — la cerca passa d'O(n) a alguna cosa propera a logarítmica. La paraula "aproximat" té lletra petita; la desgranem a l'apartat següent.
  3. Metadades i filtratge. Cada vector porta adjunt un diccionari de metadades (categoria, versió, URL d'origen, data...) i les consultes poden combinar proximitat semàntica amb filtres exactes: "els 4 chunks més semblants a aquesta pregunta que siguin de la categoria permisos i de la versió 2026.2". Per a DocuBot això connecta directament amb el classificador de 6 categories de 02-02: classificar la pregunta primer permet cercar només a la secció rellevant de la documentació.

A més, segons el producte: API d'upsert (inserir-o-actualitzar, clau per a re-ingestes), esborrat per id, rèpliques, control d'accés... L'essencial és entendre que la base vectorial no calcula embeddings (això continua sent feina del nostre embeddings.py, encara que algunes ofereixen fer-ho per comoditat) ni genera respostes (això és el LLM): és la peça intermèdia que recorda i troba.

Índexs ANN: canviar una engruna d'exactitud per molta velocitat

Com troba el veí més proper sense mirar tots els vectors? La idea general dels índexs ANN és preorganitzar els vectors en una estructura que permeti descartar regions senceres de l'espai sense visitar-les.

L'índex més popular avui és HNSW (Hierarchical Navigable Small World), que fan servir Chroma, Qdrant, Weaviate o pgvector. Sense entrar en matemàtica, la intuïció:

  • Imagina els vectors com a ciutats al mapa de significats, connectades amb carreteres a les seves veïnes properes.
  • HNSW afegeix a sobre capes d'autopistes: una capa superior amb poques ciutats molt separades i connexions de llarga distància, capes intermèdies més denses, i a baix la xarxa local completa.
  • Una cerca entra per l'autopista (salts llargs cap a la regió correcta), va baixant de capa (salts cada cop més fins) i acaba explorant només el veïnatge local de la resposta.
  • Resultat: en comptes de visitar els 50.000 vectors, en visita uns centenars.

La lletra petita és la paraula aproximat: l'índex no garanteix trobar exactament els k veïns més propers; garanteix trobar gairebé sempre gairebé tots. La mètrica que ho mesura es diu recall de l'índex (percentatge dels veritables k veïns que l'índex retorna — no el confonguis encara amb el recall de recuperació de 04-05, que mesura una altra cosa a un altre nivell).

Configuració de l'índex Velocitat Exactitud (recall de l'índex) Memòria
Índex més "dens" (més connexions, més exploració) Menor Major (99%+) Major
Índex més "lleuger" (menys connexions, menys exploració) Major Menor (90–95%) Menor
Força bruta (sense índex) La pitjor 100% exacta Mínima

Dues conseqüències pràctiques per no obsessionar-s'hi:

  • A l'escala de DocuBot (milers de chunks), els valors per defecte de qualsevol base vectorial donen un recall altíssim amb latència de mil·lisegons. No toquis els paràmetres de l'índex fins a tenir un problema mesurable.
  • Perdre ocasionalment el 4t veí més proper gairebé mai no importa en RAG: si la teva resposta depèn de recuperar exactament aquell chunk i cap altre de semblant, el problema real sol ser al chunking o a la redundància del corpus, no a l'índex.

ChromaDB pas a pas: la col·lecció de DocuBot

ChromaDB és una base vectorial open source que corre en local (embeguda al teu procés Python o com a servidor), persisteix a disc i té una API mínima — ideal per a desenvolupament i per a projectes de la mida de DocuBot. Instal·lació:

pip install chromadb

Creem el mòdul que centralitzarà l'accés (els mòduls 4, 5 i 6 l'importaran tal qual):

# vectordb.py — accés a la base vectorial de DocuBot
import os
import chromadb

RUTA_DB = os.environ.get("DOCUBOT_VECTORDB", "./docubot_db")

def obtenir_colleccio():
    """Retorna la col·lecció de documentació de Nubelia (la crea si no existeix)."""
    client = chromadb.PersistentClient(path=RUTA_DB)
    return client.get_or_create_collection(
        name="docs_nubelia",
        metadata={"hnsw:space": "cosine"},   # mesurar amb distància cosinus
    )

Desglossament:

  • PersistentClient(path=...) ho desa tot al directori indicat. Aquí hi ha la persistència: mata el procés, torna a arrencar, i els vectors continuen. (Existeix també chromadb.Client() purament en memòria, útil només per a tests.)
  • get_or_create_collection: una col·lecció és l'equivalent a una taula — un conjunt de vectors amb el mateix model d'embeddings al darrere. docs_nubelia serà la nostra col·lecció canònica.
  • {"hnsw:space": "cosine"}: li diem a l'índex HNSW que mesuri amb cosinus, coherent amb 04-01. Compte amb un matís que causa confusió: Chroma retorna distàncies, no similituds, i amb espai cosinus la relació és similitud = 1 - distancia. Distància 0.2 ⇒ similitud 0.8.

Afegim documents. Un punt de disseny deliberat: calculem els embeddings nosaltres, amb l'embed() de 04-01, i els passem ja fets a Chroma. Chroma pot calcular-los pel seu compte amb un model local per defecte, però perdríem el control sobre quin model es fa servir — i ja sabem de 04-01 que barrejar models d'embeddings és un error silenciós i letal.

# poblar_demo.py — càrrega manual de prova (la ingesta real arriba a 04-03)
from embeddings import embed
from vectordb import obtenir_colleccio

colleccio = obtenir_colleccio()

documents = [
    "Per restablir la teva contrasenya, ves a Configuració > Seguretat i prem 'Restablir'.",
    "Els informes es poden exportar a CSV i Excel. L'exportació a PDF no està suportada.",
    "El pla Pro permet fins a 500 tasques per projecte i 25 membres per espai.",
    "L'API de Nubelia fa servir autenticació per token Bearer, generat a Configuració > Desenvolupadors.",
]
metadades = [
    {"categoria": "permisos",      "versio": "2026.2", "url": "wiki/seguretat/restablir-password"},
    {"categoria": "altres",        "versio": "2026.2", "url": "wiki/informes/exportacio"},
    {"categoria": "facturacio",    "versio": "2026.2", "url": "wiki/plans/limits-pro"},
    {"categoria": "api",           "versio": "2026.2", "url": "wiki/api/autenticacio"},
]
ids = ["seguretat-password-01", "informes-export-01", "plans-pro-01", "api-auth-01"]

colleccio.upsert(
    ids=ids,                                   # id estable i únic per chunk
    documents=documents,                       # el text original (recuperable!)
    embeddings=embed(documents, tipus="document"),
    metadatas=metadades,
)
print(colleccio.count())  # 4

Tres detalls importants:

  • upsert en comptes d'add: si l'id ja existeix, actualitza en lloc de fallar o duplicar. És el que farà re-executable la ingesta de 04-03.
  • Els ids són responsabilitat nostra i convé que siguin deterministes (derivats de l'origen del chunk), no aleatoris: així la mateixa pàgina re-ingerida sobreescriu els seus propis chunks.
  • Chroma desa el text al costat del vector (documents): en consultar recuperem directament el fragment per al bloc <documentacio>, sense una segona base de dades.

I la consulta:

from embeddings import embed
from vectordb import obtenir_colleccio

colleccio = obtenir_colleccio()

resultat = colleccio.query(
    query_embeddings=embed(["no puc entrar al meu compte"], tipus="query"),
    n_results=2,
)

for doc, meta, dist in zip(resultat["documents"][0],
                           resultat["metadatas"][0],
                           resultat["distances"][0]):
    print(f"similitud={1 - dist:.3f}  [{meta['categoria']}]  {doc[:55]}...")
similitud=0.781  [permisos]  Per restablir la teva contrasenya, ves a Configuració >...
similitud=0.412  [api]       L'API de Nubelia fa servir autenticació per token Beare...

Fixa't en la forma del resultat: cada camp és una llista de llistes (resultat["documents"][0]), perquè query accepta diverses consultes alhora i retorna una llista de resultats per consulta. Com que enviem una sola pregunta, sempre indexem amb [0]. És l'ensopegada número u amb l'API de Chroma.

Metadades i filtratge: cercar només on té sentit

El paràmetre where combina la cerca semàntica amb filtres exactes sobre les metadades:

# Només documentació de la categoria 'permisos'
resultat = colleccio.query(
    query_embeddings=embed(["no puc entrar al meu compte"], tipus="query"),
    n_results=2,
    where={"categoria": "permisos"},
)

# Combinant condicions: categoria 'api' I versió actual del producte
resultat = colleccio.query(
    query_embeddings=embed(["com m'autentico contra l'API"], tipus="query"),
    n_results=4,
    where={"$and": [
        {"categoria": "api"},
        {"versio": "2026.2"},
    ]},
)

Quan filtrar a DocuBot? Dos casos amb recorregut:

  • Per categoria, reutilitzant el classificador de 02-02: si prompt_classificacio() diu que la pregunta és de facturacio, cercar només en aquesta categoria elimina d'arrel que un chunk de l'API "es coli" per semblança superficial. (A 04-04 decidirem quan compensa activar aquest filtre i quan és millor cercar a tot arreu.)
  • Per versió del producte: quan conviuen documentació de la versió actual i de versions antigues, el filtre evita respondre amb instruccions obsoletes — un tipus d'error especialment danyós en suport.

Una advertència simètrica: cada filtre és una aposta. Si el classificador s'equivoca de categoria, el filtre amaga justament els chunks correctes i la recuperació fracassa sense soroll. Filtra quan la metadada sigui fiable (la versió ho és; una classificació automàtica, només a vegades), i mesura l'efecte amb l'avaluació de 04-05 abans de deixar-lo fix.

Panorama d'alternatives: com triar

Chroma és la nostra elecció didàctica i perfectament vàlida per a DocuBot, però l'ecosistema és ampli. El que és honest no és un rànquing sinó un mapa de criteris:

Opció Tipus Tret distintiu Quan encaixa bé
Chroma Local (embeguda o servidor), open source API mínima, zero fricció per començar Prototips, projectes petits/mitjans, desenvolupament local
pgvector Extensió de PostgreSQL Els vectors viuen al costat de les teves dades relacionals, amb SQL, transaccions i backups de sempre Equips que ja operen PostgreSQL i no volen una peça nova d'infraestructura
Qdrant Servidor dedicat, open source (amb núvol gestionat) Filtratge sobre metadades molt potent i bon rendiment Càrregues mitjanes/grans amb filtres complexos, control del desplegament
Weaviate Servidor dedicat, open source (amb núvol gestionat) Mòduls integrats (embeddings, híbrida keyword+vector) Qui vol cerca híbrida i funcionalitat "amb piles incloses"
Pinecone Servei gestionat (SaaS) Zero operació: escala, rèpliques i disponibilitat són problema del proveïdor Producció a escala sense equip d'infraestructura, si la dada pot sortir a un tercer
FAISS Biblioteca (no base de dades) Índexs vectorials d'altíssim rendiment, sense persistència ni metadades "de sèrie" Recerca, pipelines a mida on tu construeixes tota la resta al voltant

Els criteris de decisió, en ordre raonable d'importància:

  1. Local o gestionat? La mateixa disjuntiva de 01-03 amb els LLMs: control i privadesa de la dada davant de comoditat operativa. Per a documentació interna, on viuen els vectors importa (els embeddings no són xifratge: d'un vector se'n pot reconstruir molt del contingut).
  2. Què opera ja el teu equip? Si hi ha PostgreSQL en producció, pgvector sol guanyar per simplicitat operativa encara que una altra opció sigui "millor" sobre el paper.
  3. Escala real (no aspiracional): milers de vectors → qualsevol val, comença simple; desenes de milions → servidor dedicat o gestionat.
  4. Filtratge i funcions: si les teves consultes depenen de filtres rics o cerca híbrida, mira Qdrant/Weaviate abans.

La bona notícia: com que el nostre codi aïlla l'accés a vectordb.py i els embeddings a embeddings.py, migrar de base vectorial més endavant és un canvi localitzat, no una reescriptura.

Errors Comuns i Consells

  • Confondre distància amb similitud. Chroma retorna distàncies; amb espai cosinus, similitud = 1 - distancia. Si les teves "similituds" surten millors com més baixes, estàs llegint distàncies. Converteix en un sol lloc i treballa sempre en similitud.
  • Oblidar metadata={"hnsw:space": "cosine"} en crear la col·lecció: l'espai per defecte pot no ser cosinus, i els números deixaran de casar amb els de 04-01. Es fixa en crear la col·lecció — canviar-ho després implica recrear-la.
  • Deixar que la base calculi els embeddings amb el seu model per defecte. Tindràs un corpus embeddit amb un model i preguntes embeddides amb un altre: resultats dolents sense cap error visible. Passa sempre embeddings= calculats amb el teu embed().
  • Ids aleatoris (uuid4) a la càrrega. Cada re-ingesta duplicarà el corpus sencer. Ids deterministes + upsert és la parella correcta.
  • Oblidar el [0] en llegir els resultats de query (són llistes de llistes, una per consulta).
  • Optimitzar l'índex ANN prematurament. A escala DocuBot, els defaults sobren. Mesura primer (04-05), ajusta després.
  • Consell: desa a les metadades tot el que puguis necessitar per filtrar o citar (categoria, versió, URL, data). Afegir una metadada a posteriori significa re-processar tot el corpus.

Exercicis

  1. Persistència demostrada. Executa poblar_demo.py, tanca l'intèrpret de Python, obre'l de nou i, sense tornar a afegir res, executa una consulta sobre obtenir_colleccio(). Comprova amb colleccio.count() que els 4 documents continuen allà. Què hauria passat amb la llista VECTORS de 04-01?
  2. El filtre que ajuda i el filtre que fa nosa. Amb els 4 documents de la demo, consulta "quantes tasques puc tenir en un projecte?" primer sense filtre i després amb where={"categoria": "api"}. Compara resultats i explica quina lliçó pràctica se n'extreu sobre els filtres per classificació automàtica.
  3. Upsert idempotent. Executa poblar_demo.py tres vegades seguides i comprova colleccio.count(). Després, canvia el text del document informes-export-01 (per exemple, afegeix-hi "L'exportació a PDF està prevista per a 2027."), re-executa i consulta "puc exportar a PDF?" per verificar que recuperes la versió nova. Quines dues decisions del codi ho fan possible?

Solucions

  1. count() retorna 4 i la consulta funciona: PersistentClient va escriure els vectors a ./docubot_db i els recarrega del disc. La llista VECTORS de 04-01 hauria desaparegut amb el procés, i reconstruir-la hauria exigit tornar a cridar l'API d'embeddings per a tot el corpus — pagant en temps i diners. Aquesta és exactament la diferència entre una estructura en memòria i una base de dades.

  2. Sense filtre, el chunk de "El pla Pro permet fins a 500 tasques per projecte" surt primer amb similitud clarament superior — recuperació correcta. Amb where={"categoria": "api"}, aquest chunk queda exclòs d'arrel (la seva categoria és facturacio) i la consulta retorna el chunk d'autenticació de l'API, que és el més semblant dins del filtre però irrellevant per a la pregunta. Lliçó: un filtre basat en una classificació equivocada no degrada la cerca — la saboteja, i a més amb similituds baixes que semblen "resultats normals". Per això a 04-04 tractarem el filtre per categoria com a opcional i condicionat a la confiança del classificador, i per això el llindar mínim de similitud de l'exercici 1 de 04-01 serà la nostra xarxa de seguretat.

  3. count() continua sent 4 després de tres execucions: upsert amb els mateixos ids sobreescriu en lloc de duplicar (si haguéssim fet servir add amb ids nous cada vegada, en tindríem 12). En canviar el text d'informes-export-01, la re-execució recalcula el seu embedding i sobreescriu vector, text i metadades sota el mateix id, així que la consulta recupera la versió actualitzada. Les dues decisions: ids deterministes (el document "sap" quin és el seu id) i upsert. Són la base de l'script d'ingesta re-executable de la propera lliçó — juntament amb un tercer pas que aquí vam fer mentalment: invalidar els caches de respostes de 03-04/04-01, perquè la documentació acaba de canviar.

Conclusió

DocuBot ja té memòria de llarg termini: la col·lecció docs_nubelia de ChromaDB persisteix els vectors a disc (s'embedeix una vegada, es consulta sempre), els troba en mil·lisegons gràcies a un índex HNSW que canvia una engruna d'exactitud per ordres de magnitud de velocitat, i desa al costat de cada vector el text original i unes metadades (categoria, versió, URL) que permeten filtrar la cerca i que aviat alimentaran el camp fonts del contracte JSON. Hem encapsulat l'accés a vectordb.py::obtenir_colleccio(), après a convertir les distàncies de Chroma en les similituds de 04-01, i situat Chroma en un mapa d'alternatives (pgvector, Qdrant, Weaviate, Pinecone, FAISS) on l'elecció correcta depèn de criteris —dada local o gestionada, què opera ja l'equip, escala real— i no de rànquings. Però la nostra col·lecció continua poblada amb quatre fragments escrits a mà en un script de demo. La documentació real de Nubelia són centenars de pàgines markdown a la wiki i els repos, amb encapçalaments, taules i seccions de longituds salvatges. Convertir aquest material brut en chunks ben tallats, amb metadades riques i mitjançant un script d'ingesta re-executable és l'ofici que aprenem a la lliçó següent: ingesta i chunking de documents (04-03).

© Copyright 2026. Tots els drets reservats