Sul portatile mi sono ritrovato negli anni una cartella ~/Note/ che è cresciuta a forza di Markdown buttati lì in fretta. Appunti di letture, snippet di configurazione, riassunti di conferenze, runbook che ho scritto per me stesso, post-mortem personali su problemi che ho risolto e voglio ricordare. grep funziona finché ricordo le parole esatte; per il resto, perdo dieci minuti a navigare nelle sottocartelle ogni volta che cerco “quella cosa che avevo scritto su X”. Da qualche mese ho aggiunto un piccolo strato di ricerca semantica costruito con sentence-transformers, e fa esattamente quello che mi serviva.

Il modello che uso è all-MiniLM-L6-v2, un classico del settore: 22 milioni di parametri, dimensione embedding 384, peso intorno ai 90 MB. Sul portatile a CPU gira senza farsi notare e produce risultati onesti.

Setup con sentence-transformers

L’installazione è in un virtualenv dedicato. Su Debian 13 e su macOS la procedura è identica, cambia solo il modo in cui attivo l’ambiente.


sudo apt install -y python3-venv
python3 -m venv ~/.venvs/embed
source ~/.venvs/embed/bin/activate
pip install --upgrade pip
pip install sentence-transformers numpy

La prima volta che istanzio il modello, la libreria lo scarica in ~/.cache/huggingface/hub/. Sono 90 MB scarsi, scaricati una volta sola, da lì in poi tutto offline.

Il flusso che ho messo in piedi è semplice. Uno script Python attraversa l’archivio Markdown, calcola un embedding per ogni file (o per ogni paragrafo se il file è lungo), salva il tutto in un file Numpy .npy insieme a un indice. Quando voglio cercare, lo stesso script prende la query, calcola il suo embedding, fa il prodotto scalare con tutti i vettori salvati e mi restituisce i primi cinque risultati per similarità coseno.


from pathlib import Path
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
notes_root = Path.home() / "Note"

docs = []
paths = []
for md in notes_root.rglob("*.md"):
    text = md.read_text(encoding="utf-8")
    docs.append(text)
    paths.append(str(md))

embeddings = model.encode(
    docs,
    batch_size=16,
    show_progress_bar=True,
    normalize_embeddings=True,
)

np.save("notes.npy", embeddings)
Path("notes.idx").write_text("\n".join(paths))

Per la ricerca, le poche righe che mi servono:


import numpy as np
from pathlib import Path
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = np.load("notes.npy")
paths = Path("notes.idx").read_text().splitlines()

query = "configurazione rsyslog per inviare a un collector remoto"
q = model.encode([query], normalize_embeddings=True)[0]
scores = embeddings @ q

top = np.argsort(-scores)[:5]
for i in top:
    print(f"{scores[i]:.3f}  {paths[i]}")

Reindicizzo una volta a settimana con un alias fish che lancia lo script di build. Sui circa 1200 file Markdown che ho oggi il calcolo finisce in due minuti scarsi sul portatile.

Un esempio reale

Un pomeriggio della settimana scorsa stavo configurando un container con Caddy come reverse proxy davanti a un’API interna, e ricordavo vagamente di aver scritto mesi fa qualcosa su come gestire gli upstream timeout di Caddy quando l’API risponde lenta sulla prima richiesta. Niente da fare con grep: avevo provato caddy, upstream, timeout, reverse_proxy, e mi tornavano fuori venti file con questi termini sparsi.

Ho lanciato lo script di ricerca semantica con la query “Caddy reverse proxy timeout quando upstream lento”, e nei primi tre risultati c’era esattamente la nota che cercavo: un appunto di marzo dove avevo annotato la sintassi transport http { dial_timeout, read_timeout, write_timeout } per uno specifico problema che avevo risolto su un microservizio FastAPI. Non c’era la parola “lento” da nessuna parte nella nota, c’era “latenza”, e MiniLM aveva colto la corrispondenza semantica.

Cinque minuti di lavoro risparmiati, e soprattutto la sensazione che la mia knowledge base personale fosse finalmente di nuovo interrogabile. Quel singolo episodio è quello che mi ha convinto a stabilizzare lo script e integrarlo nelle abitudini.

Cosa fa bene

Query in linguaggio naturale dove ricordo il concetto ma non le parole esatte. Sinonimi tecnici (latenza/lento, autenticazione/auth, certificato/cert). Riformulazioni dello stesso problema. Funziona ragionevolmente sia in italiano sia in inglese, perché il modello è multilingue di fatto pur essendo addestrato principalmente su inglese. Per ricerche tipo “come avevo configurato il backup di Postgres in quel laboratorio” mi tira fuori la nota giusta anche se non ci sono parole letterali in comune.

Cosa fa meno bene

Per ricerche esatte di stringhe (nomi di flag, opzioni di comando, IP, sigle specifiche) il vecchio grep resta più veloce e preciso. Sui paragrafi molto brevi (una riga di appunto) la qualità peggiora perché c’è poco contesto da embeddare. Le sigle ambigue (CA come Certificate Authority vs CA come California) non le disambigua bene.

Privacy – vantaggio del modello locale

I miei appunti contengono di tutto: configurazioni di ambienti di esempio che preferisco tenere riservate, password offuscate, riferimenti a setup di laboratorio personali, idee per articoli che non ho ancora pubblicato. Una ricerca semantica via API esterna mi obbligherebbe a uploadare query e contesto a un fornitore terzo, e nel migliore dei casi a pagare con il tempo di elaborazione, nel peggiore con la possibilità che query e snippet finiscano nei log o nel training del fornitore. Con MiniLM in locale tutto sta sull’host: il modello, gli embeddings, le query.

Il modello scaricato sta in ~/.cache/huggingface/hub/models--sentence-transformers--all-MiniLM-L6-v2/. Si elimina con rm -rf quando voglio liberare spazio (e si riscarica al prossimo bisogno). Confronto con servizi cloud equivalenti: API di embedding come quelle di OpenAI o Cohere richiedono upload di ogni nota e di ogni query, con policy di retention e residency da leggere; qui non c’è nulla di tutto questo.

Licenza: sentence-transformers come libreria è Apache 2.0, sviluppata da UKP Lab e dalla community. Il modello all-MiniLM-L6-v2 è rilasciato sotto Apache 2.0. Stack interamente permissivo, riutilizzabile anche dentro tooling commerciale senza vincoli copyleft.

In pratica

Lo script di build sta in un alias fish (embed-notes) che lancio il sabato mattina mentre faccio colazione. Lo script di ricerca è un altro alias (note) che richiama il modello con la query passata sulla riga di comando. Tempo medio di una ricerca dopo il caricamento del modello (che richiede un paio di secondi): meno di mezzo secondo per risposta su 1200 file. Per la mia knowledge base personale è il livello di velocità e qualità che cercavo.


Immagine generata con ComfyUI Mac M1 / RealVisXL V5 Lightning.

Articolo originale su rpi.temporiti.net