Guide technique

Architecture de Recherche Vectorielle : Construire des Systèmes de Recherche par Similarité Prêts pour la Production

Guide technique des systemes de recherche vectorielle. Decouvre les bases de donnees vectorielles, algorithmes d'indexation (HNSW, IVF) et scaling.

10 février 202618 min de lectureÉquipe d'Ingénierie Oronts

Pourquoi la Recherche Vectorielle est Importante Maintenant

Voici le problème avec la recherche traditionnelle : elle ne trouve que les correspondances exactes. Tu cherches "chaussures de course" et tu rates tous les résultats sur "baskets de jogging" parce que les mots ne correspondent pas. La recherche vectorielle résout cette limitation fondamentale en comprenant le sens, pas juste en matchant des mots-clés.

On a déployé des systèmes de recherche vectorielle pour la découverte de produits e-commerce, la recherche documentaire dans des pipelines RAG, et des moteurs de recommandation gérant des millions de requêtes par jour. La technologie a considérablement mûri ces deux dernières années, et l'outillage est enfin prêt pour la production.

La recherche vectorielle ne consiste pas seulement à trouver des éléments similaires. Il s'agit de construire des systèmes qui comprennent ce que les utilisateurs veulent vraiment dire, pas juste ce qu'ils tapent.

Laisse-moi te montrer comment construire ces systèmes correctement, du choix de la bonne base de données à la mise à l'échelle pour du vrai trafic.

Comment la Recherche Vectorielle Fonctionne Réellement

Avant de plonger dans les bases de données et les algorithmes, comprenons ce qu'on fait vraiment. La recherche vectorielle fonctionne en trois étapes :

Étape 1 : Créer les Embeddings

Tu prends tes données (texte, images, produits, peu importe) et tu les convertis en vecteurs avec un modèle d'embedding. Ces vecteurs sont des tableaux de nombres qui capturent le sens sémantique de ton contenu.

// Utiliser les embeddings OpenAI
const response = await openai.embeddings.create({
  model: "text-embedding-3-small",
  input: "chaussures de course confortables pour marathons"
});

const vector = response.data[0].embedding;
// Retourne : [0.0023, -0.0142, 0.0089, ...] (1536 dimensions)

Étape 2 : Stocker et Indexer les Vecteurs

Tu stockes ces vecteurs dans une base de données spécialisée qui construit un index pour une récupération rapide. C'est là que la magie opère avec des algorithmes comme HNSW et IVF.

Étape 3 : Requêter par Similarité

Quand un utilisateur cherche, tu convertis sa requête en vecteur et tu trouves les voisins les plus proches dans ton index. La base de données retourne les éléments les plus similaires classés par distance.

ComposantButExemple
Modèle d'EmbeddingConvertit les données en vecteursOpenAI ada-002, Cohere embed-v3
Base de Données VectorielleStocke et indexe les vecteursPinecone, Weaviate, Qdrant
Métrique de DistanceMesure la similaritéCosine, Euclidean, Dot Product
Algorithme ANNRecherche approximative rapideHNSW, IVF, PQ

Choisir une Base de Données Vectorielle

Comparons les options principales. Je vais être honnête sur les compromis parce que chaque base de données excelle dans différents scénarios.

Pinecone : Simplicité Managée

Pinecone est le plus facile pour démarrer. Entièrement managé, scale automatiquement, et ça marche tout simplement. L'inconvénient ? Tu es enfermé dans leur infrastructure et les prix peuvent grimper à l'échelle.

import { Pinecone } from '@pinecone-database/pinecone';

const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });
const index = pinecone.index('products');

// Upsert des vecteurs
await index.upsert([
  {
    id: 'product-123',
    values: embedding,
    metadata: { category: 'chaussures', price: 129.99 }
  }
]);

// Requête avec filtrage par métadonnées
const results = await index.query({
  vector: queryEmbedding,
  topK: 10,
  filter: { category: { $eq: 'chaussures' } },
  includeMetadata: true
});

Idéal pour : Les équipes qui veulent shipper vite sans gérer d'infrastructure. Les startups avec du financement. Les use cases sous 10M de vecteurs où le coût n'est pas la préoccupation principale.

Weaviate : Schema-First avec GraphQL

Weaviate prend une approche différente avec son design basé sur un schéma et son API GraphQL. Tu définis des classes d'objets avec des propriétés, et Weaviate gère la vectorisation automatiquement si tu le souhaites.

import weaviate from 'weaviate-ts-client';

const client = weaviate.client({
  scheme: 'https',
  host: 'your-cluster.weaviate.network'
});

// Définir le schéma
await client.schema.classCreator().withClass({
  class: 'Product',
  vectorizer: 'text2vec-openai',
  properties: [
    { name: 'name', dataType: ['text'] },
    { name: 'description', dataType: ['text'] },
    { name: 'price', dataType: ['number'] }
  ]
}).do();

// Requête avec GraphQL
const result = await client.graphql
  .get()
  .withClassName('Product')
  .withNearText({ concepts: ['chaussures de marathon'] })
  .withLimit(10)
  .withFields('name description price _additional { distance }')
  .do();

Idéal pour : Les équipes qui construisent des knowledge graphs, les applications nécessitant une recherche hybride (mot-clé + vecteur), les projets où l'enforcement du schéma est important.

Qdrant : Orienté Performance et Open Source

Qdrant est écrit en Rust et optimisé pour la performance. C'est open source, peut être auto-hébergé, et a un excellent système de filtrage. On l'a vu gérer 50M+ vecteurs avec des latences sous 100ms sur du matériel modeste.

import { QdrantClient } from '@qdrant/js-client-rest';

const client = new QdrantClient({ url: 'http://localhost:6333' });

// Créer une collection avec des paramètres optimisés
await client.createCollection('products', {
  vectors: {
    size: 1536,
    distance: 'Cosine'
  },
  optimizers_config: {
    indexing_threshold: 20000
  },
  hnsw_config: {
    m: 16,
    ef_construct: 100
  }
});

// Upsert avec payload
await client.upsert('products', {
  points: [
    {
      id: 'product-123',
      vector: embedding,
      payload: { category: 'chaussures', price: 129.99, in_stock: true }
    }
  ]
});

// Requête avec filtres complexes
const results = await client.search('products', {
  vector: queryEmbedding,
  limit: 10,
  filter: {
    must: [
      { key: 'category', match: { value: 'chaussures' } },
      { key: 'price', range: { lte: 150 } },
      { key: 'in_stock', match: { value: true } }
    ]
  }
});

Idéal pour : Les déploiements auto-hébergés, les projets sensibles aux coûts à l'échelle, les applications nécessitant un filtrage complexe, les équipes qui veulent le contrôle sur l'infrastructure.

pgvector : Recherche Vectorielle dans PostgreSQL

Si tu fais déjà tourner PostgreSQL, pgvector te permet d'ajouter la recherche vectorielle sans une autre base de données. Ce n'est pas l'option la plus rapide, mais c'est souvent assez rapide et ça simplifie drastiquement ton architecture.

-- Activer l'extension
CREATE EXTENSION vector;

-- Créer une table avec colonne vectorielle
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT,
  description TEXT,
  category TEXT,
  price NUMERIC,
  embedding VECTOR(1536)
);

-- Créer un index HNSW pour des requêtes plus rapides
CREATE INDEX ON products
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- Requêter les plus proches voisins
SELECT id, name, price,
       1 - (embedding <=> query_embedding) AS similarity
FROM products
WHERE category = 'chaussures' AND price < 150
ORDER BY embedding <=> query_embedding
LIMIT 10;

Idéal pour : Les équipes déjà sur PostgreSQL, les applications sous 5M de vecteurs, les use cases où tu as besoin de transactions ACID avec la recherche vectorielle, la réduction de la complexité opérationnelle.

Comparaison des Bases de Données Vectorielles

FonctionnalitéPineconeWeaviateQdrantpgvector
DéploiementManagé uniquementManagé + Auto-hébergéManagé + Auto-hébergéAuto-hébergé
Échelle MaxMilliardsCentaines de millionsCentaines de millions~10 millions
Latence Requête<50ms<100ms<50ms<200ms
FiltrageBonExcellentExcellentSQL natif
Courbe d'ApprentissageFacileModéréeFacileMinimale si tu connais SQL
Coût à l'ÉchelleÉlevéModéréFaible (auto-hébergé)Faible
Recherche HybrideLimitéeExcellenteBonneVia recherche full-text

Comprendre les Algorithmes d'Indexation

L'algorithme d'index détermine à quelle vitesse tu peux chercher dans des millions de vecteurs. Voici ce que tu dois savoir sur les deux approches les plus courantes.

HNSW : Hierarchical Navigable Small World

HNSW est l'algorithme dominant pour la recherche vectorielle. Il construit un graphe multi-couches où les couches supérieures ont moins de nœuds et des sauts plus grands, tandis que les couches inférieures ont plus de nœuds et des connexions plus courtes. La recherche commence en haut et descend.

ParamètreCe qu'il contrôleCompromis
mNombre de connexions par nœudPlus élevé = meilleur recall, plus de mémoire
ef_constructLargeur de recherche pendant la constructionPlus élevé = meilleure qualité, indexation plus lente
ef_searchLargeur de recherche pendant les requêtesPlus élevé = meilleur recall, requêtes plus lentes
// Configuration HNSW Qdrant
const hnswConfig = {
  m: 16,              // 16 connexions par nœud (défaut)
  ef_construct: 100,  // Qualité de construction
  full_scan_threshold: 10000  // Passer en brute force en dessous
};

// Pour des exigences de recall élevé
const highRecallConfig = {
  m: 32,
  ef_construct: 200
};

// Pour des environnements limités en mémoire
const lowMemoryConfig = {
  m: 8,
  ef_construct: 50
};

Avantages : Requêtes rapides, bon recall, fonctionne bien pour la plupart des use cases Inconvénients : Intensif en mémoire, mises à jour d'index lentes, pas idéal pour les données en streaming

IVF : Inverted File Index

IVF clusterise tes vecteurs et ne cherche que dans les clusters pertinents. C'est plus rapide à construire que HNSW et utilise moins de mémoire, mais a typiquement un recall plus faible.

ParamètreCe qu'il contrôleCompromis
nlistNombre de clustersPlus élevé = plus de précision, requêtes plus lentes
nprobeClusters à chercherPlus élevé = meilleur recall, requêtes plus lentes
# Exemple Faiss IVF
import faiss

dimension = 1536
nlist = 100  # Nombre de clusters

# Créer un index IVF avec quantizer plat
quantizer = faiss.IndexFlatL2(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)

# Entraîner sur des données sample
index.train(training_vectors)
index.add(all_vectors)

# Chercher avec nprobe
index.nprobe = 10  # Chercher 10 clusters
distances, indices = index.search(query_vector, k=10)

Avantages : Efficace en mémoire, construction d'index rapide, bon pour les mises à jour en streaming Inconvénients : Recall plus faible que HNSW, nécessite une étape d'entraînement, plus de tuning nécessaire

Product Quantization : Compresser les Vecteurs

Quand la mémoire est limitée, la Product Quantization (PQ) compresse les vecteurs en les divisant en sous-vecteurs et en utilisant des codebooks. Tu échanges du recall contre des économies de mémoire drastiques.

# IVF + PQ pour une échelle massive
m = 8  # Nombre de sous-quantizers
bits = 8  # Bits par sous-vecteur

index = faiss.IndexIVFPQ(quantizer, dimension, nlist, m, bits)
# Réduit la mémoire ~32x comparé à un index plat

Utilise PQ quand : Tu as 100M+ vecteurs et tu ne peux pas te permettre le stockage en précision complète. Attends-toi à une perte de recall de 5-10%.

Métriques de Similarité : Choisir la Bonne Distance

La métrique de distance détermine comment la similarité est calculée. Le choix compte plus que tu ne le penses.

MétriqueFormuleIdéal Pour
Cosine1 - (A.B / ||A|| ||B||)Embeddings texte, vecteurs normalisés
Euclidean (L2)sqrt(sum((A-B)^2))Embeddings image, features denses
Dot ProductA.BQuand la magnitude compte, recommandations

Règle générale : Si tes embeddings viennent d'un modèle texte (OpenAI, Cohere, etc.), utilise la similarité cosine. Les vecteurs sont déjà normalisés, et cosine les gère correctement. Pour les embeddings image ou les modèles custom, Euclidean fonctionne souvent mieux.

// La plupart des APIs d'embedding texte retournent des vecteurs normalisés
// Similarité cosine = dot product pour les vecteurs normalisés
const cosineSimilarity = (a, b) => {
  return a.reduce((sum, val, i) => sum + val * b[i], 0);
};

// Distance euclidienne pour les vecteurs non normalisés
const euclideanDistance = (a, b) => {
  return Math.sqrt(
    a.reduce((sum, val, i) => sum + Math.pow(val - b[i], 2), 0)
  );
};

Mettre à l'Échelle la Recherche Vectorielle pour la Production

C'est là que la théorie rencontre la réalité. Laisse-moi te partager ce qu'on a appris en scalant la recherche vectorielle pour gérer des millions de requêtes.

Stratégies de Sharding

Quand un seul nœud ne peut pas contenir tous tes vecteurs, tu dois sharder. Il y a deux approches principales :

Sharding Géographique : Diviser par région si les requêtes sont localisées

shard_fr: Produits France (5M vecteurs)
shard_eu: Produits UE (3M vecteurs)
shard_global: Produits Mondiaux (2M vecteurs)

Sharding par Hash : Distribuer uniformément entre les nœuds

const shardId = hash(productId) % numShards;

Sharding par Métadonnées : Diviser par catégorie ou attribut

shard_electronique: Produits Électroniques
shard_vetements: Produits Vêtements
shard_maison: Produits Maison & Jardin

Réplication pour la Disponibilité

Fais tourner au moins 3 réplicas pour les workloads de production. La recherche vectorielle est intensive en lecture, donc les réplicas aident aussi avec le débit.

# Configuration cluster Qdrant
cluster:
  enabled: true
  replication_factor: 3
  shard_number: 6

# Pinecone (automatique)
# Sélectionne juste le tier "production" avec plusieurs pods

# Weaviate
replicationConfig:
  factor: 3

Mettre en Cache les Requêtes Fréquentes

Certaines requêtes sont bien plus communes que d'autres. Cache-les.

import { Redis } from 'ioredis';

const redis = new Redis();
const CACHE_TTL = 3600; // 1 heure

async function searchWithCache(query, filters) {
  const cacheKey = `vsearch:${hash(query)}:${hash(filters)}`;

  // Vérifier le cache d'abord
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Requêter la base vectorielle
  const embedding = await generateEmbedding(query);
  const results = await vectorDB.search({
    vector: embedding,
    filter: filters,
    limit: 20
  });

  // Cacher les résultats
  await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(results));

  return results;
}

Batching et Traitement Asynchrone

Ne traite pas les vecteurs un par un. Batch tout.

// Mauvais : Traitement séquentiel
for (const doc of documents) {
  const embedding = await generateEmbedding(doc.text);
  await vectorDB.upsert({ id: doc.id, vector: embedding });
}

// Bon : Traitement par batch
const BATCH_SIZE = 100;
for (let i = 0; i < documents.length; i += BATCH_SIZE) {
  const batch = documents.slice(i, i + BATCH_SIZE);

  // Générer les embeddings en parallèle
  const embeddings = await Promise.all(
    batch.map(doc => generateEmbedding(doc.text))
  );

  // Upsert en batch
  await vectorDB.upsert(
    batch.map((doc, j) => ({
      id: doc.id,
      vector: embeddings[j],
      metadata: doc.metadata
    }))
  );
}

Exemple d'Architecture de Production

Voici une vraie architecture qu'on utilise pour un système de recherche e-commerce gérant 10M+ produits et 1000+ requêtes par seconde :

┌─────────────────────────────────────────────────────────────┐
│                         Load Balancer                        │
└─────────────────────┬───────────────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────────────┐
│                      API Gateway                             │
│              (Rate limiting, auth, routing)                  │
└─────────────────────┬───────────────────────────────────────┘
                      │
         ┌────────────┼────────────┐
         │            │            │
   ┌─────▼─────┐ ┌────▼────┐ ┌────▼────┐
   │   Cache   │ │ Search  │ │ Search  │
   │  (Redis)  │ │ Node 1  │ │ Node 2  │
   └───────────┘ └────┬────┘ └────┬────┘
                      │           │
              ┌───────▼───────────▼───────┐
              │     Cluster Qdrant        │
              │   (3 shards, 3 réplicas)  │
              └───────────────────────────┘
                          │
              ┌───────────▼───────────────┐
              │   Service Embedding       │
              │   (Accéléré GPU)          │
              └───────────────────────────┘

Composants Clés

Search Nodes : Services stateless qui gèrent le traitement des requêtes, la génération d'embeddings et le classement des résultats. On fait tourner 2-4 instances derrière un load balancer.

Cluster Qdrant : 3 shards pour la distribution des données, 3 réplicas pour la disponibilité. Chaque shard gère ~3.5M vecteurs. Mémoire totale : ~50GB sur le cluster.

Service Embedding : Service GPU dédié pour générer les embeddings. On utilise des modèles optimisés ONNX pour une inférence 10x plus rapide que les transformers vanilla.

Cache Redis : Cache les requêtes communes et les embeddings fréquents. Réduit la charge Qdrant de ~60%.

Pièges Courants et Comment les Éviter

Piège 1 : Ne Pas Normaliser les Vecteurs

Si tu utilises la similarité cosine mais que tes vecteurs ne sont pas normalisés, tu auras des résultats faux. La plupart des APIs d'embedding retournent des vecteurs normalisés, mais si tu utilises un modèle custom, normalise-les toi-même.

function normalize(vector) {
  const magnitude = Math.sqrt(
    vector.reduce((sum, val) => sum + val * val, 0)
  );
  return vector.map(val => val / magnitude);
}

Piège 2 : Ignorer le Modèle d'Embedding

Le modèle d'embedding compte plus que la base de données. Un bon modèle avec pgvector battra un mauvais modèle avec Pinecone. Prends le temps d'évaluer la qualité des embeddings avant d'optimiser l'infrastructure.

Piège 3 : Ne Pas Planifier les Mises à Jour

La plupart des bases vectorielles sont optimisées pour les lectures, pas les écritures. Si tu as besoin de mises à jour fréquentes, conçois en conséquence :

  • Utilise des write-ahead logs
  • Batch les mises à jour pendant les périodes de faible trafic
  • Considère un index de staging pour les nouvelles données

Piège 4 : Trop Filtrer Avant la Recherche Vectorielle

Filtrer après la recherche vectorielle est généralement plus efficace que filtrer avant. Laisse l'index vectoriel faire son travail, puis filtre les résultats.

// Moins efficace : Filtrer d'abord, puis chercher dans les vecteurs restants
// Plus efficace : Chercher tout, puis filtrer les meilleurs résultats

const results = await vectorDB.search({
  vector: queryEmbedding,
  limit: 100  // Obtenir plus de résultats que nécessaire
});

const filtered = results.filter(r =>
  r.metadata.price < maxPrice &&
  r.metadata.in_stock
).slice(0, 10);

Monitoring et Observabilité

Tu ne peux pas améliorer ce que tu ne mesures pas. Surveille ces métriques :

MétriqueCibleAction si dépassée
Latence p50<50msVérifier la configuration de l'index
Latence p99<200msAjouter des réplicas ou shards
Recall@10>95%Augmenter ef_search ou m
QPS par nœud<1000Ajouter plus de nœuds
Utilisation mémoire<80%Sharder ou utiliser la compression PQ
// Tracker la latence de recherche
const startTime = Date.now();
const results = await vectorDB.search(query);
const latency = Date.now() - startTime;

metrics.histogram('vector_search_latency_ms', latency, {
  collection: 'products',
  filter_count: Object.keys(query.filter || {}).length
});

// Tracker le recall (nécessite une ground truth)
const recall = calculateRecall(results, groundTruth);
metrics.gauge('vector_search_recall', recall);

Démarrer : Recommandations Pratiques

Si tu pars de zéro, voici ce que je recommanderais :

  1. Moins de 1M vecteurs : Commence avec pgvector. C'est simple, probablement assez rapide, et tu connais déjà SQL.

  2. 1M-10M vecteurs : Qdrant auto-hébergé te donne le meilleur rapport performance/prix. Pinecone si tu ne veux pas gérer d'infrastructure.

  3. 10M-100M vecteurs : Qdrant ou Weaviate avec un sharding approprié. Considère le tier enterprise de Pinecone si le budget le permet.

  4. 100M+ vecteurs : Tu as besoin d'une architecture spécialisée. Considère Milvus, Qdrant multi-cluster, ou des solutions custom avec Faiss.

Commence avec des index HNSW (le défaut dans la plupart des bases). N'optimise que quand tu rencontres de vrais problèmes de performance. L'optimisation prématurée gaspille du temps sur des problèmes que tu n'as peut-être pas.

Conclusion

La recherche vectorielle est passée de curiosité de recherche à nécessité de production. Que tu construises de la recherche sémantique, des systèmes de recommandation ou des pipelines RAG, comprendre ces fondamentaux t'aidera à prendre de meilleures décisions architecturales.

La technologie est mature, l'outillage est bon, et la communauté a résolu la plupart des problèmes courants. Ce qui reste, c'est de choisir les bons outils pour ton use case spécifique et de les implémenter de manière réfléchie.

Si tu construis des systèmes de recherche vectorielle et que tu veux discuter architecture, on est toujours contents de partager ce qu'on a appris des déploiements en production.

Sujets couverts

recherche vectoriellebase de données vectoriellePineconeWeaviateQdrantpgvectorHNSWIVFrecherche par similaritéembeddingsrecherche sémantiqueANNapproximate nearest neighbor

Prêt à construire des systèmes IA prêts pour la production ?

Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.

Démarrer une conversation