La recherche par mots-clés fait correspondre des chaînes. La recherche sémantique fait correspondre le sens — « J’ai oublié mon identifiant » trouve l’article intitulé « Réinitialiser votre mot de passe » alors qu’ils ne partagent aucun mot. Le mécanisme, ce sont les embeddings : un modèle transforme chaque morceau de texte en un vecteur de quelques milliers de nombres, et les textes au sens proche se retrouvent côte à côte dans cet espace. Génère ces vecteurs une fois, stocke-les, et tu peux classer n’importe quoi par pertinence — la moitié « récupération » de tout système RAG.
Brievio expose /v1/embeddings comme un endpoint compatible OpenAI prêt à l’emploi, donc l’appel SDK est identique à celui que tu écris déjà — seule la base_url change. Les embeddings sont bon marché, tu paies des décomptes de tokens honnêtes, et les appels 4xx/5xx en échec ne coûtent rien. Cet article est le chemin pratique : générer des vecteurs, les noter avec la similarité cosinus, les stocker et les interroger avec pgvector, puis brancher le résultat dans un pipeline de récupération — avec les pièges qui mordent vraiment.
Générer des embeddings
Un seul appel. Passe une liste de chaînes, récupère une liste de vecteurs dans le même ordre. Regroupe toujours — quelques centaines d’entrées par requête coûtent le même prix au token qu’une à la fois, mais t’épargnent des centaines d’allers-retours :
# Génère des embeddings avec le SDK OpenAI — même appel, base_url différente.
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
EMBED_MODEL = "text-embedding-3-large" # choisis un seul modèle et garde-le
def embed(texts: list[str]) -> list[list[float]]:
# Regroupe jusqu'à quelques centaines d'entrées par appel — bien moins
# d'allers-retours, même prix au token. La réponse préserve l'ordre des entrées.
resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [row.embedding for row in resp.data]
vecs = embed(["Comment réinitialiser mon mot de passe ?", "Où est ma facture ?"])
print(len(vecs), "x", len(vecs[0])) # 2 x 3072 (dimension selon le modèle)
# l'usage est rapporté honnêtement — les embeddings sont comptés au token comme tout appel
print(resp.usage.prompt_tokens, "tokens facturés")La longueur du vecteur (sa dimension) est fixée par le modèle — couramment 768, 1536 ou 3072. Les dimensions plus élevées capturent un peu plus de nuance mais coûtent plus cher à stocker et à comparer. La règle la plus importante de toutes : choisis un seul modèle d’embeddings et n’en mélange jamais. Un vecteur issu d’un modèle et un vecteur issu d’un autre vivent dans des espaces différents et incompatibles — les comparer produit du bruit. Consulte le catalogue de modèles en direct pour les modèles d’embeddings disponibles et leurs dimensions ; le tarif par modèle figure sur la page de tarifs.
Noter avec la similarité cosinus
Pour trouver les correspondances les plus proches, tu compares le vecteur de la requête à tes vecteurs stockés. La métrique standard, c’est la similarité cosinus : elle mesure l’angle entre deux vecteurs et ignore leur longueur, donc elle se soucie de la direction (le sens) plutôt que de la magnitude. 1.0 signifie la même direction, 0 signifie sans rapport :
# Recherche sémantique = embarque la requête, note-la face à chaque vecteur
# stocké, renvoie le plus proche. La fonction de score est la similarité cosinus.
import numpy as np
def cosine(a: np.ndarray, b: np.ndarray) -> float:
# 1.0 = même direction, 0 = sans rapport, -1 = opposé.
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
# Beaucoup de modèles d'embeddings renvoient déjà des vecteurs de norme 1. Dans ce
# cas, la similarité cosinus se réduit à un simple produit scalaire — moins coûteux à l'échelle :
def cosine_unit(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b)
query = np.array(embed(["J'ai oublié mon identifiant"])[0])
corpus = np.array(embed(documents)) # (N, dim)
scores = corpus @ query / (
np.linalg.norm(corpus, axis=1) * np.linalg.norm(query)
)
top = scores.argsort()[::-1][:5] # indices des 5 meilleures correspondances
for i in top:
print(round(float(scores[i]), 3), documents[i][:60])Deux remarques pratiques. D’abord, beaucoup de modèles d’embeddings renvoient déjà des vecteurs de norme 1 — dans ce cas, la similarité cosinus n’est qu’un produit scalaire, ce qui rend la recherche en force brute sur des dizaines de milliers de vecteurs assez rapide pour se passer entièrement d’une base de données. Ensuite, la boucle en force brute convient jusqu’à quelques dizaines de milliers de vecteurs environ. Au-delà, scanner chaque vecteur à chaque requête devient lent, et tu veux un vrai magasin de vecteurs avec un index.
Stocker et interroger avec pgvector
Si tes données vivent déjà dans Postgres, tu n’as pas besoin d’une base de données vectorielle séparée — l’extension pgvector ajoute un type de colonne vector et des opérateurs de plus proches voisins. Tu embarques chaque chunk une fois, tu stockes le vecteur à côté de son texte, et tu laisses Postgres faire la recherche :
-- pgvector transforme Postgres en magasin de vecteurs. Active-le une fois, puis
-- stocke l'embedding de chaque chunk à côté de son texte et de ses métadonnées.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
content text NOT NULL,
embedding vector(3072) -- doit correspondre à la dimension de ton modèle
);
-- Index des plus proches voisins approchés. <=> est la distance cosinus dans pgvector
-- (0 = le plus proche). Construis l'index APRÈS le chargement en masse, jamais avant.
CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops);
-- Récupération : passe l'embedding de la requête en paramètre ($1) et prends les
-- lignes les plus proches. Trier par distance croissante = le plus similaire d'abord.
SELECT id, doc_id, content, 1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE doc_id = ANY($2) -- pré-filtre optionnel sur les métadonnées
ORDER BY embedding <=> $1
LIMIT 5;L’opérateur <=> est la distance cosinus (0 = identique), donc 1 - (embedding <=> $1) te redonne un score de similarité. L’index hnsw rend la recherche approchée mais rapide — pour la plupart des charges de récupération, le minuscule compromis sur le rappel est invisible, et tu le construis après le chargement en masse, jamais avant. La clause WHERE optionnelle est le superpouvoir discret : filtre par locataire, document, langue ou date avant la recherche vectorielle pour ne jamais récupérer un voisin que l’utilisateur n’a pas le droit de voir.
Le brancher dans le RAG
La génération augmentée par récupération, ce sont deux étapes cousues ensemble : une recherche sémantique pour trouver le contexte pertinent, puis un modèle de génération pour répondre à partir de ce contexte. Le côté récupération, c’est tout ce qui précède. Le côté génération est une complétion de chat normale — et c’est là que tu associes un modèle d’embeddings bon marché à un solide modèle de raisonnement :
- Découpe en chunks, puis embarque. Découpe les documents en passages d’environ quelques centaines de tokens avec un léger chevauchement, embarque chacun, et stocke-les. Le découpage est le levier dans lequel la plupart des équipes sous-investissent : des chunks trop grands noient la réponse dans le bruit ; trop petits, ils perdent le contexte qui leur donne du sens. Règle-le sur tes propres données.
- Récupère au moment de la requête. Embarque la question de l’utilisateur avec le même modèle, tire les 3 à 8 meilleurs chunks, et colle leur texte dans le prompt comme contexte.
- Génère la réponse. Envoie ce contexte plus la question à un modèle capable — Claude ou Gemini via le même endpoint Brievio — et demande-lui de répondre uniquement à partir du contexte fourni et de citer le chunk qu’il a utilisé.
L’économie tient parce que les deux moitiés ont des prix radicalement différents. Embarquer tout ton corpus est un travail par lots ponctuel et peu coûteux ; le ré-embarquement n’arrive que quand le contenu change. Le coût par requête est dominé par l’étape de génération, pas par la récupération — c’est précisément pourquoi les techniques de maîtrise des coûts de notre guide d’optimisation des coûts des API d’IA (mettre en cache les instructions statiques, plafonner la sortie, choisir le bon modèle par tâche) font bien plus bouger l’aiguille que quoi que ce soit du côté des embeddings.
Les pièges qui mordent vraiment
- Les vecteurs ne sont pas interchangeables entre modèles. Change de modèle d’embeddings et tu dois ré-embarquer tout le corpus — les vecteurs de la requête et ceux stockés doivent venir du même modèle, sinon les scores n’ont aucun sens. Traite le choix du modèle comme une décision de schéma.
- La dimension est un compromis stockage / vitesse. Un vecteur de 3072 dimensions pèse quatre fois plus d’octets qu’un de 768 et se compare plus lentement. Plus gros n’est pas automatiquement meilleur pour ta tâche — mesure le rappel sur tes propres requêtes avant de payer pour le plus grand modèle.
- La qualité du découpage l’emporte sur celle du modèle. La plupart des mauvaises réponses RAG remontent à de mauvais chunks, pas à un modèle d’embeddings faible. Respecte la structure du document — découpe sur les titres et les paragraphes, pas sur un décompte aveugle de caractères.
- La recherche sémantique peut rater les termes exacts. Les références produit (SKU), les codes d’erreur et les noms sont parfois mieux retrouvés par la recherche par mots-clés. En pratique, les systèmes les plus solides sont hybrides : ils combinent la similarité vectorielle avec un score classique en texte intégral ou BM25.
- Les embeddings ont eux aussi une limite de contexte. Le texte qui dépasse la limite d’entrée du modèle est tronqué en silence — ton « embedding » ne représente alors que la première partie d’un chunk trop long. Garde les chunks confortablement sous la limite.
Ce qu’il faut retenir, concrètement
La recherche sémantique, ce sont quatre pièces mobiles : un modèle d’embeddings, une métrique de similarité, un magasin, et une stratégie de découpage. Choisis un seul modèle d’embeddings et tiens-t’y ; utilise la similarité cosinus ; commence par la force brute en NumPy et passe à l’index HNSW de pgvector quand ton corpus dépasse un seul scan ; et dépense ton budget de réglage sur le découpage, car c’est là que la qualité de récupération se gagne ou se perd. Via Brievio, l’appel d’embeddings est le SDK OpenAI que tu connais déjà avec une seule ligne modifiée, compté sur des décomptes de tokens honnêtes, et il côtoie les modèles Claude et Gemini que tu utiliseras pour l’étape de génération — une clé, une base_url, tout le pipeline RAG. La référence de l’endpoint et un démarrage rapide exécutable se trouvent dans la documentation.