Vai al contenuto

Indicizzare 50M articoli con Typesense: il piano, l’errore, il fix

Indicizzare 50M articoli con Typesense: il piano, l’errore, il fix

Indicizzare 50M articoli con Typesense: il piano, l’errore, il fix

Aprile 2024, una notte di mercoledì sul primo nodo di search. Stavamo terminando l’import bulk di 50 milioni di articoli editoriali sul cluster Typesense di AI Multisite e la latenza p99 sulla query principale era schizzata oltre i 380ms, con la RAM del nodo coordinatore a 30,2 GB su 32. Indicizzare 50M articoli con Typesense in modo serio non è un esercizio da tutorial: a quel volume i dettagli di schema che a 1M sono invisibili, in produzione ti fanno saturare un nodo prima che il batch sia finito. Negli altri post di Production racconto le scelte di stack e infrastruttura di AI Multisite.

Indicizzare 50M articoli con Typesense: il contesto reale

AI Multisite gestisce oggi 40+ testate editoriali sotto un unico hub. Ogni testata ha la sua redazione, il suo dominio, le sue regole di pubblicazione, ma tutti gli articoli convivono nello stesso cluster di ricerca per due funzioni interne: la dashboard centrale (cercare un pezzo cross-testata in meno di 100ms) e la pipeline di stilometria che, dato un autore, deve recuperare in tempo reale il suo corpus storico per costruire il prompt di generazione.

A inizio 2024 il volume cumulato degli archivi storici dei partner editoriali stava per superare i 50 milioni di articoli, e indicizzare 50M articoli con Typesense in modo prevedibile è diventato un problema di capacity planning prima che di engineering. Il numero non spaventa, sulla carta: Typesense gira tranquillamente con dataset di quel calibro se lo schema è disegnato bene. Spaventa quando il dataset è eterogeneo, scritto da decine di redazioni diverse, con pezzi che vanno da 800 a 14.000 caratteri, metadata sparsi e categorie tassonomiche disallineate fra testate.

Perché Typesense e non MeiliSearch per il workload editoriale

La scelta dell’engine l’avevamo presa qualche mese prima, quando il dataset era ancora a 8 milioni di documenti. Avevamo confrontato Typesense con MeiliSearch, il candidato naturale del nostro stack (Laravel Scout ha driver maturo per entrambi). Tre criteri tecnici hanno spostato la decisione su Typesense:

  1. Curated results e overrides. Typesense permette di forzare il ranking di documenti specifici per query specifiche con regole dichiarative. In editoria serve: una testata vuole vedere in cima un articolo redazionale piuttosto che il match testuale migliore.
  2. Faceting performante con cardinalità alta. Le testate volevano filtrare per autore, categoria, data, tag. La cardinalità sul campo author su 50M documenti era ~120K valori distinti. Sui benchmark interni Typesense reggeva il facet count in <40ms anche a quella cardinalità, MeiliSearch (versione di allora) andava oltre i 200ms in più scenari.
  3. API REST pulita e self-hosted senza tier proprietari. Niente lock-in cloud, niente feature gate sulle versioni. Questo punto, a posteriori, è stato decisivo per il debugging del problema di RAM raccontato sotto: avevamo accesso completo a metriche, log e behavior interno.

Nessuno dei due è “migliore” in assoluto. Per il workflow editoriale multi-testata e per indicizzare 50M articoli con Typesense in modo sostenibile, l’engine ha vinto sui dettagli giusti.

Il piano iniziale per indicizzare 50M articoli con Typesense (e dove era sbagliato)

Lo schema della collection articles v1 era questo, tradotto in formato Typesense:

{
  "name": "articles",
  "fields": [
    {"name": "title",        "type": "string", "facet": false},
    {"name": "body",         "type": "string", "facet": false},
    {"name": "excerpt",      "type": "string", "facet": false},
    {"name": "author",       "type": "string", "facet": true},
    {"name": "publication",  "type": "string", "facet": true},
    {"name": "category",     "type": "string", "facet": true},
    {"name": "tags",         "type": "string[]", "facet": true},
    {"name": "language",     "type": "string", "facet": true},
    {"name": "country",      "type": "string", "facet": true},
    {"name": "status",       "type": "string", "facet": true},
    {"name": "published_at", "type": "int64", "facet": false},
    {"name": "updated_at",   "type": "int64", "facet": false},
    {"name": "word_count",   "type": "int32", "facet": false},
    {"name": "seo_title",    "type": "string", "facet": false},
    {"name": "seo_desc",     "type": "string", "facet": false},
    {"name": "url_slug",     "type": "string", "facet": false},
    {"name": "thumbnail_url","type": "string", "facet": false},
    {"name": "stylo_vector", "type": "float[]", "num_dim": 384}
  ],
  "default_sorting_field": "published_at"
}

Sul tavolino sembrava sano. Ogni campo aveva una motivazione: il vector da 384 dimensioni era usato dalla pipeline di stilometria per la similarity search per autore, le decine di campi descrittivi venivano già popolati dalla sync con WordPress, tanto valeva metterli tutti (errore numero uno).

Il primo bulk import partiva con IMPORT_BATCH_SIZE=8000 (8.000 documenti per request, NDJSON). Stimavamo 6 ore di indicizzazione completa su un singolo nodo da 32 GB di RAM, con 8 vCPU.

L’errore in produzione: come si è rotto l’import di 50M articoli su Typesense

Alle 4:12 del mattino, dopo circa 38 milioni di documenti indicizzati su 50, l’oom-killer di Linux ha terminato il processo typesense-server. Restart automatico, ripresa dal checkpoint, di nuovo OOM dopo 90 minuti. Tre cicli prima che fermassi il job e mi mettessi a leggere veramente le metriche (import iniziale in ambiente di staging, nessun impatto sul traffico cliente).

Tre cause concorrenti, in ordine di peso:

Prima causa: campi indicizzati per default come searchable. I campi seo_title, seo_desc, url_slug, thumbnail_url erano dichiarati string senza specifica. In Typesense, fino alla v0.25, qualunque campo string finisce in indice testuale completo. Su 50M documenti, indicizzare quattro campi accessori che nessuno cerca significa moltiplicare le posting list per quattro, senza alcun beneficio applicativo. Stavo pagando RAM per dati che la dashboard non interrogava mai.

Seconda causa: dimensione del vector. I vettori stylo_vector da 384 dimensioni in float32 pesano 1,5 KB per documento solo per il campo. A 50M documenti: ~75 GB di soli embedding nel solo storage e una porzione significativa in RAM per servire la similarity. La pipeline di stilometria, però, non interrogava tutti i 50M articoli: serviva solo l’embedding per autore, su un sottoinsieme attivo (ultimi 18 mesi, ~12M documenti). Stavo embeddando archivi storici inutilmente.

Terza causa: batch size sbagliato per il nodo. Con 8.000 documenti per request e una media di 6 KB per articolo, ogni batch caricava ~48 MB di payload più la struttura intermedia di parsing. Il picco di RAM transitoria sul singolo batch superava il margine residuo del nodo già pieno di indici, e l’OOM scattava lì.

Il fix: schema dimagrante, vector sharding, batch tuning

La riprogettazione l’abbiamo fatta in tre giorni con il senior dev, partendo dalla query reale della dashboard e della pipeline, non dallo schema percepito.

Schema v2. Ridotti i campi searchable a title, body, excerpt. Tutti gli altri campi diventati metadata (index: false) o solo facet. Impatto stimato: -40% sull’indice testuale.

{
  "name": "articles",
  "fields": [
    {"name": "title",        "type": "string"},
    {"name": "body",         "type": "string"},
    {"name": "excerpt",      "type": "string", "optional": true},
    {"name": "author",       "type": "string", "facet": true},
    {"name": "publication",  "type": "string", "facet": true},
    {"name": "category",     "type": "string", "facet": true, "optional": true},
    {"name": "tags",         "type": "string[]", "facet": true, "optional": true},
    {"name": "language",     "type": "string", "facet": true},
    {"name": "country",      "type": "string", "facet": true},
    {"name": "published_at", "type": "int64"},
    {"name": "word_count",   "type": "int32", "optional": true},
    {"name": "url_slug",     "type": "string", "index": false, "optional": true},
    {"name": "thumbnail_url","type": "string", "index": false, "optional": true},
    {"name": "seo_title",    "type": "string", "index": false, "optional": true},
    {"name": "seo_desc",     "type": "string", "index": false, "optional": true}
  ],
  "default_sorting_field": "published_at"
}

Vector separato. Il stylo_vector è uscito dalla collection principale. Abbiamo creato una collection articles_stylo distinta, popolata solo per gli articoli degli ultimi 18 mesi. La similarity per autore gira lì, in un dataset che oscilla intorno ai 12M documenti, con consumo RAM lineare e prevedibile. La sync verso articles_stylo è un job Horizon dedicato, idempotente, che si autoripara su retry.

Batch tuning. Abbiamo abbassato IMPORT_BATCH_SIZE a 2.000 documenti, alzato il numero di worker concorrenti a 3 e aggiunto pre-flight check sulla RAM disponibile (se il nodo è oltre il 75%, il job dorme 30 secondi prima di riprendere). Il throughput totale di indicizzazione è sceso del 18% rispetto al piano iniziale, ma è diventato sostenibile per ore senza OOM.

Cluster su due nodi con replica. Risolto lo schema, abbiamo preso la decisione architetturale di passare da nodo singolo a cluster Typesense a 2 nodi (primary più replica), montati su VM Debian con dataset ZFS dedicato. La replica non era necessaria per le performance, era necessaria per il deploy senza downtime: durante una re-indicizzazione futura potevamo togliere un nodo dalla rotazione senza interrompere il servizio.

Sopra a tutto, una nuova metrica di osservabilità: il job di import emette su Prometheus la dimensione media del batch in KB e la RAM percentuale del nodo target prima e dopo ogni batch. Se vediamo un picco anomalo, il job rallenta da solo, prima che lo faccia l’oom-killer.

I numeri del fix dopo aver re-indicizzato 50M articoli con Typesense

Prima del fix, schema v1, nodo singolo 32 GB:

  • p99 query principale: 380ms
  • RAM nodo: 30,2 GB su 32 (saturazione costante)
  • Tempo medio per batch da 8.000 doc: 11,2s
  • OOM frequenti durante l’import iniziale

Dopo il fix, schema v2, cluster a 2 nodi 32 GB ciascuno:

  • p99 query principale: 38ms
  • RAM nodo (steady state): 19,4 GB su 32
  • Tempo medio per batch da 2.000 doc: 2,1s
  • Zero OOM in 14 mesi successivi

Dieci volte più veloci sul p99, RAM con margine reale per la crescita prevista nei 18 mesi successivi, una collection di vettori indipendente che possiamo riallenare senza toccare la collection di testo.

Cosa mi sono portato a casa dall’indicizzare 50M articoli con Typesense

Tre cose, in ordine di impatto operativo.

Prima. A 50M documenti, lo schema è codice critico, non configurazione. Ogni campo string indicizzato che non serve a una query reale è una tassa permanente sulla RAM. Il principio è lo stesso degli indici SQL: si aggiungono solo per query reali, non per opzioni future.

Seconda. Embedding vettoriali su archivi storici eterogenei vanno isolati in collection dedicate, mai mescolati con la collection di testo. Costo, ciclo di re-training e dimensione del dataset rilevante non coincidono quasi mai.

Terza. Il batch size di import non è un parametro statico. Va dimensionato sul payload medio per documento e sulla RAM residua del nodo, e va misurato in ambiente di staging che riproduce la cardinalità reale, non la struttura. Su dataset eterogenei, la varianza del payload medio fra documento corto e documento lungo può essere 1:18.

Stavo per dichiarare l’import un successo quando il primo OOM ha tolto il nodo dalla rotazione. Senza quella nottata in piedi, oggi avremmo uno schema grasso che continuerebbe a costare RAM ogni mese e ci troveremmo a dover scalare verticalmente per un problema che era di disegno, non di carico. Le scelte di Typesense sono ancora le stesse del 2024, quello che è cambiato è il rispetto per lo schema come prima superficie di performance. Indicizzare 50M articoli con Typesense, dopo questa esperienza, è un esercizio che misuriamo prima sul payload medio e sulla cardinalità reale, e solo dopo sul numero totale di documenti.