Recherche vectorielle dans Azure AI Search : HNSW, profils et un récupérateur RAG en Python
Créer un index vectoriel dans Azure AI Search avec Python : HNSW contre KNN exhaustif, profils, embeddings, requêtes vectorielles et un prompt RAG, ainsi que des conseils pour la recherche hybride.
Sources: Building a Vector Index in Azure AI Search: HNSW, Profiles, and RAG Retrieval
La récupération est la partie d’un système RAG qui détermine si la réponse finale est fondée sur des sources concrètes ou reste vague. Cette présentation explique le fonctionnement de la recherche vectorielle au sein d’Azure AI Search, puis crée un petit service Python qui prend une question, trouve le texte le plus pertinent par son sens et assemble un prompt pour un modèle de langage. Une précision sur la nomenclature : ce service s’appelait autrefois Azure Cognitive Search, et de nombreux articles et exemples anciens utilisent encore ce nom. Les concepts restent identiques.
Deux composantes du RAG
La génération augmentée par la récupération se divise en récupération et en génération. Lorsqu’une question arrive, le système recherche dans une base de connaissances et extrait les passages les plus pertinents. Il transmet ensuite ces passages à un modèle de langage accompagnés de la question, afin que la réponse soit basée sur du texte source réel.
La qualité de la génération dépend uniquement de ce qui lui est fourni. Si la récupération des données renvoie des passages incorrects, le modèle répondra de manière erronée ou évasive, quelle que soit sa capacité. C’est pourquoi le choix de la technique de recherche est si important.
Pourquoi la correspondance par mots-clés ne suffit pas
La recherche classique compare des mots littéraux. Une requête pour "voiture" peut manquer un document qui ne mentionne que "automobile". La recherche vectorielle résout ce problème en se basant sur le sens.
Chaque fragment de texte est converti en une liste de nombres appelée embedding. Les textes ayant des significations similaires se trouvent proches les uns des autres dans l’espace vectoriel. Lors de la consultation, la question est également convertie en embedding, et le service cherche les vecteurs stockés qui lui sont les plus proches, une tâche connue sous le nom de recherche du voisin le plus proche. Dans Azure AI Search, vous déclarez un champ vectoriel dans l’index, y stockez les embeddings, et le service crée une structure permettant d’analyser rapidement un très grand nombre de vecteurs.
Algorithmes et profils dans Azure AI Search
Le service propose deux algorithmes de recherche pour les vecteurs :
- HNSW (Hierarchical Navigable Small World) est une méthode approximative de recherche du voisin le plus proche. Elle est rapide et constitue généralement le choix privilégié en environnement de production.
Les algorithmes ne sont pas associés directement à un champ. Un index contient une configuration de recherche vectorielle avec une liste d’algorithmes et une liste de profils, où chaque profil correspond à un choix d’algorithme. Un champ vectoriel fait alors référence à un profil par son nom, permettant ainsi de partager les mêmes paramètres d’algorithme entre plusieurs champs.
Le type de champ est Collection(Edm.Single), c’est-à-dire une collection de nombres flottants à précision simple. Vous devez y indiquer le nombre de dimensions, et cette valeur doit correspondre à la taille de sortie du modèle d’incorporation que vous utilisez.
La structure du système
Il existe deux approches. L’ingestion s’exécute une fois, ou chaque fois que les données changent, afin de remplir l’index. L’approche basée sur les requêtes s’exécute à chaque question posée. Une règle les lie : les deux approches doivent utiliser le même modèle d’incorporation. Si l’on incorpore les documents avec un modèle et les questions avec un autre, les vecteurs ne peuvent pas être comparés, ce qui rend les résultats sans signification.
Les documents sont généralement stockés sous forme de fragments plutôt que de fichiers complets. Des fragments plus petits permettent des recherches plus précises et s’intègrent plus facilement dans une requête. Chaque fragment devient un document de recherche contenant son texte, son embedding ainsi que des métadonnées permettant de le relier à sa source. L’exemple présenté ici utilise des documents courts, de sorte que chacun compte comme un seul fragment. Un système en production ajouterait une étape de fragmentation, mais la structure de l’index reste identique.
Mise en œuvre pratique
Vous avez besoin d’un abonnement Azure, de Python 3.9 ou une version ultérieure, ainsi que d’une certaine maîtrise de l’exécution de scripts. Les embeddings sont générés localement à l’aide d’un petit modèle open source, il n’est donc pas nécessaire de recourir à Azure OpenAI pour suivre ce processus. Le passage à Azure OpenAI est abordé vers la fin.
Étape 1 : Créer le service de recherche
Dans le portail Azure, créez une ressource Azure AI Search. Le plan gratuit suffit ici. Une fois créé, copiez deux valeurs : l’endpoint du service, ayant la forme https://<votre-service>.search.windows.net, et une clé d’administration dans la section Clés. Traitez cette clé comme un secret. Dans un projet réel, elle doit être stockée dans une variable d’environnement ou dans Azure Key Vault, et jamais dans le code source.
Étape 2 : Installer les bibliothèques
Deux paquets sont nécessaires : le SDK Azure Search et sentence-transformers.
pip install azure-search-documents sentence-transformers
Étape 3 : Définir l’index vectoriel
Le script suivant crée un index à l’aide d’un champ vectoriel, puis définit la configuration de recherche vectorielle en indiquant l’algorithme et le profil avant de créer l’index.
from azure.core.credentials import AzureKeyCredential
from azure.search.documents.indexes import SearchIndexClient
from azure.search.documents.indexes.models import (
SearchIndex,
SimpleField,
SearchableField,
SearchField,
SearchFieldDataType,
VectorSearch,
HnswAlgorithmConfiguration,
HnswParameters,
VectorSearchProfile,
)
endpoint = "https://<your-service>.search.windows.net"
admin_key = "<your-admin-key>"
index_name = "rag-docs"
index_client = SearchIndexClient(
endpoint=endpoint,
credential=AzureKeyCredential(admin_key),
)
# 1. Define the fields. The embedding field is the vector field.
fields = [
SimpleField(name="doc_id", type=SearchFieldDataType.String, key=True, filterable=True),
SearchableField(name="text", type=SearchFieldDataType.String),
SimpleField(name="source", type=SearchFieldDataType.String, filterable=True),
SearchField(
name="embedding",
type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
searchable=True,
vector_search_dimensions=384, # must match the embedding model
vector_search_profile_name="my-vector-profile",
),
]
# 2. Define the vector search configuration: one algorithm and one profile.
vector_search = VectorSearch(
algorithms=[
HnswAlgorithmConfiguration(
name="my-hnsw",
parameters=HnswParameters(
m=4,
ef_construction=400,
ef_search=500,
metric="cosine",
),
)
],
profiles=[
VectorSearchProfile(
name="my-vector-profile",
algorithm_configuration_name="my-hnsw",
)
],
)
# 3. Create (or update) the index.
index = SearchIndex(name=index_name, fields=fields, vector_search=vector_search)
index_client.create_or_update_index(index)
print("Index created:", index_name)
Quelques choix dans ce script méritent une explication. Le nombre de dimensions est de 384, car c’est ce que génère le modèle d’incorporation choisi. Le champ vectoriel pointe vers un profil nommé my-vector-profile, qui à son tour pointe vers un algorithme HNSW appelé my-hnsw. La métrique de distance est le cosinus, valeur par défaut courante pour les embeddings de texte.
HNSW expose des paramètres de réglage, et Azure propose des valeurs par défaut judicieuses. Les valeurs documentées sont m égal à 4 pour le nombre de liens bidirectionnels par nœud, efConstruction égal à 400 pour la liste de candidats lors de la construction du graphe, et efSearch égal à 500 pour la liste de candidats lors des requêtes. Augmenter ces valeurs améliore le taux de rappel au détriment du temps de latence et du temps de construction ; commencez donc par les valeurs par défaut et ajustez-les en fonction de vos objectifs.
Étape 4 : Incorporer et télécharger
Ce script charge le modèle, crée une petite base de connaissances composée de cinq documents courts, calcule leurs embeddings et les télécharge dans l’index.
from azure.search.documents import SearchClient
from sentence_transformers import SentenceTransformer
search_client = SearchClient(
endpoint=endpoint,
index_name=index_name,
credential=AzureKeyCredential(admin_key),
)
# Load the embedding model (produces 384-dimensional vectors)
model = SentenceTransformer("all-MiniLM-L6-v2")
# Our small knowledge base
documents = [
{"doc_id": "1", "source": "billing-faq",
"text": "You can update your payment method from the account settings page under Billing."},
{"doc_id": "2", "source": "billing-faq",
"text": "Refunds are processed within five to seven business days to the original payment method."},
{"doc_id": "3", "source": "shipping-faq",
"text": "Standard delivery takes three to five working days within the country."},
{"doc_id": "4", "source": "account-faq",
"text": "To reset your password, click the forgot password link on the login screen."},
{"doc_id": "5", "source": "shipping-faq",
"text": "International orders may take up to fourteen working days depending on customs."},
]
# Create embeddings for the text of each document
texts = [d["text"] for d in documents]
vectors = model.encode(texts)
# Attach the embedding to each document and upload
to_upload = []
for doc, vector in zip(documents, vectors):
to_upload.append({
"doc_id": doc["doc_id"],
"source": doc["source"],
"text": doc["text"],
"embedding": vector.tolist(),
})
result = search_client.upload_documents(documents=to_upload)
print("Uploaded", len(result), "documents.")
Un projet réel lirait des fichiers ou une base de données, diviserait le texte en blocs et enverrait des milliers ou des millions de documents via la même appel upload_documents, généralement par lots.
Étape 5 : Recherche par vecteur
La récupération incorpore la question de l’utilisateur avec le même modèle, puis effectue une recherche vectorielle. Un objet VectorizedQuery indique au service quel vecteur utiliser, combien de voisins retourner et quel champ rechercher.
from azure.search.documents.models import VectorizedQuery
def search(question, k=3):
# Embed the question with the same model
query_vector = model.encode([question])[0]
vector_query = VectorizedQuery(
vector=query_vector.tolist(),
k_nearest_neighbors=k,
fields="embedding",
)
results = search_client.search(
search_text=None, # pure vector search
vector_queries=[vector_query],
select=["doc_id", "source", "text"],
)
output = []
for r in results:
output.append({
"score": r["@search.score"],
"source": r["source"],
"text": r["text"],
})
return output
for item in search("how do I get my money back", k=3):
print(round(item["score"], 4), "|", item["source"], "|", item["text"])
Essayez une question formulée comme « Récupérer mon argent ». Aucun des documents d’exemple ne contient ces mots, pourtant le document concernant les remboursements devrait figurer en premier, car la comparaison porte sur le sens et non sur les mots-clés. C’est précisément le comportement dont a besoin un système RAG.
Étape 6 : Assembler le prompt
La récupération des informations est terminée. La dernière étape consiste à utiliser ces résultats pour créer un prompt. Aucun modèle en particulier n’est utilisé ici, car vous pouvez employer Azure OpenAI ou tout autre fournisseur. Le script se contente de préparer l’entrée.
def build_prompt(question, k=3):
hits = search(question, k=k)
context = "\n\n".join(f"- {hit['text']}" for hit in hits)
prompt = (
"You are a support assistant. Use only the context below to answer "
"the question. If the answer is not in the context, say that you do "
"not have enough information.\n\n"
f"Context:\n{context}\n\n"
f"Question: {question}\n"
"Answer:"
)
return prompt
print(build_prompt("how do I get my money back"))
Le prompt résultant contient la question ainsi que les passages les plus pertinents. L’envoi de ce prompt à un modèle finalise le cycle d’amélioration par la récupération, avec Azure AI Search en tant que stockage vectoriel et outil de récupération.
Utilisation des embeddings Azure OpenAI
De nombreuses équipes d’Azure utilisent des modèles d’embeddings hébergés tels que text-embedding-3-small au lieu d’un modèle local. La modification est mineure : il suffit d’appeler le client Azure OpenAI pour créer chaque embedding et de définir le nombre de dimensions de l’index en fonction du modèle, par exemple 1536 pour text-embedding-ada-002. Tout le reste dans le code d’indexation et de requête reste inchangé.
from openai import AzureOpenAI
client = AzureOpenAI(
api_key="<your-azure-openai-key>",
api_version="2024-10-21",
azure_endpoint="https://<your-resource>.openai.azure.com/",
)
def get_embedding(text):
response = client.embeddings.create(
input=text,
model="text-embedding-3-small", # your deployment name
)
return response.data[0].embedding
Azure AI Search prend également en charge la vectorisation intégrée : le service invoque directement un modèle Azure OpenAI pour convertir le texte en vecteurs lors de l’indexation, ainsi que pour chaque requête. Cela vous épargne d’avoir à écrire du code de génération d’embeddings et convient aux pipelines plus complexes, mais le flux de base décrit ci-dessus suffit à comprendre le fonctionnement de la recherche vectorielle.
Quelles peuvent être les erreurs
Trois erreurs expliquent la plupart des résultats initiaux décevants. La première est un déséquilibre de dimension : si l’index indique 384 mais que le modèle génère 1536 nombres, les téléchargements échouent ; la solution consiste à lire la taille de sortie du modèle et à définir le champ en conséquence. La deuxième est l’utilisation mixte de modèles d’embedding lors de l’ingestion et des requêtes, ce qui produit des résultats qui semblent plausibles mais sont en réalité aléatoires. La troisième est le changement de modèle ultérieur sans reconstruction, car chaque vecteur stocké appartient alors à l’espace du modèle ancien et doit être régénéré puis re-téléchargé.
Une habitude qui permet d’économiser du temps consiste à conserver le nom du modèle et le nombre de dimensions dans une même valeur de configuration que lisent à la fois le script d’ingestion et la fonction de requête, afin qu’ils ne s’éloignent pas l’un de l’autre.
Vérification de la qualité de la récupération
Au lieu de faire confiance directement au système de recherche, rédigez une dizaine de questions réalistes et indiquez pour chacune le document qui devrait être retourné. Utilisez la fonction de recherche et examinez les trois premiers résultats. Si le passage souhaité manque, les causes habituelles sont des blocs de texte trop grands ou trop petits, un modèle d’embedding insuffisant pour votre domaine, ou des paramètres HNSW qui privilégient la vitesse au détriment de la précision. Comparez cela avec une recherche KNN exhaustive sur les mêmes données pour voir quelle perte de précision subit la recherche approximative, puisque KNN fournit une réponse exacte à comparer.
Notes pour aller au-delà de la démonstration
- Modèle d’embedding. Il définit la dimension de vos vecteurs et influence fortement la qualité des résultats de recherche, il convient donc de le choisir avec soin.
- Division en blocs. Divisez les documents longs en passages ciblés avant de les indexer.
search_text en même temps que la requête vectorielle. Le service fusionne les résultats basés sur les mots-clés BM25 avec les résultats vectoriels.À partir de là, vous pouvez ajouter vos propres documents, des fonctionnalités de segmentation, une recherche hybride ainsi qu’un modèle de votre choix pour transformer cette démonstration en une application RAG complète.