Doc Analyzer si Reinventa: Addio Gradio e ChromaDB, Benvenuti Qdrant e Streaming

Ogni tanto arriva il momento in cui un progetto accumula abbastanza debito tecnico da renderti necessaria una scelta: lo rattoppi o lo rifai? Con Doc Analyzer ho scelto la seconda opzione. Anzi, l’ho scelta quattro volte di fila, una versione maggiore dopo l’altra.
Se non l’hai mai incontrato prima: Doc Analyzer è il mio progetto open source per analizzare documenti usando un’IA completamente locale — niente cloud, niente API key, niente dati che girano su server di qualcun altro. Se vuoi il contesto completo, puoi recuperare i post precedenti della serie. Qui parliamo di quello che è successo dalle versioni 0.2.0 alla 0.5.0, che rappresentano praticamente una riscrittura totale.
Prendo un respiro e parto dall’inizio.
Addio LangChain e ChromaDB (v0.2.0)
La prima cosa che ho buttato fuori è stata la coppia LangChain + ChromaDB. Non perché fossero cattive scelte all’epoca — LangChain era il framework RAG di riferimento e ChromaDB funzionava — ma entrambe avevano un problema comune: troppa magia sotto il cofano.
LangChain in particolare è diventato nel tempo uno di quei framework dove per fare una cosa semplice devi attraversare cinque livelli di astrazione, capire quale versione dell’API stai usando e pregare che l’aggiornamento di ieri non abbia rotto qualcosa. Ho deciso di fare a meno e implementare direttamente quello che mi serviva: un Document dataclass personalizzato e un RecursiveCharacterTextSplitter scritto da me. Niente di complicato, ma almeno so esattamente cosa fa.
Al posto di ChromaDB è arrivato Qdrant, che usa cosine distance e vettori a 1024 dimensioni. Il motivo della migrazione è semplice: Qdrant ha un’API più pulita, una gestione della persistenza più prevedibile e — dettaglio non banale — un client Python che si comporta come ci si aspetta.
L’altra novità importante di questa versione è la separazione dei modelli: ora c’è un EMBEDDING_MODEL dedicato (mxbai-embed-large), separato dal modello LLM usato per generare le risposte. Prima era tutto sullo stesso modello, il che funzionava ma era un po’ come usare un martello sia per piantare chiodi che per sbucciare le mele.
Addio Gradio (v0.3.0)

Questa è la modifica che mi ha dato più soddisfazione personale. Gradio andava bene per i prototipi veloci, ma aveva una serie di comportamenti che mi facevano venire il nervoso:
- Le SSE (Server-Sent Events) per lo streaming avevano drop casuali
BodyStreamBuffer abortedcompariva nei log senza un motivo chiaro- La pagina si resettava durante l’inference
- Ogni aggiornamento di Gradio era una roulette russa sulla retrocompatibilità
Ho riscritto l’interfaccia in vanilla HTML/JS, servita da FastAPI come FileResponse su templates/index.html. Zero dipendenze frontend. Zero npm. Zero CDN. Funziona.
Il layout è sidebar + chat con dark theme, completamente responsive. La textarea si ridimensiona automaticamente, si invia con Enter (Shift+Enter per andare a capo come un essere umano), e il bottone di invio si disabilita automaticamente quando non ci sono documenti caricati — piccolo dettaglio di UX che fa una differenza enorme nella pratica.
La cosa che mi ha convinto definitivamente a fare questo passo è stato lo streaming delle risposte LLM. Con Gradio era una lotta continua. Ora c’è un endpoint dedicato POST /api/query/stream con SSE veri, i token arrivano progressivamente con un cursore lampeggiante mentre il modello genera, e il Markdown viene renderizzato in tempo reale durante lo streaming. Vedere i blocchi di codice formarsi token per token mentre il modello risponde è uno di quei momenti in cui ti ricordi perché fai questo lavoro.
Sempre in questa versione ho aggiunto il fallback OCR per i PDF vettoriali o scansionati: se PyMuPDF non riesce a estrarre testo da una pagina, il sistema la renderizza a 300 DPI e ci passa sopra pytesseract. Automatico, trasparente, silenzioso.
Multi-Collection con Toggle Memory/Persist (v0.4.0)
Questa versione ha introdotto una funzionalità che molti mi avevano chiesto: la gestione di collection separate con un toggle Memory/Persist direttamente dall’interfaccia, senza toccare il file .env.
L’idea di base è che i documenti possono essere organizzati in collection tematiche — per progetto, per tipo, per cliente, per quello che vuoi. Puoi creare e cancellare collection dalla sidebar, scegliere quale collection usare come target per l’upload, e selezionare tramite checkbox quali collection includere nella query.
Quando interroghi su più collection, i risultati vengono uniti e riordinati per score di cosine similarity, mantenendo i migliori 4 globali. Il modello non sa che stai interrogando su più collection: vede solo il contesto più rilevante, indipendentemente da dove viene.
Il toggle Memory/Persist merita due parole:
Memory mode (default): Qdrant gira tutto in memoria, esattamente come prima. Veloce, zero configurazione, dati persi al restart. Perfetto per sessioni di analisi veloci.
Persist mode: Qdrant salva su disco, i dati sopravvivono ai restart del container. Al passaggio da Memory a Persist, la files_map viene ricostruita automaticamente dai payload di Qdrant — niente da fare manualmente.
Cambiare modalità dal bottone nell’header è immediato. Nessun restart, nessuna modifica alla configurazione.
Refactoring Architetturale e Sistema Prompt (v0.5.0)
L’ultima versione è quella di cui sono più soddisfatto dal punto di vista tecnico, anche se dall’esterno si vede meno. Il tema principale è: eliminare lo stato globale e separare le responsabilità.
Il Service Layer
Ho estratto un DocumentService (src/services/document_service.py) che centralizza tutto lo stato e la business logic legata ai documenti. Prima, app.py era diventato un blob con variabili globali mutabili, keyword global sparsi in giro e helper function libere che giravano nell’aria senza una casa.
Ora le route FastAPI sono thin HTTP adapter che delegano al service. Non sanno niente di Qdrant, non gestiscono file, non tengono stato. Ricevono una richiesta, chiamano il service, restituiscono una risposta. Come dovrebbe essere.
Dependency Injection nel RAGProcessor
Il RAGProcessor accettava prima le configurazioni via variabili d’ambiente al momento della costruzione. Questo rendeva i test un incubo: dovevi fare monkeypatch delle env var, patch dei client, sperare che l’ordine degli import non rompesse tutto.
Ora il costruttore accetta ollama_client e qdrant_client iniettati direttamente. Per la produzione c’è il classmethod RAGProcessor.from_env() che fa quello che faceva prima. Per i test, passi dei MagicMock() e vai. 92 test, zero global state, zero patch di client.
La ProcessorFactory Riscritta
La factory originale era una catena di if/elif che violava l’Open/Closed Principle nel modo più classico possibile: per aggiungere un formato, dovevi modificare la factory.
Ora c’è un _PROCESSOR_MAP: dict[str, type[DocumentProcessor]]. Aggiungere il supporto per un nuovo formato è letteralmente una riga:
_PROCESSOR_MAP = {
".pdf": PdfProcessor,
".docx": WordProcessor,
".txt": TextProcessor,
# aggiungere un formato = aggiungere una riga qui
}
Il Sistema Prompt con Auto-Discovery
L’ultima novità riguarda i ruoli di analisi. Prima erano definiti come dizionario hardcoded nel codice Python. Aggiungere un ruolo significava modificare il codice, fare un commit, riavviare il server.
Ora i ruoli vivono come file .md in src/prompts/. Il PromptRegistry fa auto-discovery via glob("*.md"), parsando il primo # Heading come nome display e il filename stem come chiave API. Aggiungere un ruolo significa creare un file con questo formato:
# Il Mio Ruolo Personalizzato
Sei un esperto di [dominio]. Quando analizzi i documenti,
concentrati su [aspetti specifici] e rispondi sempre in [tono].
E premere il pulsante ↻ nell’header per ricaricare i prompt a caldo, senza restart del server. Nessuna modifica al codice.
Come Aggiornare
Se stai usando una versione precedente, l’aggiornamento è un pull + rebuild:
git pull origin main
docker compose up --build -d
Attenzione alla configurazione: ora il file .env richiede EMBEDDING_MODEL separato da LLM_MODEL:
LLM_MODEL=qwen2.5:14b
EMBEDDING_MODEL=mxbai-embed-large:latest
OLLAMA_HOST=localhost # Docker lo sovrascrive automaticamente con host.docker.internal
OLLAMA_PORT=11434
QDRANT_DB_PATH=./data/qdrant
CHUNK_SIZE=1000
CHUNK_OVERLAP=200
Pull dei modelli necessari su Ollama:
ollama pull mxbai-embed-large # obbligatorio per gli embedding
ollama pull qwen2.5:14b # o qualsiasi LLM preferisci
Dove Siamo Adesso
Guardando il progetto oggi, è difficile riconoscerlo rispetto alla versione iniziale. L’architettura è completamente diversa, lo stack è cambiato quasi interamente, e l’interfaccia è stata riscritta da zero. Eppure il principio fondante è rimasto invariato: tutti i tuoi dati restano sulla tua macchina, punto.
Niente cloud, niente API key, niente telemetria nascosta, niente abbonamenti mensili. Il tuo documento rimane tuo.
Se vuoi dare un’occhiata al codice, fare una domanda o aprire una PR, il repository è su GitHub. I contributi sono sempre benvenuti, soprattutto se portano test.
Risorse utili: