Loom cresce: ricerca semantica opzionale, multilingua e server HTTP

Qualche settimana fa ho presentato Loom: un tool open source in Go che trasforma una cartella di file nella memoria interrogabile dei tuoi LLM, ispirato al pattern llm-wiki di Karpathy. L’idea di fondo era — ed è ancora — radicalmente semplice: niente embedding, niente vector database, niente Docker. Solo riassunti pre-calcolati e una ricerca BM25 su SQLite.
E funzionava. Funziona tuttora. Ma poi è arrivato il caso d’uso che ha messo in crisi quella semplicità.
Quando le parole chiave non bastano
BM25 è un algoritmo eccellente, ma ha un punto cieco preciso: trova i documenti che condividono le parole con la tua domanda. Se cerchi “carbonara” e nelle note c’è scritto “carbonara”, perfetto. Se cerchi “pasta col guanciale” e nelle note c’è solo “carbonara”, BM25 non se ne accorge — per lui sono parole diverse e basta.
Per le note personali questo è raramente un problema: di solito sai già che terminologia hai usato, perché l’hai scritta tu. Ma ho incontrato due scenari in cui la cosa diventa fastidiosa sul serio:
Le parafrasi. Cerchi un concetto con parole diverse da quelle che hai usato nell’appunto. Tu pensi “gestione degli errori”, il documento dice “error handling” o “trattamento delle eccezioni”. Stessa cosa, parole diverse, zero risultati.
Il multilingua. È il caso che mi ha fatto cedere. Note scritte in italiano, domanda posta in inglese (o viceversa). BM25 qui è completamente cieco: “check-in time” e “orario di arrivo” per lui non hanno niente in comune. Eppure è esattamente quello che capita quando lavori con documentazione tecnica mista, com’è normale che sia.
La ricerca semantica risolve esattamente questo: non confronta le parole, confronta i significati. E per farlo servono gli embeddings — proprio quella cosa che avevo orgogliosamente tenuto fuori da Loom.
Cos’è un embedding, in due parole. È un modo per trasformare un testo in una lista di numeri (un vettore) in cui testi dal significato simile finiscono “vicini” nello spazio. “Cane” e “cucciolo” stanno vicini; “cane” e “frigorifero” stanno lontani. Confrontando la distanza tra vettori puoi trovare cose pertinenti anche se non condividono nemmeno una parola — ed è per questo che funziona col multilingua: il significato di “gatto” e “cat” è lo stesso, quindi i loro vettori sono vicini.
La soluzione: ricerca ibrida, ma spenta di default
Avrei potuto fare la cosa facile: aggiungere gli embeddings, dichiarare Loom “un vero sistema RAG” e tradire tutta la premessa del progetto. Non l’ho fatto.
La filosofia di Loom resta intatta. Gli embeddings sono opzionali e disattivati di default. Se non li attivi esplicitamente, Loom si comporta byte per byte come prima: pura ricerca BM25, zero dipendenze nuove, nessun modello extra da scaricare. La semplicità di partenza è ancora lì, identica, per chi la vuole.
Per chi invece ha bisogno della ricerca semantica, c’è ora la ricerca ibrida: Loom calcola un vettore di embedding per ogni file durante la scansione, e al momento della query fonde la similarità semantica con il ranking BM25 usando un algoritmo che si chiama Reciprocal Rank Fusion (RRF). In pratica: il meglio dei due mondi. La precisione delle parole chiave quando il termine combacia, la comprensione del significato quando non combacia.
E qui viene la parte di cui vado più fiero: anche attivando gli embeddings, Loom resta fedele a sé stesso.
- I vettori vivono nello stesso file SQLite, in una piccola tabella
file_vectors. Niente database vettoriale separato. - La ricerca di similarità è un coseno brute-force scritto in Go puro: niente
sqlite-vec, niente CGO, niente server vettoriale da tenere acceso. - Resta un binario, resta un file. Solo, ora, con un po’ più di intelligenza dentro.
In altre parole: ho aggiunto la ricerca semantica senza aggiungere infrastruttura. Che era l’unico modo in cui aveva senso aggiungerla.
Come attivare la ricerca ibrida
Si fa tutto da ~/.loom/config.toml, aggiungendo un blocco [embeddings]:
[embeddings]
enabled = true
provider = "ollama"
model = "embeddinggemma:300m" # multilingua, ~700 MB di RAM, gira su CPU
endpoint = "http://localhost:11434"
dim = 768 # opzionale; 0 = usa la dimensione nativa del modello
# api_key_env = "OPENAI_API_KEY" # solo per provider = "openai"
Poi scarichi il modello e re-indicizzi, così i vettori vengono costruiti:
ollama pull embeddinggemma:300m # una tantum, ~620 MB
loom scan --force
Qualche nota pratica:
- Modello consigliato:
embeddinggemma:300msu Ollama. È multilingua (oltre 100 lingue, perfetto per il caso d’uso che mi ha spinto a tutto questo), leggero, gira su CPU senza bisogno di GPU. In alternativa va beneprovider = "openai"o qualsiasi endpoint compatibile/v1/embeddings. - Cambiare modello di embedding cambia lo spazio dei vettori. I vettori prodotti da un modello diverso vengono semplicemente ignorati finché non rifai
loom scan --forceper ricalcolarli — e nel frattempo la ricerca per quei file degrada con grazia tornando a BM25, senza mai dare errore. - La granularità è per file. Loom calcola un vettore per file, coerentemente con il suo modello “una riga per file”. Per documenti molto lunghi, conviene spezzarli in file più piccoli così ogni vettore resta focalizzato.
- Disattivarli è banale:
enabled = false(o togli il blocco). I vettori esistenti restano sul disco ma vengono ignorati, e BM25 continua a funzionare come sempre.
Sotto il cofano lo schema del database è salito alla versione 3, ma in modo puramente additivo: la nuova tabella file_vectors viene creata accanto alle strutture esistenti, che continuano a funzionare immutate. Gli indici già costruiti non vanno rifatti. La tabella resta vuota finché non attivi gli embeddings.
La ricerca ibrida si applica ovunque, in modo trasparente: loom ask, i tool loom_search / loom_ask via MCP, l’endpoint HTTP /search (ci arriviamo) e la GUI. Quando è disattivata, è il vecchio percorso BM25 puro, identico al bit precedente.
La retrocompatibilità non è negoziabile
Lo ripeto perché è il punto che mi stava più a cuore: se aggiorni Loom e non tocchi la configurazione, non cambia assolutamente niente.
Nessun nuovo modello da scaricare. Nessun comando in più da imparare. Nessuna migrazione del database. Gli indici esistenti continuano a funzionare. La ricerca resta BM25 puro. Loom 0.6 si comporta esattamente come la versione che usavi prima.
Tutta la roba nuova — embeddings, ricerca ibrida — è dietro un interruttore che parte spento. È una scelta deliberata: chi usa Loom per la semplicità non deve pagare il prezzo di feature che non gli servono.
L’altra novità: Loom su HTTP
C’è un secondo pezzo che nel post precedente avevo lasciato fuori e che merita spazio: loom-http, un piccolo server REST opzionale.
Il MCP è perfetto per parlare con Claude Desktop o Claude Code. Ma se hai un’altra applicazione — un backend, un microservizio, qualcosa che non sa niente di MCP — e vuoi usarci Loom come livello di retrieval, ti serviva un modo più universale. Quel modo è HTTP.
LOOM_HTTP_ADDR=:8080 loom-http --config ~/.loom/config.toml
L’API è minuscola, di proposito:
| Metodo e path | Cosa fa |
|---|---|
GET /healthz | Liveness check |
GET /corpora | Lista dei corpus (modalità multi-corpus) |
POST /search {query, limit?, corpus?} | Restituisce i risultati grezzi (rel_path, title, summary, content, rank) — nessuna generazione di risposta (BM25, o ibrido se gli embeddings sono attivi) |
POST /scan {force?, corpus?} | (Re)indicizza la cartella (usa l’LLM per i riassunti) |
L’endpoint /search è pensato esattamente per lo schema “recupera qui, rispondi nel mio modello”: la tua app riceve i file più pertinenti e compone la risposta con il suo prompt, senza far generare niente a Loom.
curl -s localhost:8080/search -d '{"query":"orario check-in","limit":3}'
Riusa lo stesso config.toml di loom e loom-mcp (via --config o la variabile LOOM_CONFIG); l’indirizzo di ascolto si imposta con LOOM_HTTP_ADDR (default :8080). Ed è completamente additivo e opzionale: non cambia di una virgola l’idea di fondo di Loom — una cartella di file sul tuo disco come fonte di verità, interrogata in locale.
Modalità multi-corpus (per chi ospita più knowledge base)
Ultima aggiunta, utile in scenari un po’ più strutturati. Di default un processo loom-http serve il singolo corpus indicato nel suo file di config. Ma se imposti la variabile LOOM_CORPUS_ROOT, lo stesso processo può servire tante knowledge base isolate — comodo per chi ha esigenze multi-tenant (pensa a un’app che gestisce le note di più clienti diversi).
LOOM_CORPUS_ROOT=/srv/knowledge LOOM_HTTP_ADDR=:8080 loom-http
A quel punto ogni richiesta porta con sé un nome corpus, che viene risolto in <root>/<corpus>/{notes,index.db} — con un indice SQLite separato per ogni corpus:
curl -s localhost:8080/scan -d '{"corpus":"acme","force":true}'
curl -s localhost:8080/search -d '{"corpus":"acme","query":"politica resi"}'
curl -s localhost:8080/corpora
Due cose importanti sull’isolamento:
- I nomi dei corpus sono validati come un singolo segmento di path sicuro (
[A-Za-z0-9_-], massimo 64 caratteri). Così un corpus non può in nessun modo leggere i file di un altro — niente../furbi, niente sorprese. - I provider LLM ed embeddings sono condivisi tra i corpus (stessi modelli, stessa config). La ricerca ibrida si applica per corpus, una volta che il suo indice ha i vettori.
E come al solito: se ometti corpus, viene usato quello del file di config. Le integrazioni single-corpus esistenti continuano a funzionare senza cambiare nulla.
Tirando le somme
Questo aggiornamento aveva un rischio: tradire la premessa di Loom. “Tool minimalista senza embeddings” che improvvisamente aggiunge gli embeddings suona come la classica feature creep che gonfia un progetto fino a renderlo come tutti gli altri.
Ho cercato di evitarlo nell’unico modo che mi sembrava onesto: aggiungere la potenza senza togliere la semplicità. Gli embeddings ci sono per chi ne ha bisogno — il multilingua e le parafrasi sono problemi reali — ma restano spenti per chi non li vuole. Il server HTTP apre Loom a un mondo di integrazioni senza toccare il modo in cui lo usi da CLI. Il multi-corpus serve a chi ospita più knowledge base e non interessa a nessun altro.
In tutti i casi vale la stessa regola: se non attivi una cosa, è come se non esistesse. La cartella di file resta la verità, l’indice SQLite resta un file rigenerabile, e l’LLM che già usi resta l’unico motore. Solo, ora, con qualche marcia in più nel cassetto — pronta all’uso quando, e solo quando, ti serve.
Risorse
- Repository Loom: github.com/MatteoAdamo82/loom
- Homebrew tap: github.com/MatteoAdamo82/homebrew-loom
- Reciprocal Rank Fusion (paper): plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- embeddinggemma: ollama.com/library/embeddinggemma
- Il post precedente su Loom: Loom: una Memoria Locale per i Tuoi LLM, Ispirata a Karpathy
P.S. Sì, mi rendo conto dell’ironia: avevo scritto un intero post per spiegare perché Loom NON usa gli embeddings, e qualche settimana dopo eccomi qui ad aggiungerli. Ma li ho aggiunti con l’interruttore spento. Il che, nella mia testa, fa di me una persona coerente. Vostro onore, la difesa ha concluso.