Collection in Qdrant

Quando ho iniziato a costruire l’assistente dietro a AI RICORSI FORENSI, sono incappato in un problema che all’inizio sembra banale e che invece decide tutto.

L’assistente deve rispondere a quesiti sul Codice FORENSE. Per farlo ha bisogno di due cose molto diverse tra loro: da una parte la legge, cioè norme, manuali, dottrina, giurisprudenza — materiale uguale per tutti; dall’altra il fascicolo del cliente, cioè i documenti caricati per quella specifica pratica: estratti conto, relazioni del gestore, visure, certificati anagrafici.

La prima è conoscenza generale. La seconda è conoscenza privata, e soprattutto rigorosamente separata: i documenti di un cliente non devono mai finire nelle risposte di un altro.

La tentazione iniziale è mettere tutto in un unico contenitore e filtrare a runtime. Ci ho provato mentalmente, e poi ho capito perché era la scelta sbagliata. In questo articolo racconto come ho risolto usando due collection in Qdrant contemporaneamente, perché questa separazione è diventata la spina dorsale dell’intera applicazione, e come la si può replicare.

Il problema: due tipi di conoscenza che non si mescolano

Un sistema RAG classico recupera frammenti da una collection e li passa al modello. Funziona benissimo quando tutta la conoscenza è omogenea: un manuale, una documentazione tecnica, un sito.

Nel mio caso la conoscenza è di due nature diverse:

Collection generale Collection di pratica
Contenuto Leggi, manuali, dottrina Documenti del cliente
Visibilità Condivisa tra tutti gli utenti Privata, isolata per pratica
Origine Curata, caricata a regime Caricata dagli utenti, al volo
Ciclo di vita Persistente Legata alla pratica, effimera
Come la cerco Senza filtri Con un filtro practice_id

Una collection sola avrebbe costretto a filtrare sempre su practice_id anche per la legge, oppure a rischiare che un filtro mancante facesse vedere a un cliente i documenti di un altro. Era troppo fragile.

La scelta architetturale è stata netta: due collection, due ruoli, una sola istanza Qdrant.

Le due collection del progetto

La prima collection è la knowledge base generale. Contiene il materiale legale e normativo: il Codice della Crisi, i manuali di dominio, la giurisprudenza. È la stessa per tutti gli utenti e viene interrogata senza filtri. Quando l’utente fa una domanda, è qui che l’assistente va a recuperare “cosa dice la legge”.

La seconda collection è quella dei documenti di pratica. Si chiama practice_documents nel codice e ogni frammento che la popola porta con sé, nel proprio payload, un campo practice_id. Quando l’assistente deve ragionare su una pratica specifica, interroga questa collection con un filtro hard su practice_id, così recupera solo i documenti della pratica corretta.

La distinzione, scritta nel README del progetto, suona così:

una risposta può usare norme e fonti generali ma, quando ragiona su una pratica, deve recuperare solo i documenti della pratica corretta.

È una frase semplice, ma è il contratto su cui poggia tutto.

Perché due collection e non una con un filtro

A prima vista, una collection unica con un filtro su practice_id sembrerebbe più semplice. In pratica è più pericolosa, per tre motivi.

Primo: il confine è implicito, non fisico. Con una collection sola, la separazione tra “legge pubblica” e “fascicolo privato” esiste solo come logica applicativa. Un bug nel codice — un filtro dimenticato, una query scritta male — e il confine sparisce. Con due collection, il confine è una proprietà della struttura: per sbagliare devi proprio interrogare la collection sbagliata.

Secondo: il ciclo di vita è diverso. La knowledge base generale va curata, indicizzata a regime, aggiornata raramente. I documenti di pratica nascono e muoiono con la pratica: un cliente carica i suoi estratti conto, l’assistente li indicizza, e quando la pratica si chiude quei dati vanno via. Tenere insieme due cicli di vita opposti in una collection sola complica ogni operazione di manutenzione.

Terzo: le pipeline di ingestione sono diverse. I manuali entrano tramite un caricamento controllato; i documenti di pratica entrano tramite un servizio che fa chunking, embedding e upsert al volo. Separare le collection significa poter ottimizzare ciascuna pipeline senza compromettere l’altra.

In sintesi: una collection sola risparmia una riga di configurazione e costa una classe intera di bug. Due collection rendono l’isolamento una proprietà dell’architettura, non un auspicabile comportamento del codice.

Come funziona tecnicamente: il filtro su practice_id

La differenza tra le due ricerche, nel codice, è minima e netta. Entrambe usano lo stesso client Qdrant e gli stessi metodi di ricerca — dense, sparse, ibrida. Cambia solo un argomento: practiceId.

La ricerca sulla collection generale non ha filtro:

const results = await qdrant.searchSimilarDocuments(
  activeCollection,    // es. "manuali_v4"
  query,
  embedding,
  queryMode             // "dense" | "sparse" | "hybrid"
)

La ricerca sulla collection di pratica aggiunge il filtro:

const results = await qdrant.searchPracticeDocuments(
  "practice_documents",
  practiceId,           // es. "prac_123"
  query,
  embedding,
  queryMode
)

Sotto il cofano, quel practiceId si traduce in un filtro Qdrant molto esplicito:

const results = await this._client.search(collection, {
  filter: typeof practiceId === "string"
    ? {
        must: [
          { key: "practice_id", match: { value: practiceId } }
        ]
      }
    : {},
  limit,
  vector: { name: "dense", vector: embedding },
  with_payload: true
})

Il filtro must con match è una condizione hard: un punto che non ha quel practice_id esatto non viene mai restituito. Non è un suggerimento, non è un re-rank: è un vincolo del motore. È esattamente il grado di garanzia che serve quando si maneggiano documenti privati.

Come i documenti di pratica finiscono nella collection

La collection practice_documents si popola tramite un servizio di ingestione. Quando un documento viene caricato per una pratica, il testo viene normalizzato, diviso in chunk di dimensione controllata (nel progetto, 1.800 caratteri), e ogni chunk riceve un embedding.

L’upsert inserisce ogni chunk con un payload che porta con sé tutta la provenance:

await this._client.upsert(collection, {
  points: chunks.map((chunk) => ({
    id: crypto.randomUUID(),
    payload: {
      chunk_index: chunk.chunkIndex,
      document_id: chunk.documentId,
      fonte: chunk.fileName,        // nome del file originale
      practice_id: chunk.practiceId, // isolamento
      sezione: chunk.category,      // es. "Relazione OCC"
      testo: chunk.text
    },
    vector: { dense: chunk.embedding }
  })),
  wait: true
})

Notate il campo practice_id nel payload: è quello che il filtro andrà a matchare in fase di ricerca. Senza quel campo, il chunk sarebbe irrecuperabile dalla ricerca filtrata — e questo, paradossalmente, è un bene: un chunk senza practice_id è un chunk che non può “scappare” verso la pratica sbagliata.

Usare le due collection contemporaneamente

Qui arriviamo al punto che rende l’architettura interessante. Le due collection non sono alternative: sono complementari, e l’assistente le usa insieme.

Quando l’utente fa una domanda su una sua pratica, l’assistente ha bisogno di entrambe:

  • dalla collection generale, recupera cosa dice la legge (norme, articoli, giurisprudenza sul punto);
  • dalla collection di pratica, recupera cosa c’è in questo fascicolo (i fatti specifici, gli importi, i documenti caricati).

La prima dice il quadro, la seconda dice i fatti. La risposta buona nasce solo dall’incrocio.

Dal punto di vista del codice, le due ricerche sono indipendenti e possono girare in parallelo:

const [generalSources, practiceSources] = await Promise.all([
  qdrant.searchSimilarDocuments(
    activeCollection, query, embedding, queryMode
  ),
  qdrant.searchPracticeDocuments(
    "practice_documents", practiceId, query, embedding, queryMode
  )
])

I due risultati vengono poi fusi e — questo è il passaggio che chiude il cerchio — rerankati insieme. Il motore di reranking (di cui ho scritto a parte) non sa, e non deve sapere, da quale collection viene ogni fonte: valuta solo la pertinenza rispetto alla domanda. Le fonti normative e i documenti di pratica competono sullo stesso piano, e vincono le più utili.

In pratica il contesto che arriva al modello è un mix ordinato:

  • qualche frammento dalla legge, per ancorare la risposta al quadro normativo;
  • qualche frammento dal fascicolo, per ancorarla ai fatti concreti di quel cliente;
  • tutto il resto scartato dal relevance filter.

Il risultato è una risposta che cita la norma giusta e i dati reali del caso, senza mai mescolare i fascicoli di clienti diversi.

Un esempio concreto che rende l’idea

Proviamo con un caso reale, depurato dei nomi. L’utente chiede all’assistente: “il mio reddito mensile è sufficiente per la procedura di esdebitazione?“

L’assistente riformula la query e lancia due ricerche in parallelo.

Sulla collection generale recupera frammenti che spiegano i requisiti dell’esdebitazione: il ruolo delle “utilità rilevanti”, il parametro ISTAT per il fabbisogno, la giurisprudenza sull’incapienza. Questi frammenti sono uguali per qualunque utente.

Sulla collection di pratica, con practice_id = "prac_123", recupera solo i documenti di quel cliente: la relazione del gestore con il reddito dichiarato, l’elenco delle spese mensili, le dichiarazioni dei redditi degli ultimi anni. Frammenti che nessun altro utente potrebbe mai vedere.

Il reranking mette insieme le due liste, punteggia ogni fonte per pertinenza e ne seleziona le migliori. Il modello riceve un contesto in cui la norma e il fascicolo coesistono, e può rispondere: “secondo l’art. 283 e la giurisprudenza recuperata, il requisito è l’incapienza; dalle risultanze della sua relazione OCC, il reddito netto mensile è X e il fabbisogno è Y, quindi…”.

Senza la collection generale, la risposta non conoscebbe la legge. Senza la collection di pratica, non conoscerebbe i fatti. Senza l’isolamento sul practice_id, i fatti sarebbero quelli di qualcun altro. Le tre cose reggono insieme o non reggono affatto.

I vantaggi, riassunti

Riprendendo i fili, i vantaggi di questa architettura a due collection sono concreti:

Isolamento per costruzione. I documenti privati vivono in una collection che si interroga solo con un filtro hard. Non c’è modo di recuperarli senza dichiarare la pratica.

Chiarezza di ruoli. Una collection è la legge, l’altra è il fascicolo. Il codice stesso diventa più leggibile, perché ogni funzione sa quale dei due mondi sta toccando.

Indipendenza operativa. Aggiornare i manuali non tocca i documenti di pratica; cancellare una pratica non tocca la knowledge base. Manutenzione e lifecycle restano puliti.

Recupero parallelo. Le due ricerche girano in simultanea, quindi il costo aggiuntivo di una seconda collection non è una seconda latenza: è quasi zero.

Reranking unificato. Le due fonti competono sullo stesso piano di pertinenza, e il modello riceve il meglio di entrambi i mondi senza rumore.

Scalabilità dei tenant. In un setup multi-tenant, ogni pratica è isolata dal suo practice_id, e la collection generale resta un bene comune. Aggiungere clienti non richiede nuove collection, solo nuovi practice_id.

Quando conviene davvero

Questa architettura non è per ogni progetto. È la scelta giusta quando si verificano due condizioni insieme.

La prima: esiste una conoscenza di dominio stabile e condivisa, che vale per tutti gli utenti. Se tutto il tuo materiale è privato e per-user, una collection sola con filtro basta e avanza.

La seconda: esiste una conoscenza privata per utente o per pratica, che non deve mai mescolarsi. Se l’isolamento è solo “desiderabile” e non “vincolante”, due collection sono overengineering. Ma se l’isolamento è un requisito — come nel legale, nel sanitario, nel finanziario — allora la separazione fisica smette di essere un lusso e diventa una garanzia.

Nel mio caso le due condizioni c’erano entrambe, e con forza. La legge è la stessa per tutti; i fascicoli sono strettamente personali. Due collection non erano una scelta estetica: erano l’unica struttura che rendesse l’isolamento una proprietà del sistema invece che una promessa del codice.

Conclusione

Avere due collection in Qdrant non è un’ottimizzazione. È un modo di pensare la conoscenza.

Una collection dice cosa è vero in generale. L’altra dice cosa è vero per questo cliente. La prima è aperta e curata, la seconda è privata ed effimera. Tenere le due separate, nella struttura, significa che il sistema non deve ricordarsi di separarle a ogni query: è la geometria stessa del dato a garantire la separazione.

La parte più importante non è il codice in sé. È il cambio di prospettiva: quando la privacy e la pertinenza diventano proprietà dell’architettura, e non responsabilità di ogni singola funzione, l’assistente smette di essere “un motore che recupera testo” e diventa “un motore che recupera il testo giusto, per la persona giusta, dalle due sorgenti giuste”.

Ed è proprio qui che due collection in Qdrant mostrano il loro valore: non nel recuperare di più, ma nel riuscire a tenere insieme la legge di tutti e il fascicolo di ognuno senza che l’uno contaminì mai l’altra.

Fonti


AI Disclaimer

Questo articolo è stato sviluppato a partire dai miei appunti di lavoro e dall’architettura effettiva del mio progetto, con l’ausilio dell’intelligenza artificiale per riorganizzare i contenuti, rendere più chiara la struttura e trasformare le note iniziali in una guida più leggibile e coerente per tutti.

Di R. Andrea Belvedere

Mi occupo di AI applicata, automazione, LLM, RAG e blockchain, con esperienza in contesti enterprise e industriali. Sono formatore tecnico, scrittore SEO e divulgatore scientifico: mi piace spiegare tecnologie complesse in modo semplice, pratico e orientato all’uso reale. Tutti gli Articoli sono elaborati dal sottoscritto sulla base di un progetto sviluppato direttamente dall’autore. Strumenti di intelligenza artificiale sono stati utilizzati come supporto all’analisi della documentazione e alla redazione. Il contenuto è stato verificato e revisionato dall’autore, che ne assume la responsabilità editoriale.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

error: Content is protected !!