Sembrava la scelta naturale.

2026/07/07 · Marco Orlandin · 6 min · TL;DR

L'indice vettoriale dell'intero corpus normativo stava dentro PGlite, insieme al resto dell'app. La scelta più ovvia del mondo.

Poi l'ho misurato.

Rappresentazione visiva di un indice vettoriale come struttura separata, in sola lettura, distinta dal database applicativo di un'app desktop legale

A 250.000 chunk, PGlite ci mette 17 minuti a costruire l'indice. Un motore nativo, sugli stessi dati, 23 secondi.

Nessuno aveva controllato se quell'indice, alla scala vera, in PGlite ci stesse. Era la premessa su cui poggiava il resto, un draft di progetto scritto in buona fede.

Ho iniziato da poco a contribuire a Italian Builders, un gruppo open source. Uno dei primi progetti è Magistra: un assistente legale che gira come app desktop e cita la normativa con riferimenti verificabili. Sotto il cofano è un RAG su un corpus normativo italiano. Come ogni RAG, ha un indice vettoriale "da qualche parte".

Quel "da qualche parte" era PGlite. La logica non faceva una piega: il motore di Magistra è Postgres con pgvector, e per un'app Electron PGlite è lo stesso Postgres portato in WebAssembly ed embeddato nell'app. Un solo motore per il database applicativo (utenti, conversazioni, messaggi) e per l'indice del corpus. E l'indice lo spedisci già pronto dentro il bundle. Comodo.

L'unica cosa di cui si discuteva era la concorrenza: worker e API sulla stessa istanza PGlite mentre l'indice viene ricostruito. Preoccupazione legittima, PGlite non ama due processi sullo stesso file. Ma dava per scontata una cosa: che l'indice, in PGlite, ci stesse.

Cosa ho misurato

Non l'ho dedotto. L'ho misurato.

Un benchmark riproducibile: PGlite con pgvector contro un motore embedded nativo, tenuto come controllo. Vettori sintetici, 1024 dimensioni, normalizzati. Qui misuro la scala (tempi di build, latenza, memoria, disco), non la qualità del recupero: per la scala, un vettore finto stressa l'indice esattamente come uno vero.

Il tempo di costruzione dell'indice, stessi dati:

N chunkPGlite (HNSW)controllo nativo
10.00016,6 s2,1 s
100.000282 s20 s
250.0001057 s (~17 min)23 s

A 250.000 chunk, ancora sotto la scala di un MVP, PGlite ci mette 17 minuti dove il controllo ne mette 23. E la curva è quadratica: raddoppi i dati, quadruplichi il tempo. Le query con filtro sui metadati stanno sui 431 ms contro i 7 ms del controllo, e il divario cresce con N. Il corpus pieno di Magistra è nell'ordine dei milioni di chunk. PGlite non ci arriva vicino.

Non la concorrenza. Il motore.

Il collo di bottiglia non erano i dati. Era il motore.

Pgvector dentro la sandbox WebAssembly è lento a costruire un indice ANN. Lo è uguale a 32 e a 64 bit. C'è anche un tetto: wasm32 ha 4 GiB di memoria lineare, e a corpus pieno l'indice non entra nemmeno nello spazio di indirizzamento. Ma quel tetto qui non l'ho toccato: le run finiscono. È un muro che si vede arrivare, non la causa di quello che ho misurato. La causa è il tempo di build, che esplode molto prima.

Ed ecco il punto. Si discuteva di come gestire l'accesso concorrente a un indice che, alla scala vera, non si sarebbe nemmeno riusciti a costruire. Il sintomo era la concorrenza. Il problema era il posto dove stava l'indice.

L'indice esce da PGlite

La decisione è netta. L'indice del corpus non vive in PGlite. Va in uno store separato, in sola lettura, ricostruito offline e messo online con uno swap atomico. PGlite resta per i dati dell'app, che è il suo mestiere: un piccolo database transazionale embeddato.

E qui succede una cosa. Il problema di concorrenza, quello da cui si era partiti, si scioglie da solo. Con l'indice in uno store separato read-only, il worker non tocca più PGlite per l'ingest: lo swap è sull'indice, non sul database applicativo. PGlite torna ad avere un solo writer, e lo scenario non supportato (due processi sullo stesso file) sparisce. Non ho risolto la concorrenza, semplicemente non esiste più un problema di concorrenza.

Capita spesso: separi due carichi che stavano insieme per comodità e come effetto collaterale risolvi il problema che sembrava quello principale. Vale la pena chiedersi se davvero devono stare nello stesso motore.

La seconda misura

C'era la tentazione facile: il controllo del benchmark andava benissimo, quindi lo scelgo come motore e ho finito. Ma una misura che squalifica non è una misura che decide.

Sarebbe stata la stessa scorciatoia che avrei contestato a chi avesse scelto PGlite senza misurare. Il benchmark di scala misura build e latenza, non la recall: quanti dei documenti giusti l'indice recupera davvero. E in ambito legale la recall non è un dettaglio. Un richiamo mancato è una norma che non citi. Un danno, non un fastidio.

Così ho fatto un secondo benchmark, su embedding reali di tutta la legislazione statale italiana, con la recall confrontata contro un ground truth esatto. Sotto esame i candidati veri: LanceDB in tre varianti, USearch, DuckDB.

Il risultato ha ribaltato quello che davo per scontato. Senza filtro sono tutti bravi uguale: recuperano circa 94 documenti giusti su 100, e la differenza tra un motore e l'altro è rumore. Ma un indice normativo non lo interroghi quasi mai senza filtro: cerchi dentro la normativa vigente, dentro un tipo di atto. E sotto filtro il quadro si spacca. I motori che applicano il filtro dopo la ricerca (postfilter) precipitano da 94 a 5 documenti giusti su 100: li perdono quasi tutti, e non li recuperi nemmeno spingendo i parametri di 64 volte. LanceDB applica il filtro prima (prefilter) e resta a 99 su 100. Non è un numero a decidere. È dove metti il filtro.

La raccomandazione, misurata: l'indice in uno store LanceDB separato, read-only, con prefilter nativo su vigenza e tipo di atto. La variante e la taratura le lascio al progetto: il mio lavoro era misurare e leggere il dato, non decidere nel repository di altri. E le cose ancora aperte le ho scritte, non nascoste (la prima: le query di prova non sono ancora domande reali).

Il filo comune

È sempre lo stesso schema: il sintomo non è mai il problema. Qui il sintomo era la concorrenza sul database. Il problema era un indice nel motore sbagliato. Chi discuteva ragionava sul sintomo, in buona fede: succede quando un'architettura sembra ovvia e nessuno ne ha misurato la premessa.

C'è anche il filo della scelta del motore giusto per la domanda che gli fai: un database si sceglie per la query, non per la comodità di averne uno solo. PGlite è ottimo per un piccolo database applicativo. Per un indice ANN a scala di corpus è il posto sbagliato. Il motore comodo per i dati dell'app è spesso quello sbagliato per l'indice.

E poi la disciplina, che è la parte che mi porto dietro. Quando una misura ti boccia un'opzione, non chiudere con la prima alternativa che sembra buona. Aver dimostrato che PGlite non regge non mi autorizzava a scegliere il sostituto senza misurarlo dove conta, qui la recall. Quello che hai dimostrato e quello che devi ancora misurare vanno tenuti separati.

Se stai decidendo dove tenere un indice vettoriale a scala reale, e la tentazione è metterlo nello stesso motore del database applicativo "per semplicità"... parliamone . Ti dico cosa misurare prima di scegliere.

ADR

Questo caso l'ho documentato in modo più strutturato come Architecture Decision Record, con contesto reale, trade-off valutati e risultati osservati.

ADR260629: Indice vettoriale di un corpus normativo: fuori da PGlite, motore sostitutivo da misurare

In sintesi (TL;DR)

  • Magistra, assistente legale open source e desktop, teneva l'indice vettoriale del corpus normativo dentro PGlite, lo stesso Postgres-in-WebAssembly del database applicativo: naturale, dato che il motore era già Postgres con pgvector. Ho voluto verificare se, a quella scala, l'indice ci stesse davvero.
  • Un benchmark riproducibile: a 250.000 chunk, sotto la scala di un MVP, PGlite costruisce l'indice in circa 17 minuti contro i 23 secondi di un motore nativo. Il collo di bottiglia è la sandbox WebAssembly, non i dati.
  • L'indice esce da PGlite: store separato, in sola lettura, ricostruito offline con swap atomico. Come effetto collaterale, il problema di concorrenza svanisce da solo.
  • Il motore sostitutivo non l'ho scelto sulla reputazione: un secondo benchmark su embedding reali, dove sotto filtro il prefilter di LanceDB tiene la recall e il postfilter di altri motori la fa crollare.