Salta al contenuto principale

HNSW in Redis: recall e latenza misurati sui nostri vettori

Con i default l'HNSW di Redis perde dei vicini veri: recall 0,94 su 300 ricerche reali. Come misurarlo con un FLAT e cosa cambia con EF_RUNTIME.

Cosa ne pensi di questo articolo?

HNSW in Redis: recall e latenza misurati sui nostri vettori
Redisfedericocalo.dev

In Breve

Con i default l'HNSW di Redis perde dei vicini veri: recall 0,94 su 300 ricerche reali. Come misurarlo con un FLAT e cosa cambia con EF_RUNTIME.

In breve. Un indice HNSW di Redis non restituisce sempre i dieci vicini veri: ne restituisce quasi sempre dieci, e quel «quasi» si misura. Sui 2.426 vettori del sito, con 300 interrogazioni mai indicizzate e un indice FLAT come riferimento esatto, l'indice creato come fa il ml-service (nessun parametro oltre tipo, dimensione e distanza) ha un recall@10 medio di 0,9443: in 94 interrogazioni su 300 manca almeno un vicino, e nel caso peggiore ne mancano sei su dieci. Alzando EF_RUNTIME da 10 a 50 il recall sale a 0,9963, a 200 a 0,9997. Il prezzo, a questa scala, è piccolo: 0,2-0,3 ms per interrogazione. Ma anche il guadagno è piccolo, perché FLAT costa 0,26-0,30 ms ed è esatto. Con poco più di duemila vettori, la domanda onesta non è «quale EF_RUNTIME», è «mi serve davvero un indice approssimato».

Metodo. Tutto è stato eseguito il 12 novembre 2026 su Redis Stack 7.4.7 in un contenitore, con un client Python su loopback in una macchina condivisa. I vettori sono stati solo letti dall'istanza di sviluppo del sito (porta 6379); gli indici vivono su un'altra istanza, che ho spento a fine lavoro. La configurazione di produzione non l'ho verificata, e non la deduco.

Che cosa si misura, e rispetto a che cosa

Il recall in una frase

Un indice approssimato risponde a «dammi i dieci vettori più vicini a questo» guardando solo una parte del grafo. Il recall@10 è la frazione dei dieci vicini veri che compaiono nella sua risposta: 1,0 vuol dire che ha trovato tutti, 0,6 che ne ha trovati sei. Non dice se i risultati siano buoni per l'utente, dice quanto l'indice si discosta da ciò che avrebbe risposto una scansione completa. È una misura di fedeltà, e come tutte le misure di fedeltà ha bisogno di un originale con cui confrontarsi.

Perché FLAT è il metro

La pagina di FT.CREATE dice che si sceglie FLAT «quando hai piccoli insiemi di dati (meno di un milione di vettori) o quando la precisione perfetta conta più della latenza di ricerca». Un indice FLAT confronta la query con ogni vettore e quindi non sbaglia: è il riferimento ideale per sapere cosa avrebbe dovuto rispondere l'HNSW. Costruisco quindi, sugli stessi vettori, un indice FLAT e uno o più HNSW, interrogo tutti con le stesse query, e conto quanti dei dieci id del FLAT ritrovo negli altri. Se il riferimento fosse a sua volta approssimato, la misura non direbbe nulla.

L'ambiente, prima dei numeri

Le versioni, e perché un contenitore

Il Redis nativo della mia macchina non ha il modulo di ricerca, quindi non sa creare un indice vettoriale. Lo si vede in due comandi, e spiega perché il ml-service registra «RediSearch module not loaded» e prosegue senza indicizzare quando gira contro un Redis così:

$ redis-server --version
Redis server v=8.0.5 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=d47fc40702b1fad8
$ redis-cli module list | tr '\n' ' '
name vectorset ver 1 path  args
$ redis-cli ft.create x on hash schema a text
ERR unknown command 'ft.create', with args beginning with: 'x' 'on' 'hash' 'schema' 'a' 'text'

Il modulo vectorset che compare in elenco è un'altra cosa, un tipo di dato nuovo con comandi suoi: non è RediSearch e non risponde a FT.*. Per fare la misura ho avviato l'immagine redis/redis-stack-server, che porta il modulo search, legata a 127.0.0.1 e su una porta che non è quella dello sviluppo:

$ docker run -d --rm --name serie-redis-a10 -p 127.0.0.1:6398:6379 redis/redis-stack-server:latest
$ redis-cli -p 6398 info server | grep redis_version
redis_version:7.4.7
$ redis-cli -p 6398 module list | tr '\n' ' ' | grep -o 'name search ver [0-9]*'
name search ver 21020

Il --rm fa sparire il contenitore quando lo fermo e il 127.0.0.1 impedisce che la porta sia raggiungibile da fuori. Due versioni diverse, 8.0.5 nativa e 7.4.7 in Stack, sono già una avvertenza: i numeri che seguono valgono per 7.4.7 e per quella versione del modulo, e non li trasferisco ad altre senza rifare la prova.

I vettori del sito, letti senza toccarli

I vettori sono gli embedding degli articoli, un hash article:* per articolo e per lingua con un campo embedding da 384 float32. Prima di usarli controllo due cose: quanti sono e che siano tutti della dimensione attesa, perché un solo vettore storto fa fallire un indice. Il comando è di sola lettura:

$ redis-cli -p 6379 --scan --pattern 'article:*' --count 1000 | wc -l
2426
$ redis-cli -p 6379 hstrlen article:1416:en embedding
1536
$ redis-cli -p 6379 hkeys article:1416:en | tr '\n' ' '
title article_id embedding slug excerpt lang

1.536 byte sono 384 numeri da 4 byte: la conferma che il formato è quello dichiarato nello schema. Nel mio script ho poi verificato la lunghezza su tutte le 2.426 chiavi e nessuna è stata scartata. Ho usato --scan e non KEYS perché la prima scorre a blocchi senza fermare il server, mentre la seconda lo blocca finché non ha finito: su un'istanza che altri stanno usando conta.

Costruire gli indici

Lo schema del sito, riga per riga

Il ml-service crea l'indice degli articoli in create_index, nel file ml-service/app/storage/redis_vectors.py. Per il campo vettoriale passa VECTOR HNSW 6 TYPE FLOAT32 DIM 384 DISTANCE_METRIC COSINE: quel 6 è il numero di argomenti che seguono, tre coppie nome-valore. Non c'è M, non c'è EF_CONSTRUCTION, e la query a riga 464 è KNN {k} @embedding $vec AS score, senza EF_RUNTIME. Quindi tutto quello che conta vale per i default. Riproduco lo stesso schema e chiedo a Redis quali valori ha scelto:

$ redis-cli -p 6398 ft.create idx:articles on hash prefix 1 article: schema title text slug tag lang tag excerpt text article_id numeric embedding vector hnsw 6 type float32 dim 384 distance_metric cosine
OK
$ redis-cli -p 6398 ft.info idx:articles | grep -A14 '^embedding$' | tail -n +4 | head -14 | tr '\n' ' '
type VECTOR algorithm HNSW data_type FLOAT32 dim 384 distance_metric COSINE M 16 ef_construction 200

L'FT.INFO di 7.4.7 mostra M 16 ed ef_construction 200 e non mostra ef_runtime. Per il parametro di ricerca vale la pagina dei vettori, che indica 10 come valore predefinito; la stessa pagina riporta 16 e 200 per gli altri due, e coincidono con ciò che il server ha dichiarato. Ne segue un fatto che non mi aspettavo: l'indice dei dataset del sito, in semantic_search_datasets.py, imposta M 16 ed EF_CONSTRUCTION 200, che sono gli stessi valori che avrebbe ottenuto senza scriverli. È una documentazione esplicita del default, non una taratura.

Tenere fuori le interrogazioni

Il rischio di questa misura è l'autoriferimento: se interrogo l'indice con un vettore che contiene già, il primo vicino è il vettore stesso, in qualunque algoritmo, e il recall sembra migliore di quanto sia. Per evitarlo estraggo a caso 300 vettori e non li metto nell'indice: li uso soltanto come interrogazioni. Gli altri 2.126 finiscono in tre indici su prefissi diversi, uno FLAT e due HNSW:

random.seed(7)
held = set(random.sample(names, 300))          # interrogazioni: mai indicizzate
corpus = [n for n in names if n not in held]   # 2126 vettori indicizzati

mk('idx:flat',  'f:', 'FLAT', [])
mk('idx:hnsw',  'h:', 'HNSW', [])                                  # come il ml-service
mk('idx:hnsw4', 'g:', 'HNSW', ['M', '4', 'EF_CONSTRUCTION', '20']) # grafo povero, per contrasto

def q(idx, blob, ef=None, k=10):
    attr = '' if ef is None else '=>{$EF_RUNTIME: %d}' % ef
    return dst.execute_command('FT.SEARCH', idx,
        f'*=>[KNN {k} @embedding $B AS d]' + attr,
        'PARAMS', '2', 'B', blob, 'SORTBY', 'd', 'NOCONTENT', 'DIALECT', '2', 'LIMIT', '0', str(k))

La funzione mk compone il comando FT.CREATE come sopra. Il parametro dell'interrogazione si passa con la sintassi degli attributi, =>{$EF_RUNTIME: 50}, dopo la parte KNN; NOCONTENT restituisce solo gli id, perché a me servono gli id e la latenza. Il grafo povero (M 4, EF_CONSTRUCTION 20) non è una proposta: serve a mostrare come l'accuratezza dipenda anche da come l'indice è stato costruito, non solo da come lo interroghi.

La misura

Il calcolo del recall

La verità viene dall'indice FLAT: per ognuna delle 300 interrogazioni salvo i dieci id che restituisce. Poi, per ogni configurazione, il recall di un'interrogazione è la dimensione dell'intersezione fra i suoi dieci id e quei dieci, divisa per dieci. Ripeto ogni configurazione per tre giri e cronometro con time.perf_counter attorno alla sola chiamata, quindi i tempi comprendono rete locale e trasferimento della risposta:

truth = {n: ids(q('idx:flat', vecs[n])) for n in hq}
for name, idx, ef in cfg:
    for giro in range(3):
        for n in hq:
            t = time.perf_counter(); r = q(idx, vecs[n], ef)
            lat.append((time.perf_counter() - t) * 1000)
            if giro == 0:
                rec.append(len(set(ids(r)) & set(truth[n])) / 10)

Il recall lo calcolo solo al primo giro perché le risposte, a indice fermo, sono le stesse; i giri successivi servono alla latenza. Il numero «quante interrogazioni sotto 1,0» è quello che più dice a un lettore: una media di 0,94 nasconde se tutte perdono un poco o se poche perdono molto, e il minimo dice quanto può andare male una singola ricerca.

Perché l'HNSW di Redis con i default perde dei vicini?

Questa è l'uscita completa, seme 7, con gli indici costruiti come il ml-service costruisce il suo. Le colonne sono il recall medio, il recall minimo, il numero di interrogazioni sotto 1,0, il p50 della latenza nei tre giri e il 95° percentile:

config                     recall    min   <1.0 | p50 ms per giro (3 giri) | p95
FLAT                       1.0000    1.0      0 | 0.268 0.280 0.299 | 0.34
HNSW default               0.9443    0.4     94 | 0.193 0.165 0.158 | 0.24
HNSW EF_RUNTIME 10         0.9443    0.4     94 | 0.150 0.180 0.215 | 0.24
HNSW EF_RUNTIME 50         0.9963    0.9     11 | 0.270 0.269 0.244 | 0.35
HNSW EF_RUNTIME 200        0.9997    0.9      1 | 0.333 0.325 0.317 | 0.40
HNSW M4 EFC20 default      0.6643    0.0    237 | 0.167 0.143 0.163 | 0.19
HNSW M4 EFC20 EF 200       0.9747    0.4     54 | 0.320 0.285 0.322 | 0.41

Le righe 2 e 3 sono identiche nel recall, 0,9443 e gli stessi 94 casi, perché EF_RUNTIME 10 è il default: la differenza di latenza fra loro (0,158-0,193 contro 0,150-0,215) è il rumore della macchina, e dà la misura di quanto vale un confronto fra tempi così piccoli. Il dato che pesa è la colonna min: 0,4 vuol dire che esiste una ricerca in cui l'indice ha restituito quattro vicini veri su dieci.

Perché la prima stima era troppo ottimista

Se ripeto la stessa prova interrogando con 300 vettori già indicizzati, il risultato cambia. Il vettore stesso è sempre il primo vicino e il resto delle righe si regge un po' meglio:

SEED 7 SELF
HNSW default               0.9837    0.6     41 | 0.176 0.172 0.176 | 0.23
HNSW EF_RUNTIME 50         0.9997    0.9      1 | 0.233 0.224 0.232 | 0.29
HNSW EF_RUNTIME 200        1.0000    1.0      0 | 0.335 0.347 0.321 | 0.41

Da 0,9837 a 0,9443 è il prezzo dell'autoriferimento, e vale quasi quattro punti su cento: chi misura il recall interrogando l'indice con vettori che contiene ottiene un numero più bello di quello che vedrà un utente. Per chiudere il dubbio che il seme sia fortunato ho ripetuto la misura con altri due, 11 e 23: nei tre casi il recall di default è 0,9443, 0,9390 e 0,9487, con 94, 100 e 89 interrogazioni su 300 sotto 1,0. La dispersione è sotto un punto: il numero è stabile per questo insieme di vettori.

EF_RUNTIME: la sintassi e le trappole

Il parametro si può passare per interrogazione, e questo è il modo giusto di provarlo: non cambia l'indice, non richiede una ricostruzione, e si può confrontare con un altro valore sulla stessa istanza. Ma la sintassi è severa, e gli errori di Redis dicono poco. Tre casi che ho incontrato, con i messaggi esatti:

$ redis-cli -p 6398 ft.search idx:f '*=>[KNN 3 @v $B]=>{$EF_RUNTIME: 50}' params 2 B abcdabcdabcdabcd dialect 2
Error parsing vector similarity parameters: Invalid option
$ redis-cli -p 6398 ft.search idx:h '*=>[KNN 3 @v $B]' params 2 B abcdabcd dialect 2
Error parsing vector similarity query: query vector blob size (8) does not match index's expected size (16).
$ redis-cli -p 6398 ft.search idx:h '*=>[KNN 3 @v $B]' params 2 B abcdabcdabcdabcd
Syntax error at offset 1 near >[

Il primo è EF_RUNTIME su un indice FLAT: la pagina lo spiega («per FLAT non ci sono parametri di esecuzione») e il server risponde Invalid option senza dire quale. Il secondo è un vettore di dimensione sbagliata, l'errore più frequente quando si cambia modello di embedding. Il terzo è la dimenticanza di DIALECT 2, senza cui la sintassi KNN non si legge. Su un indice vuoto il primo caso non dà errore, ma restituisce zero risultati: un esperimento fatto prima di caricare i dati avrebbe nascosto il problema.

Quale EF_RUNTIME conviene usare?

Sull'insieme dei tre semi, con le interrogazioni tenute fuori, le tre manopole si leggono così:

  • EF_RUNTIME 10 (default): recall 0,9443 / 0,9390 / 0,9487, fra 89 e 100 interrogazioni su 300 con almeno un vicino mancante, p50 da 0,148 a 0,193 ms.
  • EF_RUNTIME 50: recall 0,9963 / 0,9970 / 0,9987, fra 2 e 11 interrogazioni imperfette, p50 da 0,211 a 0,273 ms.
  • EF_RUNTIME 200: recall 0,9997 / 1,0000 / 1,0000, da 0 a 1 interrogazione imperfetta, p50 da 0,311 a 0,384 ms.
  • FLAT: recall 1,0000 per definizione, p50 da 0,263 a 0,299 ms.

La lettura che conta sta fra le righe: il passaggio da 10 a 50 compra quasi tutto il recall che c'è da comprare (da 0,94 a 0,996) e costa fra 0,04 e 0,10 ms; il passaggio da 50 a 200 compra al massimo qualche millesimo e costa un altro 0,1 ms. Con EF_RUNTIME 200 l'indice approssimato diventa più lento di quello esatto, perché a duemila vettori la ricerca visita quasi tutto il grafo e in più paga la navigazione. Non c'è un valore giusto per tutti: c'è un punto oltre il quale non compri più niente, e qui è vicino a 50.

Cosa succede con un grafo povero

La riga M4 EFC20 serve a ricordare che l'accuratezza non dipende solo dal parametro di ricerca. Con un grafo costruito con pochi collegamenti per nodo (M 4) e poca cura (EF_CONSTRUCTION 20) il recall di default scende a 0,6643, con 237 interrogazioni su 300 imperfette e un minimo di 0, cioè una ricerca che non ha trovato nessuno dei dieci vicini veri. Alzare EF_RUNTIME a 200 lo porta a 0,9747: non lo riporta a 1 perché il grafo, una volta costruito male, non si ripara dall'esterno. Per questo gli indici HNSW hanno due manopole, una che paghi quando costruisci e una che paghi a ogni ricerca.

Il costo: tempo e memoria

La latenza, e quanto vale il rumore

I tempi vanno letti con diffidenza. Sono mediane su 300 chiamate per giro, un client solo, loopback, su una macchina dove girava altro, e si vede: le due righe identiche (default ed EF_RUNTIME 10) differiscono di 0,05 ms in certi giri. Un confronto fra due configurazioni ha senso solo quando la differenza supera quel rumore: il default contro EF_RUNTIME 50 lo supera, EF_RUNTIME 10 contro il default no. Che il guadagno complessivo di HNSW su FLAT, a questa scala, sia di 0,1 ms circa, è un risultato vero e piccolo.

La memoria: HNSW pesa di più

L'altro costo è lo spazio, e FT.INFO lo riporta per indice, nel campo vector_index_sz_mb:

idx:flat vector_index_sz_mb b'4.613433837890625'
idx:hnsw vector_index_sz_mb b'5.4040069580078125'

Il grafo aggiunge circa 0,79 MB a un indice di 4,61, cioè il 17%, per 2.126 vettori. È una quantità trascurabile qui e proporzionale al numero di collegamenti per nodo, quindi cresce con M: la pagina dei vettori dice che un valore più alto aumenta anche la memoria. Un'avvertenza: 4,61 MB per 2.126 vettori da 1.536 byte è più dei 3,11 MiB di dati grezzi che mi aspettavo, e non so spiegare da dove venga la differenza. Riporto il campo come l'ho letto, e non lo uso per dimensionare niente.

Con poche migliaia di vettori serve davvero un indice HNSW?

A 2.126 vettori sceglierei FLAT. Guadagna zero ambiguità, perché il risultato è sempre quello esatto, e perde 0,1 ms su una pagina che ne spende molti di più altrove: l'approssimazione non compra nulla che l'utente possa notare. L'HNSW comincia a convenire quando le scansioni complete diventano il costo dominante, e quel punto non l'ho misurato: non so a quanti vettori cada, e per questo non lo scrivo. Se il sito dovesse restare su HNSW, imposterei EF_RUNTIME a 50 nella query e non nell'indice, perché la query si cambia a ogni rilascio e l'indice si ricostruisce.

  • Misura il recall sui tuoi vettori, non su quelli di un tutorial: dipende dalla distribuzione dei dati e cambia da un corpus all'altro.
  • Interroga con vettori fuori dall'indice: quelli già dentro regalano il primo vicino e gonfiano il numero.
  • Confronta con un FLAT sullo stesso insieme prima di scegliere l'algoritmo, e riporta il minimo oltre alla media.
  • Prova EF_RUNTIME per interrogazione, partendo da 50: costa una riga e non cambia l'indice.
  • Scrivi la versione di Redis accanto a ogni numero: un modulo diverso può comportarsi diversamente.

Le cinque voci sono in ordine di esecuzione, e le prime tre sono quelle che non si saltano: senza un riferimento esatto e senza interrogazioni esterne, il numero che ottieni non si può interpretare. Le ultime due sono manutenzione. Il passo che costa di più, nel mio caso, era il secondo, e non era difficile: bastava tenere da parte trecento vettori prima di creare gli indici.

Dove la misura non arriva

Questa prova ha limiti che conviene dichiarare invece di scoprirli dopo:

  • Le interrogazioni sono articoli, non domande: l'utente del sito scrive frasi brevi, il cui embedding può cadere in zone diverse dello spazio. Non avevo un modello di embedding sulla macchina per produrle, e non ho misurato quel caso.
  • Le ricerche del sito hanno filtri (per lingua, per esempio): la pagina di FT.SEARCH descrive un modo in cui, quando i documenti che passano il filtro sono pochi, il server calcola la distanza di ciascuno e restituisce risultati esatti anche su un indice approssimato. Non ho misurato le interrogazioni filtrate.
  • L'istanza è di sviluppo: i vettori sono gli stessi del sito, ma la versione e la configurazione di produzione non le ho verificate.
  • Client unico, loopback: nessuna concorrenza. I tempi dicono quanto costa una ricerca, non quanto regge il servizio sotto carico.

Nessuno di questi limiti ribalta la conclusione, ma ognuno la restringe. Il primo è il più importante, perché il recall dipende da come sono fatte le domande oltre che dai documenti; per misurarlo servirebbe un campione di domande reali e il loro embedding, ed è il passo che farei prima di cambiare qualsiasi parametro.

Cosa sai fare adesso

Sai costruire un riferimento esatto con un indice FLAT, tenere fuori trecento vettori come interrogazioni vere e contare quanti vicini perde un indice HNSW, con la media, il minimo e il numero di ricerche imperfette. Sai che i default di Redis (M 16, EF_CONSTRUCTION 200, EF_RUNTIME 10) danno, sui vettori del sito, un recall di circa 0,94, che EF_RUNTIME 50 lo porta sopra 0,99 e che passare a 200 a questa scala costa più dell'indice esatto. E sai che prima di scegliere l'algoritmo conviene chiedersi se ne serve uno approssimato.

Per sapere quanto spazio occupa ciascun vettore nell'hash che li contiene c'è l'articolo sulle strutture dati e quanto costano; per la memoria complessiva dell'istanza, quello su maxmemory, eviction e OOM; per il metodo di misura di latenza e memoria, misurare Redis. Il contesto in cui il sito usa questi vettori, cioè il RAG, è nell'articolo sul setup completo di RAG con RediSearch e in quello sul reranking con cross-encoder.

Hai un indice vettoriale e non sai quanto ti sbaglia? Prenota una call →

Fonti

  • Redis, Vectors - i parametri di HNSW con i valori predefiniti (M 16, EF_CONSTRUCTION 200, EF_RUNTIME 10), l'avviso che l'aumento di M aumenta la memoria, la sintassi EF_RUNTIME per interrogazione e il fatto che per FLAT non ci sono parametri di esecuzione. Consultata il 2026-11-12.
  • Redis, FT.CREATE - la frase sulla scelta di FLAT per insiemi sotto il milione di vettori o quando la precisione perfetta conta più della latenza. Consultata il 2026-11-12.
  • Redis, FT.SEARCH - la modalità di forza bruta quando il filtro lascia pochi documenti, con risultati esatti anche su indice approssimato. Consultata il 2026-11-12.
  • Malkov e Yashunin, Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs - l'algoritmo da cui nasce HNSW; ho verificato titolo e autori sulla pagina di arXiv, non ho riletto il contenuto. Consultata il 2026-11-12.
  • Misure proprie: lo script e i comandi dei blocchi, eseguiti il 12 novembre 2026 su Redis Stack 7.4.7 in contenitore (redis/redis-stack-server:latest), con i vettori letti dalla porta 6379.
  • Codice di questo sito: ml-service/app/storage/redis_vectors.py (create_index, la query a riga 464) e ml-service/app/core/semantic_search_datasets.py (righe 33-35), letti il 12 novembre 2026.

Le prime quattro voci sono documentazione e una pubblicazione: servono a controllare che io abbia letto bene i parametri e i loro default, e se una pagina cambia i numeri, il comando FT.INFO sulla tua versione ha l'ultima parola. Le ultime due si rifanno: lo script, con un altro seme, dà una misura diversa nei dettagli e la stessa nella sostanza.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti