Tenim una base vectorial (docs_nubelia, 04-02) i sabem embeddir i comparar significats (04-01), però la col·lecció continua poblada amb quatre fragments de joguina escrits a mà. La documentació real de Nubelia és una altra cosa: centenars de pàgines markdown repartides entre la wiki i els repos, amb guies pas a pas, referència d'API, notes de versió, encapçalaments imbricats i seccions que van de dues línies a dues mil paraules. Aquesta lliçó cobreix l'ofici que converteix aquest material brut en un corpus consultable: per què cal trossejar els documents (no és opcional), com trossejar-los bé (les estratègies de chunking i els seus trade-offs), quines metadades ha de portar cada tros (spoiler: són les que ompliran el camp fonts del contracte JSON), i com empaquetar-ho tot en un script d'ingesta re-executable — perquè la documentació canvia cada setmana i el corpus ha de poder seguir-li el ritme sense drames. La qualitat de tot el pipeline RAG que muntarem a 04-04 es decideix, en gran part, aquí.

Contingut

  1. El corpus de Nubelia: què tenim entre mans
  2. Per què trossejar: context, precisió i cost
  3. Estratègies de chunking: comparativa
  4. Implementació: chunking per encapçalaments amb solapament
  5. Metadades per chunk: l'origen de les fonts
  6. El pipeline d'ingesta: ingesta.py
  7. Re-ingestes: upsert i invalidació de caches

El corpus de Nubelia: què tenim entre mans

Abans d'escriure codi, inventari. La documentació de Nubelia —dispersa, com vam confessar a 01-01— s'ha consolidat en un directori de fitxers markdown exportats de la wiki i els repos:

docs_nubelia/
├── guies/
│   ├── primers-passos.md
│   ├── gestio-de-tasques.md
│   └── informes-i-exportacio.md
├── administracio/
│   ├── seguretat-i-acces.md
│   ├── membres-i-permisos.md
│   └── plans-i-facturacio.md
├── api/
│   ├── autenticacio.md
│   ├── endpoints-projectes.md
│   └── webhooks.md
└── integracions/
    ├── slack.md
    └── calendari.md

Cada fitxer segueix una convenció raonable (no perfecta — és documentació real): un # Títol de pàgina, seccions ## i subseccions ###, i una primera línia de comentari amb metadades de publicació que la wiki afegeix en exportar:

<!-- url: wiki/administracio/seguretat-i-acces | versio: 2026.2 | actualitzat: 2026-06-15 -->
# Seguretat i accés

## Restablir contrasenya

Per restablir la teva contrasenya, ves a **Configuració > Seguretat**...

## Bloqueig de compte

Si el teu compte queda bloquejat després de diversos intents fallits...

Treballem amb markdown perquè és el cas més habitual (wikis, READMEs, portals de docs) i perquè la seva estructura d'encapçalaments és or pur per al chunking. Si el teu corpus real inclou PDF, HTML o Word, el patró és idèntic amb un pas previ de conversió a text/markdown (biblioteques com pypandoc, beautifulsoup4 o extractors de PDF); no ho desenvolupem aquí per no desviar el focus.

Per què trossejar: context, precisió i cost

La temptació inicial és no trossejar: embeddir cada pàgina sencera com un sol vector. Tres raons ho descarten:

  1. Límits de context. La pàgina sencera pot no cabre a la finestra del model d'embeddings (04-01), i encara que hi càpiga, diverses pàgines senceres concatenades al bloc <documentacio> poden desbordar —o degradar— la finestra del LLM: recorda "lost in the middle" de 01-04. Volem donar-li al LLM poc i bo, no molt i diluït.
  2. Precisió de la recuperació. L'embedding d'una pàgina que parla de contrasenyes, bloquejos, sessions i auditoria és una mitjana de tots aquests temes: no és especialment a prop de cap pregunta concreta. L'embedding de la secció "Restablir contrasenya" apunta com un làser a les preguntes sobre contrasenyes. Chunks temàticament enfocats ⇒ similituds més nítides ⇒ millor top-k.
  3. Cost. Cada token del bloc <documentacio> es paga a cada crida (ho vam mesurar amb estimar_cost a 03-04). Recuperar 4 chunks de ~300 tokens en comptes de 4 pàgines de ~3.000 divideix per deu aquesta part de la factura — i és la part que es repeteix a cada pregunta.

El trossejament també té un cost propi: tallar malament pot separar una instrucció de la seva condició ("...prem Restablir." / [tall] / "Nota: aquesta opció requereix ser administrador."). Tot el disseny d'aquest capítol persegueix minimitzar aquest dany.

Estratègies de chunking: comparativa

Les tres famílies que cobreixen la pràctica totalitat dels casos:

Estratègia Com talla Avantatges Inconvenients Quan fer-la servir
Mida fixa amb solapament Cada N tokens/caràcters, repetint els últims M al chunk següent Trivial d'implementar; chunks de mida uniforme i predictible Talla a cegues: parteix frases, barreja temes; el solapament mitiga però no cura Text sense estructura (transcripcions, bolcats) o com a primera versió ràpida
Per estructura (encapçalaments markdown) Als límits de secció ##/### Respecta les fronteres temàtiques que el mateix autor va marcar; chunks amb sentit complet; el títol de secció és una metadada gratis Seccions de mida molt dispar: algunes enormes (cal sub-trossejar), altres minúscules Documentació amb estructura — el nostre cas, i el més comú en corpus corporatius
Per paràgrafs / semàntic Als salts de paràgraf; la variant "semàntica" talla on l'embedding entre frases consecutives canvia bruscament Granularitat fina; la semàntica detecta canvis de tema sense dependre del format Chunks de vegades massa petits (perden context); la variant semàntica embedeix frase a frase: més cost i complexitat Textos llargs sense encapçalaments; refinament quan l'estructura no basta

Dos paràmetres transversals a qualsevol estratègia:

  • Mida objectiu. El compromís habitual és entre 200 i 600 tokens per chunk: suficient perquè el fragment s'entengui sol, prou petit per apuntar a un tema. No hi ha número màgic — a 04-05 el tractarem com a variable experimental.
  • Solapament (overlap). Repetir el final d'un chunk al principi del següent (10–20% de la mida) assegura que una frase partida per un tall existeixi completa en almenys un dels dos chunks. Preu: una mica d'emmagatzematge duplicat i el risc de recuperar dos chunks gairebé iguals al mateix top-k.

Per a Nubelia triem l'estratègia que aprofita el que ja tenim: per encapçalaments, amb sub-trossejament amb solapament per a les seccions llargues. El millor de dues famílies.

Implementació: chunking per encapçalaments amb solapament

El pla de la funció: separar el document per encapçalaments ##/###, i si una secció excedeix la mida màxima, sub-trossejar-la per paràgrafs amb solapament. Cada chunk surt amb el seu text i les seves metadades.

# chunking.py — trossejament de markdown per encapçalaments amb solapament
import re

TAM_MAX = 1800       # mida màxima del chunk en caràcters (~450 tokens)
SOLAPAMENT = 300     # caràcters repetits entre sub-chunks consecutius

def trossejar_markdown(text: str, url: str, versio: str, data: str) -> list[dict]:
    """Converteix un document markdown en una llista de chunks amb metadades."""
    titol_pagina, seccions = _separar_per_encapcalaments(text)
    chunks = []
    for titol_seccio, cos in seccions:
        for index, tros in enumerate(_sub_trossejar(cos)):
            chunks.append({
                # Context explícit: el chunk "sap" de quina pàgina i secció ve
                "text": f"[{titol_pagina} > {titol_seccio}]\n{tros}",
                "metadades": {
                    "titol_seccio": titol_seccio,
                    "titol_pagina": titol_pagina,
                    "url": url,
                    "versio": versio,
                    "data": data,
                },
                # Id determinista: mateixa secció => mateix id a cada re-ingesta
                "id": f"{url.replace('/', '-')}--{_slug(titol_seccio)}--{index:02d}",
            })
    return chunks

def _separar_per_encapcalaments(text: str):
    """Divideix el markdown en (titol_pagina, [(titol_seccio, cos), ...])."""
    titol_pagina = "Sense títol"
    m = re.search(r"^# (.+)$", text, flags=re.MULTILINE)
    if m:
        titol_pagina = m.group(1).strip()
    # Partim per encapçalaments de nivell 2 o 3, conservant el títol de cada secció
    parts = re.split(r"^(#{2,3} .+)$", text, flags=re.MULTILINE)
    seccions, titol_actual = [], "Introducció"   # text abans del primer ##
    for part in parts:
        if re.match(r"^#{2,3} ", part):
            titol_actual = part.lstrip("#").strip()
        elif part.strip():
            seccions.append((titol_actual, part.strip()))
    return titol_pagina, seccions

def _sub_trossejar(cos: str) -> list[str]:
    """Si la secció hi cap, un sol chunk; si no, talla per paràgrafs amb solapament."""
    if len(cos) <= TAM_MAX:
        return [cos]
    trossos, actual = [], ""
    for paragraf in cos.split("\n\n"):             # mai no partim un paràgraf per dins
        if len(actual) + len(paragraf) > TAM_MAX and actual:
            trossos.append(actual.strip())
            actual = actual[-SOLAPAMENT:]          # el solapament: arrossega el final anterior
        actual += "\n\n" + paragraf
    if actual.strip():
        trossos.append(actual.strip())
    return trossos

def _slug(text: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")

Decisions que convé entendre (són les que marquen la diferència entre un chunking correcte i un de mediocre):

  • El prefix [Pàgina > Secció] dins del text del chunk. Un chunk que comença per "Per restablir la teva contrasenya..." és bo; un que comença per "[Seguretat i accés > Restablir contrasenya]\nPer restablir..." és millor: el context entra a l'embedding (millora la similitud amb preguntes que mencionen el tema general) i el LLM el veurà en redactar, reduint ambigüitat. Tècnica barata amb retorn alt.
  • Mai no partim un paràgraf per dins. El sub-trossejament acumula paràgrafs complets; el pitjor cas és un chunk una mica més llarg que TAM_MAX, no una frase decapitada.
  • El solapament s'arrossega entre sub-chunks de la mateixa secció (actual[-SOLAPAMENT:]), no entre seccions diferents: creuar la frontera d'un encapçalament barrejaria temes, just el que l'estratègia evita.
  • Ids deterministes (url--seccio--index): la peça que a 04-02 vam identificar com a requisit de l'upsert idempotent.

Metadades per chunk: l'origen de les fonts

Mereix el seu propi apartat perquè les metadades no són decoració — cada camp té un consumidor concret ja construït o ja anunciat:

Metadada Qui la consumeix Per a què
url Camp fonts del contracte JSON (02-03) Que la resposta de DocuBot citi la pàgina real d'on va sortir — verificable per l'usuari
titol_seccio, titol_pagina Bloc <documentacio> (04-04) i fonts Cites llegibles ("Seguretat i accés > Restablir contrasenya") en lloc d'URLs crues
versio Filtres where de 04-02 No respondre amb documentació d'una versió que l'usuari no fa servir
data Filtres i diagnòstic Detectar documentació rància; desempatar entre chunks duplicats

Fins ara, el camp fonts del contracte JSON era el que el LLM deia haver fet servir — amb la documentació passada a mà, poc verificable. A partir de 04-04, fonts s'omplirà des d'aquestes metadades, és a dir, des de fets: quins chunks es van recuperar realment. És un salt de fiabilitat que es decideix aquí, a la ingesta, desant bé l'origen de cada tros.

El pipeline d'ingesta: ingesta.py

Ho assemblem tot a l'script que s'executarà cada vegada que la documentació canviï:

flowchart LR
    A[Llegir fitxers .md] --> B[Netejar i extreure metadades]
    B --> C[Trossejar amb chunking.py]
    C --> D[Embeddir per lots amb embeddings.py]
    D --> E[Upsert a docs_nubelia]
    E --> F[Invalidar caches de respostes]
# ingesta.py — pipeline d'ingesta re-executable de la documentació de Nubelia
import os
import re
import pathlib
from chunking import trossejar_markdown
from embeddings import embed
from vectordb import obtenir_colleccio

DIR_DOCS = os.environ.get("DOCUBOT_DOCS", "./docs_nubelia")
TAM_LOT = 64   # quants chunks embedim per crida a l'API

def netejar(text: str) -> str:
    """Normalitza el markdown abans de trossejar."""
    text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)  # comentaris HTML fora
    text = re.sub(r"\n{3,}", "\n\n", text)                   # col·lapsar línies en blanc
    return text.strip()

def extreure_metadades_publicacio(text: str, ruta: pathlib.Path) -> dict:
    """Llegeix la línia '<!-- url: ... | versio: ... | actualitzat: ... -->' de la wiki."""
    m = re.search(r"<!--\s*url:\s*(\S+)\s*\|\s*versio:\s*(\S+)\s*\|\s*actualitzat:\s*(\S+)\s*-->", text)
    if m:
        return {"url": m.group(1), "versio": m.group(2), "data": m.group(3)}
    # Valors de recanvi si la pàgina no porta la línia (documentació real = imperfecta)
    return {"url": str(ruta.with_suffix("")).replace("\\", "/"), "versio": "desconeguda", "data": "desconeguda"}

def ingerir():
    colleccio = obtenir_colleccio()
    tots = []
    for ruta in sorted(pathlib.Path(DIR_DOCS).rglob("*.md")):
        cru = ruta.read_text(encoding="utf-8")
        meta = extreure_metadades_publicacio(cru, ruta.relative_to(DIR_DOCS))
        chunks = trossejar_markdown(netejar(cru), **meta)
        tots.extend(chunks)
        print(f"  {ruta.name}: {len(chunks)} chunks")

    # Embeddir i desar PER LOTS: menys crides, menys latència, mateix cost per token
    for i in range(0, len(tots), TAM_LOT):
        lot = tots[i : i + TAM_LOT]
        vectors = embed([c["text"] for c in lot], tipus="document")
        colleccio.upsert(
            ids=[c["id"] for c in lot],
            documents=[c["text"] for c in lot],
            embeddings=vectors,
            metadatas=[c["metadades"] for c in lot],
        )
    print(f"Ingesta completa: {len(tots)} chunks a '{colleccio.name}' ({colleccio.count()} totals).")

    # La documentació ha canviat => les respostes cachejades poden estar obsoletes
    invalidar_caches()

def invalidar_caches():
    """Regla de 03-04: publicar documentació invalida les respostes cachejades."""
    from cache_semantic import CacheSemantic   # i el CacheRespostes exacte de 03-04
    # En un desplegament real, aquí es buidaria el magatzem compartit de tots dos caches.
    print("Caches de respostes invalidats (CacheRespostes + CacheSemantic).")

if __name__ == "__main__":
    ingerir()

Lectura guiada de l'script:

  • netejar() és mínima a propòsit: treu comentaris HTML (un cop extretes les seves metadades) i col·lapsa espais. La neteja agressiva (treure taules, codi, emojis) sol destruir informació útil; neteja només allò que demostri fer nosa.
  • Lots de 64: el patró de 04-01 en acció. Amb ~2.000 chunks són ~32 crides a l'API d'embeddings en lloc de 2.000. El cost per token és el mateix; la latència total i la fiabilitat, incomparables. (Si l'API d'embeddings retorna errors transitoris, embolcalla embed amb el cridar_amb_reintents de reintents.py — els patrons de 03-02 serveixen per a qualsevol API, no només la del LLM.)
  • upsert per lots amb ids deterministes: executar ingesta.py dues vegades deixa la col·lecció exactament igual; executar-la després d'editar una pàgina sobreescriu només els chunks d'aquella pàgina.
  • La impressió per fitxer no és cosmètica: "gestio-de-tasques.md: 47 chunks" quan la resta en té 8-12 és el senyal d'una pàgina monstruosa que potser mereix reestructurar-se — la ingesta és també un espieta de qualitat de la documentació.

Re-ingestes: upsert i invalidació de caches

La documentació de Nubelia canvia cada setmana; el corpus ha de poder actualitzar-se amb una sola ordre i sense efectes secundaris sorpresa. El nostre disseny ho garanteix amb tres propietats:

  1. Idempotència: re-executar sense canvis no duplica res (ids deterministes + upsert, de 04-02).
  2. Actualització real: una pàgina editada regenera els seus chunks sota els mateixos ids, amb embeddings i metadades frescos.
  3. Coherència amb els caches: la regla que vam fixar a 03-04 —publicar documentació invalida CacheRespostes— s'estén ara a CacheSemantic (04-01), i ingesta.py és el lloc natural per disparar-la: és l'únic punt del sistema que sap que la documentació acaba de canviar. Un cache que sobreviu a una re-ingesta pot servir respostes basades en documentació que ja no existeix — el pitjor bug possible en un assistent de suport, perquè ningú no el veu.

Queda una aresta honesta: si una secció s'elimina d'una pàgina (o una pàgina desapareix), els seus chunks antics continuen a la col·lecció amb els seus ids orfes — l'upsert actualitza i afegeix, però no esborra allò que ja no es genera. La solució robusta és comparar els ids generats a la ingesta amb els existents (colleccio.get()) i esborrar la diferència; ho deixem assenyalat com a exercici, perquè és exactament el tipus de detall que separa un script de demo d'una peça operable.

Errors Comuns i Consells

  • Chunks sense context propi. Un chunk que diu "Després, prem Acceptar i el canvi s'aplica" no el recupera cap pregunta ni ajuda el LLM. El prefix [Pàgina > Secció] i el tall per encapçalaments existeixen per evitar-ho; revisa una mostra de chunks a ull després de cada canvi d'estratègia.
  • Trossejar per mida fixa tenint estructura disponible. Si el document té encapçalaments, fes-los servir: tallar "cada 1.000 caràcters" un markdown ben estructurat és llençar informació de disseny que l'autor ja et va donar.
  • Chunks enormes "per no perdre res". Recuperaràs fragments que contenen la resposta... diluïda entre tres temes més, amb similituds planes i el bloc <documentacio> inflat (i pagat, 03-04). Si dubtes entre gran i petit, comença petit-mitjà i deixa que l'avaluació de 04-05 decideixi.
  • Ids no deterministes o derivats d'un comptador global. Un comptador (chunk-0001, chunk-0002...) canvia d'assignació així que s'afegeix una pàgina al principi del recorregut: els upserts sobreescriuran chunks equivocats. L'id ha de derivar del contingut estable (url + secció + índex).
  • Oblidar la invalidació de caches a la re-ingesta. El símptoma és diabòlic: la col·lecció té la doc nova, les proves directes al pipeline funcionen, però els usuaris continuen rebent respostes velles (cachejades). Invalidar a ingesta.py tanca la porta.
  • Consell: versiona chunking.py amb cura i anota (com amb PROMPT_VERSION a 02-04) quina versió del chunking va generar el corpus actual. Canviar l'estratègia de chunking canvia els ids i el contingut dels chunks: exigeix re-ingesta completa i re-avaluació.

Exercicis

  1. Auditoria de chunks. Executa trossejar_markdown() sobre la pàgina seguretat-i-acces.md d'exemple i imprimeix, per a cada chunk: id, primers 80 caràcters i longitud. Verifica que (a) cap chunk no supera TAM_MAX en més d'un paràgraf, (b) els ids són únics, i (c) el prefix [Pàgina > Secció] hi és present. Després, duplica el contingut d'una secció fins a fer-la superar TAM_MAX i comprova que apareix el sub-trossejament amb solapament.
  2. Esborrat d'orfes. Implementa purgar_orfes(colleccio, ids_vigents) que esborri de la col·lecció els chunks l'id dels quals no sigui a la ingesta actual, i crida-la al final d'ingerir(). Pista: colleccio.get() sense filtres retorna els ids existents, i colleccio.delete(ids=[...]) esborra per id.
  3. El chunking com a variable. Sense executar res: per a la pregunta "quant dura l'enllaç de restablir contrasenya?", la resposta de la qual ("24 hores") és a l'última frase d'una secció llarga, raona què passaria amb (a) chunks per pàgina sencera, (b) mida fixa de 300 caràcters sense solapament, (c) la nostra estratègia. Quina recupera millor i per què?

Solucions

  1. Amb la pàgina d'exemple, la sortida esperada és de l'estil:
wiki-administracio-seguretat-i-acces--restablir-contrasenya--00 | [Seguretat i accés > Restablir contrasenya]\nPer restabl... | 412
wiki-administracio-seguretat-i-acces--bloqueig-de-compte--00    | [Seguretat i accés > Bloqueig de compte]\nSi el teu compt... | 388

En inflar una secció per sobre de TAM_MAX, apareixen ids --00, --01... de la mateixa secció, i l'inici del chunk --01 repeteix els últims ~300 caràcters del --00 (el solapament). Si a més observes que el chunk --01 comença a mitja idea, acabes de veure en viu per què el solapament existeix: la idea completa està garantida al --00.

  1. Una implementació directa:
def purgar_orfes(colleccio, ids_vigents: set[str]):
    existents = set(colleccio.get()["ids"])
    orfes = list(existents - ids_vigents)
    if orfes:
        colleccio.delete(ids=orfes)
        print(f"Purgats {len(orfes)} chunks orfes.")

I a ingerir(), després del bucle d'upserts: purgar_orfes(colleccio, {c['id'] for c in tots}). Amb això la ingesta passa d'"afegeix i actualitza" a mirall fidel del directori de documentació. Matís d'operació: si la ingesta corre contra un directori incomplet per error (p. ex. un checkout parcial), la purga esborraria mig corpus — un guarda-rail raonable és avortar si ids_vigents és sospitosament petit respecte dels existents. Les decisions destructives mereixen cinturó i tirants, com el sostre de pressupost de 03-04.

  1. (a) Pàgina sencera: l'embedding de la pàgina fa la mitjana de contrasenyes, bloquejos i auditoria; la similitud amb la pregunta serà mediocre i, encara que es recuperi, el LLM rebrà 3.000 tokens on la dada clau està enterrada al final — risc "lost in the middle" (01-04) i cost màxim. (b) Mida fixa 300 sense solapament: alta probabilitat que el tall caigui entre "rebràs un correu amb un enllaç" i "vàlid durant 24 hores" — el chunk recuperat conté la meitat de la resposta i DocuBot, fidel a la regla 2 de SYSTEM_DOCUBOT, no podrà respondre amb precisió. (c) La nostra estratègia: la secció completa "Restablir contrasenya" és un chunk (o dos amb solapament), temàticament pur, amb la frase de les 24 hores intacta en almenys un chunk, i amb el prefix de secció reforçant la similitud. Recupera millor perquè cada decisió (tallar en fronteres temàtiques, no partir paràgrafs, solapar) protegeix exactament el cas que les altres dues trenquen.

Conclusió

La col·lecció docs_nubelia ja no conté quatre frases de demo sinó la documentació real de Nubelia, convertida en chunks amb ofici: trossejats per encapçalaments (respectant les fronteres temàtiques que va marcar l'autor), sub-trossejats amb solapament quan una secció s'allarga, prefixats amb el seu [Pàgina > Secció] perquè el context viatgi dins de l'embedding, i carregats amb les metadades —url, titol_seccio, versio, data— que alimentaran els filtres de 04-02 i, sobretot, el camp fonts del contracte JSON amb fets en lloc de declaracions. Tot orquestrat per ingesta.py: llegir → netejar → trossejar → embeddir per lots → upsert, re-executable a voluntat gràcies als ids deterministes, i amb la disciplina d'invalidar CacheRespostes i CacheSemantic a cada publicació, perquè un cache dessincronitzat de la documentació és un generador silenciós de respostes obsoletes. Ja tenim les tres peces sobre la taula: sabem embeddir i comparar (04-01), sabem emmagatzemar i recuperar amb filtres (04-02) i sabem poblar el magatzem amb material de qualitat (04-03). El que falta és unir-les amb els artefactes dels mòduls anteriors —SYSTEM_DOCUBOT i el seu bloc <documentacio> que per fi s'omplirà sol, prompt_resposta(), el respondre() robust de 03-02— en una sola funció que rebi una pregunta i retorni una resposta fonamentada amb fonts reals. Aquest és el moment culminant del mòdul: la construcció del pipeline RAG complet (04-04).

© Copyright 2026. Tots els drets reservats