Accueil / Articles

Un Retriever, trois systèmes de stockage en arrière-plan : portage de la fonction de recherche NumPy vers Chroma, FAISS et Pinecone

Intégrez un moteur de recherche en cosinus NumPy développé manuellement dans Chroma, FAISS et Pinecone, et découvrez ce que les index, les filtres de métadonnées et la persistance apportent, ainsi que les erreurs qui altèrent la pertinence.

Éditeur : REACTAPP.TOP11 min de lecture2493 mots

Sources: Vector Databases, Hands-On — From Your numpy Array to Pinecone

Le stockage de vecteurs le plus simple consiste en une matrice NumPy d’embeddings accompagnée d’une seule opération arithmétique : chunk_vectors @ q_vec évalue chaque chunk par rapport à la requête, et un tri sélectionne les meilleurs résultats. Il s’agit d’une véritable base de données vectorielle, mais celle qui est la plus minimale possible. Cette présentation prend précisément ce mécanisme de récupération et l’adapte à trois backends réels, Chroma, FAISS et Pinecone, afin que vous puissiez voir exactement ce que chacun d’eux apporte, quel produit parmi les nombreux existants (Chroma, FAISS, Qdrant, Weaviate, Milvus, pgvector, Pinecone) convient à quelle situation, et quels erreurs compromettent discrètement la pertinence des résultats.

Les exemples locaux n’ont pas besoin de clés API et ne coûtent rien. Pinecone propose un plan d’essai sans serveur gratuit qui nécessite une inscription et une clé, mais, au moment de la rédaction, aucune carte de paiement n’est requise, ce qui permet également d’essayer la solution gérée gratuitement. Le code suppose l’existence d’un seul script, vdb.py, que l’on exécute bloc par bloc.

Lorsque un tableau NumPy devient insuffisant

Un moteur de recherche personnalisé calcule la similarité cosinus avec chaque morceau stocké pour chaque requête. Avec quelques centaines de morceaux, c’est instantané. Avec quelques millions, cela devient lent : toute la matrice doit être stockée en RAM, et tous les embeddings disparaissent lorsque le processus s’arrête. Une base de données vectorielle comble trois lacunes :

  1. Vitesse à grande échelle. Plutôt que de procéder à une comparaison exacte O(n) avec chaque vecteur, cette méthode crée un index d’adjacents les plus proches approximatif comme HNSW, qui permet de trouver des vecteurs presque les plus proches en environ O(log n). On sacrifie une petite partie de la précision pour obtenir un gain de vitesse considérable.
  2. Persistence. Les vecteurs sont stockés sur disque ou sur un serveur géré, ce qui leur permet de survivre aux redémarrages.
  3. Métadonnées et filtrage. Chaque vecteur peut contenir des attributs tels que {"source": "doc12", "date": "2026-01"}, et une requête peut être restreinte à ceux-ci, par exemple pour trouver les vecteurs les plus proches dont la source est doc12.

Le contraste entre ces deux approches de requête se résume en deux lignes :

your numpy array:   query ──► compare to ALL n vectors ──► sort ──► top-k     (exact, slow at scale)
a vector database:  query ──► walk HNSW graph ──► ~top-k     (approximate, fast, on disk, filterable)

C’est là toute la valeur ajoutée. Tout le reste relève de la mise en œuvre technique.

Vocabulaire que vous rencontrerez fréquemment

  • Embedding ou vecteur : la liste de nombres qui encode le sens ; les exemples ici utilisent all-MiniLM-L6-v2.
  • Métrique de distance : manière de mesurer le degré de proximité. Le cosinus (l’angle entre les vecteurs) est le choix habituel pour le texte ; le produit scalaire et la métrique eucclidienne (L2) en sont les alternatives. Utilisez celle pour laquelle votre modèle d’embedding a été entraîné.
  • Index : la structure qui permet des recherches rapides. HNSW, un graphe navigable, est la valeur par défaut dans la plupart des bases de données ; IVF et PQ comprennent des compromis entre mémoire et vitesse de différentes manières.
  • Upsert : insérer un vecteur par ID, ou le mettre à jour si cet ID existe déjà.
  • Filtrage des métadonnées : affiner une recherche en fonction des attributs stockés.
  • Espace de noms ou collection : une partition au sein d’une base de données afin que des données non liées ne se mélangent jamais.
  • Chroma : la première mise à niveau naturelle

    Chroma s’exécute dans votre processus Python après une seule installation, sans serveur ni Docker. Cela en fait l’évolution la plus directe par rapport à un tableau NumPy. Installez-le en même temps que la bibliothèque d’embedding :

    pip install chromadb sentence-transformers
    

    Le script ci-dessous utilise un petit corpus décrivant un produit fictif de prise de notes nommé Nimbus, avec une étiquette thématique par document. Il crée un client en mémoire, ouvre une collection configurée pour la distance cosinus via hnsw:space, incorpore explicitement les documents et les ajoute avec des IDs et des métadonnées. Ensuite, il incorpore une question et demande les deux documents les plus proches, en affichant chacun avec sa distance. Notez le commentaire concernant chromadb.Client() : il suffit de remplacer par PersistentClient(path=...) pour conserver les données sur disque.

    # vdb.py
    import chromadb
    from sentence_transformers import SentenceTransformer
    
    embedder = SentenceTransformer("all-MiniLM-L6-v2")
    
    # Same Nimbus corpus from the RAG tutorial
    DOCS = [
        "Nimbus Pro costs 8 dollars per month billed annually, with 50 GB storage.",
        "Nimbus offers a 30-day refund policy on all paid plans.",
        "Nimbus Lite (free) includes 1 GB of storage and up to 50 notes.",
        "Nimbus stores notes encrypted at rest with AES-256; E2E encryption is Pro-only.",
        "Live chat support is Pro-only, Mon-Fri 9-6 UTC; free users get 48h email support.",
    ]
    metadatas = [{"topic": t} for t in ["pricing","refund","pricing","security","support"]]
    
    client = chromadb.Client()                       # in-memory; use PersistentClient(path=...) to save to disk
    collection = client.create_collection(
        "nimbus", metadata={"hnsw:space": "cosine"}   # choose the distance metric
    )
    
    # Embed and add. (↔ your chunk_vectors = embedder.encode(...))
    collection.add(
        ids=[f"doc{i}" for i in range(len(DOCS))],
        embeddings=embedder.encode(DOCS).tolist(),
        documents=DOCS,
        metadatas=metadatas,
    )
    
    # Query. (↔ your retrieve(): embed query, find nearest)
    q = "How much does the paid plan cost?"
    res = collection.query(query_embeddings=embedder.encode([q]).tolist(), n_results=2)
    for doc, dist in zip(res["documents"][0], res["distances"][0]):
        print(f"[{dist:.3f}] {doc}")
    

    Cela revient en fait à la version manuelle, sans rien de conceptuellement nouveau. collection.add remplace l’encodage des documents et la gestion de la matrice. collection.query remplace le produit de matrices, l’opération argsort ainsi que le prélèvement des k meilleurs éléments. Ce que Chroma apporte, c’est l’index HNSW, ainsi que le support de la persistance et des métadonnées basés sur cette même idée.

    Chroma peut également intégrer du texte pour vous à l’aide d’un modèle intégré si vous passez documents= sans embeddings=. En définissant explicitement les embeddings, comme ci-dessus, on garde la logique visible et l’on laisse le choix du modèle entre vos mains, ce qui est important car le même modèle doit être utilisé tant pour l’ajout que pour la recherche.

    Filtrage par métadonnées

    Le filtrage est une fonctionnalité que les tableaux simples ne possédaient jamais. La requête suivante cherche "sécurité des données", mais restreint les résultats aux vecteurs dont le topic est security grâce à l’argument where:

    res = collection.query(
        query_embeddings=embedder.encode(["data security"]).tolist(),
        n_results=3,
        where={"topic": "security"},   # only search vectors tagged security
    )
    print(res["documents"][0])
    

    Dans un environnement de production, c’est ce qui permet des requêtes telles que ">tickets similaires de ce client" ou ">documentation mise à jour cette année". Le filtre est appliqué en même temps que la recherche vectorielle et non comme étape de post-traitement, ce qui garantit toujours l’obtention de n_results résultats correspondants. Comme exercice, changez le filtre en where={"topic": "pricing"} et vérifiez que les documents relatifs aux remboursements et à la sécurité ne reviennent jamais.

    FAISS : une bibliothèque d’indexation rapide, pas une base de données

    FAISS, développé par Meta, n’est pas du tout une base de données. Il s’agit d’une bibliothèque hautement optimisée pour la recherche des voisins les plus proches, dépourvue de métadonnées et ne disposant pas de propre couche de stockage, conçue pour être intégrée dans votre application. De nombreux autres systèmes l’utilisent en interne. Installez la version CPU :

    pip install faiss-cpu
    

    Le code encode les documents avec normalize_embeddings=True et les convertit en float32, ce que FAISS exige. Il crée un IndexFlatIP, un index à produit scalaire ; comme les vecteurs sont normalisés à une longueur unitaire, le produit scalaire équivaut à la similarité cosinus. Après avoir ajouté les vecteurs, il encode la requête de la même manière et appelle search pour obtenir les deux meilleurs résultats, puis il reconvertit les positions retournées en documents originaux.

    import faiss, numpy as np
    
    vectors = embedder.encode(DOCS, normalize_embeddings=True).astype("float32")
    index = faiss.IndexFlatIP(vectors.shape[1])   # IP = inner product = cosine on normalized vectors
    index.add(vectors)                            # ↔ exactly your numpy array, but optimized in C++
    
    q = embedder.encode(["refund window?"], normalize_embeddings=True).astype("float32")
    scores, idx = index.search(q, k=2)            # ↔ your argsort top-k
    for s, i in zip(scores[0], idx[0]):
        print(f"[{s:.3f}] {DOCS[i]}")
    

    C’est pratiquement littéralement le récupérateur de NumPy : IndexFlatIP correspond à une recherche par cosinus par force brute, simplement mise en œuvre en C++ optimisé. Les avantages apparaissent lorsque l’on le remplace par un index approximatif tel que IndexHNSWFlat ou IndexIVFFlat, qui conservent la même interface mais effectuent des recherches en temps sub-linéaire. Choisissez FAISS lorsque vous souhaitez la vitesse maximale au sein d’un seul processus et que vous êtes prêt à gérer vous-même la persistance et les métadonnées, par exemple en conservant une correspondance séparée entre les positions et les enregistrements de documents.

    Pinecone : entièrement géré et sans serveur

    Jusqu’à présent, tout s’est exécuté dans votre propre processus. Pinecone est une base de données vectorielle gérée accessible via le réseau : il n’y a pas de serveur à faire fonctionner, et elle s’adapte automatiquement en taille. C’est l’option idéale pour les équipes qui ne souhaitent pas gérer d’infrastructure.

    Prévoyez que le SDK Pinecone a subi de profondes modifications. Le style ancien pinecone.init(api_key=, environment=) n’existe plus ; vous devez maintenant créer un objet client Pinecone, et le package s’appelle pinecone, et non pinecone-client. Vérifiez la documentation actuelle du SDK si quoi que ce soit échoue.

    Le bloc commence par la commande d’installation, suivie du code Python (séparez-les si vous le copiez). Il crée un client, puis un index sans serveur nommé nimbus uniquement s’il n’existe pas déjà, en utilisant dimension=384 pour correspondre à MiniLM et metric="cosine", puis se connecte à cet index. Il insère ou met à jour des tuples (id, vector, metadata), en stockant le texte original dans les métadonnées car Pinecone ne renvoie que ce que vous avez enregistré. Enfin, il effectue une recherche pour obtenir les deux meilleures correspondances, en appliquant un filtre $eq de style Mongo sur topic, et affiche chaque score ainsi que le texte correspondant. Dans un code réel, lisez la clé depuis une variable d’environnement ou un stockage de secrets plutôt que de la placer directement dans le code source.

    pip install pinecone
    
    from pinecone import Pinecone, ServerlessSpec
    
    pc = Pinecone(api_key="YOUR_API_KEY")          # get a free key at app.pinecone.io
    
    # Create a serverless index (only once). dimension must match your embedder: MiniLM = 384.
    if not pc.has_index("nimbus"):
        pc.create_index(
            name="nimbus",
            dimension=384,
            metric="cosine",
            spec=ServerlessSpec(cloud="aws", region="us-east-1"),
        )
    index = pc.Index("nimbus")
    
    # Upsert: (id, vector, metadata) tuples
    index.upsert(vectors=[
        (f"doc{i}", embedder.encode(d).tolist(), {"text": d, "topic": m["topic"]})
        for i, (d, m) in enumerate(zip(DOCS, metadatas))
    ])
    
    # Query with metadata filtering
    res = index.query(
        vector=embedder.encode("how much is pro?").tolist(),
        top_k=2,
        include_metadata=True,
        filter={"topic": {"$eq": "pricing"}},      # Mongo-style filter operators
    )
    for match in res["matches"]:
        print(f"[{match['score']:.3f}] {match['metadata']['text']}")
    

    La structure correspond exactement à Chroma : créez un index, effectuez des opérations d’insertion ou de mise à jour des IDs, des vecteurs et des métadonnées, puis effectuez une recherche top-k avec un filtre optionnel. L’étape de récupération est identique à celle précédente, mais elle s’exécute désormais sur l’infrastructure d’autoscaling d’un tiers via une API. Pinecone prend également en charge les namespaces, qui partitionnent un seul index, ainsi que l’inférence intégrée, où upsert_records et search_records gèrent eux-mêmes l’incorporation de texte, vous évitant ainsi d’avoir à le faire vous-même.

    La leçon principale est que le passage de Chroma à Pinecone n’a modifié que la manière dont on se connecte et appelle le stockage, sans affecter la logique RAG qui l’entoure. Dans une application bien structurée, le stockage de vecteurs est un composant interchangeable, c’est aussi pourquoi une méthode de framework comme .as_retriever() de LangChain reste identique quel que soit le backend utilisé.

    Choisir un backend adapté à vos contraintes

    Aucune base de données vectorielle n’est universellement la meilleure ; une seule l’est pour un ensemble donné de contraintes. En résumé :

    • Tableau NumPy (construit manuellement) : fonctionne en mémoire ; adapté pour environ 10 000 vecteurs au maximum, pour l’apprentissage ou lorsque vous souhaitez un contrôle total.
    • Chroma : base de données intégrée, en mémoire ou sous forme de serveur local ; idéale pour les prototypes et les applications de petite à moyenne taille, c’est le point de départ le plus simple.
    • FAISS : bibliothèque d’indexation, en mémoire ; offre la vitesse maximale lorsque vous gérez vous-même le stockage et les métadonnées.
    • pgvector : extension de Postgres intégrée à votre base de données existante ; parfaite si vous utilisez déjà Postgres et souhaitez gérer les vecteurs en même temps que les données relationnelles.
    • Qdrant : base de données auto-hébergée avec Docker ou dans le cloud ; offre un filtrage puissant, des performances grâce à Rust, est open source et permet une recherche hybride.
  • Weaviate : base de données, auto-hébergée ou dans le cloud ; modules intégrés, recherche hybride, une API GraphQL.
  • Milvus : base de données, auto-hébergée (plus complexe à gérer) ou dans le cloud ; conçue pour des milliards de vecteurs.
  • Pinecone : uniquement gérée, dans son propre cloud ; aucune opération requise et mise à l’échelle automatique sans serveur.
  • La question qui revient sans cesse est géré versus auto-hébergé. Les services gérés, tels que Pinecone ou les versions cloud des autres solutions, éliminent le travail opérationnel mais entraînent des coûts continus et placent vos données sur les serveurs du fournisseur. L’auto-hébergement de Chroma, Qdrant, Weaviate, Milvus ou pgvector signifie que vous les gérez vous-même tout en conservant le contrôle sur les coûts et la localisation des données. Une règle empirique souvent citée est que l’auto-hébergement devient avantageux lorsque la facture mensuelle des services gérés atteint plusieurs centaines de dollars ; en dessous de ce seuil, les heures d’ingénierie consacrées aux opérations coûtent généralement plus que ce qu’elles permettent d’économiser. Considérez cela comme une indication, et non comme une règle absolue, car la capacité opérationnelle de votre équipe est tout aussi importante que le montant de la facture.

    Pour l’apprentissage et la plupart des projets secondaires, Chroma ou pgvector suffisent. Pinecone ou Qdrant Cloud s’avèrent utiles lorsque votre équipe préfère ne pas gérer l’infrastructure elle-même, tandis que Milvus est adapté lorsque vous traitez réellement des milliards de vecteurs. Si vous hésitez à savoir s’il vous faut vraiment un stockage vectoriel dédié, consultez pourquoi le RAG en production dépasse les bases de données vectorielles dédiées.

    Erreurs qui posent problème une fois la base de données en fonctionnement

    • Déséquilibre des dimensions est l’erreur la plus fréquente. La dimension de l’index doit correspondre à la taille de sortie du modèle d’incorporation : 384 pour MiniLM, 1536 pour de nombreux modèles d’incorporation d’OpenAI. Mélanger des modèles dans un même index entraîne systématiquement des bugs.
  • Désaccord de métrique détruit silencieusement la pertinence, par exemple en utilisant une distance euclidienne avec un modèle conçu pour la cosinus. Il faut adapter la métrique au modèle.
  • L’approximation n’est pas exacte. Les index ANN manquent parfois du véritable voisin le plus proche ; c’est le prix à payer pour la vitesse. La plupart des bases de données proposent un paramètre, comme ef_search pour HNSW ou nprobe pour IVF, qui améliore le taux de rappel au détriment du temps de réponse. Pour en savoir plus sur ces paramètres, consultez l’article sur l’ajustement des index HNSW pour les systèmes RAG en production.
  • Re-encodage à chaque redémarrage se produit lorsque rien n’est persisté, ce qui signifie que vous devez payer le coût d’encodage à chaque exécution. Utilisez PersistentClient de Chroma ou une base de données gérée par un serveur.
  • Les filtres ont leur propre coût. Un filtrage intensif des métadonnées peut ralentir les requêtes ; indexez les champs sur lesquels vous effectuez le filtrage si votre base de données le permet.
  • Un bon exercice consiste à prendre un script RAG existant basé sur NumPy et à remplacer uniquement sa fonction de récupération par la collection Chroma présentée précédemment. La partie de génération ne change pas du tout, ce qui montre que la récupération constitue véritablement une couche indépendante et remplaçable.

    Référence rapide pour la résolution des problèmes

    • dimension does not match : vos vecteurs ne correspondent pas à la taille attendue par l’index. Recréez l’index avec la dimension correcte, ou utilisez l’embedder pour lequel il a été créé.
    • Les résultats de Chroma semblent aléatoires : les métriques sont probablement mélangées, ou des embedders différents ont été utilisés pour l’ajout et la consultation. Ils doivent être identiques dans les deux cas.
  • Erreurs has_index ou create_index de Pinecone : assurez-vous d’utiliser la version actuelle du SDK (from pinecone import Pinecone) et non plus la méthode obsolète pinecone.init.
  • La fonction search de FAISS renvoie des IDs égaux à -1 : l’index contient moins de vecteurs que la valeur k. Ajoutez des données ou réduisez la valeur de k.
  • Tout est lent à grande échelle : il s’agit très probablement d’un index plat et exact. Passez à HNSW ou IVF et ajustez le paramètre de rappel.
  • Points clés

  • Le filtrage et la persistance des métadonnées sont des fonctionnalités de production qui manquent à un simple tableau.
  • Lorsque la qualité des résultats est médiocre, déterminez d’abord si le problème vient des embeddings (modèle ou métrique incorrects) ou de l’index (taux de rappel trop faible). Les distances retournées vous indiquent généralement la cause.
  • Maintenez le stockage derrière une interface restreinte afin que le passage de Chroma à Pinecone, ou à tout autre système, ne concerne que les codes de connexion et d’appel.