
Vous n’avez probablement pas besoin d’une base de données vectorielles pour votre RAG – pour le moment
grâce à la Retrieval Augmented Generation (RAG), les bases de données vectorielles suscitent beaucoup d’attention dans le monde de l’IA.
Beaucoup de gens disent que vous avez besoin d’outils comme Pinecone, Weaviate, Milvus ou Qdrant pour créer un système RAG et gérer vos intégrations. Si vous travaillez sur des applications d’entreprise comportant des centaines de millions de vecteurs, des outils comme ceux-ci sont essentiels. Ils vous permettent d’effectuer des opérations CRUD, de filtrer par métadonnées et d’utiliser une indexation sur disque qui va au-delà de la mémoire de votre ordinateur.
Mais pour la plupart des outils internes, des robots de documentation ou des agents MVP, l’ajout d’une base de données vectorielles dédiée peut s’avérer excessif. Cela augmente la complexité, les retards du réseau, ajoute des coûts de sérialisation et rend les choses plus compliquées à gérer.
La vérité est que la « recherche vectorielle » (c’est-à-dire la partie récupération de RAG) n’est qu’une multiplication matricielle. Et Python dispose déjà de certains des meilleurs outils au monde pour cela.
Dans cet article, nous montrerons comment créer un système prêt pour la production. composant de récupération d’un pipeline RAG pour les volumes de données petits à moyens en utilisant uniquement NumPy et SciKit-Learn. Vous verrez qu’il est possible de rechercher des millions de chaînes de texte en quelques millisecondes, le tout en mémoire et sans aucune dépendance externe.
Comprendre la récupération en tant que mathématiques matricielles
En règle générale, RAG comporte quatre étapes principales :
- Intégrer : transformez le texte de vos données sources en vecteurs (listes de nombres à virgule flottante)
- Store : écureuil ces vecteurs dans une base de données
- Récupérer : recherchez des vecteurs mathématiquement « proches » du vecteur de requête.
- Générer : envoyez le texte correspondant à un LLM et obtenez votre réponse finale.
Les étapes 1 et 4 reposent sur de grands modèles de langage. Les étapes 2 et 3 relèvent du domaine de la base de données Vector. Nous nous concentrerons sur les parties 2 et 3 et sur la façon dont nous évitons complètement d’utiliser des bases de données vectorielles.
Mais lorsque nous effectuons une recherche dans notre base de données vectorielles, qu’est-ce que la « proximité » ? Habituellement, c’est Similitude cosinus. Si vos deux vecteurs sont normalisés pour avoir une magnitude de 1, alors la similarité cosinusoïdale n’est que le produit scalaire des deux.
Si vous disposez d’un vecteur de requête unidimensionnel de taille N, Q(1xN) et d’une base de données de vecteurs de documents de taille M par N, D(MxN), trouver les meilleures correspondances n’est pas une requête de base de données ; c’est une opération de multiplication matricielle, le produit scalaire de D avec la transposée de Q.
Scores = D.Q^T
NumPy est conçu pour effectuer ce type d’opération efficacement, en utilisant des routines qui exploitent les fonctionnalités modernes du processeur telles que la vectorisation.
La mise en œuvre
Nous allons créer une classe appelée SimpleVectorStore pour gérer l’ingestion, l’indexation et la récupération. Nos données d’entrée seront constituées d’un ou plusieurs fichiers contenant le texte sur lequel nous souhaitons effectuer une recherche. L’utilisation de Sentence Transformers pour les intégrations locales fera que tout fonctionnera hors ligne.
Conditions préalables
Configurez un nouvel environnement de développement, installez les bibliothèques requises et démarrez un notebook Jupyter.
Tapez les commandes suivantes dans un shell de commande. J’utilise UV comme gestionnaire de paquets ; modifiez-le en fonction de l’outil que vous utilisez.
$ uv init ragdb
$ cd ragdb
$ uv venv ragdb
$ source ragdb/bin/activate
$ uv pip install numpy scikit-learn sentence-transformers jupyter
$ jupyter notebook
Le magasin de vecteurs en mémoire
Nous n’avons pas besoin d’un serveur compliqué. Tout ce dont nous avons besoin est une fonction pour charger nos données texte à partir des fichiers d’entrée et les diviser en morceaux de la taille d’un octet, ainsi qu’une classe avec deux listes : une pour les morceaux de texte brut et une pour la matrice d’intégration. Voici le code.
import numpy as np
import os
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
from typing import List, Dict, Any
from pathlib import Path
class SimpleVectorStore:
def __init__(self, model_name: str = 'all-MiniLM-L6-v2'):
print(f"Loading embedding model: {model_name}...")
self.encoder = SentenceTransformer(model_name)
self.documents = [] # Stores the raw text and metadata
self.embeddings = None # Will become a numpy array
def add_documents(self, docs: List[Dict[str, Any]]):
"""
Ingests documents.
docs format: [{'text': '...', 'metadata': {...}}, ...]
"""
texts = [d['text'] for d in docs]
# 1. Generate Embeddings
print(f"Embedding {len(texts)} documents...")
new_embeddings = self.encoder.encode(texts)
# 2. Normalize Embeddings
# (Critical optimization: allows dot product to approximate cosine similarity)
norm = np.linalg.norm(new_embeddings, axis=1, keepdims=True)
new_embeddings = new_embeddings / norm
# 3. Update Storage
if self.embeddings is None:
self.embeddings = new_embeddings
else:
self.embeddings = np.vstack([self.embeddings, new_embeddings])
self.documents.extend(docs)
print(f"Store now contains {len(self.documents)} documents.")
def search(self, query: str, k: int = 5):
"""
Retrieves the top-k most similar documents.
"""
if self.embeddings is None or len(self.documents) == 0:
print("Warning: Vector store is empty. No documents to search.")
return []
# 1. Embed and Normalize Query
query_vec = self.encoder.encode([query])
norm = np.linalg.norm(query_vec, axis=1, keepdims=True)
query_vec = query_vec / norm
# 2. Vectorized Search (Matrix Multiplication)
# Result shape: (1, N_docs)
scores = np.dot(self.embeddings, query_vec.T).flatten()
# 3. Get Top-K Indices
# argsort sorts ascending, so we take the last k and reverse them
# Ensure k doesn't exceed the number of documents
k = min(k, len(self.documents))
top_k_indices = np.argsort(scores)[-k:][::-1]
results = []
for idx in top_k_indices:
results.append({
"score": float(scores[idx]),
"text": self.documents[idx]['text'],
"metadata": self.documents[idx].get('metadata', {})
})
return results
def load_from_directory(directory_path: str, chunk_size: int = 1000, overlap: int = 200):
"""
Reads .txt files and splits them into overlapping chunks.
"""
docs = []
# Use pathlib for robust path handling and resolution
path = Path(directory_path).resolve()
if not path.exists():
print(f"Error: Directory '{path}' not found.")
print(f"Current working directory: {os.getcwd()}")
return docs
print(f"Loading documents from: {path}")
for file_path in path.glob("*.txt"):
try:
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
# Simple sliding window chunking
# We iterate through the text with a step size smaller than the chunk size
# to create overlap (preserving context between chunks).
step = chunk_size - overlap
for i in range(0, len(text), step):
chunk = text[i : i + chunk_size]
# Skip chunks that are too small (e.g., leftover whitespace)
if len(chunk) < 50:
continue
docs.append({
"text": chunk,
"metadata": {
"source": file_path.name,
"chunk_index": i
}
})
except Exception as e:
print(f"Warning: Could not read file {file_path.name}: {e}")
print(f"Successfully loaded {len(docs)} chunks from {len(list(path.glob('*.txt')))} files.")
return docs
Le modèle d’intégration utilisé
Le modèle all-MiniLM-L6-v2 utilisé dans le code provient du Transformateurs de phrases bibliothèque. Cela a été choisi parce que,
- C’est rapide et léger.
- Il produit des vecteurs à 384 dimensions qui utilisent moins de mémoire que les modèles plus grands.
- Il fonctionne bien sur une grande variété de tâches en anglais sans nécessiter de réglages spécialisés.
Ce modèle n’est qu’une suggestion. Vous pouvez utiliser n’importe quel modèle d’intégration de votre choix si vous avez un favori particulier.
Pourquoi normaliser ?
Vous remarquerez peut-être les étapes de normalisation dans le code. Nous l’avons déjà mentionné, mais pour être clair, étant donné deux vecteurs X et Y, la similarité cosinus est définie comme
Similarité = (X · Y) / (||X|| * ||Y||)
Où:
- X · Y est le produit scalaire des vecteurs X et Y
- ||X|| est la grandeur (longueur) du vecteur X
- ||Y|| est la norme du vecteur Y
Étant donné que la division nécessite un calcul supplémentaire, si tous nos vecteurs ont une magnitude unitaire, le dénominateur est 1, donc la formule se réduit au produit scalaire de X et Y, ce qui rend la recherche beaucoup plus rapide.
Tester les performances
La première chose que nous devons faire est d’obtenir des données d’entrée avec lesquelles travailler. Vous pouvez utiliser n’importe quel fichier texte d’entrée pour cela. Pour les expériences RAG précédentes, j’ai utilisé un livre que j’ai téléchargé du projet Gutenberg. Le toujours captivant :
« Maladies des bovins, des moutons, des chèvres et des porcs par Jno. AW Dollar & G. Moussu”
Notez que vous pouvez consulter la page Autorisations, licences et autres demandes courantes du projet Gutenberg en utilisant le lien suivant.
https://www.gutenberg.org/policy/permission.html
Mais pour résumer, la grande majorité des livres électroniques du Projet Gutenberg sont dans le domaine public aux États-Unis et dans d’autres régions du monde. Cela signifie que personne ne peut accorder ou refuser la permission de faire de cet objet ce qui vous plaît.
« … à votre guise » inclut toute utilisation commerciale, réédition dans n’importe quel format, création d’œuvres ou de performances dérivées
J’ai téléchargé le texte du livre depuis le site Web du Projet Gutenberg sur mon PC local en utilisant ce lien,
https://www.gutenberg.org/ebooks/73019.txt.utf-8
Ce livre contenait environ 36 000 lignes de texte. L’interrogation du livre ne prend que six lignes de code. Pour mon exemple de question, la ligne 2315 du livre traite d’une maladie appelée CONDYLOMATA. Voici l’extrait,
INFLAMMATION DE L’ESPACE INTERDIGITAL.
(CONDYLOMATES.)
Les condylomes résultent d’une inflammation chronique de la peau recouvrant la
ligament interdigital. Toute blessure à cette région causant même
des dommages superficiels peuvent entraîner une inflammation chronique de la peau et
hypertrophie des papilles, première étape de la production de
condylomes.Blessures produites par des cordons glissés dans l’espace interdigital pour le
Le but de lever les pieds lors du ferrage des bœufs de travail est également fructueux
causes.
C’est donc ce que nous demanderons : « Qu’est-ce que les condylomes ? » Notez que nous n’obtiendrons pas de réponse appropriée car nous n’incorporons pas nos résultats de recherche dans un LLM, mais nous devrions voir que notre recherche renvoie un extrait de texte qui donnerait au LLM toutes les informations requises pour formuler une réponse si nous l’avions fait.
%%time
# 1. Initialize
store = SimpleVectorStore()
# 2. Load Documents
real_docs = load_from_directory("/mnt/d/book")
# 3. Add to Store
if real_docs:
store.add_documents(real_docs)
# 4. Search
results = store.search("What is Condylomata?", k=1)
results
Et voici le résultat.
Loading embedding model: all-MiniLM-L6-v2...
Loading documents from: /mnt/d/book
Successfully loaded 2205 chunks from 1 files.
Embedding 2205 documents...
Store now contains 2205 documents.
CPU times: user 3.27 s, sys: 377 ms, total: 3.65 s
Wall time: 3.82 s
[{'score': 0.44883957505226135,
'text': 'two last\nphalanges, the latter operation being easier than
the former, and\nproviding flaps of more regular shape and better adapted
for the\nproduction of a satisfactory stump.\n\n\n
INFLAMMATION OF THE INTERDIGITAL SPACE.\n\n(CONDYLOMATA.)\n\n
Condylomata result from chronic inflammation of the skin covering
the\ninterdigital ligament. Any injury to this region causing
even\nsuperficial damage may result in chronic inflammation of the
skin and\nhypertrophy of the papillæ, the first stage in the production
of\ncondylomata.\n\nInjuries produced by cords slipped into the
interdigital space for the\npurpose of lifting the feet when shoeing
working oxen are also fruitful\ncauses.\n\nInflammation of the
interdigital space is also a common complication of\naphthous eruptions
around the claws and in the space between them.\nContinual contact with
litter, dung and urine favour infection of\nsuperficial or deep wounds,
and by causing exuberant granulation lead to\nhypertrophy of the papillary
layer of ',
'metadata': {'source': 'cattle_disease.txt', 'chunk_index': 122400}}]
Moins de 4 secondes pour lire, fragmenter, stocker et interroger correctement un document texte de 36 000 lignes, c’est plutôt bien.
SciKit-Learn : le chemin de mise à niveau
NumPy fonctionne bien pour les recherches par force brute. Mais que se passe-t-il si vous avez des dizaines ou des centaines de documents et que la force brute est trop lente ? Avant de passer à une base de données vectorielle, vous pouvez essayer les voisins les plus proches de SciKit-Learn. Il utilise des structures arborescentes telles que KD-Tree et Ball-Tree pour accélérer les recherches vers O(log N) au lieu de O(N).
Pour tester cela, j’ai téléchargé un tas d’autres livres de Gutenberg, notamment : –
- Un chant de Noël de Charles Dickens
- La vie et les aventures du Père Noël de L. Frank Baum
- Guerre et Paix de Tolstoï
- Un adieu aux armes par Hemingway
Au total, ces livres contiennent environ 120 000 lignes de texte. J’ai copié et collé dix fois les cinq fichiers du livre d’entrée, ce qui a donné cinquante fichiers et 1,2 million de lignes de texte. Cela représente environ 12 millions de mots, en supposant une moyenne de 10 mots par ligne. Pour donner un peu de contexte, cet article contient environ 2 800 mots, le volume de données avec lequel nous testons équivaut donc à plus de 4 000 fois le volume de ce texte.
$ dir
achristmascarol\ -\ Copy\ (2).txt cattle_disease\ -\ Copy\ (9).txt santa\ -\ Copy\ (6).txt
achristmascarol\ -\ Copy\ (3).txt cattle_disease\ -\ Copy.txt santa\ -\ Copy\ (7).txt
achristmascarol\ -\ Copy\ (4).txt cattle_disease.txt santa\ -\ Copy\ (8).txt
achristmascarol\ -\ Copy\ (5).txt farewelltoarms\ -\ Copy\ (2).txt santa\ -\ Copy\ (9).txt
achristmascarol\ -\ Copy\ (6).txt farewelltoarms\ -\ Copy\ (3).txt santa\ -\ Copy.txt
achristmascarol\ -\ Copy\ (7).txt farewelltoarms\ -\ Copy\ (4).txt santa.txt
achristmascarol\ -\ Copy\ (8).txt farewelltoarms\ -\ Copy\ (5).txt warandpeace\ -\ Copy\ (2).txt
achristmascarol\ -\ Copy\ (9).txt farewelltoarms\ -\ Copy\ (6).txt warandpeace\ -\ Copy\ (3).txt
achristmascarol\ -\ Copy.txt farewelltoarms\ -\ Copy\ (7).txt warandpeace\ -\ Copy\ (4).txt
achristmascarol.txt farewelltoarms\ -\ Copy\ (8).txt warandpeace\ -\ Copy\ (5).txt
cattle_disease\ -\ Copy\ (2).txt farewelltoarms\ -\ Copy\ (9).txt warandpeace\ -\ Copy\ (6).txt
cattle_disease\ -\ Copy\ (3).txt farewelltoarms\ -\ Copy.txt warandpeace\ -\ Copy\ (7).txt
cattle_disease\ -\ Copy\ (4).txt farewelltoarms.txt warandpeace\ -\ Copy\ (8).txt
cattle_disease\ -\ Copy\ (5).txt santa\ -\ Copy\ (2).txt warandpeace\ -\ Copy\ (9).txt
cattle_disease\ -\ Copy\ (6).txt santa\ -\ Copy\ (3).txt warandpeace\ -\ Copy.txt
cattle_disease\ -\ Copy\ (7).txt santa\ -\ Copy\ (4).txt warandpeace.txt
cattle_disease\ -\ Copy\ (8).txt santa\ -\ Copy\ (5).txtLet's say we are ut
Disons que nous cherchions finalement une réponse à la question suivante,
Pour qui, après les vacances de Noël, Nicolas a-t-il fait part de son amour à sa mère ?
Au cas où vous ne le sauriez pas, cela vient du roman Guerre et Paix.
Voyons comment notre nouvelle recherche se comporte par rapport à ce vaste ensemble d’informations.
Voici le code utilisant SciKit-Learn.
Tout d’abord, nous avons une nouvelle classe qui implémente l’algorithme du voisin le plus proche de SciKit-Learn.
from sklearn.neighbors import NearestNeighbors
class ScikitVectorStore(SimpleVectorStore):
def __init__(self, model_name='all-MiniLM-L6-v2'):
super().__init__(model_name)
# Brute force is often faster than trees for high-dimensional data
# unless N is very large, but 'ball_tree' can help in specific cases.
self.knn = NearestNeighbors(n_neighbors=5, metric='cosine', algorithm='brute')
self.is_fit = False
def build_index(self):
print("Building Scikit-Learn Index...")
self.knn.fit(self.embeddings)
self.is_fit = True
def search(self, query: str, k: int = 5):
if not self.is_fit: self.build_index()
query_vec = self.encoder.encode([query])
# Note: Scikit-learn handles normalization internally for cosine metric
# if configured, but explicit is better.
distances, indices = self.knn.kneighbors(query_vec, n_neighbors=k)
results = []
for i in range(k):
idx = indices[0][i]
# Convert distance back to similarity score (1 - dist)
score = 1 - distances[0][i]
results.append({
"score": score,
"text": self.documents[idx]['text']
})
return results
Et notre code de recherche est aussi simple que pour la version NumPy.
%%time
# 1. Initialize
store = ScikitVectorStore()
# 2. Load Documents
real_docs = load_from_directory("/mnt/d/book")
# 3. Add to Store
if real_docs:
store.add_documents(real_docs)
# 4. Search
results = store.search("Who, after the Christmas holidays, did Nicholas tell his mother of his love for", k=1)
results
Et notre production.
Loading embedding model: all-MiniLM-L6-v2...
Loading documents from: /mnt/d/book
Successfully loaded 73060 chunks from 50 files.
Embedding 73060 documents...
Store now contains 73060 documents.
Building Scikit-Learn Index...
CPU times: user 1min 46s, sys: 18.3 s, total: 2min 4s
Wall time: 1min 13s
[{'score': 0.6972659826278687,
'text': '\nCHAPTER XIII\n\nSoon after the Christmas holidays Nicholas told
his mother of his love\nfor Sónya and of his firm resolve to marry her. The
countess, who\nhad long noticed what was going on between them and was
expecting this\ndeclaration, listened to him in silence and then told her son
that he\nmight marry whom he pleased, but that neither she nor his father
would\ngive their blessing to such a marriage. Nicholas, for the first time,
\nfelt that his mother was displeased with him and that, despite her love\n
for him, she would not give way. Coldly, without looking at her son,\nshe
sent for her husband and, when he came, tried briefly and coldly to\ninform
him of the facts, in her son's presence, but unable to restrain\nherself she
burst into tears of vexation and left the room. The old\ncount began
irresolutely to admonish Nicholas and beg him to abandon his\npurpose.
Nicholas replied that he could not go back on his word, and his\nfather,
sighing and evidently disconcerted, very soon became silent ',
'metadata': {'source': 'warandpeace - Copy (6).txt',
'chunk_index': 1396000}}]
Presque toutes les 1 min 13 s nécessaires pour effectuer le traitement ci-dessus ont été consacrées au chargement et au découpage de nos données d’entrée. La partie recherche proprement dite, lorsque je l’ai exécutée séparément, a pris moins d’un dixième de seconde !
Pas trop mal du tout.
Résumé
Je ne dis pas que les bases de données vectorielles ne sont pas nécessaires. Ils résolvent des problèmes spécifiques que NumPy et SciKit-Learn ne gèrent pas. Vous devriez migrer de quelque chose comme notre SimpleVectorStore ou ScikitVectorStore à Weaviate/Pinecone/pgvector, etc., lorsque l’une des conditions suivantes s’applique.
Persistance : vous avez besoin de données pour survivre à un redémarrage du serveur sans reconstruire à chaque fois l’index à partir des fichiers source. Bien que np.save ou pickling fonctionne pour une simple persistance. L’ingénierie implique toujours des compromis. L’utilisation d’une base de données vectorielle ajoute de la complexité à votre configuration en échange d’une évolutivité dont vous n’avez peut-être pas besoin pour le moment. Si vous commencez avec une configuration RAG plus simple en utilisant NumPy et/ou SciKit-Learn pour le processus de récupération, vous obtenez :
La RAM est le goulot d’étranglement : votre matrice d’intégration dépasse la mémoire de votre serveur. Remarque : 1 million de vecteurs de 384 dimensions [float32] n’est qu’environ 1,5 Go de RAM, vous pouvez donc en stocker beaucoup en mémoire.
Fréquence CRUD : vous devez constamment mettre à jour ou supprimer des vecteurs individuels pendant la lecture. Les tableaux NumPy, par exemple, sont immuables et l’ajout nécessite la copie de l’intégralité du tableau, ce qui est lent.
Filtrage des métadonnées : vous avez besoin de requêtes complexes telles que « Rechercher des vecteurs proches de X où user_id=10 AND date > 2023 ». Faire cela dans NumPy nécessite des masques booléens qui peuvent devenir compliqués.
L’ingénierie implique toujours des compromis. L’utilisation d’une base de données vectorielle ajoute de la complexité à votre configuration en échange d’une évolutivité dont vous n’avez peut-être pas besoin pour le moment. Si vous commencez avec une configuration RAG plus simple en utilisant NumPy et/ou SciKit-Learn pour le processus de récupération, vous obtenez :
- Latence inférieure. Aucun saut de réseau.
- Coûts inférieurs. Pas d’abonnement SaaS ni d’instances supplémentaires.
- Simplicité. C’est juste un script Python.
Tout comme vous n’avez pas besoin d’une voiture de sport pour aller à l’épicerie. Dans de nombreux cas, NumPy ou SciKit-Learn peuvent suffire à la recherche RAG dont vous avez besoin.



