base de données vectorielle

Base de données vectorielle : l’architecture de Milvus

Analyse technique approfondie GoAvancé

Base de données vectorielle : l'architecture de Milvus

La recherche de similarité dans des espaces à haute dimension s’effondre avec les index B-Tree classiques. Une base de données vectorielle devient indispensable dès que l’on manipule des embeddings de modèles comme CLIP ou BERT.

Milvus résout ce problème via une architecture découplée. Il traite des milliards de vecteurs avec une latence de l’ordre de la milliseconde. L’enjeu est la gestion du ratio précision/vitesse lors de l’utilisation d’algorithmes ANN (Approximate Nearest Neighbor).

Après cette lecture, vous comprendrez le fonctionnement des nœuds de requête et l’implément et de l’indexation HNSW sous Go.

base de données vectorielle

🛠️ Prérequis

Environnement Linux (Ubuntu 22.04 recommandé) et outils suivants :

  • Go 1.22 ou supérieur
  • Docker et Docker Compose
  • Milvus Standalone (v2.3.x ou plus)
  • Un client Milvus (milvus-sdk-go)

📚 Comprendre base de données vectorielle

Une base de données vectorielle ne cherche pas une égalité stricte. Elle calcule une distance mathématique (L2, Inner Product ou Cosine). L’architecture de Milvus repose sur le principe du découplage compute/storage.

Le système utilise un Log Broker (Kafka ou Pulsar) pour la persistance des écritures. Les données sont organisées en segments. Chaque segment est une unité de stockage immuable. Cette approche rappelle les architectures LSM-Tree des bases NoSQL. Pour l’indexation, l’algorithme HNSW (Hierarchical Navigable Small World) est le standard. Il construit un graphe multi-niveaux. La recherche commence au niveau supérieur avec peu de nœuds. Elle descend progressivement vers les couches plus denses. Cela réduit la complex et permet une complexité de recherche en O(log N). Contrairement à une bibliothèque comme Faiss, Milvus gère la réplication et la haute disponibilité nativement.

🐹 Le code — base de données vectorielle

Go
package main

import (
	"context"
	"fmt"
	"github.com/milvus-io/milvus-sdk-go/v2/client"
	"github.com/milvus-io/milvus-sdk-go/v2/entity"
)

func main() {
	ctx := context.Background()
	// Connexion au cluster Milvus
	c, err := client.NewClient(ctx, client.Config{Address: "localhost:19530"})
	if err != nil {
		panic(err)
	}
	defer c.Close()

	// Définition du schéma pour la base de données vectorielle
	schema := entity.NewSchema()
	schema.WithName("user_embeddings")
	schema.AddField(entity.NewField[int64]("user_id", entity.Int64Type, false))
	schema.AddField(entity.NewField[[]float32]("vector", entity.FloatVectorType, false, 128))

	// Création de la collection
	err = c.CreateCollection(ctx, schema, autoID)
	if err != nil {
		fmt.Printf("Erreur création: %v\n", err)
	}
}

📖 Explication

Dans le premier snippet, l’utilisation de context.Background() est standard mais risquée en production. Préférez un context.WithTimeout pour éviter les blocages réseau. La définition du schéma utilise entity.NewField. Notez l’utilisation de float32 pour la dimension 128. C’est un choix pragmatique pour la performance CPU via les instructions SIMD (AVX-512). Dans le second snippet, la fonction Insert prend deux slices séparés. C’est une optimisation de l’implémentation pour éviter l’interlacement des données en mémoire. L’appel à Flush est crucial. Sans lui, les données restent dans le buffer de l’application et ne sont pas visibles dans les recherches immédiates. Attention au piège : appeler Flush trop souvent détruit les performances d’écriture en créant trop de petits segments.

Documentation officielle Go

🔄 Second exemple

Go
func insertVectors(ctx context.Context, c client.Client, ids []int64, vectors [][]float32) error {
	// Préparation des données pour l'insertion massive
	data := make([]entity.PrimValue, len(ids))
	for i, id := range ids {
		data[i] = entity.NewInt64PrimValue(id)
	}

	// Insertion des vecteurs dans la base de données vectorielle
	_, err := c.Insert(ctx, "user_embeddings", data, vectors)
	if err != nil {
		return fmt.Errorf("échec insertion: %w", err)
	}

	// Forcer la persistance des données sur le disque
	return c.Flush("user_embeddings")
}

Analyse technique approfondie

L’architecture de Milvus est divisée en plusieurs composants spécialisés : Query Nodes, Data Nodes, Index Nodes et Root Coord. Le Query Node est le cœur de la performance. C’est lui qui maintient les segments en mémoire vive. En pratique, la performance dépend de la capacité de ce nœud à charger des segments indexés. L’utilisation de l’index HNSW nécessite une allocation mémoire massive. Chaque lien dans le graphe HNSW occupe de la RAM. Si vous avez 100 millions de vecteurs de 128 dimensions, la consommation dépasse les 64 Go sans compression.

Le mécanisme de ‘Log Broker’ garantit que même en cas de crash, les données sont récupérables. Chaque écriture est d’abord un message dans le log. Le Data Node consomme ce log pour construire les segments. L’Index Node surveille les segments complets pour lancer les calculs d’indexation en arrière-plan. Ce processus est asynchrone. Cela signifie qu’un vecteur inséré n’est pas immédiatement searchable avec l’index HNSW. Il faut attendre la fin de l’étape d’indexation. C’est un point critique pour les applications temps réel. On observe une latence de ‘visibility’ dépendante de la taille des batchs.

La gestion de la mémoire en Go lors de la manipulation de ces vecteurs est périlleuse. L’utilisation de []float32 est obligatoire. Utiliser float64 doublerait l’empreinte mémoire sans gain de précision utile pour l’ANN. Le Garbage Collector (GC) de Go peut souffrir de la fragmentation si vous créez trop de petits slices. Il est préférable de pré-allouer de grands buffers pour les batchs d’insertion. La gestion des pointeurs dans les structures de graphe complexes peut augmenter le temps de scan du GC. Une base de données vectorielle performante doit minimiser l’allocation sur le heap.

▶️ Exemple d’utilisation

Scénario : Recherche du vecteur le plus proche d’un utilisateur dans un set de 1000 vecteurs pré-existants.

// Requête de recherche
searchParams, _ := entity.NewIndexIvfFlatSearchParam()
results, err := c.Search(ctx, "user_embeddings", queryVectors, searchParams, limit)

// Sortie attendue dans la console :
// Found 1 match:
// ID: 452, Distance: 0.0123, Metadata: {user_id: 452}
// Found 2 match:
// ID: 12, Distance: 0.0456, Metadata: {user_id: 12}

🚀 Cas d’usage avancés

1. Recherche d’images par similarité : Extraction de features via ResNet, puis stockage dans la base de données vectorielle. Utilisation de l’index IVF_PQ pour réduire la mémoire via la quantification produit. search(query_vector, top_k=10).

2. Systèmes RAG (Retrieval Augmented Generation) : Stockage des chunks de documents transformés en embeddings via OpenAI (text-embedding-3-small). Filtrage hybride utilisant des métadonnées scalaires (date, auteur) combinées à la recherche vectorielle. expr := "author == 'admin'".

3. Détection d’anomalies en streaming : Utilisation de Kafka pour envoyer des vecteurs de logs en temps réel. Comparaison avec les vecteurs de référence stockés. Analyse de la distance L2 pour identifier les dérives de comportement.

🐛 Erreurs courantes

⚠️ Oubli du Flush

Les données sont insérées avec succès mais ne sont pas retrouvables lors de la recherche immédiate.

✗ Mauvais

c.Insert(ctx, col, ids, vectors)
✓ Correct

c.Insert(ctx, col, ids, vectors); c.Flush(col)

⚠️

Un paramètre ‘efConstruction’ trop bas réduit la précision de la base de données vectorielle de façon drastique.

✗ Mauvais

m: 4, efConstruction: 10
✓ Correct

m: 16, efConstruction: 200

⚠️ Fuite de mémoire Go

Création de nouveaux slices de vecteurs dans une boucle infinie sans réutilisation de buffer.

✗ Mauvais

for { v := make([]float32, 128); ... }
✓ Correct

buffer := make([]float32, 128); for { copy(buffer, newData); ... }

⚠️ Timeout de contexte

Le contexte expire avant que la recherche massive ne se termine sur un large dataset.

✗ Mauvais

ctx, _ := context.WithTimeout(context.Background(), 10*time.Millisecond)
✓ Correct

ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)

✅ Bonnes pratiques

Pour maintenir une base de données vectorielle saine en production :

  • Utilisez systématiquement float32 pour les vecteurs afin d’optimiser l’utilisation du cache CPU.
  • Implémentez un mécanisme de pooling de slices (sync.Pool) pour réduire la pression du GC sur les gros volumes d’embeddings.
  • Configurez l’index HNSW avec un paramètre ef (search) qui équilibre la latence et le recall selon vos besoins métier.
  • Séparez vos workloads d’écriture (Data Nodes) et de lecture (Query Nodes) via des instances Kubernetes distinctes.
  • Surveillez la métrique segment_count ; trop de petits segments dégradent les performances de recherche.
Points clés

  • L'architecture de Milvus est cloud-native et découplée.
  • L'algorithme HNSW est essentiel pour la recherche ANN efficace.
  • Le choix de float32 est crucial pour la performance SIMD.
  • Le Log Broker garantit la persistance des données insérées.
  • La gestion de la mémoire en Go impacte directement la latence.
  • Le paramètre efConstruction définit la qualité de l'index.
  • Le flushing des données est indispensable pour la visibilité.
  • Le filtrage hybride (scalaire + vectoriel) est un cas d'usage majeur.

❓ Questions fréquentes

Peut-on utiliser Milvus pour des recherches exactes ?

Non, Milvus est conçu pour l’ANN (Approximate Nearest Neighbor). Pour une recherche exacte, utilisez une base SQL classique, mais la performance s’effondrera sur de grandes dimensions.

Comment gérer la montée en charge ?

Grâce à son architecture distribuée, vous pouvez scaler horizontalement les Query Nodes pour augmenter le débit de lecture.

Est-ce que Milvus supporte le multi-tenancy ?

Oui, via l’utilisation de partitions ou de collections distinctes, bien que la gestion logique doive être implémentée au niveau de votre application Go.

Quel est l'impact de la dimension du vecteur ?

Plus la dimension est élevée, plus l’empreinte mémoire et le temps de calcul de distance augmentent linéairement.

📚 Sur le même blog

🔗 Le même sujet sur nos autres blogs

📝 Conclusion

Milvus transforme la complexité de la recherche haute dimension en un service gérable. La maîtrise de ses composants internes et de la gestion mémoire en Go est la clé pour des systèmes de production stables. Pour approfondir l’usage des types de données, consultez la documentation Go officielle. Ne négligez jamais le ratio mémoire/précision lors du choix de vos paramètres d’indexation.

Publications similaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *