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

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

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 :

  1. Embedding : Convertir le texte en un vecteur numérique (tableau de nombres) représentant son sens
  2. Stockage : Sauvegarder les vecteurs dans une base de données spécialisée comme Pinecone
  3. 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-app

Installez les packages nécessaires :

npm install openai @pinecone-database/pinecone

Structure 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-search

Ne 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èleDimensionsPrix par 1M tokensPerformance
text-embedding-3-small1 5360,02 $Excellent pour un usage général
text-embedding-3-large3 0720,13 $Précision supérieure
text-embedding-ada-0021 5360,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

  1. Connectez-vous à console.pinecone.io
  2. Créez un nouvel index nommé semantic-search
  3. Définissez les dimensions à 1536 (correspondant au modèle OpenAI)
  4. 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 dev

Dans un autre terminal :

curl -X POST http://localhost:3000/api/index-content

Vous 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êteRé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
vercel

Ajoutez les variables d'environnement dans le tableau de bord Vercel :

  • OPENAI_API_KEY
  • PINECONE_API_KEY
  • PINECONE_INDEX

Considérations pour la production

  1. Limitation de débit : Protégez votre API contre les abus
  2. Monitoring : Suivez les appels API OpenAI et leurs coûts
  3. Cache : Utilisez Redis pour un cache persistant des embeddings
  4. 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

ComposantCoûtNotes
OpenAI Embeddingsenviron 0,02 $ par 1M tokensTrès abordable
PineconeGratuit jusqu'à 100 000 vecteursLe niveau gratuit suffit pour démarrer
VercelGratuit pour les projets personnelsPlan 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-large pour 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Introduction au MCP : Guide de Demarrage Rapide pour Debutants.

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.

30 min read·