Pilote Orléans · configuration, pas un fork · aucun lieu inventé

Livrable 15

Recherche

Objectif

Intention → résultats réels, rapidement, dans une ville.

Exemples à supporter comme parseurs, pas comme promesses de résultats Orléans : « bar techno », « manger après minuit », « concert gratuit », « terrasse romantique », « quelque chose pour ce soir », « rue de bourgogne », « burger ouvert maintenant ».

MVP

PostgreSQL :

  • tsvector config french si le dictionnaire est présent ;
  • pg_trgm sur les noms si l'extension charge ;
  • normalisation accents côté application (NFD + strip) stockée dans name_fold, pour ne pas dépendre de unaccent.

Boot : détecter les extensions. Si absentes (PGLite probable) : name_fold ILIKE + filtres structurés. Même API. Moins tolérant. Honnête.

Pas d'OpenSearch / Elasticsearch / Algolia au MVP. Réouverture seulement avec une mesure (latence ou pertinence) — aucune mesure n'existe encore.

Synonymes

Table search_synonyms (city_id nullable, term, expands_to[]). Globaux + override ville. Seed minimal linguistique (techno/électronique, burger/hamburger) comme dictionnaire, pas comme contenu local. Le city manager peut ajouter « Bourgogne » → zone rue-de-bourgogne une fois la zone créée.

Intent

Structure cible, remplie seulement pour les slots reconnus :

{
  "what": ["bar"],
  "music": "techno",
  "when": "tonight",
  "open_now": false,
  "price": "free",
  "zone_slug": null,
  "attributes": ["terrace"],
  "party_size": null,
  "budget_per_person": null,
  "sequence": []
}

MVP : règles (listes de mots + heures), pas un LLM obligatoire. Le LLM, si XAI_API_KEY et appel déclenché par l'utilisateur, peut proposer cette structure. Elle est jetée si elle cite un lieu qui n'est pas un id renvoyé par la base. THIRD-PARTY DEPENDENCY + plafond de tokens. Sinon : règles seules.

Ranking organique

Fonction documentée, poids en table ranking_weights :

text + distance + open_status + freshness + event_match + quality

  • quality MVP = complétude (horaire présent, source non UNKNOWN, pas de signalement ouvert). Pas un avis.
  • Le paiement n'est pas une feature.
  • Sponsorisé : requête séparée, slots identifiés, plafond, jamais réinjectés dans le score organique.

Distance : si pas de point utilisateur, le terme distance est omis (pas de faux centre).

Search gaps

À chaque requête 0 résultat : upsert search_gaps sur (city_id, query_normalized, day) compteur +1. Pas d'IP, pas d'user id. Le city manager voit le compte. Exemple d'usage produit : « karaoké » sans fiche — l'exemple n'est pas une statistique.

Décisions

D1 — FTS d'abord

  • Alternatives : SaaS search (moins d'ops, lock-in, coût, donnée qui sort de l'UE — LEGAL si hors UE) ; LLM qui répond en prose (interdit comme source de vérité).
  • Compromis : pertinence linguistique limitée.
  • Multi-ville : filtre city_id obligatoire dans chaque requête. Test d'isolation.

D2 — Pages de recherche non indexées

Voir SEO. L'URL canonique d'une intention stable et non vide peut être une page catégorie (/bars), pas /recherche?q=.

Flags

  • Dictionnaire french de PostgreSQL sur Neon : à vérifier, EXTERNAL DATA REQUIRED (capacité d'extension).
  • Coût d'un SaaS search : non chiffré.