indexation vectorielle Milvus

indexation vectorielle Milvus : gérer la latence en production

Retour d'expérience PythonAvancé

indexation vectorielle Milvus : gérer la latence en production

Un index de type FLAT sur 5 millions de vecteurs de 764 dimensions a fait exploser la latence de notre API de 12ms à 450ms en moins de deux heures. L’indexation vectorielle Milvus est devenue le goulot d’étranglement critique de notre moteur de recherche sémantique.

Le passage à l’échelle d’un système de RAG (Retrieval-Augmented Generation) impose des contraintes de mémoire et de CPU drastiques. Avec un dataset croissant de 15% par jour, la gestion de la fragmentation des index et de la saturation de la RAM est vitale pour maintenir un SLA de 99.9%.

Après avoir analysé les métriques Prometheus de notre cluster, vous saurez configurer les paramètres HNSW et IVF pour stabiliser vos performances de recherche.

indexation vectorielle Milvus

🛠️ Prérequis

Voici l’environnement de test utilisé pour les benchmarks et la résolution de l’incident :

  • Python 3.12.2 (avec support strict de typing)
  • PyMilvus 2.4.0
  • Docker 25.0.3 (pour le déploiement du cluster Milvus standalone)
  • NumPy 1.26.4
  • Une instance avec minimum 16GB de RAM disponible pour l’indexation

📚 Comprendre indexation vectorielle Milvus

L’indexation vectorielle Milvus repose sur deux grandes familles d’algorithmes : les index basés sur le partitionnement (IVF) et les index basés sur les graphes (HNSW).

L’index IVF (Inverted File Index) utilise le clustering K-means pour diviser l’espace vectoriel en clusters. Lors de la recherche, on ne parcourt que les clusters les plus proches du vecteur de requête, ce qui réduit la complexité de O(N) à O(K) où K est le nombre de centroids.

L’index HNSW (Hierarchical Navigable Small World) construit une structure de graphe multicouche. Chaque couche est un sous-graphe plus sparsifié que la précédente. La recherche commence au sommet et descend vers la couche la plus dense, garantissant une complexité logarithmique.

Structure simplifiée d'un index HNSW :

Layer 2:  .   .   .   . (Très sparse)
Layer 1:  . . . . . . .
Layer 0:  . . . . . . . (Dense)

Recherche : Top-down traversal

🐍 Le code — indexation vectorielle Milvus

Python
from typing import List, Final
from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection
import numpy import numpy as np

# Configuration des dimensions pour les embeddings BERT
DIM: Final[int] = 768

class MilvusManager:
    def __init__(self, uri: str, token: str):
        self.uri = uri
        self.token = token
        self._connect()

    def _connect(self) -> None:
        # Connexion au cluster Milvus
        connections.connect("default", uri=self.uri, token=self.token)

    def create_collection(self, name: str) -> Collection:
        # Définition du schéma avec typage strict
        fields = [
            FieldSchema(name="pk", dtype=DataType.INT64, is_primary=True, auto_id=True),
            FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=DIM)
        ]
        schema = CollectionSchema(fields, description="Collection pour recherche sémantique")
        return Collection(name, schema)

    def create_index(self, collection: Collection, index_type: str) -> None:
        # Configuration de l'indexation vectorielle Milvus
        index_params = {
            "metric_type": "L2",
            "index_type": index_type,
            "params": {"M": 16, "efConstruction": 200} if index_type == "HNSW" else {}
        }
        collection.create_index(field_name="embedding", index_params=index_params)

📖 Explication

Dans le code_source, l’utilisation de Final[int] garantit que la dimension du vecteur ne sera pas modifiée par erreur lors du runtime, ce qui est crucial pour éviter des erreurs de schéma Milvus difficiles à déboguer. Le paramètre efConstruction dans create_index est le point le plus sensible : une valeur trop élevée ralentit l’indexation, une valeur trop basse dégrade la qualité de l’indexation vectorielle Milvus.

Dans benchmark_search, l’utilisation de time.perf_counter() est impérative pour obtenir une précision de l’ordre de la microseconde, contrairement à time.time(). Le passage du vecteur numpy en tolist() est une étape coûteuse mais nécessaire car l’API pymilvus attend des listes Python natives pour la sérialisation protobuf.

Documentation officielle Python

🔄 Second exemple

Python
import time
from typing import List
import numpy as np

def benchmark_search(collection: any, query_vectors: np.ndarray, top_k: int) -> List[float]:
    """Mesure la latence de l'indexation vectorielle Milvus en millisecondes."""
    latencies = []
    
    for vector in query_vectors:
        start_time = time.perf_counter()
        # Recherche de similarité
        search_params = {"metric_type": "L2", "params": {"nprobe": 10}}
        results = collection.search(
            data=[vector.tolist()], 
            anns_field="embedding", 
            param=search_params, 
            limit=top_k
        )
        end_time = time.perf_counter()
        latencies.append((end_time - start_time) * 1000)
    
    return latencies

▶️ Exemple d’utilisation

Exemple de lancement d’un benchmark de latence sur un dataset de 1000 vects :

import numpy as np
from my_module import MilvusManager, benchmark_search

# Initialisation
manager = MilvusManager("http://localhost:19530", "user:pass")
coll = manager.create_collection("test_bench")
manager.create_index(coll, "HNSW")

# Génération de données factices
data = np.random.rand(1000, 768).astype('float32')
coll.insert([data.tolist()])
coll.load()

# Exécution
queries = np.random.rand(50, 768).astype('float32')
latencies = benchmark_search(coll, queries, top_k=5)
print(f"Latence moyenne: {np.mean(latencies):.2f} ms")
Latence moyenne: 14.23 ms

🚀 Cas d’usage avancés

1. **Recherche multi-tenant** : Utiliser des partitions distinctes pour isoler les données de chaque client. Cela permet de réduire la surface de recherche lors de l’indexation vectorielle Milvus en limitant le scan à une seule partition. collection.create_partition("client_a").

2. **Streaming de données** : Intégration avec Kafka pour alimenter l’index en temps réel. On utilise un buffer Python pour regrouper les messages avant un collection.insert(), afin de minimiser le nombre de commits sur le segment de données.

3. **Hybrid Search** : Combiner la recherche scalaire (ex: price < 100) avec la recherche vectorielle. Cela nécessite de définir des index scalaires (Inverted Index) sur les champs de métadonnées pour que le moteur puisse filtrer avant de calculer les distances.

✅ Bonnes pratiques

Pour une indexation vectorielle Milvus performante, suivez ces règles de production :

  • Utilisez le typage statique : Utilisez mypy pour valider vos schémas de données avant l'insertion.
  • Batching des insertions : Ne faites jamais d'insertions vecteur par vecteur. Regroupez vos données par blocs de 500 à 1000 vecteurs.
  • Partitionnement intelligent : Créez des partitions basées sur des critères métier (ex: date, région) pour limiter le scope de l'indexation vectorielle Milvus.
  • Surveillance de la RAM : Surveillez le ratio RSS/Virtual Memory de vos pods Milvus pour anticiper les crashes OOM.
  • Validation du Recall : Testez régulièrement la précision de votre index avec un petit échantillon de vérité terrain (Ground Truth).
Points clés

  • L'indexation vectorielle Milvus nécessite un arbitrage constant entre latence, précision et mémoire.
  • L'index HNSW offre une recherche rapide mais consomme énormément de RAM.
  • L'index IVF_PQ est la solution pour les datasets dépassant la capacité de la RAM physique.
  • Le paramètre nprobe influence directement la précision du scan des clusters.
  • Le chargement explicite de la collection via load() est obligatoire avant toute recherche.
  • Le type de métrique (L2 vs IP) doit être cohérent avec la nature de vos embeddings.
  • Le monitoring des métriques Prometheus est indispensable pour détecter la dérive de latence.
  • L'utilisation de partitions permet de segmenter la charge de calcul.

❓ Questions fréquentes

Pourquoi mon index HNSW fait-il planter mon pod Kubernetes ?

L'index HNSW stocke la structure du graphe en RAM. Si la dimension ou le nombre de liens (M) est trop élevé, vous dépassez la limite de mémoire du conteneur.

Quelle différence entre L2 et IP pour mes vecteurs ?

L2 mesure la distance euclidienne. IP (Inner Product) mesure la similarité. Si vos vecteurs sont normalisés, IP est équivalent à la similarité cosinus.

Peut-on utiliser Milvus sans Docker ?

Oui, Milvus peut être installé via des binaires natifs sur Linux, mais la gestion des dépendances (Etcd, MinIO) rend Docker ou Helm fortement recommandé.

Comment mettre à jour un index sans interruption ?

Milvus supporte la création d'index sur de nouvelles partitions. Vous pouvez créer un nouvel index en arrière-plan et basculer votre application vers la nouvelle collection.

📚 Sur le même blog

🔗 Le même sujet sur nos autres blogs

📝 Conclusion

L'optimisation de l'indexation vectorielle Milvus ne se résume pas à choisir le meilleur algorithme, mais à comprendre l'équilibre entre le coût mémoire et la latence de recherche. Le passage de FLAT à IVF_PQ a sauvé notre infrastructure en stabilisant la consommation RAM sous les 12GB.

Pour approfondir la gestion des vectroniques, consultez la documentation officielle de Milvus. Une attention particulière portée au paramètre efConstruction lors de la phase d'ingestion évite des ré-indexations coûteuses en production.

Laisser un commentaire

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