De la V1 à la V2: Mon retour d'expérience sur Google Cloud Vertex AI Vector Search et Milvus

Retour d'expérience sur notre architecture RAG : les limites de Vertex AI Vector Search V1, l'alternative Milvus, et l'apport de la V2.

2026-08-01

gcpAI

Cet article est le troisième volet de notre série sur l'exploration des bases de données vectorielles pour notre agent IA Ops (RAG).

Après avoir analysé Vertex AI RAG Engine

puis la première version de Vertex AI Vector Search

Pour rappel, l'objectif de nos travaux de R&D est de construire des agents IA capables d'ingérer nos documentations privées et nos modules Terraform, afin d'assister nos équipes en générant du code d'infrastructure respectant nos standards internes.

La mise en œuvre d'un système RAG sur des fichiers techniques comme Terraform révèle rapidement que l'interaction avec le LLM n'est pas la partie la plus complexe. La principale difficulté réside dans l'architecture d'ingestion. Comment stocker, filtrer (pour différencier un bloc HCL d'un fichier Markdown) et interroger efficacement nos embeddings sous charge ?

Ce retour d'expérience détaille les limites que j'ai rencontrées avec la V1 de Google Cloud Vector Search, les raisons de ma migration temporaire vers Milvus (en self-managed), et les facteurs qui m'ont convaincu de migrer vers la version récente Vertex AI Vector Search V2 (Collections).

1. Les limites architecturales de Vertex AI Vector Search V1

Initialement, le Matching Engine de Vector Search (la V1) promettait des performances de recherche à très grande échelle. Cependant, pour un cas d'usage applicatif moderne, l'expérience de développement s'est avérée complexe et contraignante.

L'architecture "Index-as-a-Service"

La V1 ne fonctionnait pas comme une base de données traditionnelle intégrée, mais plutôt comme un assemblage de composants distincts. Pour faire simple : ce n'était pas du tout immédiat (straightforward).

Le processus d'ingestion nécessitait de segmenter les fichiers (chunking), de générer les embeddings avec Gemini, puis de les ingérer dans l'index.

De plus, l'indexation seule ne suffisait pas pour exécuter des requêtes. Il fallait déployer cet index sur un point de terminaison (Index Endpoint). L'intégration avec LangChain exigeait aussi une configuration fastidieuse pour assembler ces différents éléments dans mon code :

vector_store = VectorSearchVectorStore.from_components(
    project_id="my-gcp-project",
    region="europe-west3",
    gcs_bucket_name="my-staging-bucket",
    index_id="projects/.../indexes/1234567890",
    endpoint_id="projects/.../indexEndpoints/0987654321",
    embedding=embeddings,
    stream_update=True,
)

Doc: VectorSearchVectorStore Docs

Cette architecture posait un problème majeur de latence d'ingestion et de surcoût d'infrastructure. La création d'un point de terminaison imposait aussi de dédier des ressources permanentes (par exemple, des instances de type e2-standard-2). Pire : le déploiement d'un endpoint pouvait durer jusqu'à 45 minutes. 45 minutes !!!

La gestion rigide des namespaces et des métadonnées

Le filtrage des requêtes s'est également révélé contraignant. Par exemple, pour restreindre la recherche de notre agent IA aux seules variables GKE dans des fichiers comme gke-variables.tf, la V1 imposait l'utilisation de Namespaces via des jetons d'inclusion et d'exclusion (Namespace(name="filename", allow_tokens=[filename])).

De plus, l'intégration LangChain utilisée n'acceptait pas les valeurs booléennes (has_code: true) pour le filtrage, exigeant exclusivement des chaînes de caractères ou des listes. Il fallait donc contourner cette limitation en stockant la chaîne "true".

L'administration de l'index manquait de flexibilité. Aucune commande native ne permettait de réinitialiser ou de supprimer massivement des documents. Je devais donc utiliser des méthodes internes de LangChain (vector_store._searcher.get_datapoints_by_filter({})) pour récupérer les identifiants, puis les supprimer un par un. Ces frictions d'exploitation m'ont conduit à évaluer d'autres solutions.

2. Le choix de Milvus : Flexibilité applicative et coûts d'infrastructure

Pour surmonter ces contraintes, je me suis tourné vers Milvus, une solution open source spécialisée dans le stockage vectoriel.

Pour la phase de prototypage, Milvus apporte une grande flexibilité. L'ingestion s'effectue directement en Python, sans passer par un stockage intermédiaire sur GCS. Le support des schémas dynamiques (enable_dynamic_fields=True) me permettait d'ajouter des métadonnées sans restructurer les tables. Les types scalaires (booléens, tableaux) sont natifs, et la fonctionnalité AUTOINDEX gère de manière transparente les paramètres d'indexation de l'arbre.

La syntaxe de requête est également plus directe et lisible :

results = client.search(
    collection_name="terraform_docs",
    data=[query_vector],
    anns_field="embedding",
    limit=5,
    # Exemple de filtre lisible :
    filter='has_code == true and ARRAY_CONTAINS(file_tags, "web")',
    search_params={"metric_type": "COSINE"},
    output_fields=["content", "filename"]
)

Empreinte mémoire et complexité d'infrastructure de Milvus

Cependant, l'exploitation de Milvus en mode self-managed a introduit une charge d'infrastructure significative, particulièrement visible sur la consommation de ressources.

Lors du déploiement de Milvus via un manifest ArgoCD sur mon cluster GKE de test, j'ai rapidement constaté que même en mode standalone (cluster.enabled: false), l'architecture nécessite de nombreux composants complémentaires s'exécutant en parallèle :

  • Un cluster etcd : 3 réplicas sont requis pour assurer la cohérence des métadonnées (avec une limite de 512 Mo de RAM par instance).
  • La messagerie Pulsar v3 : au lieu d'une file d'attente locale simplifiée, Milvus s'appuie sur un déploiement Apache Pulsar complet. Cela comprend 3 pods Zookeeper (limite d'1 Go chacun), 2 pods Bookkeeper (limite de 2 Go chacun, configurés avec PULSAR_MEM="-Xms512m -Xmx1536m" et 50 Go de disque pour les ledgers), 1 Broker Pulsar (limite de 2 Go) et 1 Proxy Pulsar.
  • Le stockage d'objets MinIO : Requis pour stocker les fichiers de chunks (limite d'un 1 Go, PVC de 10 Go).
  • Le pod Milvus Standalone : Configuré avec une limite de 3 Go de RAM.

Voici un extrait des valeurs Helm configurant cette topologie standalone sur GKE. On peut y voir la manière dont les limites de mémoire s'accumulent :

# Extrait de ma config ArgoCD 
spec:
  source:
    repoURL: https://zilliztech.github.io/milvus-helm/
    chart: milvus
    targetRevision: 5.0.17
    helm:
      valuesObject:
        cluster:
          enabled: false # Mode standalone

        etcd:
          replicaCount: 3
          resources:
            limits:
              memory: 512Mi

        pulsarv3:
          enabled: true
          zookeeper:
            replicaCount: 3
            configData:
              PULSAR_MEM: "-Xms256m -Xmx512m"
            resources:
              limits:
                memory: 1Gi
          bookkeeper:
            replicaCount: 2
            configData:
              PULSAR_MEM: "-Xms512m -Xmx1536m -XX:MaxDirectMemorySize=1024m"
            resources:
              limits:
                memory: 2Gi
          broker:
            replicaCount: 1
            configData:
              PULSAR_MEM: "-Xms512m -Xmx1536m -XX:MaxDirectMemorySize=1024m"
            resources:
              limits:
                memory: 2Gi
          proxy:
            replicaCount: 1
            configData:
              PULSAR_MEM: "-Xms256m -Xmx512m -XX:MaxDirectMemorySize=256m"
            resources:
              limits:
                memory: 1Gi

        standalone:
          resources:
            limits:
              memory: 3Gi

        minio:
          mode: standalone
          resources:
            limits:
              memory: 1Gi

Au total, ce déploiement minimal de Milvus nécessitait près de 15 Go de limites de RAM sur mon cluster GKE, ce qui représente une empreinte très importante pour un environnement de développement hébergeant quelques milliers d'embeddings Terraform.

De plus, Milvus est conçu pour maintenir les index en mémoire (In-Memory) afin de garantir des recherches rapides. Cela impose de gérer manuellement le chargement et le déchargement des collections dans mon code applicatif pour éviter les saturations de mémoire (OOM) :

client.load_collection("terraform_docs")  # Chargement en mémoire
# ... exécution des recherches ...
client.release_collection("terraform_docs") # Libération de la mémoire

La gestion de ce cycle de vie applicatif, combinée à la supervision de l'infrastructure d'accompagnement (Pulsar, etcd, MinIO) dans ArgoCD, augmentait significativement ma charge opérationnelle. Cela se faisait au détriment de l'optimisation fonctionnelle du RAG.

3. L'apport de la V2 (Collections API)

La sortie de la V2 de Vector Search par Google Cloud a modifié mon approche. Cette version abandonne l'obligation d'un index GCS externe pour introduire les concepts de Collections et de Data Objects.

Il s'agit d'une base de données vectorielle serverless accessible via une API unique (REST/gRPC). Plus besoin de provisionner ou d'avoir des machines dédiées. De plus, chaque collection valide ses données via un schéma JSON, garantissant la cohérence des métadonnées stockées.

Le moteur de filtrage supporte désormais les opérateurs logiques standards ($eq, $in, $and), et l'ingestion de données s'implémente simplement en Python :

from google.cloud import vectorsearch_v1beta
data_client = vectorsearch_v1beta.DataObjectServiceClient()

data_object = vectorsearch_v1beta.DataObject(
    data={
        "content": item.get("content", ""),
        "filename": item.get("metadata", {}).get("filename", ""),
        "chunk_type": item.get("metadata", {}).get("chunk_type", ""),
        "headers": item.get("metadata", {}).get("headers", []),
        "file_tag": item.get("metadata", {}).get("file_tag", [])
    },
    vectors={
        "embedding": {
            "dense": {"values": embedding} # Vecteur 3072 dimensions
        }
    }
)

request = vectorsearch_v1beta.CreateDataObjectRequest(
    parent=COLLECTION_FULL_NAME,
    data_object_id=f"chunk-{global_idx}",
    data_object=data_object
)
# (Ensuite, on utilise BatchCreateDataObjectsRequest pour envoyer par lots)

L'accès aux métadonnées en l'absence d'interface graphique

Bien que la V2 ne propose pas d'interface utilisateur d'administration dédiée (encore, Google please), l'API permet de lister les objets existants. J'exploite cette possibilité dans l'agent pour indexer dynamiquement les tags ou fichiers disponibles avant de générer des réponses :

search_client = vectorsearch_v1beta.DataObjectSearchServiceClient(transport="rest")

request = vectorsearch_v1beta.QueryDataObjectsRequest(
    parent=COLLECTION_FULL_NAME,
    output_fields=vectorsearch_v1beta.OutputFields(
        data_fields=["filename", "chunk_type", "file_tag"]
    ),
    page_size=1000
)

results = search_client.query_data_objects(request=request)
# Récupération dynamique des métadonnées pour notre application.

Le filtrage associé aux requêtes de recherche vectorielle est désormais plus intuitif :

search_filter = {
    "$and": [
        {"chunk_type": {"$in": ["terraform_blocks", "markdown"]}},
        {"file_tag": {"$eq": "schema"}}
    ]
}

vector_search = vectorsearch_v1beta.VectorSearch(
    search_field="embedding",
    vector={"values": query_embedding},
    filter=search_filter,
    top_k=15, 
    output_fields=vectorsearch_v1beta.OutputFields(
        data_fields=["content", "filename", "headers"]
    )
)

Point de vigilance : L'asymétrie sémantique (Task Type)

Un aspect technique mérite une attention particulière car il impacte directement la pertinence des résultats de recherche.

Lors de mes premiers tests en V2, les scores de similarité n'excédaient pas 0.65, et l'agent retournait parfois des documents non pertinents malgré une forte correspondance textuelle entre la question de l'utilisateur et le contenu indexé.

La cause résidait dans l'absence de spécification du paramètre Task Type lors de l'utilisation des modèles d'embedding de la famille Gemini.

Les modèles tels que gemini-embedding-001 projettent en effet les vecteurs dans des sous-espaces sémantiques distincts selon leur finalité :

  • Les documents stockés à l'ingestion doivent être optimisés pour le stockage : task_type="RETRIEVAL_DOCUMENT".
  • Les questions de l'utilisateur doivent être optimisées pour la recherche : task_type="RETRIEVAL_QUERY".

Sans cette distinction, ou en appliquant un type identique par défaut, la proximité mathématique entre la question et le document n'est pas garantie dans l'espace multidimensionnel.

L'ajustement du paramètre lors de la génération des embeddings d'ingestion a résolu ce problème :

embed_response = ai_client.models.embed_content(
    model="gemini-embedding-001",
    contents=batch_contents,
    config=types.EmbedContentConfig(
        output_dimensionality=3072,
        task_type="RETRIEVAL_DOCUMENT" # configuration essentielle pour l'ingestion
    )
)

En alignant correctement les types de tâches (RETRIEVAL_DOCUMENT à l'ingestion et RETRIEVAL_QUERY lors de la recherche), les scores de similarité ont dépassé 0.90. L'agent obtient ainsi des correspondances hautement précises.

Bilan

Milvus demeure une solution performante pour des besoins cloud agnostic exigeant des schémas entièrement dynamiques et bénéficiant d'une équipe dédiée à l'administration d'infrastructure.

Dans mon cas, pour un déploiement s'exécutant sur Google Cloud, la V2 de Vector Search apporte un excellent compromis. Elle offre la souplesse de requêtage et de filtrage attendue d'une base vectorielle moderne. Et elle conserve les avantages d'un service serverless managé : absence de maintenance d'infrastructure, tarification à l'usage, gestion simplifiée de la mémoire.