ADR260629
2026/06/29 · Marco Orlandin · 14 minIndice vettoriale di un corpus normativo: fuori da PGlite, motore sostitutivo da misurare
Autore: Marco Orlandin, Architect
Data: 29 Giugno 2026 (aggiornato 30 Giugno 2026)
Status: Decisione in due tempi. Round-1: l'indice esce da PGlite, misurato. Round-2: recall del sostituto misurata su embedding reali. Senza filtro la recall non separa i candidati; sotto filtro per metadato sì, ed è l'asse che decide (LanceDB prefiltra, DuckDB postfiltra). Raccomandazione misurata: store LanceDB separato read-only; la scelta finale e il tuning restano a Magistra.
Settore: Legal-tech / RAG su corpus normativo / Open Source
Contesto: contributo a Magistra (Italian Builders), assistente legale desktop con citazioni verificabili
Scala in gioco: corpus ~110.000 atti vigenti / ~204.000 totali; MVP da 10^5 a mezzo milione di chunk; corpus pieno nell'ordine dei milioni
Constraint principali: app desktop locale (Electron), indice distribuito dentro il bundle, nessun servizio server-side, recall da non sacrificare
Contesto e problema
Magistra è un assistente legale open source che gira come app desktop locale: cita la normativa con riferimenti verificabili, quindi sotto il cofano ha un RAG su un corpus normativo italiano. L'architettura proposta teneva due cose nello stesso store, PGlite (Postgres compilato in WebAssembly, embedded nell'app): il database applicativo (utenti, conversazioni, messaggi) e l'indice vettoriale del corpus, con pgvector per la ricerca ANN. L'idea, sensata in apparenza, era poter distribuire l'indice già pronto dentro il bundle desktop e avere un solo motore da gestire.
La discussione aperta sul progetto verteva sulla concorrenza: API e worker che insistono sulla stessa istanza PGlite mentre l'indice viene aggiornato. La domanda che ho posto è arrivata un passo prima: quell'indice, dentro PGlite, ci sta davvero? Prima ancora di risolvere chi accede e quando, vale la pena chiedersi se il motore regge il volume. Se non regge, il problema di concorrenza cambia forma.
Requisiti non funzionali
- Tempo di costruzione dell'indice accettabile su un laptop, non su un server
- Query con filtro per metadato (vigenza, tipo di atto) a bassa latenza: nel dominio legale il filtro è la norma, non l'eccezione
- Scalabilità verso i milioni di chunk del corpus pieno
- Recall da non sacrificare: in ambito legale un richiamo mancato è un danno, non un fastidio
- Esecuzione embedded dentro Electron, senza componenti server-side
Cosa ho misurato
Ho scritto un benchmark riproducibile che mette PGlite con pgvector (indice HNSW) a confronto con un motore embedded nativo (LanceDB) tenuto come semplice controllo, per separare due variabili: è il volume dei dati a essere ingestibile, o è il motore dentro la sandbox WASM? I vettori sono sintetici, 1024 dimensioni, normalizzati. Misuro la scala (tempi di build, latenza, memoria, disco), non la qualità del recupero: con vettori finti la recall non si misura, ed è un punto su cui torno sotto.
Tempo di costruzione dell'indice, a parità di dati:
| N chunk | PGlite (HNSW) | controllo nativo |
|---|---|---|
| 10.000 | 16,6 s | 2,1 s |
| 100.000 | 282 s | 20 s |
| 250.000 | 1057 s (~17 min) | 23 s |
A 250.000 chunk, che è ancora sotto la scala di un MVP, PGlite impiega circa 17 minuti per costruire un indice che il controllo fa in 23 secondi, e la curva è grosso modo quadratica. Le query con filtro sui metadati stanno sui 431 ms contro i circa 7 ms del controllo, con il divario che si allarga al crescere di N. Il controllo regge pulito fino a 1 milione di vettori.
Da quello che ho visto il collo di bottiglia è il motore, non i bit: pgvector dentro la sandbox WASM è lento a costruire un indice ANN, e lo è uguale a 32 o a 64 bit. Esiste anche il tetto dei 4 GiB di memoria lineare di wasm32 (V8, proposta memory64) che a corpus pieno, milioni di chunk più il grafo HNSW, non farebbe nemmeno entrare l'indice nello spazio di indirizzamento. Ma quel tetto qui non l'ho toccato: le run completano, è un limite documentato che si vede arrivare, non la causa di ciò che ho misurato. La causa è il tempo di build che esplode molto prima.
Opzioni valutate
Opzione 1: l'indice resta in PGlite
- Pro: un solo motore, indice distribuito nel bundle, niente componenti aggiuntivi.
- Contro: i dati sopra. Build ingestibile già sotto la scala MVP, filtro per metadato lento, e il tetto wasm32 in agguato a corpus pieno. Scartata in base alla misura.
Opzione 2: indice in uno store separato, in sola lettura, ricostruito offline con swap atomico
- Pro: il motore dell'indice viene scelto per quello che deve fare (ANN su disco, prefilter nativo per metadato), e si scollega dal ciclo di vita del database applicativo. Il worker ricostruisce l'indice fuori linea e lo mette online con uno swap atomico; l'API legge sempre da una copia stabile.
- Contro: due store da gestire invece di uno, e una logica di swap da tenere pulita. Soprattutto: quale motore? Questo è il punto che resta aperto.
I candidati per l'Opzione 2 sono motori embedded nativi disk-based con prefilter per metadato. LanceDB (con indice IVF_HNSW a piena precisione, da non confondere con l'IVF_PQ lossy) è il più promettente; USearch (HNSW puro) è la riserva. Ma quale dei due, e con quale configurazione, è una domanda che i dati attuali non bastano a chiudere.
Decisione
La decisione si divide in due, e tenerle separate è il punto.
Deciso. L'indice del corpus normativo 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 applicativi, dove ha senso. Questa parte la considero chiusa: la misura è netta e il maintainer del progetto è arrivato alla stessa conclusione in modo indipendente.
Non deciso, deliberatamente. Quale motore sostituisce PGlite per l'indice. I dati attuali misurano la scala, non la recall, e la recall in ambito legale è il requisito che non posso barattare. Scegliere il motore adesso, sulla base della reputazione o del fatto che il controllo andava bene, sarebbe esattamente la scorciatoia che sto criticando in chi aveva scelto PGlite senza misurare. Un dato che squalifica (PGlite fuori) non autorizza la scelta successiva (un motore preciso dentro) senza la sua misura dedicata.
C'è anche una precisazione tecnica che pesa proprio qui: il controllo del benchmark usava IVF_PQ, che è quantizzato e lossy, mentre il candidato vero è IVF_HNSW a piena precisione. Sono indici diversi con curve di recall diverse. È un'altra ragione per cui la latenza e la build misurate non si possono usare per ratificare un motore: il motore va misurato sull'asse che qui manca.
Quell'asse l'ho poi misurato. La sezione Round-2 lo fa, e una delle cose che trova corregge proprio la frase qui sopra sul "candidato vero".
Conseguenze
- Il problema di concorrenza si scioglie quasi da sé. Con l'indice in uno store separato in sola lettura, il worker non tocca più PGlite per l'ingest: lo swap atomico è sull'indice, non sul database applicativo. PGlite resta con un solo writer per i dati applicativi, e lo scenario non supportato (due processi sullo stesso file) sparisce. La promessa di reattività durante gli aggiornamenti regge senza acrobazie.
- Il follow-up obbligato l'ho eseguito: un secondo benchmark, stessi assi di questo ma sui candidati giusti (LanceDB IVF_HNSW a piena precisione, la variante con scalar quantization, RaBitQ con rerank, contro USearch) e con una misura di recall vera su embedding reali. La catena era: modello di embedding, poi recall, poi motore. La sezione Round-2 qui sotto la percorre.
- Quel dato adesso c'è, quindi il motore si può nominare. Non come preferenza, ma come raccomandazione misurata, con i limiti dichiarati nella sezione. La decisione e il tuning restano di Magistra.
Round-2: la recall, misurata
Embedding reali di tutta la legislazione statale italiana (667.736 articoli, da italia-corpus) con snowflake-arctic-embed-m-v2.0, embedder open-weight scelto sull'evidenza di JuriFindIT per il retrieval normativo in italiano, non per reputazione. Open-weight è un requisito, non un gusto: un progetto OSS non può dipendere da API a pagamento per produrre gli embedding. Ground truth: KNN esatto a forza bruta su GPU. Sotto esame i candidati, tutti motori embedded nativi: LanceDB IVF_HNSW a piena precisione, la sua variante con scalar quantization, LanceDB con RaBitQ 1-bit più rerank, USearch (HNSW puro full-precision) come controllo a implementazione indipendente, e DuckDB-VSS, aggiunto perché proposto come candidato principale nella discussione su #13. Operating point fisso, uguale per tutti.
A 500k vettori, oltre la scala di un MVP (il corpus pieno a 667k ripete lo stesso quadro):
| motore | recall@10 | recall@100 | recall@10 filtr* | build | query p95 | filtr p95 | disco | peak RSS |
|---|---|---|---|---|---|---|---|---|
| LanceDB IVF_HNSW piena precisione | 0,938 | 0,890 | 0,48 | 54 s | 16 ms | 30 ms | 2980 MB | 5,4 GB |
| LanceDB IVF_HNSW + scalar quant | 0,940 | 0,894 | 0,51 | 14 s | 10 ms | 51 ms | 1878 MB | 5,5 GB |
| LanceDB IVF + RaBitQ + rerank | 0,947 | 0,909 | 0,24 | 10 s | 12 ms | 11 ms | 1519 MB | 5,2 GB |
| USearch HNSW full-precision | 0,948 | 0,929 | n/d | 113 s | 1,6 ms | n/d | 1536 MB | 3,1 GB |
| DuckDB-VSS (HNSW) | 0,941 | 0,924 | 0,05 | 146 s | 38 ms | 48 ms | 3863 MB | 8,3 GB |
*recall@10 sotto filtro per tipo atto, al nprobes/ef di base: il pavimento, non il tetto. Vedi sotto.
Quattro risultati, e il filtro li riordina tutti.
Senza filtro, la recall non separa i motori. A ogni scala ognuno sta a recall@10 di 0,94 più o meno 0,02. L'asse su cui pensavo si sarebbe decisa la scelta è piatto. Serviva a escludere un crollo, e il crollo non c'è. Già questo cambia la domanda: non "quale motore ha la recall migliore", ma "a parità di recall adeguata, cosa distingue i motori".
Senza filtro, RaBitQ pareggia la piena precisione. Più su, in questo stesso documento, ho scritto che il candidato vero era l'IVF_HNSW a piena precisione e RaBitQ un compromesso lossy. Misurato, su query non filtrate: RaBitQ pareggia il recall@10, batte il recall@100, costruisce a un terzo del tempo e occupa metà disco. La perdita dell'1-bit la recupera il rerank. Lascio scritta la frase di prima, l'errore e poi la correzione: una decisione architetturale onesta non riscrive la propria storia. Ma "senza filtro" è la chiave, e il punto dopo spiega perché.
Il dominio legale filtra, e il filtro decide. Un indice normativo si interroga quasi sempre con un filtro: vigenza, tipo atto. E sotto filtro la differenza fra i motori non è più un delta, è un muro. DuckDB, proposto come candidato principale, ha il core ANN costruito su usearch: lo si vede dalle query non filtrate, identiche a USearch. Ma il suo filtro SQL applica il WHERE dopo il top-k della HNSW, cioè postfiltra: la recall@10 sotto filtro crolla a 0,05 e non si recupera nemmeno alzando ef_search di 64 volte (a 8192 resta 0,07, e la latenza va a 100 ms). LanceDB invece prefiltra: la stessa recall sotto filtro risale alzando nprobes, fino a 0,99 a piena precisione (a costo di latenza) e fino a circa 0,80 con RaBitQ (restando economico). Prefilter recuperabile contro postfilter no: è questa, non un numero di recall, la differenza che decide. USearch, da parte sua, un prefilter nativo non ce l'ha affatto.
C'è una nota di merito per chi ha proposto DuckDB, perché il ragionamento era giusto: corpus grande, precostruito, versionato, interrogato soprattutto in lettura. È esattamente il profilo dello store separato e read-only di questo ADR. Solo, quello store può essere LanceDB, che precostruisci, versioni e interroghi in lettura con un prefilter che tiene, non DuckDB, il cui filtro sull'asse che conta non regge.
La raccomandazione misurata, quindi: indice in uno store LanceDB separato e read-only, con prefilter nativo per vigenza e tipo. La variante, e qui correggo di nuovo me stesso, non è "RaBitQ e basta": RaBitQ vince su build, disco e recall non filtrata, ma sul filtrato la piena precisione arriva più in alto (0,99 contro 0,80). Piena precisione se la recall sotto filtro è prioritaria, RaBitQ se il suo tetto intorno a 0,80 basta e conta l'efficienza. La mia parte è la misura e la lettura, non la decisione nel repo altrui: la scelta della variante e il tuning di nprobes restano di Magistra. E due cose restano aperte, da dichiarare invece che nascondere. Primo, qui le query sono articoli tenuti fuori dalla base (distribuzione simmetrica), non domande reali: il ranking fra motori e il "tutti adeguati senza filtro" reggono, i numeri assoluti su query-domanda possono spostarsi, ed è il lavoro che porta alla scelta dell'embedder. Secondo, i 7 GB di picco di RAM di LanceDB su un laptop sono un vincolo reale per un'app desktop, e va pesato sul parco macchine vero di Magistra.
Lezioni apprese
- Un Postgres in WebAssembly è ottimo per un piccolo database applicativo, ed è il posto sbagliato per un indice ANN a scala di corpus. La sandbox WASM paga sulla build dell'indice, e paga in modo che peggiora con il volume.
- Separare l'indice immutabile e read-heavy dal database read-write dell'app spesso risolve, come effetto collaterale, anche il problema di concorrenza che sembrava il problema principale. Vale la pena chiedersi se i due carichi debbano davvero convivere nello stesso motore.
- Una misura che squalifica non è una misura che decide. Aver dimostrato che PGlite non regge non mi autorizza a scegliere il sostituto senza misurarlo sull'asse che conta per quel sostituto, qui la recall. La disciplina sta nel non riempire il vuoto con la prima opzione plausibile.
- Il tetto dei 4 GiB di wasm32 è reale, ma in questo caso è una nota a margine: la build esplode molto prima di arrivarci. Conviene distinguere il limite strutturale che vedi arrivare dal limite operativo che ti ferma davvero.
Stack e metodo
- Benchmark: Node.js, esecuzione in processi figli isolati, pulizia delle directory dati tra una run e l'altra.
- Sotto esame: PGlite (
@electric-sql/pglite) con l'estensione pgvector, indice HNSW. - Controllo: LanceDB, motore embedded nativo disk-based, indice IVF_PQ (con il caveat sopra sul perché serve poi misurare IVF_HNSW).
- Dati: vettori sintetici 1024-dim normalizzati L2, generati con PRNG deterministico; misura di scala, non di recall.
- Riproducibilità: codice e metodo pubblici, benchmarks/indice-normativo-scala.
Fonti
- JuriFindIT: Dalla Noce et al., Statutory Article Retrieval for Italian Law, Findings of EACL 2026. Primo dataset di retrieval di articoli normativi per il diritto italiano. Citato come evidenza per la scelta dell'embedder (arctic-m) usato in Round-2; il dataset non è ancora rilasciato pubblicamente, quindi se ne segue il metodo, non i dati.
- Benchmark Round-2 (recall): codice e metodo pubblici, benchmarks/indice-normativo-recall. Embedding reali arctic-m del corpus normativo, KNN esatto come ground truth, LanceDB (piena precisione, scalar quant, RaBitQ) contro USearch.
Quando vale questo ragionamento
Se stai valutando dove tenere un indice vettoriale embedded a scala reale (decine o centinaia di migliaia di chunk e oltre), e la tentazione è metterlo nello stesso motore del database applicativo "per semplicità", misura la build e la latenza filtrata prima di decidere. Spesso il motore comodo per i dati applicativi è il motore sbagliato per l'indice.
E se una misura ti squalifica un'opzione, resisti alla voglia di chiudere subito con la prima alternativa. Una decisione architetturale onesta tiene separato ciò che hai dimostrato da ciò che devi ancora misurare.
Hai un caso simile (RAG su corpus grande, indice vettoriale embedded, scelta del motore di ricerca)?
Contattami. Valutiamo insieme dove conviene tenere l'indice e cosa va misurato prima di sceglierlo.