La recherche sémantique est la colonne vertébrale des applications d'IA modernes — les pipelines RAG, les moteurs de recommandation, la déduplication et les fonctionnalités « trouver des éléments similaires » en dépendent toutes. La plupart des tutoriels se tournent directement vers un service cloud managé, mais cela implique une tarification au vecteur, des données qui quittent votre région et une dépendance rigide que vous ne pouvez pas déplacer. Qdrant change cette équation : c'est une base de données vectorielle open source, écrite en Rust, que vous pouvez exécuter sur votre propre ordinateur portable, sur votre propre VPS en Tunisie ou en Arabie saoudite, ou sur Qdrant Cloud — avec exactement la même API.
Dans ce tutoriel, vous allez construire un service de recherche sémantique complet à partir de zéro. Vous exécuterez Qdrant dans Docker, générerez des embeddings avec OpenAI, les stockerez avec de riches payloads de métadonnées et exposerez un point de terminaison de recherche typé dans un projet Next.js App Router. À la fin, vous comprendrez toute la boucle de récupération et en maîtriserez chaque partie.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (
node --version) - Docker installé et en cours d'exécution (
docker --version) - Des connaissances de base en TypeScript et Next.js App Router
- Une clé API OpenAI (ou tout fournisseur d'embeddings — le schéma est identique)
- Un éditeur de code tel que VS Code
Vous n'avez pas besoin de compte Qdrant. Nous exécuterons tout en local, et le même code se déploie sur un serveur sans modification.
Ce que vous allez construire
Une API de recherche sémantique pour un catalogue d'articles. Un utilisateur envoie une requête en langage naturel comme « comment rendre ma base de données plus rapide » et obtient en retour les documents les plus pertinents sémantiquement — même si aucun de ces mots exacts n'apparaît dans le texte. L'architecture se présente ainsi :
- Ingestion (Ingest) — transformer chaque document en vecteur d'embedding et le stocker dans Qdrant avec un payload de métadonnées.
- Requête (Query) — transformer la question de l'utilisateur en vecteur et demander à Qdrant les plus proches voisins.
- Filtrage (Filter) — restreindre les résultats selon des métadonnées structurées (catégorie, langue, statut de publication) sans perdre le classement sémantique.
- Service (Serve) — exposer le tout via un gestionnaire de route Next.js propre.
Pourquoi Qdrant ?
Les vecteurs ne sont que des listes de nombres — un modèle d'embedding projette le texte dans un espace de haute dimension où les significations similaires se retrouvent proches les unes des autres. Une base de données vectorielle stocke des millions de ces vecteurs et répond à la question « quels vecteurs stockés sont les plus proches de celui-ci ? » en quelques millisecondes grâce à un index HNSW. Voici pourquoi Qdrant se distingue en 2026 :
- Auto-hébergeable et open source (licence Apache 2.0). Vos données et votre index résident là où vous le décidez — un véritable avantage sous les règles de résidence des données de la région MENA comme le cadre INPDP tunisien ou la PDPL saoudienne.
- Écrit en Rust pour une faible empreinte mémoire et une latence prévisible.
- Un filtrage par payload riche qui combine conditions structurées et recherche sémantique dans une seule requête.
- Une Query API unifiée qui couvre la recherche dense, la recherche éparse, la fusion hybride et les recommandations via une seule méthode.
- Pas de facturation au vecteur en auto-hébergement — vous payez la machine, pas les lignes.
Étape 1 : exécuter Qdrant avec Docker
Le moyen le plus rapide d'obtenir une instance Qdrant est l'image Docker officielle. Créez un fichier docker-compose.yml à la racine de votre projet :
services:
qdrant:
image: qdrant/qdrant:v1.18.0
restart: always
ports:
- "6333:6333" # REST + interface Web
- "6334:6334" # gRPC
volumes:
- ./qdrant_storage:/qdrant/storage
environment:
QDRANT__SERVICE__API_KEY: "local-dev-key"Démarrez-le :
docker compose up -dQdrant est maintenant en cours d'exécution. Deux choses méritent d'être connues :
- Les données persistées résident dans
./qdrant_storage, ajoutez donc ce dossier à votre.gitignore. - Ouvrez
http://localhost:6333/dashboarddans votre navigateur — Qdrant est livré avec une interface Web intégrée où vous pouvez inspecter les collections, exécuter des requêtes et visualiser vos vecteurs. C'est inestimable lors du débogage.
En production, définissez une QDRANT__SERVICE__API_KEY robuste depuis un gestionnaire de secrets et placez le service derrière TLS. N'exposez jamais le port 6333 à l'Internet public sans authentification.
Étape 2 : configurer le projet Next.js
Si vous n'avez pas encore de projet, créez-en un :
npx create-next-app@latest qdrant-search --typescript --app --no-tailwind
cd qdrant-searchInstallez le client Qdrant et le SDK OpenAI :
npm install @qdrant/js-client-rest openaiCréez un fichier .env.local. N'écrivez jamais ces valeurs en dur dans le code :
QDRANT_URL="http://localhost:6333"
QDRANT_API_KEY="local-dev-key"
OPENAI_API_KEY="sk-your-key-here"Étape 3 : créer le client Qdrant et l'assistant d'embeddings
Centralisez les deux clients dans un seul module afin que le reste de l'application reste propre. Créez lib/qdrant.ts :
import { QdrantClient } from "@qdrant/js-client-rest";
import OpenAI from "openai";
// Un seul client Qdrant partagé pour toute l'application
export const qdrant = new QdrantClient({
url: process.env.QDRANT_URL!,
apiKey: process.env.QDRANT_API_KEY,
});
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const COLLECTION = "articles";
export const VECTOR_SIZE = 1536; // taille de sortie de text-embedding-3-small
// Transformer n'importe quel texte en vecteur d'embedding dense
export async function embed(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return response.data[0].embedding;
}Le modèle d'embedding text-embedding-3-small produit des vecteurs à 1536 dimensions. La règle la plus importante de la recherche vectorielle : la taille du vecteur et la métrique de distance que vous choisissez à la création de la collection doivent correspondre exactement à votre modèle d'embedding, et vous devez utiliser le même modèle pour l'ingestion et la requête. Mélanger les modèles produit des résultats dénués de sens.
Étape 4 : créer la collection
Une collection dans Qdrant est comme une table — elle contient des points (vecteurs et payloads) et définit comment ils sont indexés. Créez scripts/init-collection.ts :
import { qdrant, COLLECTION, VECTOR_SIZE } from "../lib/qdrant";
async function init() {
// Recréer proprement si elle existe déjà (commodité de dev uniquement)
const exists = await qdrant.collectionExists(COLLECTION);
if (exists.exists) {
await qdrant.deleteCollection(COLLECTION);
}
await qdrant.createCollection(COLLECTION, {
vectors: {
size: VECTOR_SIZE,
distance: "Cosine", // idéal pour les embeddings de texte normalisés
},
});
// Indexer les champs de payload sur lesquels nous prévoyons de filtrer.
// Sans cela, le filtrage fonctionne mais est bien plus lent à grande échelle.
await qdrant.createPayloadIndex(COLLECTION, {
field_name: "category",
field_schema: "keyword",
});
await qdrant.createPayloadIndex(COLLECTION, {
field_name: "published",
field_schema: "bool",
});
console.log(`La collection "${COLLECTION}" est prête.`);
}
init().catch((err) => {
console.error("Échec de l'initialisation de la collection :", err);
process.exit(1);
});L'option distance est cruciale. Pour les embeddings de texte, Cosine est presque toujours correct car il mesure l'angle entre les vecteurs plutôt que leur magnitude. Qdrant prend également en charge Dot, Euclid et Manhattan pour d'autres cas d'usage.
Exécutez-le avec tsx :
npx tsx scripts/init-collection.tsRemarquez les appels à createPayloadIndex. Dans Qdrant, vous pouvez filtrer sur n'importe quel champ de payload sans index, mais créer un index sur les champs que vous interrogez fréquemment maintient des performances stables à mesure que votre jeu de données passe de milliers à des millions de points.
Étape 5 : ingérer des documents
Chargez maintenant des données. Chaque document devient un point : un identifiant, un vecteur et un payload de métadonnées que vous pourrez ensuite filtrer et renvoyer. Créez scripts/ingest.ts :
import { qdrant, embed, COLLECTION } from "../lib/qdrant";
const documents = [
{
id: 1,
title: "Accélérer Postgres avec une indexation appropriée",
body: "Les index B-tree et GIN réduisent considérablement la latence des requêtes sur les grandes tables.",
category: "database",
published: true,
},
{
id: 2,
title: "Stratégies de mise en cache avec Redis",
body: "Les schémas cache-aside et write-through réduisent la charge sur votre stockage principal.",
category: "database",
published: true,
},
{
id: 3,
title: "Concevoir des systèmes de couleurs accessibles",
body: "Les ratios de contraste et les jetons de couleur rendent les interfaces utilisables par tous.",
category: "design",
published: true,
},
{
id: 4,
title: "Un brouillon interne sur le sharding",
body: "Le partitionnement horizontal répartit la charge d'écriture sur de nombreux nœuds.",
category: "database",
published: false,
},
];
async function ingest() {
// Embedder tous les documents. Nous combinons titre + corps pour un contexte plus riche.
const points = await Promise.all(
documents.map(async (doc) => ({
id: doc.id,
vector: await embed(`${doc.title}. ${doc.body}`),
payload: {
title: doc.title,
body: doc.body,
category: doc.category,
published: doc.published,
},
}))
);
// upsert insère ou remplace les points par identifiant. wait:true bloque
// jusqu'à ce que l'opération soit entièrement indexée — pratique dans les scripts.
await qdrant.upsert(COLLECTION, {
wait: true,
points,
});
console.log(`${points.length} documents ingérés.`);
}
ingest().catch((err) => {
console.error("Échec de l'ingestion :", err);
process.exit(1);
});Exécutez-le :
npx tsx scripts/ingest.tsQuelques notes pour la production :
- Regroupez vos upserts par lots. Embedder un document par requête convient pour une démo, mais pour des charges réelles, envoyez les documents à l'API d'embeddings par lots et faites des upserts par blocs de quelques centaines de points.
- Les identifiants de point doivent être des entiers non signés ou des UUID. Utiliser un identifiant stable (comme la clé primaire de votre base de données) signifie que ré-ingérer un document l'écrase simplement.
- Le texte que vous embeddez doit refléter ce que les utilisateurs recherchent. Combiner le titre et le corps, comme nous le faisons ici, surpasse généralement l'embedding du corps seul.
Étape 6 : exécuter une requête sémantique avec la Query API
C'est là que la magie opère. La manière moderne de récupérer des points dans Qdrant est la Query API unifiée — client.query() — qui gère la recherche dense, la recherche hybride et les recommandations via une seule méthode cohérente. Créez lib/search.ts :
import { qdrant, embed, COLLECTION } from "./qdrant";
export interface SearchResult {
id: string | number;
score: number;
title: string;
body: string;
category: string;
}
export async function semanticSearch(
query: string,
options: { limit?: number; category?: string } = {}
): Promise<SearchResult[]> {
const { limit = 5, category } = options;
// 1. Transformer la requête utilisateur en vecteur avec le MÊME modèle qu'à l'ingestion
const queryVector = await embed(query);
// 2. Construire un filtre structuré optionnel.
// On restreint toujours aux documents publiés, et éventuellement
// à une seule catégorie.
const filter = {
must: [
{ key: "published", match: { value: true } },
...(category ? [{ key: "category", match: { value: category } }] : []),
],
};
// 3. Demander à Qdrant les plus proches voisins
const response = await qdrant.query(COLLECTION, {
query: queryVector,
limit,
filter,
with_payload: true,
});
// 4. Mapper la réponse typée vers notre propre forme
return response.points.map((point) => ({
id: point.id,
score: point.score,
title: point.payload?.title as string,
body: point.payload?.body as string,
category: point.payload?.category as string,
}));
}Trois détails rendent ceci robuste :
- Le filtre est appliqué avant le scoring, de sorte que les conditions structurées ne faussent jamais le classement sémantique. Le tableau
mustsignifie que chaque condition doit correspondre (un ET logique). Qdrant prend aussi en chargeshould(OU) etmust_not(NON). with_payload: truerenvoie les métadonnées stockées avec chaque correspondance, vous évitant un second aller-retour vers votre base de données principale pour afficher les résultats.- Chaque résultat porte un
scoreentre 0 et 1 pour la similarité cosinus. Vous pouvez fixer un seuil (par exemple, écarter tout ce qui est sous 0,3) pour éviter de faire remonter des correspondances faibles.
Étape 7 : exposer un gestionnaire de route Next.js
Reliez la fonction de recherche à un point de terminaison App Router. Créez app/api/search/route.ts :
import { NextRequest, NextResponse } from "next/server";
import { semanticSearch } from "@/lib/search";
// Les embeddings + Qdrant nécessitent le runtime Node.js, pas Edge
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
const category = searchParams.get("category") ?? undefined;
if (!query || query.trim().length === 0) {
return NextResponse.json(
{ error: "Paramètre de requête manquant : q" },
{ status: 400 }
);
}
try {
const results = await semanticSearch(query, { category, limit: 5 });
return NextResponse.json({ query, count: results.length, results });
} catch (error) {
// Ne jamais avaler les erreurs en silence — journaliser et renvoyer un 500 propre
console.error("Échec de la recherche :", error);
return NextResponse.json(
{ error: "Échec de la recherche. Veuillez réessayer." },
{ status: 500 }
);
}
}Lancez le serveur de développement et essayez-le :
npm run devcurl "http://localhost:3000/api/search?q=how%20do%20I%20make%20my%20database%20faster"Vous devriez récupérer les articles sur l'indexation Postgres et la mise en cache Redis classés en tête — bien que la requête ne contienne aucun de ces mots. Le brouillon sur le sharding (published: false) est correctement exclu par le filtre. C'est la recherche sémantique fonctionnant de bout en bout.
Essayez aussi une requête filtrée :
curl "http://localhost:3000/api/search?q=color%20contrast&category=design"Étape 8 : ajouter un seuil de score et la pagination
Pour de vraies applications, vous voulez rarement les plus proches voisins bruts. Deux petits raffinements font une grande différence. La Query API de Qdrant prend en charge score_threshold et offset directement :
const response = await qdrant.query(COLLECTION, {
query: queryVector,
limit,
offset: 0, // ignorer N résultats pour la pagination
score_threshold: 0.3, // écarter entièrement les correspondances faibles
filter,
with_payload: true,
});Le score_threshold garantit que lorsqu'une requête n'a pas de bonne réponse, vous renvoyez une liste vide plutôt que du bruit non pertinent — ce qui est exactement ce que vous voulez pour une barre de recherche ou un récupérateur RAG alimentant un LLM.
Tester votre implémentation
Vérifiez chaque couche indépendamment :
- Qdrant est sain — visitez
http://localhost:6333/dashboardet confirmez que la collectionarticlesaffiche 4 points. - Les embeddings fonctionnent — ajoutez un
console.log(queryVector.length)temporaire et confirmez qu'il affiche1536. - Le filtrage fonctionne — recherchez avec
category=databaseet confirmez qu'aucun article de design n'apparaît. - L'exclusion fonctionne — confirmez que le brouillon non publié sur le sharding n'apparaît jamais dans les résultats.
- La pertinence est cohérente — les requêtes sémantiquement liées doivent renvoyer les bons documents avec des scores supérieurs à votre seuil.
Dépannage
Connection refused sur le port 6333 — Qdrant n'est pas en cours d'exécution. Vérifiez docker compose ps et docker compose logs qdrant.
Wrong input: Vector dimension error — la taille de votre embedding ne correspond pas à la collection. Vous avez créé la collection avec un modèle et interrogé avec un autre, ou changé de modèle en cours de route. Recréez la collection avec la bonne valeur VECTOR_SIZE.
Résultats vides pour des requêtes évidentes — confirmez que l'ingestion a bien eu lieu (wait: true aide) et que votre filtre n'est pas trop strict. Retirez temporairement la condition published pour isoler le problème.
Erreurs Unauthorized — votre QDRANT_API_KEY dans .env.local ne correspond pas à QDRANT__SERVICE__API_KEY dans docker-compose.yml.
Requêtes filtrées lentes à grande échelle — vous avez oublié de créer un index de payload sur le champ filtré. Revoyez l'étape 4.
Prochaines étapes
Vous possédez désormais une pile de recherche sémantique complète et auto-hébergée. À partir de là, vous pouvez l'étendre dans plusieurs directions :
- Recherche hybride — combinez vecteurs denses et vecteurs épars (mots-clés) à l'aide des vecteurs nommés et de la fusion de Qdrant dans le même appel Query API, obtenant le meilleur de la correspondance sémantique et lexicale.
- Pipeline RAG — fournissez les documents récupérés comme contexte à un LLM pour bâtir un système de question-réponse. Associez ceci à notre guide sur la création d'une application RAG avec Next.js et l'AI SDK.
- Recommandations — utilisez le mode
recommendde la Query API pour trouver des points similaires à ceux qu'un utilisateur a déjà appréciés. - Embeddings locaux — remplacez OpenAI par un modèle d'embedding auto-hébergé pour garder tout le pipeline au sein de votre propre infrastructure, supprimant entièrement les appels d'API externes.
Conclusion
Qdrant vous offre la puissance de la recherche sémantique de niveau production sans renoncer au contrôle de vos données ni de votre facture. Dans ce tutoriel, vous avez exécuté Qdrant dans Docker, créé une collection avec la bonne métrique de distance et les index de payload, ingéré des documents avec des embeddings OpenAI et construit un point de terminaison de recherche typé dans Next.js à l'aide de la Query API unifiée moderne — avec filtrage des métadonnées, seuils de score et pagination.
Le modèle mental clé à retenir : un modèle d'embedding transforme le sens en géométrie, et une base de données vectorielle trouve ce qui est proche. Tout le reste — RAG, recommandations, déduplication, clustering — n'est qu'une variation de cette seule idée. Parce que Qdrant est open source et auto-hébergeable, vous pouvez tout bâtir sur une infrastructure que vous possédez entièrement, ce qui importe plus que jamais pour les équipes opérant sous des exigences régionales de résidence des données.