Construire un moteur de recherche sémantique avec Next.js 15, OpenAI et Pinecone

La recherche traditionnelle par mots-clés ne suffit plus à l'ère de l'intelligence artificielle. Quand un utilisateur cherche "comment accélérer mon application", une recherche classique ne trouvera pas un article intitulé "Optimiser les performances React" — même si c'est la réponse parfaite. C'est là qu'intervient la recherche sémantique, qui comprend le sens plutôt que de simplement faire correspondre des caractères.
Dans ce tutoriel complet, vous allez construire un moteur de recherche sémantique complet avec :
- Next.js 15 avec App Router et Server Actions
- OpenAI Embeddings API pour convertir le texte en vecteurs
- Pinecone comme base de données vectorielle cloud-native
- TypeScript pour la sécurité des types de bout en bout
Ce que vous allez construire
Une application web permettant aux utilisateurs de rechercher dans une collection d'articles via la recherche sémantique. L'application comprend l'intention de l'utilisateur et affiche les résultats les plus pertinents par le sens, même quand les mots ne correspondent pas exactement.
Fonctionnalités principales :
- Interface de recherche interactive avec résultats instantanés
- Indexation automatique du contenu via API Route
- Classement des résultats par score de similarité sémantique
- Support de la recherche multilingue
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 18 ou plus récent
- Des connaissances de base en React et Next.js
- Un compte OpenAI avec une clé API
- Un compte Pinecone (le niveau gratuit suffit)
- Un éditeur de code comme VS Code
Étape 1 : Comprendre la recherche vectorielle
Comment fonctionne la recherche sémantique ?
La recherche vectorielle repose sur trois étapes :
- Embedding : Convertir le texte en un vecteur numérique (tableau de nombres) représentant son sens
- Stockage : Sauvegarder les vecteurs dans une base de données spécialisée comme Pinecone
- Requête : Convertir la question de l'utilisateur en vecteur et le comparer aux vecteurs stockés
Le modèle text-embedding-3-small d'OpenAI produit des vecteurs de 1 536 dimensions. Chaque dimension représente un aspect du sens du texte. Les textes ayant des significations similaires ont des vecteurs proches dans l'espace multidimensionnel.
Un vecteur est simplement un tableau de nombres. Par exemple : [0.023, -0.41, 0.87, ...] — chaque nombre représente une dimension du sens. La proximité entre deux vecteurs indique une similarité de sens.
Étape 2 : Créer le projet
Créez un nouveau projet Next.js 15 :
npx create-next-app@latest semantic-search-app --typescript --tailwind --app --src-dir
cd semantic-search-appInstallez les packages nécessaires :
npm install openai @pinecone-database/pineconeStructure du projet
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── api/
│ │ └── index-content/
│ │ └── route.ts
│ └── actions/
│ └── search.ts
├── lib/
│ ├── openai.ts
│ ├── pinecone.ts
│ └── types.ts
└── components/
├── SearchBar.tsx
├── SearchResults.tsx
└── ArticleCard.tsx
Étape 3 : Configurer les variables d'environnement
Créez un fichier .env.local à la racine du projet :
OPENAI_API_KEY=sk-your-openai-api-key
PINECONE_API_KEY=your-pinecone-api-key
PINECONE_INDEX=semantic-searchNe partagez jamais vos clés API. Ajoutez .env.local à .gitignore (Next.js le fait automatiquement).
Étape 4 : Configurer le client OpenAI
Créez src/lib/openai.ts :
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return response.data[0].embedding;
}
export async function generateEmbeddings(
texts: string[]
): Promise<number[][]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: texts,
});
return response.data.map((item) => item.embedding);
}Pourquoi text-embedding-3-small ?
| Modèle | Dimensions | Prix par 1M tokens | Performance |
|---|---|---|---|
| text-embedding-3-small | 1 536 | 0,02 $ | Excellent pour un usage général |
| text-embedding-3-large | 3 072 | 0,13 $ | Précision supérieure |
| text-embedding-ada-002 | 1 536 | 0,10 $ | Génération précédente |
Le petit modèle offre un équilibre idéal entre performance et coût pour la plupart des applications.
Étape 5 : Configurer Pinecone
Créer l'index Pinecone
- Connectez-vous à console.pinecone.io
- Créez un nouvel index nommé
semantic-search - Définissez les dimensions à 1536 (correspondant au modèle OpenAI)
- Choisissez cosine comme métrique de similarité
Créez src/lib/pinecone.ts :
import { Pinecone } from "@pinecone-database/pinecone";
const pinecone = new Pinecone({
apiKey: process.env.PINECONE_API_KEY!,
});
export const index = pinecone.index(process.env.PINECONE_INDEX!);
export interface ArticleMetadata {
title: string;
summary: string;
url: string;
category: string;
language: string;
}
export async function upsertVectors(
vectors: {
id: string;
values: number[];
metadata: ArticleMetadata;
}[]
) {
// Pinecone accepte jusqu'à 100 vecteurs par opération
const batchSize = 100;
for (let i = 0; i < vectors.length; i += batchSize) {
const batch = vectors.slice(i, i + batchSize);
await index.upsert(batch);
}
}
export async function queryVectors(
queryVector: number[],
topK: number = 5,
filter?: Record<string, string>
) {
const results = await index.query({
vector: queryVector,
topK,
includeMetadata: true,
filter,
});
return results.matches || [];
}Étape 6 : Définir les types
Créez src/lib/types.ts :
export interface Article {
id: string;
title: string;
content: string;
summary: string;
url: string;
category: string;
language: string;
}
export interface SearchResult {
id: string;
score: number;
title: string;
summary: string;
url: string;
category: string;
}
export interface SearchState {
results: SearchResult[];
query: string;
isLoading: boolean;
error: string | null;
}Étape 7 : Construire l'API d'indexation du contenu
Créez src/app/api/index-content/route.ts :
import { NextResponse } from "next/server";
import { generateEmbeddings } from "@/lib/openai";
import { upsertVectors, type ArticleMetadata } from "@/lib/pinecone";
import type { Article } from "@/lib/types";
// Données d'exemple — en production, récupérez depuis votre CMS ou base de données
const articles: Article[] = [
{
id: "1",
title: "Optimiser les performances des applications React",
content:
"Guide complet pour améliorer les performances React avec memo, useMemo, useCallback et le chargement paresseux...",
summary: "Techniques d'optimisation des performances React",
url: "/tutorials/react-performance",
category: "frontend",
language: "fr",
},
{
id: "2",
title: "Construire des API REST avec Node.js et Express",
content:
"Comment concevoir et construire une API RESTful complète avec authentification et validation des données...",
summary: "Construire des API robustes avec Express.js",
url: "/tutorials/nodejs-rest-api",
category: "backend",
language: "fr",
},
{
id: "3",
title: "Les fondamentaux de TypeScript pour les développeurs",
content:
"Introduction complète à TypeScript couvrant les types de base, les interfaces et les génériques...",
summary: "Commencez votre parcours TypeScript",
url: "/tutorials/typescript-basics",
category: "language",
language: "fr",
},
{
id: "4",
title: "Deploying Next.js to Production",
content:
"Complete guide to deploying Next.js applications with Docker, CI/CD pipelines, and monitoring...",
summary: "Production deployment strategies for Next.js",
url: "/tutorials/nextjs-deployment",
category: "devops",
language: "en",
},
{
id: "5",
title: "Authentication with JWT and Refresh Tokens",
content:
"Implementing secure authentication using JSON Web Tokens with refresh token rotation...",
summary: "Secure auth implementation guide",
url: "/tutorials/jwt-auth",
category: "security",
language: "en",
},
];
export async function POST() {
try {
// Préparer les textes pour l'embedding : combiner titre et contenu
const textsToEmbed = articles.map(
(article) => `${article.title}\n\n${article.content}`
);
// Générer les embeddings en lot (plus efficace que des requêtes individuelles)
const embeddings = await generateEmbeddings(textsToEmbed);
// Préparer les vecteurs pour Pinecone
const vectors = articles.map((article, idx) => ({
id: article.id,
values: embeddings[idx],
metadata: {
title: article.title,
summary: article.summary,
url: article.url,
category: article.category,
language: article.language,
} satisfies ArticleMetadata,
}));
// Uploader les vecteurs vers Pinecone
await upsertVectors(vectors);
return NextResponse.json({
success: true,
indexed: articles.length,
});
} catch (error) {
console.error("Indexing error:", error);
return NextResponse.json(
{ error: "Failed to index content" },
{ status: 500 }
);
}
}Étape 8 : Construire la Server Action de recherche
Créez src/app/actions/search.ts :
"use server";
import { generateEmbedding } from "@/lib/openai";
import { queryVectors } from "@/lib/pinecone";
import type { SearchResult } from "@/lib/types";
export async function semanticSearch(
query: string,
language?: string
): Promise<SearchResult[]> {
if (!query.trim()) {
return [];
}
try {
// Convertir la requête de l'utilisateur en vecteur
const queryVector = await generateEmbedding(query);
// Préparer le filtre par langue (optionnel)
const filter = language ? { language } : undefined;
// Interroger Pinecone
const matches = await queryVectors(queryVector, 5, filter);
// Transformer les résultats dans la forme attendue
const results: SearchResult[] = matches.map((match) => ({
id: match.id,
score: match.score || 0,
title: (match.metadata?.title as string) || "",
summary: (match.metadata?.summary as string) || "",
url: (match.metadata?.url as string) || "",
category: (match.metadata?.category as string) || "",
}));
return results;
} catch (error) {
console.error("Search error:", error);
throw new Error("La recherche a échoué. Veuillez réessayer.");
}
}L'utilisation des Server Actions signifie que vos clés API restent sur le serveur et ne sont jamais envoyées au navigateur. C'est plus sécurisé que de créer une API Route séparée pour la recherche.
Étape 9 : Construire le composant de barre de recherche
Créez src/components/SearchBar.tsx :
"use client";
import { useState, useTransition, useCallback } from "react";
import { semanticSearch } from "@/app/actions/search";
import type { SearchResult } from "@/lib/types";
interface SearchBarProps {
onResults: (results: SearchResult[]) => void;
onLoading: (loading: boolean) => void;
}
export default function SearchBar({ onResults, onLoading }: SearchBarProps) {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const handleSearch = useCallback(() => {
if (!query.trim()) return;
onLoading(true);
startTransition(async () => {
try {
const results = await semanticSearch(query);
onResults(results);
} catch {
onResults([]);
} finally {
onLoading(false);
}
});
}, [query, onResults, onLoading]);
return (
<div className="w-full max-w-2xl mx-auto">
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Recherchez par le sens... ex : Comment accélérer mon application ?"
className="w-full px-6 py-4 text-lg border-2 border-gray-200
rounded-2xl focus:border-blue-500 focus:outline-none
transition-colors duration-200 pl-28"
/>
<button
onClick={handleSearch}
disabled={isPending || !query.trim()}
className="absolute right-3 top-1/2 -translate-y-1/2
bg-blue-600 text-white px-4 py-2 rounded-xl
hover:bg-blue-700 disabled:opacity-50
disabled:cursor-not-allowed transition-colors"
>
{isPending ? "..." : "Chercher"}
</button>
</div>
<p className="text-sm text-gray-500 mt-2">
La recherche sémantique comprend le sens — essayez des questions naturelles au lieu de mots-clés
</p>
</div>
);
}Étape 10 : Construire les composants de résultats
Créez src/components/ArticleCard.tsx :
import type { SearchResult } from "@/lib/types";
interface ArticleCardProps {
result: SearchResult;
}
export default function ArticleCard({ result }: ArticleCardProps) {
const relevancePercent = Math.round(result.score * 100);
return (
<a
href={result.url}
className="block p-6 bg-white rounded-2xl border border-gray-100
hover:border-blue-200 hover:shadow-lg transition-all
duration-200"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="text-xl font-bold text-gray-900 mb-2">
{result.title}
</h3>
<p className="text-gray-600 leading-relaxed">{result.summary}</p>
<span className="inline-block mt-3 text-sm text-blue-600
bg-blue-50 px-3 py-1 rounded-full">
{result.category}
</span>
</div>
<div className="flex-shrink-0 text-center">
<div
className={`text-2xl font-bold ${
relevancePercent >= 80
? "text-green-600"
: relevancePercent >= 60
? "text-yellow-600"
: "text-gray-400"
}`}
>
{relevancePercent}%
</div>
<div className="text-xs text-gray-400">pertinence</div>
</div>
</div>
</a>
);
}Créez src/components/SearchResults.tsx :
import type { SearchResult } from "@/lib/types";
import ArticleCard from "./ArticleCard";
interface SearchResultsProps {
results: SearchResult[];
isLoading: boolean;
}
export default function SearchResults({
results,
isLoading,
}: SearchResultsProps) {
if (isLoading) {
return (
<div className="space-y-4 mt-8">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-32 bg-gray-100 rounded-2xl animate-pulse"
/>
))}
</div>
);
}
if (results.length === 0) {
return null;
}
return (
<div className="space-y-4 mt-8">
<p className="text-sm text-gray-500">
{results.length} résultats trouvés, classés par pertinence sémantique
</p>
{results.map((result) => (
<ArticleCard key={result.id} result={result} />
))}
</div>
);
}Étape 11 : Assembler la page d'accueil
Mettez à jour src/app/page.tsx :
"use client";
import { useState } from "react";
import SearchBar from "@/components/SearchBar";
import SearchResults from "@/components/SearchResults";
import type { SearchResult } from "@/lib/types";
export default function Home() {
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
<div className="max-w-4xl mx-auto px-4 py-20">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Recherche sémantique par IA
</h1>
<p className="text-xl text-gray-600">
Recherchez par le sens, pas seulement par les mots
</p>
</div>
<SearchBar onResults={setResults} onLoading={setIsLoading} />
<SearchResults results={results} isLoading={isLoading} />
</div>
</main>
);
}Étape 12 : Indexer votre contenu
Avant que la recherche fonctionne, vous devez indexer les articles. Démarrez le serveur de développement et lancez la requête d'indexation :
npm run devDans un autre terminal :
curl -X POST http://localhost:3000/api/index-contentVous devriez obtenir :
{
"success": true,
"indexed": 5
}Étape 13 : Tester la recherche sémantique
Ouvrez votre navigateur sur http://localhost:3000 et essayez ces requêtes :
| Requête | Résultat attendu |
|---|---|
| "comment accélérer mon appli" | Article sur les performances React |
| "je veux protéger mon application" | Article JWT et authentification |
| "déployer en production" | Article déploiement Next.js |
| "apprendre un langage de programmation" | Article fondamentaux TypeScript |
Remarquez comment le moteur trouve les articles pertinents même quand les mots ne correspondent pas exactement.
Étape 14 : Améliorations avancées
Ajouter un Debounce pour la recherche automatique
Améliorez l'expérience utilisateur avec une recherche automatique pendant la saisie :
// src/hooks/useDebounce.ts
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}Cache côté serveur
Ajoutez un cache au niveau du serveur pour réduire les appels API OpenAI :
// src/lib/cache.ts
const cache = new Map<string, { data: number[]; timestamp: number }>();
const TTL = 1000 * 60 * 60; // 1 heure
export function getCachedEmbedding(text: string): number[] | null {
const entry = cache.get(text);
if (!entry) return null;
if (Date.now() - entry.timestamp > TTL) {
cache.delete(text);
return null;
}
return entry.data;
}
export function setCachedEmbedding(text: string, embedding: number[]) {
cache.set(text, { data: embedding, timestamp: Date.now() });
}Puis mettez à jour la fonction generateEmbedding :
export async function generateEmbedding(text: string): Promise<number[]> {
// Vérifier le cache d'abord
const cached = getCachedEmbedding(text);
if (cached) return cached;
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
const embedding = response.data[0].embedding;
setCachedEmbedding(text, embedding);
return embedding;
}Filtrage par métadonnées
Pinecone supporte le filtrage par métadonnées que vous pouvez combiner avec la recherche vectorielle :
// Rechercher uniquement les articles frontend
const results = await queryVectors(queryVector, 5, {
category: "frontend",
});
// Rechercher uniquement les articles en français
const results = await queryVectors(queryVector, 5, {
language: "fr",
});Étape 15 : Déployer en production
Déployer sur Vercel
npm install -g vercel
vercelAjoutez les variables d'environnement dans le tableau de bord Vercel :
OPENAI_API_KEYPINECONE_API_KEYPINECONE_INDEX
Considérations pour la production
- Limitation de débit : Protégez votre API contre les abus
- Monitoring : Suivez les appels API OpenAI et leurs coûts
- Cache : Utilisez Redis pour un cache persistant des embeddings
- Réindexation : Configurez un cron job pour réindexer périodiquement le contenu
// Exemple : Limitation de débit simple
const rateLimitMap = new Map<string, number[]>();
function isRateLimited(ip: string, maxRequests = 10, windowMs = 60000) {
const now = Date.now();
const requests = rateLimitMap.get(ip) || [];
const recentRequests = requests.filter((t) => now - t < windowMs);
if (recentRequests.length >= maxRequests) {
return true;
}
recentRequests.push(now);
rateLimitMap.set(ip, recentRequests);
return false;
}Estimation des coûts
| Composant | Coût | Notes |
|---|---|---|
| OpenAI Embeddings | environ 0,02 $ par 1M tokens | Très abordable |
| Pinecone | Gratuit jusqu'à 100 000 vecteurs | Le niveau gratuit suffit pour démarrer |
| Vercel | Gratuit pour les projets personnels | Plan Hobby |
Pour une application avec 1 000 articles et 10 000 recherches mensuelles, le coût estimé est inférieur à 1 $/mois.
Dépannage
Problèmes courants
Erreur de clé API OpenAI :
Vérifiez que votre clé est correcte et que vous avez un crédit suffisant. Vérifiez votre fichier .env.local.
L'index Pinecone ne répond pas : Assurez-vous que les dimensions de l'index correspondent à 1536 (pour text-embedding-3-small).
Résultats imprécis :
- Ajoutez plus de contenu à indexer — plus de données améliore les résultats
- Essayez de combiner titre, contenu et tags avant l'embedding
- Utilisez
text-embedding-3-largepour une meilleure précision
Réponses lentes :
- Activez le cache pour les requêtes d'embedding répétées
- Utilisez un debounce pour réduire les requêtes pendant la saisie
- Choisissez une région Pinecone proche de votre serveur
Prochaines étapes
Après avoir terminé ce tutoriel, vous pouvez :
- Ajouter le RAG (Retrieval-Augmented Generation) : Combiner les résultats de recherche avec un LLM pour générer des réponses personnalisées
- Construire une recherche multimodale : Ajouter la recherche d'images avec les modèles CLIP
- Ajouter des analytics de recherche : Suivre ce que les utilisateurs recherchent pour améliorer le contenu
- Intégrer avec un CMS : Déclencher automatiquement la réindexation quand le contenu change
Conclusion
La recherche sémantique change fondamentalement la façon dont les utilisateurs interagissent avec le contenu. Au lieu de deviner les bons mots-clés, ils peuvent simplement décrire ce qu'ils veulent en langage naturel.
Dans ce tutoriel, vous avez appris :
- Comment fonctionnent les vecteurs et les embeddings
- La configuration d'OpenAI et Pinecone pour la recherche vectorielle
- La construction de Server Actions sécurisées pour la recherche
- La conception d'une interface de recherche interactive
- Les optimisations de performance et de production
Les technologies que nous avons utilisées — OpenAI Embeddings, Pinecone et les Server Actions Next.js — représentent l'approche moderne pour construire des applications de recherche intelligentes et évolutives.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire un Agent IA Autonome avec Agentic RAG et Next.js
Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

Construire un chatbot RAG avec Supabase pgvector et Next.js
Apprenez à construire un chatbot IA qui répond aux questions en utilisant vos propres données. Ce tutoriel couvre les embeddings vectoriels, la recherche sémantique et le RAG avec Supabase et Next.js.

Construire des agents IA from scratch avec TypeScript : maîtriser le pattern ReAct avec le Vercel AI SDK
Apprenez à construire des agents IA depuis zéro avec TypeScript. Ce tutoriel couvre le pattern ReAct, l'appel d'outils, le raisonnement multi-étapes et les boucles d'agents prêtes pour la production avec le Vercel AI SDK.