Technologie

Champ de recherche sur un site web: La similarité sémantique avec Transformers.js

Armel Yara
Implémenter la Similarité Sémantique TF-IDF avec JavaScript

Salut les devs, comment allez-vous? 

Le contenu des sites web est souvent très dense, avec des fichiers assez volumineux, ce qui complique la recherche par les utilisateurs. C’est pourquoi certains sites possèdent une barre de recherche qui permet à l’utilisateur de trouver ce qu’il recherche assez rapidement. 

Cependant, la recherche d’information demande une certaine pertinence dans les résultats pour satisfaire l’utilisateur car parfois, il ne sait pas exactement ce qu'il recherche et donc ses requêtes (c'est-à-dire ce qu’il tape dans la barre de recherche comme texte) ne sont pas assez claires. 

Ainsi, lorsque la fonctionnalité de recherche n’est pas capable de comprendre le sens de la requête de l’utilisateur, c’est-à-dire la sémantique, elle ne trouve que les documents partageant le même vocabulaire. Si un utilisateur recherche "tutoriel IA" mais que le document indique "Guide d'Apprentissage Automatique", la fonctionnalité ne fera pas le lien  même s'ils sont conceptuellement identiques.

C'est là qu'interviennent les embeddings sémantiques : des représentations basées sur des réseaux de neurones qui comprennent le sens, et non seulement les mots.

Cet article vous montre comment implémenter la similarité sémantique en utilisant Transformers.js en JavaScript, permettant une véritable recherche conceptuelle sans API ou services externes. 


Tout d’abord c’est quoi LA SIMILARITÉ SÉMANTIQUE ?

La similarité sémantique c’est mesurer à quel point deux morceaux de texte sont similaires en SENS, indépendamment des mots exacts utilisés.

Exemples :

  • "développement mobile" ≈ "React Native" ≈ "Flutter" ≈ "applications iOS"

  • "IA" ≈ "Apprentissage Automatique" ≈ "Réseaux de Neurones"

  • "framework multiplateforme" ≈ "React Native" ≈ "Flutter"

Comment ça fonctionne :

D’abord le texte est converti en vecteurs numériques denses (embeddings) en utilisant un réseau de neurones, ensuite les vecteurs sont comparés en utilisant la similarité cosinus et enfin Sens similaires = vecteurs similaires = score de similarité élevé

Différence clé avec TF-IDF :

  • TF-IDF : Vecteurs creux basés sur la fréquence des mots (lexicale)

  • Embeddings : Vecteurs denses basés sur le sens (sémantique)

Pourquoi Transformers.JS ?

Nous avons choisi Transformers.js pour plusieurs raisons :

  • Fonctionne à la fois dans le navigateur (côté client) et dans Node.js (côté serveur)

  • Aucun appel d'API externe (confidentialité + économies de coûts)

  • Modèles pré-entraînés disponibles (pas besoin d'entraînement)

  • Petite taille de modèle (~90 Mo pour all-MiniLM-L6-v2)

  • Inférence rapide (~50-150 ms par embedding)

  • Même API que Hugging Face Transformers (Python)


Alternatives considérées :

  • OpenAI Embeddings API : 0,0001 $/1K tokens (les coûts s'accumulent)

  • Sentence-BERT Python : Nécessite un backend Python

  • Universal Sentence Encoder : TensorFlow.js (modèles plus volumineux)

L'ARCHITECTURE

L’implémentation comporte 3 couches :

Couche 1 : Génération d'Embedding Côté Client

  • Générer des embeddings lorsque les publications sont créées/mises à jour

  • Stocker des vecteurs de 384 dimensions dans Firestore

  • Fonctionne dans le navigateur de l'utilisateur avec Transformers.js

Couche 2 : Embedding de Requête Côté Serveur

  • Générer des embeddings pour les requêtes de recherche

  • Fonctionne dans les fonctions Firebase (Firebase Functions)

  • Même modèle que côté client pour la cohérence

Couche 3 : Calcul de Similarité

  • Calculer la similarité cosinus entre la requête et les documents

  • Intégrer au score de recherche hybride

  • Calcul vectoriel rapide (produit scalaire)


COUCHE 1 : GÉNÉRATION D'EMBEDDING CÔTÉ CLIENT

Lorsqu'un utilisateur crée ou met à jour une publication, nous générons son embedding sémantique.

CODE: EmbeddingService - Client-Side

```javascript

// From public/js/services/embeddings.js


import { pipeline } from '@xenova/transformers';


class EmbeddingService {

    constructor() {

        this.embedder = null;

        this.loading = false;

        this.model = 'Xenova/all-MiniLM-L6-v2';

    }


    // Lazy load the model (only when first needed)

    async loadModel() {

        if (this.embedder) return this.embedder;

        

        if (this.loading) {

            // Wait for existing load to complete

            while (this.loading) {

                await new Promise(resolve => setTimeout(resolve, 100));

            }

            return this.embedder;

        }


        try {

            this.loading = true;

            

            // Load the feature-extraction pipeline

            this.embedder = await pipeline(

                'feature-extraction',

                this.model

            );

            return this.embedder;

        } catch (error) {

            throw error;

        } finally {

            this.loading = false;

        }

    }


    // Generate embedding for text

    async generateEmbedding(text) {

        if (!text || typeof text !== 'string') {

            throw new Error('Text must be a non-empty string');

        }


        const embedder = await this.loadModel();

        

        // Generate embedding

        const output = await embedder(text, {

            pooling: 'mean',      // Average all token embeddings

            normalize: true       // L2 normalization for cosine similarity

        });


        // Convert to plain array

        const embedding = Array.from(output.data);

        return embedding;

    }


    // Generate embedding for a publication (with content weighting)

    async generatePublicationEmbedding(data) {

        // Weight different fields by importance

        const contentParts = [

            // Title: highest weight (3x)

            data.title || '',

            data.title || '',

            data.title || '',

            

            // Description: high weight (2x)

            data.description || '',

            data.description || '',

            

            // Tags and categories

            ...(data.tags || []),

            ...(data.categories || []),

            ...(data.technologies || []),

            

            // Abstract and content

            data.abstract || '',

            data.content || ''

        ];

        const text = contentParts.join(' ').trim();

        

        if (!text) {

            return null;

        }


        return await this.generateEmbedding(text);

    }


    // Calculate similarity between two embeddings

    calculateSimilarity(embedding1, embedding2) {

        if (!embedding1 || !embedding2) return 0;

        if (embedding1.length !== embedding2.length) return 0;


        // Cosine similarity via dot product 

        let dotProduct = 0;

        for (let i = 0; i < embedding1.length; i++) {

            dotProduct += embedding1[i] * embedding2[i];

        }

        return dotProduct;  // Range: -1 to 1

    }

}


// Export singleton instance

export default new EmbeddingService();


INTEGRATION: Creating Publications


```javascript

// From publications.js

import embeddingService from './embeddings.js';


async function createPublication(data) {

    // ... existing validation ...


    // Generate semantic embedding

    let embedding = null;

    

    try {

        embedding = await embeddingService.generatePublicationEmbedding(data);

    } 

    const publication = {

        ...data,

        embedding: embedding,  // Store 384-dimensional array

        createdAt: serverTimestamp(),

        updatedAt: serverTimestamp()

    };


    await db.collection('publications').add(publication);

}

```

EXAMPLE OUTPUT:


Input:

```javascript

{

    title: "Building React Native Apps",

    description: "A comprehensive guide to mobile development",

    tags: ["react-native", "mobile", "javascript"],

    content: "React Native is a framework for building mobile apps..."

}

```

Output (embedding):

```javascript

[

    0.0234, -0.0891, 0.1234, 0.0456, -0.0123, 0.0789, ...

    // 384 floating-point numbers

]

```


Storage in Firestore:

```javascript

{

    title: "Building React Native Apps",

    content: "...",

    embedding: [0.0234, -0.0891, 0.1234, ...],  // 384 floats

    tfidf: { ... },  // TF-IDF data (separate)

    createdAt: Timestamp

}

```


Storage size: ~1.5KB per publication (384 floats × 4 bytes)


LAYER 2: SERVER-SIDE QUERY EMBEDDING


Au moment de la recherche, nous générons un embedding pour la requête de l'utilisateur.

CODE: Server-Side Embedding Service


```javascript

// embeddings.js

const {pipeline} = require('@xenova/transformers');


class EmbeddingService {

  constructor() {

    this.embedder = null;

    this.loading = false;

    this.model = 'Xenova/all-MiniLM-L6-v2';

  }


  async loadModel() {

    if (this.embedder) return this.embedder;


    if (this.loading) {

      while (this.loading) {

        await new Promise((resolve) => setTimeout(resolve, 100));

      }

      return this.embedder;

    }


    try {

      this.loading = true;

      this.embedder = await pipeline(

        'feature-extraction',

        this.model,

      );

      return this.embedder;

    } catch (error) {

      throw error;

    } finally {

      this.loading = false;

    }

  }


  async generateEmbedding(text) {

    if (!text || typeof text !== 'string') {

      throw new Error('Text must be a non-empty string');

    }


    const embedder = await this.loadModel();


    const output = await embedder(text, {

      pooling: 'mean',

      normalize: true,

    });


    return Array.from(output.data);

  }


  calculateSimilarity(embedding1, embedding2) {

    if (!embedding1 || !embedding2) return 0;

    if (embedding1.length !== embedding2.length) return 0;


    let dotProduct = 0;

    for (let i = 0; i < embedding1.length; i++) {

      dotProduct += embedding1[i] * embedding2[i];

    }

    return dotProduct;

  }

}


// Export singleton

module.exports = new EmbeddingService();

```



INTÉGRATION : Search function

javascript

// index.js

const embeddingService = require('./services/embeddings');

exports.searchPublications = onRequest(

  {cors: true, memory: '1GiB', timeoutSeconds: 60},

  async (request, response) => {

    const {q: query} = request.query;


    // Generate query embedding for semantic search

    let queryEmbedding = null;

    if (query) {

      try {

        logger.info('Generating query embedding for semantic search...');

        queryEmbedding = await embeddingService.generateEmbedding(query);

        logger.info('Query embedding generated successfully');

      } catch (error) {

        logger.warn('Failed to generate query embedding:', error);

        // Continue without semantic search

      }

    }


    // Fetch candidate publications

    const snapshot = await db.collection('publications')

      .where('status', '==', 'approved')

      .get();


    // Calculate scores

    let results = snapshot.docs.map((doc) => {

      const data = doc.data();

      let score = 0;


      // ... keyword matching, TF-IDF, engagement ...


      // Semantic similarity (0-15 points)

      if (queryEmbedding && data.embedding) {

        try {

          const semanticSim = embeddingService.calculateSimilarity(

            queryEmbedding,

            data.embedding,

          );

          

          // Convert from [-1, 1] to [0, 1] range

          const normalizedSim = (semanticSim + 1) / 2;

          

          // Scale to points (0-15)

          score += normalizedSim * 15;

          

          logger.info(`Semantic similarity for ${doc.id}: ${semanticSim.toFixed(3)}`);

        } catch (error) {

          logger.warn(`Failed to calculate semantic similarity: ${error}`);

        }

      }


      return {

        id: doc.id,

        ...data,

        score,

        embedding: undefined  // Don't expose embeddings in response

      };

    });


    // Sort by score

    results.sort((a, b) => b.score - a.score);

    response.json({results: results.slice(0, 20)});

  },

);



EXEMPLE COMPLET : FLUX DE RECHERCHE

L'utilisateur recherche : "tutoriel IA"


ÉTAPE 1 : Générer l'embedding de requête

Requête : "tutoriel IA"

// Le serveur génère l'embedding

queryEmbedding = await embeddingService.generateEmbedding("tutoriel IA");

// Résultat : [0.0123, -0.0456, 0.0789, ..., 0.0234] (384 dimensions)

ÉTAPE 2 : Récupérer les documents candidats

// Obtenir toutes les publications approuvées

const snapshot = await db.collection('publications')

  .where('status', '==', 'approved')

  .get();


// Documents :

// 1. "Guide d'Apprentissage Automatique" - embedding: [0.0145, -0.0423, 0.0812, ...]

// 2. "Tutoriel React Native" - embedding: [0.0891, 0.0234, -0.0567, ...]

// 3. "Bases des Réseaux de Neurones" - embedding: [0.0156, -0.0445, 0.0798, ...]


ÉTAPE 3 : Calculer la similarité sémantique

Document 1 : "Guide d'Apprentissage Automatique"

Embedding de requête :    [0.0123, -0.0456, 0.0789, ...]

Embedding de document : [0.0145, -0.0423, 0.0812, ...]


// Produit scalaire (les embeddings sont normalisés)

similarité = (0.0123 × 0.0145) + (-0.0456 × -0.0423) + (0.0789 × 0.0812) + ...

           = 0.000178 + 0.001929 + 0.006407 + ...

           = 0.847  // Similarité élevée !


Document 2 : "Tutoriel React Native"

Embedding de requête :    [0.0123, -0.0456, 0.0789, ...]

Embedding de document : [0.0891, 0.0234, -0.0567, ...]


similarité = (0.0123 × 0.0891) + (-0.0456 × 0.0234) + (0.0789 × -0.0567) + ...

           = 0.001096 + (-0.001067) + (-0.004473) + ...

           = 0.234  // Faible similarité


Document 3 : "Bases des Réseaux de Neurones"

Embedding de requête :    [0.0123, -0.0456, 0.0789, ...]

Embedding de document : [0.0156, -0.0445, 0.0798, ...]


similarité = (0.0123 × 0.0156) + (-0.0456 × -0.0445) + (0.0789 × 0.0798) + ...

           = 0.000192 + 0.002029 + 0.006296 + ...

           = 0.823  // Similarité élevée !

ÉTAPE 4 : Convertir en points et classer

Document 1 : "Guide d'Apprentissage Automatique"

- Similarité sémantique : 0.847

- Normalisée : (0.847 + 1) / 2 = 0.924

- Points : 0.924 × 15 = 13.86 points


Document 2 : "Tutoriel React Native"

- Similarité sémantique : 0.234

- Normalisée : (0.234 + 1) / 2 = 0.617

- Points : 0.617 × 15 = 9.26 points


Document 3 : "Bases des Réseaux de Neurones"

- Similarité sémantique : 0.823

- Normalisée : (0.823 + 1) / 2 = 0.912

- Points : 0.912 × 15 = 13.68 points


Classement final (par similarité sémantique) :

1. Guide d'Apprentissage Automatique (13,86 pts) ✓

2. Bases des Réseaux de Neurones (13,68 pts) ✓

3. Tutoriel React Native (9,26 pts)

Résultat : Les documents liés à l'IA sont les mieux classés, même s'ils ne contiennent pas les  mots exacts "tutoriel IA". 


DÉTAILS DU MODÈLE : all-MiniLM-L6-v2


Modèle : sentence-transformers/all-MiniLM-L6-v2

Type : Sentence-BERT (bi-encodeur)

Architecture : MiniLM (BERT distillé)

Dimensions : 384

Taille : ~90 Mo

Entraînement : 1 milliard + paires de phrases


Performance

  • Génération d'embedding : 50-150 ms (client), 100-200 ms (serveur)

  • Utilisation de la mémoire : ~500 Mo (modèle chargé)

  • Précision : 82,4 % sur le benchmark STS

Pourquoi ce modèle

  • Petite taille (90 Mo contre 500 Mo pour un BERT complet)

  • Inférence rapide (6 couches contre 12 pour BERT)

  • Bonne précision pour la recherche à usage général

  • Support multilingue (anglais, français, allemand, etc.)

  • Pré-entraîné sur des données diverses


Modèles alternatifs

  • all-mpnet-base-v2 : Meilleure précision (768 dims, 420 Mo)

  • paraphrase-MiniLM-L3-v2 : Plus rapide (384 dims, 60 Mo, moins précis)

  • multilingual-e5-small : Meilleur multilingue (384 dims, 118 Mo)

Qu’est ce que LES EMBEDDINGS

Ce sont des vecteurs numériques denses qui capturent le sens sémantique.

Exemple de visualisation (simplifié en 3D) :

"tutoriel IA"           → [0.8, 0.6, 0.1]

"Apprentissage Automatique"      → [0.7, 0.5, 0.2]  ← Proche !

"Réseaux de Neurones"       → [0.75, 0.55, 0.15] ← Proche !

"React Native"          → [0.2, 0.1, 0.9]  ← Loin

Dans l'espace 3D :

  • Les termes liés à l'IA se regroupent

  • React Native est dans une région différente

  • Distance = similarité

En réalité :

  • 384 dimensions (pas 3)

  • Capture des relations sémantiques complexes

  • Entraîné sur des millions de paires de textes

Propriétés clés :

  1. Sens similaires → vecteurs similaires

  2. Normalisés (longueur = 1) pour la similarité cosinus

  3. Denses (toutes les valeurs utilisées, contrairement aux vecteurs creux TF-IDF)

LA SIMILARITÉ COSINE EXPLIQUÉE

Formule :

similarité = A · B / (||A|| × ||B||)

Avec des vecteurs normalisés (||A|| = ||B|| = 1) :

similarité = A · B (juste le produit scalaire !)


Plage :

  • 1.0 = Sens identique

  • 0.5 = Modérément similaire

  • 0.0 = Sans rapport

  • -1.0 = Sens opposé

Exemple de calcul :

embedding1 = [0.5, 0.5, 0.5, 0.5]  (normalisé)

embedding2 = [0.6, 0.4, 0.5, 0.5]  (normalisé)


dotProduct = (0.5 × 0.6) + (0.5 × 0.4) + (0.5 × 0.5) + (0.5 × 0.5)

           = 0.3 + 0.2 + 0.25 + 0.25

           = 1.0


// Puisque les vecteurs sont normalisés, c’est la similarité cosinus

similarité = 1.0  (très similaire !)

Pourquoi normaliser ?

  • Simplifie le calcul (juste le produit scalaire)

  • Se concentre sur la direction, pas sur la magnitude

  • Calcul plus rapide (pas besoin de racines carrées)


CONSIDÉRATIONS DE PERFORMANCE

Stockage :

  • Embedding par document : 384 floats × 4 octets = 1 536 octets (~1,5 Ko)

  • 1 000 publications : ~1,5 Mo

  • 10 000 publications : ~15 Mo

  • Très abordable pour Firestore

Calcul :

  • Génération d'embedding côté client : 50-150 ms

  • Génération d'embedding côté serveur : 100-200 ms

  • Calcul de similarité : <1 ms (simple produit scalaire)

  • Temps de recherche total : 200-400 ms pour 20 résultats

Mémoire :

  • Taille du modèle : ~90 Mo (téléchargé une fois, mis en cache)

  • Mémoire d'exécution : ~500 Mo (modèle chargé)

  • Fonctions Firebase : Nécessite une allocation de mémoire de 1 Go

Coûts (Firebase) :

  • Mémoire de fonction : 1 Go × 0,0000025 $/Go-sec

  • Recherche moyenne : 0,3 s × 0,0000025 $ = 0,00000075 $

  • 1 000 recherches : ~0,0008 $

  • Très abordable !

Démarrages à froid (Cold starts) :

  • Première invocation : 3-5 secondes (chargement du modèle)

  • Invocations suivantes : 200-400 ms (modèle mis en cache)

  • Atténuation : Garder les fonctions chaudes avec des pings planifiés

LIMITATIONS ET AMÉLIORATIONS

Limitations actuelles

  • Taille du modèle (90 Mo)

    • Nécessite un téléchargement lors de la première utilisation

    • Prend 3 à 5 secondes à charger

    • Atténuation : Mettre en cache de manière agressive, utiliser un CDN

  • Démarrages à froid (Cold starts)

    • La première invocation de la fonction est lente

    • Atténuation : Pings de maintien au chaud planifiés

  • Support linguistique

    • Optimisé pour l'anglais

    • Fonctionne pour le français, l'allemand, l'espagnol (moins précis)

    • Atténuation : Utiliser un modèle multilingue si nécessaire

  • Longueur du contexte

    • Max 512 jetons (~400 mots)

    • Les documents longs sont tronqués

    • Atténuation : Résumer ou découper le contenu long

Améliorations potentielles

  • Embeddings hybrides

    • Combiner plusieurs modèles

    • Utiliser différents modèles pour différentes langues

  • Affinage (Fine-tuning)

    • Entraîner sur votre domaine spécifique

    • Nécessite des données étiquetées et du calcul

  • Reranking (Reclassement)

    • Utiliser les embeddings pour la récupération initiale

    • Utiliser un modèle plus grand pour le classement final

  • Mise en cache

    • Mettre en cache les embeddings de requête pour les recherches populaires

    • Réduire les coûts de calcul


QUAND UTILISER LES EMBEDDINGS SÉMANTIQUES?

Bonne adéquation

  • Plateforme à fort contenu (articles, documents, publications)

  • Besoin de correspondance de synonymes ("IA" = "Apprentissage Automatique")

  • Découverte inter-sujets

  • Contraintes budgétaires (pas de coûts d'API)

  • Exigences de confidentialité (pas d'API externes)

  • <100 000 documents


Mauvaise adéquation

  • Autocomplétion en temps réel (trop lent)

  • Millions de documents (utiliser une base de données vectorielle)

  • Besoin de correspondance de mots-clés exacts uniquement

  • Requêtes très courtes (<3 mots, moins efficace)


La similarité sémantique via Transformers.js permet une véritable recherche conceptuelle en JavaScript sans API externes. Notre implémentation :

  1. Génère des embeddings côté client lors de la création des publications

  2. Génère des embeddings de requête côté serveur pendant la recherche

  3. Calcule la similarité cosinus pour la correspondance sémantique


Les utilisateurs trouvent du contenu pertinent même en utilisant un vocabulaire différent de celui des auteurs originaux. "Tutoriel IA" correspond automatiquement à "Guide d'Apprentissage Automatique".


Tout le code présenté provient de la base de code de production PublieDev. 



Ressources


Transformers.js documentation

all-MiniLM-L6-v2 model

https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2


Sentence-BERT paper

https://arxiv.org/abs/1908.10084


Cosine similarity explained

https://en.wikipedia.org/wiki/Cosine_similarity




Résumé par IA

Trop long ? Obtenez un résumé rapide de cet article généré par l'IA.

Vous Pourriez Aussi Aimer
Recherche de suggestions...
Statistiques de l'article
Engagement des lecteurs avec cet article.

0

Vues

0

Commentaires

📧 Restez informé

Recevez une notification par email à chaque nouvel article ou modification

Commentaires (0)

Aucun commentaire pour le moment. Soyez le premier à commenter !

Laisser un commentaire