Quando ho iniziato a lavorare su Vettore, l'obiettivo era piuttosto semplice: volevo poter fare ricerca vettoriale direttamente nelle mie applicazioni Elixir, senza dover installare un altro database o mettere in piedi un servizio solo per confrontare degli embedding.
Rust sembrava la strada più sensata. Avevo bisogno di calcoli numerici veloci, SIMD, HNSW e magari, più avanti, anche della GPU. Tutte cose per cui non aveva molto senso ostinarsi a fare tutto in puro Elixir.
E così, nelle prime versioni, Rust non si occupava soltanto dei calcoli. Possedeva anche i dati.
Il sistema funzionava, ma più aggiungevo funzionalità, più mi rendevo conto di una cosa: stavo costruendo un piccolo database dentro Rust, mentre Elixir era diventato una specie di telecomando.
E la cosa non mi convinceva.
Il dubbio non era la velocità
Tenere i vettori direttamente nella memoria nativa ha dei vantaggi evidenti. Eviti conversioni, puoi organizzare i dati nel formato più comodo per gli algoritmi e non devi attraversare continuamente il confine tra BEAM e Rust.
Ma c'era un prezzo che non avevo considerato abbastanza.
Se i record vivono soltanto nel motore nativo, anche il loro ciclo di vita dipende da quel motore. Per ispezionarli, recuperarli o ricostruire un indice devo passare attraverso API native.
E soprattutto mi stavo perdendo uno dei motivi per cui mi piace lavorare con Elixir: avere processi, stato e responsabilità chiaramente organizzati.
Mi sono chiesto se non stessi mettendo troppe cose nello stesso posto.
Un indice HNSW serve a trovare vettori velocemente. Non c'è nessun motivo per cui debba essere anche l'unico proprietario dei documenti associati a quei vettori.
Da lì ho cambiato direzione: ETS conserva i record, Rust accelera le operazioni.
Non significa che ETS sia diventato più veloce di Rust. Significa che non ho più bisogno di trattare l'indice nativo come un database.
La prova più interessante è ricostruire tutto
La cosa che apprezzo di più della nuova architettura è che posso salvare una collezione e caricarla con un indice diverso.
Questo esempio utilizza le API reali di Vettore:
{:ok, collection} =
Vettore.new(
dimensions: 3,
metric: :cosine,
index: :hnsw
)
:ok =
Vettore.put(collection, %{
id: "documento-1",
vector: [1.0, 0.0, 0.0],
metadata: %{lang: "it"}
})
:ok = Vettore.snapshot(collection, "documents.ets")
# Stessi dati, un'altra strategia di ricerca
{:ok, restored} =
Vettore.load_snapshot(
"documents.ets",
index: :flat
)Lo snapshot conserva i record e la configurazione della collezione. Il grafo HNSW non deve essere salvato come unica rappresentazione dei dati: durante il caricamento viene costruito l'indice richiesto.
Ho aggiunto anche un test che simula un indice nativo diventato inutilizzabile. La ricerca sull'indice guasto fallisce, ma i dati canonici possono ancora essere salvati e usati per ricostruire un nuovo indice.
È una differenza importante. Non rende Rust immune ai crash, e gli snapshot vanno comunque gestiti correttamente. Però evita di legare la recuperabilità dei record al funzionamento di una specifica struttura di ricerca.
La complicazione si è spostata altrove: ora devo mantenere coerenti ETS e indice nativo. Se inserisco un vettore in ETS e l'aggiornamento dell'indice fallisce, non posso lasciare tranquillamente le due strutture in disaccordo.
Per questo nel codice ci sono operazioni coordinate e rollback. Ho guadagnato controllo sullo stato, non semplicità gratuita.
La parte divertente: cercare con i bit
Separare i dati dagli algoritmi mi ha lasciato anche più libertà di sperimentare strategie di ricerca diverse.
Una di quelle che mi piacciono è la quantizzazione binaria.
Un embedding di 384 dimensioni, usando float a 32 bit, richiede 1.536 byte per i soli valori numerici. La firma dei segni rappresenta invece le 384 dimensioni con 384 bit: 48 byte di informazione codificata. È una dimensione teorica, non l'occupazione effettiva in ETS: Vettore conserva i bit in parole da 64 bit dentro una lista Elixir, che introduce ulteriore overhead.
Un esempio:
Embedding: [0.8, -0.3, 0.2, -0.9] Segni: + - + - Bit: 1 0 1 0
Posso confrontare due firme con XOR e popcount, contando quanti bit sono diversi.
È molto economico. Ma non è una ricerca esatta.
Prendiamo [1.0, 0.1] e [0.1, 1.0]: la firma binaria è identica, [1, 1]. Ma usando il primo vettore come query, la similarità coseno è 1.0 con se stesso e circa 0.198 con il secondo. La sola firma non può distinguere correttamente quelle direzioni.
In Vettore confronto le firme tramite distanza di Hamming per selezionare alcuni candidati; poi riordino soltanto quelli usando i vettori originali. È diverso da HNSW, che esplora un indice a grafo:
{:ok, results} =
Vettore.quantized_search(
collection,
[1.0, 0.0, 0.0],
candidates: 100,
limit: 10
)Qui nasce un altro compromesso interessante: se scelgo pochi candidati risparmio calcoli, ma rischio di scartare un buon risultato prima del confronto esatto.
Per capire se conviene davvero, devo misurare sia la latenza sia la recall@k, cioè quanti dei primi k risultati della ricerca esatta ritrovo nella ricerca approssimata. Se cerco i 10 vettori più vicini e ne recupero soltanto 8 dei 10 individuati dalla ricerca esatta, la recall@10 è dell'80%. Non basta vedere che un benchmark è più veloce: devo capire quali risultati sto perdendo.
Il repository include benchmark per Flat e HNSW, anche su dimensioni differenti.
Alla fine ho rinunciato a qualcosa
La nuova architettura non è necessariamente la più veloce in assoluto. ETS consuma memoria, mantenere due strutture coerenti ha un costo e ricostruire gli indici richiede tempo.
Anche la proprietà dei processi va gestita con attenzione: in Vettore una collezione viene chiusa quando termina il processo che l'ha creata. Quindi non ha senso crearla dentro una Task temporanea se deve servire l'applicazione per ore.
Però adesso ho qualcosa che si integra meglio con il resto del mio codice Elixir.
Se domani voglio sostituire HNSW, cambiare politica di ricerca o sperimentare un altro algoritmo nativo, non devo necessariamente riscrivere anche la parte che conserva i record.
Forse avrei potuto ottenere prestazioni maggiori lasciando tutto dentro Rust. Non lo escludo affatto.
Ma a un certo punto ho capito che la velocità di un algoritmo e la qualità dell'architettura che lo circonda sono due problemi diversi. E in Vettore mi interessavano entrambi.