Cours Python · Module 11: RAG et systèmes multi-agents
Bases de données vectorielles
Dans cette leçon6
Dans la leçon précédente, on gardait les embeddings dans une matrice NumPy. Avec cinq documents, ça suffit, mais avec un million, la matrice ne tient plus en mémoire, disparaît au redémarrage du programme, et la parcourir à chaque fois prend trop de temps. Il nous faut une archive d'empreintes qui survive à la nuit au campement et réponde en quelques millisecondes.
Les bases de données vectorielles sont des bases spécialisées, optimisées pour stocker et rechercher des embeddings. C'est comme une bibliothèque avec un catalogue magique qui trouve des livres similaires d'après leur contenu, et non leur titre.
Bases de données vectorielles populaires
Le schéma regroupe les bases populaires selon l'endroit où elles tournent :
1┌─────────────────────────────────────────────────────────┐
2│ Vector Databases Landscape │
3├─────────────────────────────────────────────────────────┤
4│ │
5│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
6│ │ Pinecone │ │ Qdrant │ │ Chroma │ │
7│ │ (Cloud) │ │ (Self-host) │ │ (Local) │ │
8│ └──────────────┘ └──────────────┘ └──────────────┘ │
9│ │
10│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
11│ │ Weaviate │ │ Milvus │ │ FAISS │ │
12│ │ (Semantic) │ │ (Enterprise)│ │ (In-memory) │ │
13│ └──────────────┘ └──────────────┘ └──────────────┘ │
14│ │
15└─────────────────────────────────────────────────────────┘Pinecone fonctionne uniquement dans le cloud comme service géré, Qdrant et Milvus peuvent être hébergés sur ton propre serveur, Chroma est parfait en local pour les prototypes, et FAISS est une bibliothèque de Meta qui garde l'index dans la mémoire de ton processus.
Chroma - base vectorielle locale
Chroma est une base vectorielle locale pour les applications RAG, que tu installes avec un simple pip install chromadb. D'abord, on crée un client, une fonction d'embedding et une collection, l'équivalent d'une table :
1import chromadb
2from chromadb.utils import embedding_functions
3
4# Initialise le client
5client = chromadb.Client() # In-memory
6# ou : client = chromadb.PersistentClient(path="./chroma_db") # Persistant
7
8# Configure la fonction d'embedding
9openai_ef = embedding_functions.OpenAIEmbeddingFunction(
10 api_key="your-key",
11 model_name="text-embedding-3-small"
12)
13
14# Crée une collection
15collection = client.create_collection(
16 name="python_safari",
17 embedding_function=openai_ef,
18 metadata={"description": "Python Safari course documentation"}
19)chromadb.Client() garde les données uniquement en mémoire, tandis que PersistentClient les enregistre dans un dossier sur le disque. La collection appelle elle-même openai_ef pour chaque document, tu n'as donc pas à calculer les embeddings à la main. N'écris pas la clé API dans le code comme dans l'exemple : dans un vrai projet, lis-la depuis une variable d'environnement.
Maintenant, on ajoute des documents avec des métadonnées et on lance tout de suite une recherche :
1# Ajoute des documents
2collection.add(
3 documents=[
4 "Python is a high-level programming language",
5 "RAG combines retrieval with text generation",
6 "Vector databases store embeddings"
7 ],
8 metadatas=[
9 {"topic": "python", "level": "beginner"},
10 {"topic": "ai", "level": "advanced"},
11 {"topic": "databases", "level": "intermediate"}
12 ],
13 ids=["doc1", "doc2", "doc3"]
14)
15
16# Recherche
17results = collection.query(
18 query_texts=["How to start learning programming?"],
19 n_results=2,
20 where={"level": "beginner"} # Filtre par métadonnées
21)
22
23print(results)Chaque document a besoin d'un id unique. Le paramètre where filtre selon les métadonnées avant la comparaison des vecteurs, donc seul le document dont level vaut beginner passe ici. Le résultat est un dictionnaire de listes, une par question.
Si tu as déjà tes propres vecteurs, par exemple issus de la fonction get_embedding de la leçon précédente, passe-les dans le paramètre embeddings, et Chroma saute le calcul :
1# Tes propres vecteurs à la place de la fonction d'embedding de la collection
2collection.add(
3 documents=["Elephants walk to the watering hole at dawn"],
4 embeddings=[get_embedding("Elephants walk to the watering hole at dawn")],
5 ids=["doc4"]
6)Les vecteurs doivent avoir la même dimension que le reste de la collection, c'est pourquoi on utilise le même modèle text-embedding-3-small.
Qdrant - recherche vectorielle en production
Qdrant est une base que tu lances sur un serveur ou dans un conteneur Docker. Travailler avec elle suit toujours quatre étapes : connecter le client, créer une collection avec une configuration de vecteurs, ajouter des points et lancer la recherche. Les deux premières étapes ressemblent à ceci :
1from qdrant_client import QdrantClient
2from qdrant_client.models import Distance, VectorParams, PointStruct
3import numpy as np
4
5# Connexion à Qdrant
6client = QdrantClient(host="localhost", port=6333)
7# ou : client = QdrantClient(":memory:") # In-memory
8
9# Crée une collection
10client.create_collection(
11 collection_name="documents",
12 vectors_config=VectorParams(
13 size=1536, # Taille de l'embedding
14 distance=Distance.COSINE
15 )
16)VectorParams indique à la base la longueur des vecteurs et la mesure utilisée pour les comparer. size doit correspondre au modèle d'embeddings, ici 1536 pour text-embedding-3-small.
Un point dans Qdrant, c'est un vecteur plus un payload, un dictionnaire de données quelconque :
1# Ajoute des points
2def add_documents(texts: list[str], embeddings: list[list[float]]):
3 """Ajoute des documents à Qdrant."""
4 points = [
5 PointStruct(
6 id=i,
7 vector=embedding,
8 payload={"text": text}
9 )
10 for i, (text, embedding) in enumerate(zip(texts, embeddings))
11 ]
12
13 client.upsert(
14 collection_name="documents",
15 points=points
16 )La méthode upsert insère de nouveaux points ou écrase ceux qui ont le même id, c'est pourquoi la relancer ne crée pas de doublons.
Dans le client Python actuel, on recherche avec la méthode query_points. L'ancienne méthode search a été marquée deprecated, et les nouvelles versions de qdrant-client ne l'ont plus :
1# Recherche
2def search(query_embedding: list[float], limit: int = 5):
3 """Recherche des documents similaires."""
4 results = client.query_points(
5 collection_name="documents",
6 query=query_embedding,
7 limit=limit
8 ).points
9
10 return [
11 {
12 "text": hit.payload["text"],
13 "score": hit.score
14 }
15 for hit in results
16 ]query_points renvoie un objet avec une liste points, et chaque résultat a un score et un payload.
Un filtre fonctionne comme where dans Chroma, avec simplement une syntaxe plus formelle :
1# Filtrage
2from qdrant_client.models import Filter, FieldCondition, MatchValue
3
4filtered_results = client.query_points(
5 collection_name="documents",
6 query=query_embedding,
7 query_filter=Filter(
8 must=[
9 FieldCondition(
10 key="category",
11 match=MatchValue(value="python")
12 )
13 ]
14 ),
15 limit=5
16).pointsAttention au piège : add_documents n'enregistre que text dans le payload, donc un filtre sur category ne trouve rien tant que tu n'ajoutes pas ce champ au payload. query_embedding est le vecteur de la question que tu as déjà calculé.
FAISS - recherche rapide en mémoire
FAISS n'est pas un serveur mais une bibliothèque. L'index vit dans la mémoire de ton processus. Enveloppons-le dans une classe qui retient aussi les textes des documents :
1import faiss
2import numpy as np
3from dataclasses import dataclass
4
5@dataclass
6class FAISSIndex:
7 """Wrapper pour FAISS."""
8
9 dimension: int
10 index: faiss.Index = None
11 documents: list[str] = None
12
13 def __post_init__(self):
14 # Différents types d'index
15 # Flat - exact, plus lent
16 self.index = faiss.IndexFlatL2(self.dimension)
17
18 # IVF - plus rapide, approximatif
19 # quantizer = faiss.IndexFlatL2(self.dimension)
20 # self.index = faiss.IndexIVFFlat(quantizer, self.dimension, 100)
21
22 self.documents = []
23
24 def add(self, embeddings: np.ndarray, documents: list[str]):
25 """Ajoute des vecteurs à l'index."""
26 embeddings = np.array(embeddings).astype('float32')
27 self.index.add(embeddings)
28 self.documents.extend(documents)IndexFlatL2 compare la question avec chaque vecteur, le résultat est donc exact. IndexIVFFlat, en commentaire, est plus rapide mais approximatif, et il doit être entraîné avec la méthode train avant l'ajout des vecteurs. FAISS n'accepte que du float32, d'où la conversion dans add.
La recherche renvoie les distances et les numéros des plus proches voisins :
1 def search(self, query_embedding: np.ndarray, k: int = 5) -> list[tuple[str, float]]:
2 """Recherche les k plus proches voisins."""
3 query = np.array([query_embedding]).astype('float32')
4 distances, indices = self.index.search(query, k)
5
6 results = []
7 for idx, dist in zip(indices[0], distances[0]):
8 if 0 <= idx < len(self.documents):
9 results.append((self.documents[idx], float(dist)))
10
11 return resultsUne distance L2 plus petite signifie une plus grande similarité, à l'inverse du cosinus. Quand tu demandes plus de résultats qu'il n'y a de vecteurs, FAISS renvoie l'indice -1, c'est pourquoi on vérifie 0 <= idx.
Un test sur des vecteurs aléatoires montre le mécanisme lui-même :
1# Exemple d'utilisation
2faiss_index = FAISSIndex(dimension=384)
3
4# Ajoute des documents
5embeddings = np.random.rand(100, 384).astype('float32')
6documents = [f"Document {i}" for i in range(100)]
7faiss_index.add(embeddings, documents)
8
9# Recherche
10query = np.random.rand(384).astype('float32')
11results = faiss_index.search(query, k=5)Dans du vrai code, à la place des nombres aléatoires, tu mets des embeddings issus d'un modèle à 384 dimensions.
Pinecone - base vectorielle gérée
Pinecone est un service cloud : tu n'installes pas de serveur, tu crées un index via l'API. À la création, tu indiques la dimension, la métrique et la région :
1from pinecone import Pinecone, ServerlessSpec
2
3# Initialise
4pc = Pinecone(api_key="your-key")
5
6# Crée un index
7pc.create_index(
8 name="python-safari",
9 dimension=1536,
10 metric="cosine",
11 spec=ServerlessSpec(
12 cloud="aws",
13 region="us-east-1"
14 )
15)
16
17# Connexion à l'index
18index = pc.Index("python-safari")ServerlessSpec désigne un index serverless, facturé à l'usage.
L'upsert prend une liste de dictionnaires avec un id, un vecteur et des métadonnées :
1# Upsert (insert/update)
2index.upsert(
3 vectors=[
4 {
5 "id": "doc1",
6 "values": [0.1, 0.2, ...], # 1536 valeurs
7 "metadata": {
8 "text": "Python is a programming language",
9 "category": "programming",
10 "level": 1
11 }
12 }
13 ],
14 namespace="tutorials"
15)La notation [0.1, 0.2, ...] est un raccourci : en Python, les trois points sont l'objet Ellipsis, donc dans du vrai code tu mets une liste complète de 1536 nombres. Un namespace découpe l'index en compartiments indépendants.
Une requête combine un vecteur et un filtre sur les métadonnées :
1# Recherche
2results = index.query(
3 vector=[0.1, 0.2, ...],
4 top_k=10,
5 include_metadata=True,
6 namespace="tutorials",
7 filter={
8 "category": {"$eq": "programming"},
9 "level": {"$lte": 3}
10 }
11)
12
13# Statistiques
14stats = index.describe_index_stats()
15print(f"Number of vectors: {stats['total_vector_count']}")Les opérateurs $eq et $lte signifient "égal" et "inférieur ou égal", et describe_index_stats indique combien de vecteurs se trouvent dans l'index.
Hybrid Search - combiner vecteurs et BM25
Les vecteurs saisissent très bien le sens, mais ils peuvent rater un nom exact, comme un numéro de modèle. BM25 est un algorithme classique de recherche par mots-clés. La recherche hybride combine les deux scores :
1from rank_bm25 import BM25Okapi
2import numpy as np
3
4class HybridSearch:
5 """Combine recherche sémantique et recherche par mots-clés."""
6
7 def __init__(self, documents: list[str], embeddings: np.ndarray):
8 self.documents = documents
9 self.embeddings = embeddings
10
11 # BM25 pour la recherche par mots-clés
12 tokenized = [doc.lower().split() for doc in documents]
13 self.bm25 = BM25Okapi(tokenized)Le constructeur découpe chaque document en mots et construit à partir d'eux un index BM25Okapi, et il reçoit les embeddings déjà prêts.
La méthode search normalise les deux scores dans l'intervalle 0-1 et les mélange avec le poids alpha :
1 def search(
2 self,
3 query: str,
4 query_embedding: np.ndarray,
5 alpha: float = 0.5, # Poids de la recherche sémantique
6 top_k: int = 5
7 ) -> list[tuple[str, float]]:
8 """Recherche hybride avec des poids configurables."""
9
10 # Scores de la recherche sémantique
11 semantic_scores = np.dot(self.embeddings, query_embedding)
12 semantic_scores /= np.linalg.norm(self.embeddings, axis=1)
13 semantic_scores /= np.linalg.norm(query_embedding)
14
15 # Normalise à [0, 1]
16 semantic_scores = (semantic_scores - semantic_scores.min()) / (semantic_scores.max() - semantic_scores.min())
17
18 # Scores BM25
19 bm25_scores = np.array(self.bm25.get_scores(query.lower().split()))
20 if bm25_scores.max() > 0:
21 bm25_scores = bm25_scores / bm25_scores.max()
22
23 # Score hybride
24 hybrid_scores = alpha * semantic_scores + (1 - alpha) * bm25_scores
25
26 # Top K
27 top_indices = np.argsort(hybrid_scores)[::-1][:top_k]
28
29 return [(self.documents[i], hybrid_scores[i]) for i in top_indices]alpha=1, c'est de la recherche sémantique pure, alpha=0 du BM25 pur. Je te conseille de commencer à 0.5 et d'ajuster le poids sur tes propres questions de test.
Les bases vectorielles sont l'infrastructure de tout système RAG. Pour un prototype, prends Chroma, pour la production Qdrant ou Pinecone. Dans la prochaine leçon, tu vas découvrir LlamaIndex - un framework qui simplifie la construction d'applications RAG et se connecte à ces bases en une seule ligne.
Retiens : une base vectorielle, c'est l'archive d'empreintes du campement, qui retrouve les traces les plus proches à partir d'une seule empreinte.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Laquelle des propositions suivantes est une base de données vectorielle populaire ?
2. Qu'est-ce que Chroma dans le contexte des bases de données vectorielles ?
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne les éléments :
- Éditeur de code
Implémente l'ajout de documents à Chroma
- Tri horizontal
Ordonne les éléments :
- Tri vertical
Ordonne les étapes de travail avec Qdrant dans le bon ordre :
- Clique dans l'ordre
Ordonne l'ajout au vector store :
- Tri vertical
Ordonne les étapes de création d'un index FAISS :