Flow actuel de la recherche et du ranking
Statut : Audit de l'état provisoire — lecture seule, aucune modification du code applicatif
Date : 2026-07-06
Portée : dmv-public — deux surfaces de recherche distinctes
Architecture cible : 34-ranking-engine-synthesis.md — ce document décrit l'état en place ; le doc 34 définit vers quoi migrer (scoring, bonus partenaire, unification des surfaces)
1. Résumé
Il existe deux surfaces de recherche sans ranking commun. La recherche globale (/recherche) est un simple filtre Supabase ilike sur nom et description, trié alphabétiquement, sans scoring. L'annuaire commune (/[commune]) est la surface principale : elle utilise d'abord la RPC Supabase search_acteurs_public (filtre multi-champs côté serveur), puis enrichit les résultats avec tags, coordonnées et coefficient d'abonnement, et applique enfin un scoring client-side via computeActorSearchScore() dans lib/searchUtils.ts. Ce scoring est déterministe — il combine matching textuel (nom, catégorie, tags), boosters binaires (claimed, en_avant) et un multiplicateur commercial (coeff_annuaire). Il n'y a aucun signal utilisateur (historique, localisation, favoris) dans le ranking.
2. Schéma du flux
Recherche globale
/recherche/page.tsx
└── RechercheClient.tsx
└── Supabase ilike (nom OR description)
→ ORDER BY nom ASC
→ limit 30
→ liste de résultats (pas de scoring)
Annuaire commune (avec ranking)
/[commune]/page.tsx (SSG, initialActors pré-chargés)
└── AnnuaireClient.tsx
├── [sans recherche]
│ └── filter(initialActors, kind) → affichage direct
│
├── [recherche < 3 chars]
│ └── Supabase ilike (nom OR description) → base
│
└── [recherche >= 3 chars]
└── RPC search_acteurs_public(communeId, kind, search)
→ base (nom, categorie, en_avant)
→ enrichissement (slug, tags, coords, coeff_annuaire)
→ vérification claims
→ sortActorsBySearch(base, searchParam)
└── computeActorSearchScore() ← RANKING
→ résultats triés
→ pagination 10/page
3. Fichiers exacts
dmv-public/app/recherche/page.tsx
- Rôle : Entrée de route
/recherche, wrapper Suspense - Ce qu'il décide : Rien — délègue à
RechercheClient - Ce qu'il délègue : Tout à
RechercheClient - Logique métier : Non
dmv-public/app/recherche/RechercheClient.tsx
- Rôle : Moteur de recherche globale (tous acteurs, toutes communes)
- Ce qu'il décide :
- Seuil minimum : 2 caractères
- Query Supabase directe :
SELECT id, nom, slug, kind, description, logo_url,badge_verifie, badge_label, communes(slug, nom)FROM acteursWHERE (nom ILIKE '%{term}%' OR description ILIKE '%{term}%')AND valide = trueAND active = trueORDER BY nomLIMIT 30
- Navigation : push
?q=dans l'URL - Fallback "Aucun résultat" si
results.length === 0
- Ce qu'il délègue : Rien
- Logique métier : Non — fetch simple, pas de scoring
dmv-public/app/[commune]/page.tsx (SSG)
- Rôle : Page de commune, charge
initialActorscôté serveur - Ce qu'il décide :
initialActors= tous les acteurs valides de la commune, triésen_avant DESC, nom ASC- Chaque acteur inclut
coeff_annuaireviaactor_subscriptions(subscription_plans(coeff_annuaire))
- Ce qu'il délègue : Affichage →
AnnuaireClient - Logique métier : Faible — tri initial serveur uniquement
dmv-public/app/[commune]/AnnuaireClient.tsx
- Rôle : Interface annuaire avec recherche, filtres par kind, ranking et carte (~500 lignes)
- Ce qu'il décide :
- Debounce 350ms sur la saisie
- Seuil de bascule : < 2 chars → reset, 2 chars → ilike, ≥ 3 chars → RPC
- Filtres par kind (
pro/association/public/all) - Anti-race :
reqIdRefnuméroté — les anciennes requêtes sont ignorées si dépassées - Enrichissement en 3 requêtes séquentielles :
- RPC
search_acteurs_public→ base (nom, categorie, en_avant) acteursselect → slug, tags, coords, horaires, badges,coeff_annuaireclaimsselect →is_claimedpar slug
- RPC
- Ranking final :
sortActorsBySearch(base, searchParam) - Pagination : 10 par page (
PAGE_SIZE = 10), numérotée - Map : pins des acteurs claimed avec coords, dans rayon 15 km du centre commune (
haversineKm) NEXT_PUBLIC_DEBUG_SEARCH = "true"→ log scores dans la console- Tracking
search_performed(event analytics)
- Ce qu'il délègue : Scoring →
lib/searchUtils.ts - Logique métier : Oui — orchestration du pipeline complet
dmv-public/lib/searchUtils.ts ← CŒUR DU RANKING
- Rôle : Algorithme de scoring et tri des acteurs par pertinence
- Ce qu'il décide : Score final et ordre des résultats
- Ce qu'il délègue : Normalisation texte →
lib/textUtils.ts - Logique métier : Oui — intégralité du ranking
dmv-public/lib/textUtils.ts
- Rôle : Normalisation du texte pour comparaisons
- Ce qu'il décide :
normalizeText(v): NFD → retire accents → lowercase → trim → compacte espacesnormalizeCompact(v):normalizeText+ supprime tout ce qui n'est pas[a-z0-9]tokenizeSearch(v):normalizeText→ split sur espaces → array de tokens
- Ce qu'il délègue : Rien
- Logique métier : Non — utilitaires purs
RPC Supabase search_acteurs_public
- Localisation : Migration SQL dans
dmv-backoffice/supabase/migrations/ - Rôle : Filtre multi-champs côté base de données
- Ce qu'il décide :
- Match sur : nom (
ILIKE), adresse (ILIKE), catégorie (ILIKE), tags (EXISTS) - Filtre :
valide = true,deleted_at IS NULL,commune_id = p_commune_id - Filtre optionnel :
kind = p_kind - Tri initial :
en_avant DESC NULLS LAST, nom ASC
- Match sur : nom (
- Ce qu'il délègue : Tri de pertinence → scoring client-side dans
AnnuaireClient - Logique métier : Faible — filtre d'inclusion, pas de scoring
4. Algorithme de ranking — détail complet
computeActorSearchScore() (lib/searchUtils.ts:13)
function computeActorSearchScore(actor: SearchableActor, rawSearch: string | null): number {
// Étape 1 — Normalisation de la requête en 3 formes
const fullQueryText = normalizeText(rawSearch); // accents supprimés, lowercase, espaces compactés
const fullQueryCompact = normalizeCompact(rawSearch); // + ponctuation/symboles supprimés
const tokens = tokenizeSearch(rawSearch); // découpe en mots individuels
// Étape 2 — Normalisation des champs de l'acteur
const nom = actor.nom_norm?.trim()
? actor.nom_norm.trim().toLowerCase()
: normalizeCompact(actor.nom); // priorité à nom_norm (prétraité en BDD)
const categorie = normalizeText(actor.categorie_nom);
const tags = (actor.tags ?? []).map(normalizeText);
let score = 0;
// Étape 3 — Fonction de scoring pour un terme unique
const scoreOneTerm = (termText: string, termCompact: string) => {
let termScore = 0;
if (termCompact) {
if (nom.startsWith(termCompact)) termScore += 100; // match début de nom
else if (nom.includes(termCompact)) termScore += 70; // match dans le nom
}
if (termText) {
if (categorie.includes(termText)) termScore += 40; // match catégorie
if (tags.some((tag) => tag.includes(termText))) termScore += 25; // match tags
}
return termScore;
};
// Étape 4 — Score sur la requête complète (comme un seul terme)
if (fullQueryText || fullQueryCompact) {
score += scoreOneTerm(fullQueryText, fullQueryCompact);
}
// Étape 5 — Score cumulatif sur chaque token individuel
for (const token of tokens) {
score += scoreOneTerm(token, normalizeCompact(token));
}
// Étape 6 — Boosters additifs
if (actor.is_claimed) score += 10; // acteur revendiqué
if (actor.en_avant) score += 6; // acteur mis en avant
// Étape 7 — Multiplicateur commercial
const coeff = actor.coeff_annuaire ?? 1;
return score * coeff;
}
Table des points
| Signal | Points | Notes |
|---|---|---|
nom.startsWith(termCompact) | +100 | Match en début de nom (compacté) |
nom.includes(termCompact) | +70 | Match n'importe où dans le nom |
categorie.includes(termText) | +40 | Match dans la catégorie |
tags.some(tag.includes(termText)) | +25 | Match dans au moins un tag |
is_claimed = true | +10 | Acteur revendiqué (booster additif) — voir divergence ci-dessous |
en_avant = true | +6 | Acteur mis en avant (booster additif) — voir divergence ci-dessous |
coeff_annuaire | ×N | Multiplicateur final (abonnement commercial) — voir divergence ci-dessous |
Divergences avec l'architecture cible — Ces trois valeurs décrivent l'implémentation actuelle, pas l'architecture cible définie dans 34-ranking-engine-synthesis.md (sections 7.2.1 et 7.3) :
Signal Implémentation actuelle Architecture cible is_claimed = true+10 (bonus pour les revendiqués) La fiche revendiquée est la norme (score neutre). C'est is_claimed = falsequi reçoit un malus −20 — l'exception (fiche orpheline) est pénalisée, pas la majorité gonflée artificiellement.en_avant+coeff_annuaire+6 additif puis ×N multiplicatif (deux mécanismes séparés) Fusionnés en un bonus partenaire additif plafonné (+15). Le multiplicateur est explicitement rejeté : un ×2 transforme un score de 216 en 432, pouvant faire remonter un mauvais match devant un excellent match gratuit. Le point le plus urgent selon le doc 34 (§ 8.2) : « Le multiplicateur
coeff_annuaireactuellement en place doit être converti en bonus additif plafonné — c'est le point le plus urgent car il contredit directement le principe de neutralité. »
Double comptage intentionnel : la requête complète ET chaque token sont scorés séparément. Une recherche "boulangerie du village" produit 4 évaluations (1 complète + 3 tokens), ce qui amplifie le score des acteurs qui matchent sur plusieurs termes.
nom_norm : champ prétraité en base de données (accentuation normalisée). Si absent, normalizeCompact(actor.nom) est utilisé comme fallback. La BDD est la source de vérité pour la normalisation du nom.
sortActorsBySearch() (lib/searchUtils.ts:58)
function sortActorsBySearch<T extends SearchableActor>(items: T[], rawSearch: string | null): T[] {
return [...items].sort((a, b) => {
const scoreA = computeActorSearchScore(a, rawSearch);
const scoreB = computeActorSearchScore(b, rawSearch);
if (scoreB !== scoreA) return scoreB - scoreA; // 1. Score décroissant
if (!!b.is_claimed !== !!a.is_claimed) // 2. Claimed en premier
return Number(!!b.is_claimed) - Number(!!a.is_claimed);
if (!!b.en_avant !== !!a.en_avant) // 3. En avant en premier
return Number(!!b.en_avant) - Number(!!a.en_avant);
return a.nom.localeCompare(b.nom, "fr", { sensitivity: "base" }); // 4. Alphabétique fr
});
}
Cascade de tiebreakers :
- Score de recherche (décroissant)
- Égalité de score → acteurs
claimeden premier - Toujours égaux → acteurs
en_avanten premier - Toujours égaux → ordre alphabétique (locale française, insensible aux accents)
5. Exemple de scoring chiffré
Recherche : "boulang"
Acteur : "Boulangerie du Village" (nom_norm = "boulangerie du village", claimed = true, en_avant = true, coeff = 2)
| Évaluation | Terme | Condition | Points |
|---|---|---|---|
| Requête complète | "boulang" | nom.startsWith("boulang") → oui | +100 |
| Token 1 | "boulang" | nom.startsWith("boulang") → oui | +100 |
| Boosters | — | is_claimed | +10 |
| Boosters | — | en_avant | +6 |
| Total avant coeff | 216 | ||
| × coeff_annuaire | × 2 | = 432 |
Même acteur sans abonnement (coeff = 1) : score = 216
Concurrent : "Village Fleurs" (nom_norm = "village fleurs", claimed = false, en_avant = false, coeff = 1)
- requête
"boulang":nom.includes("boulang")→ non → 0 pts - token
"boulang": idem → 0 pts - Total = 0 → classé après
6. Pipeline complet de l'annuaire (flux temporel)
[Chargement initial - SSG]
/[commune]/page.tsx
→ SELECT acteurs WHERE commune_id AND valide = true
ORDER BY en_avant DESC, nom ASC
(+ coeff_annuaire via actor_subscriptions)
→ initialActors passés à AnnuaireClient
[Sans recherche]
AnnuaireClient
→ filter(initialActors, kind) ← pas de requête, tri SSG conservé
→ slice(page, PAGE_SIZE)
[Avec recherche ≥ 3 chars]
Saisie → debounce 350ms → searchParam
Requête 1 — RPC search_acteurs_public :
→ filtre multi-champs en BDD (nom, adresse, catégorie, tags)
→ retourne : id, nom, nom_norm, kind, categorie_nom, en_avant
→ tri initial : en_avant DESC, nom ASC (ignoré ensuite)
Requête 2 — enrichissement (parallèle, sur les ids) :
→ acteurs SELECT id, slug, description, slogan, latitude, longitude,
horaires, badge_verifie, badge_label, logo_url,
acteur_tags(tags(label)),
actor_subscriptions(status, subscription_plans(coeff_annuaire))
→ extraction coeff_annuaire : subscription_plans[0].coeff_annuaire WHERE status = "active"
→ fallback coeff = 1
Requête 3 — vérification claims :
→ claims SELECT actor_slug WHERE city = communeSlug AND status = "approved"
→ Set<string> des slugs revendiqués
→ enrichit a.is_claimed
Ranking client-side :
→ sortActorsBySearch(base, searchParam)
→ computeActorSearchScore() pour chaque acteur
→ tri décroissant par score + tiebreakers
Affichage :
→ pagedItems = items.slice((page-1)*10, page*10)
→ pagination numérotée
7. Hooks et utilitaires impliqués
| Élément | Fichier | Rôle |
|---|---|---|
computeActorSearchScore | lib/searchUtils.ts | Score de pertinence d'un acteur |
sortActorsBySearch | lib/searchUtils.ts | Tri final par score + tiebreakers |
normalizeText | lib/textUtils.ts | NFD → lowercase → compact espaces |
normalizeCompact | lib/textUtils.ts | normalizeText + supprime non-alphanumérique |
tokenizeSearch | lib/textUtils.ts | Découpe en tokens |
useDebouncedValue(search, 350) | hooks/useDebounce (non listé) | Debounce 350ms saisie |
haversineKm | AnnuaireClient.tsx (inline) | Distance GPS pour pins carte |
isCurrentlyOpen | lib/horairesUtils.ts | Statut ouverture dans résultats |
useTrackEvent | lib/useTrackEvent.ts | Event analytics search_performed |
RPC search_acteurs_public | Migration Supabase | Filtre BDD multi-champs |
8. Logique métier dispersée
Scoring et ranking
Où : lib/searchUtils.ts — entièrement contenu, bien isolé
Seul fichier à modifier pour changer les poids ou ajouter des signaux.
Normalisation textuelle
Où : lib/textUtils.ts — bien isolé
Critique pour la cohérence du matching : nom_norm en BDD doit être produit avec la même logique.
Coefficient d'abonnement
Où : Dispersé sur 3 couches :
- Table BDD :
subscription_plans.coeff_annuaire page.tsx(SSG) : chargé dansinitialActorsAnnuaireClient.tsx: rechargé lors de la recherche (requête enrichissement)computeActorSearchScore(): appliqué comme multiplicateur final ← divergence architecturale (voir ci-dessus)
Absence de source unique : le coeff est chargé deux fois (SSG + recherche), sans garantie de cohérence si un abonnement change entre les deux.
Vérification claims
Où : AnnuaireClient.tsx → requête Supabase claims à chaque recherche
Logique inline dans load(). Non extraite.
Calcul de distance (carte)
Où : AnnuaireClient.tsx → haversineKm() inline
Formule de Haversine calculée à chaque render de mapPins. Non extraite en lib.
Filtre par kind
Où : AnnuaireClient.tsx — state type (all/pro/association/public)
Sans recherche : filter(initialActors, kind) côté client.
Avec recherche : paramètre p_kind passé à la RPC.
Pagination
Où : AnnuaireClient.tsx — PAGE_SIZE = 10, pagination numérotée
Entièrement côté client (slice sur items). Pas de curseur serveur.
Fallback "aucun résultat"
Où :
RechercheClient.tsx: affiche"Aucun résultat pour «...»"siresults.length === 0AnnuaireClient.tsx: non documenté explicitement — à vérifier dans le rendu
9. Absence de signaux utilisateur
Le ranking est 100% déterministe et sans personnalisation. Les éléments suivants sont confirmés absents :
| Signal | Présent ? | Notes |
|---|---|---|
| Historique de recherche | Non | Aucun localStorage, aucun cookie |
| Géolocalisation utilisateur | Non | haversineKm utilisé uniquement pour les pins de carte, pas pour le ranking |
| Acteurs favoris / suivis | Non | followedIds de WallEngine non transmis à l'annuaire |
| Fréquence de visite | Non | Aucun compteur |
| Clics sur résultats | Non | Seulement search_performed en analytics, pas de feedback loop |
| Langue / préférences | Non |
10. Comparaison des deux surfaces de recherche
| Critère | Recherche globale (/recherche) | Annuaire commune (/[commune]) |
|---|---|---|
| Scope | Tous acteurs, toutes communes | Acteurs d'une commune |
| Seuil déclenchement | 2 chars | 2 chars (ilike) / 3 chars (RPC) |
| Mécanisme BDD | ilike nom + description | RPC multi-champs (nom, adresse, catégorie, tags) |
| Scoring client | Aucun | computeActorSearchScore() |
| Ranking | Alphabétique | Score × coeff (multiplicateur à convertir — voir doc 34) |
| Tags dans le matching | Non | Oui (via RPC + scoring) |
| Claimed dans le ranking | Non | Oui (+10 pts + tiebreaker) (à convertir en malus −20 pour non-claimed) |
coeff_annuaire | Non | Oui (multiplicateur) (à convertir en additif plafonné +15) |
| Pagination | Aucune (max 30) | 10 par page |
| Carte | Non | Oui (pins claimed dans rayon 15 km) |
11. Conclusion
Meilleur point d'entrée pour modifier le ranking
lib/searchUtils.ts — computeActorSearchScore() est court (56 lignes), bien isolé, sans dépendances vers les composants. Ajouter un signal (favoris, géolocalisation, freshness) se fait en ajoutant une ligne de calcul dans scoreOneTerm ou en ajoutant un booster.
Fichiers à ne pas toucher au début
lib/textUtils.ts— normalisation partagée avec la BDD (nom_norm) ; tout changement doit être coordonné avec la migration qui produitnom_normAnnuaireClient.tsx— orchestration complexe (3 requêtes séquentielles + anti-race), toute modification du pipeline est risquée- RPC
search_acteurs_public— toute modification impacte le périmètre de résultats avant scoring
Candidats à extraction
haversineKmdansAnnuaireClient.tsx→lib/geoUtils.ts- Logique de claims (
claimedSet) dansload()→ hook ou util séparé coeff_annuairechargé deux fois (SSG + recherche) → unifier la source
Modifications requises par l'architecture cible (doc 34)
Ces changements ne sont pas optionnels — ils sont explicitement tranchés dans 34-ranking-engine-synthesis.md § 8.1 et § 8.2 :
coeff_annuairemultiplicatif → bonus partenaire additif plafonné (+15) — priorité la plus haute, contredit le principe de neutralité commerciale.en_avantpayant est fusionné dans ce même bonus.is_claimed +10bonus →is_claimed = falsemalus (−20) — change l'état par défaut : la fiche enrichie devient la norme, la fiche orpheline est l'exception pénalisée.- Unifier
/recherche(tri alphabétique) et l'annuaire commune (scoring complet) sur un seul moteur — deux implémentations du même besoin. - Brancher
haversineKmetfollowedIdsau scoring — les briques sont déjà disponibles mais non connectées au Ranking Engine des acteurs (doc 34 § 7.2.1 : proximité géographique +20 à 0 km dégressif, affinité relationnelle +30).