Champ de recherche sur un site web: La similarité sémantique avec Transformers.js
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 :
Sens similaires → vecteurs similaires
Normalisés (longueur = 1) pour la similarité cosinus
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 :
Génère des embeddings côté client lors de la création des publications
Génère des embeddings de requête côté serveur pendant la recherche
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
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
Trop long ? Obtenez un résumé rapide de cet article généré par l'IA.
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
Abonnement requis
Vous devez être abonné à la newsletter pour laisser un commentaire. L'abonnement est gratuit et vous permettra de participer aux discussions.