Architecture — Recherche hybride
better-search combine deux moteurs de recherche sur la même requête utilisateur, puis fusionne leurs résultats. C'est le cœur du service.
Les deux moteurs
1. Recherche vectorielle (sémantique)
Idée intuitive : on transforme chaque voyage en un « vecteur » de 3584 nombres qui résume son sens (les embeddings BGE Multilingual Gemma2). La requête utilisateur est transformée de la même façon. On compare alors la requête à chaque voyage en mesurant à quel point leurs vecteurs « pointent dans la même direction » — c'est la similarité cosinus, qui donne un score entre 0 et 1.
- Score proche de 1 → les significations sont très proches (ex: requête « neige » ↔ voyage « ski en Autriche »).
- Score proche de 0 → significations sans rapport.
Avantage : capture le sens. « Vacances en bord de mer » trouvera des voyages parlant de « plage », « balnéaire », « Costa Brava » même si ces mots exacts ne sont pas dans la requête.
Limite : peu efficace sur des termes très précis comme un nom propre rare (« Wengen »).
Côté SQL :
1 - (embedding <=> %(embedding)s::vector) AS vector_score- L'opérateur
<=>depgvectorcalcule la distance cosinus (0 = identique, 2 = opposé). - On la transforme en similarité :
1 - distance→ plage typique[0, 1](parfois légèrement négatif).
2. Recherche plein-texte français (tsvector)
Idée intuitive : on découpe le texte en racines de mots (« voyage », « voyages », « voyageons » → tous ramenés à voyag), on indexe ces racines, et on classe par fréquence + pondération. C'est exactement le moteur d'un moteur de recherche classique.
PostgreSQL fait le boulot via to_tsvector('french', …). La colonne search_vector est générée à partir du raw_data :
| Champ | Poids tsvector | Sens |
|---|---|---|
name | A (le + fort) | Le titre du voyage |
subtitle | B | Sous-titre marketing |
description | C | Description longue |
Le score est calculé via ts_rank_cd(search_vector, websearch_to_tsquery('french', query)). Plus le score est haut, plus le match est pertinent. Pas de bornes fixes — empiriquement on voit des scores entre 0 et ~1.
Avantage : excellent sur les correspondances de mots exactes ou stemmées (« réveillon » trouve « réveillons », « réveillonner »…).
Limite : aucun sens. « Plage » ne trouvera pas « bord de mer » sauf si l'expression exacte est présente.
Pourquoi les deux ?
Un voyage idéal ressort haut sur les deux classements : titre/description qui contient les bons mots ET un sens global aligné avec la requête. Les imperfections de l'un sont rattrapées par l'autre. La fusion (ci-dessous) gère cet équilibre proprement.
La fusion : Reciprocal Rank Fusion (RRF)
Pourquoi ne pas additionner les scores bruts ?
Les deux scores ne vivent pas sur la même échelle :
vector_score∈ [0, 1] (similarité cosinus, distribution dense autour de 0.5–0.8 en général).text_score(ts_rank_cd) ∈ [0, +∞[ en théorie, mais en pratique presque toujours < 0.5, et 0 dès qu'il n'y a aucun match texte.
Une addition naïve donne tout le poids au vectoriel. Une normalisation par max/min est fragile (un seul outlier casse l'échelle).
La formule RRF
On classe les résultats séparément par chaque moteur (rang 1, 2, 3…), puis on combine les rangs, pas les scores :
RRF(doc) = w · 1/(60 + rank_vector(doc)) + (1-w) · 1/(60 + rank_text(doc))rank_vector= position de ce voyage dans le classement vectoriel (1 = le plus pertinent).rank_text= idem pour le classement texte.60= constante de lissage standard (cf. Cormack et al. 2009, valeur conventionnelle qui réduit l'écart entre les rangs 1 et 2).w=DEFAULT_RRF_VECTOR_WEIGHTou paramètre queryrrfVectorWeight(entre 0 et 1).
Lecture intuitive :
w = 1→ pur sémantique. Le rang texte est ignoré.w = 0→ pur texte. Le rang sémantique est ignoré.w = 0.5→ moitié-moitié.
Propriétés :
- Robuste : pas besoin de normaliser les scores.
- Symétrique : doubler
wet1-wne change pas l'ordre relatif. - Plafonné : un voyage classé hors top 1000 dans un moteur (
COALESCE(..., 1000)) contribue ~0 sur ce versant — c'est voulu.
Les planchers (floors)
RRF répond quels documents méritent d'être combinés. Mais que faire d'un voyage avec 0 match texte ET une similarité sémantique très faible ? Le RRF lui donnerait quand même un petit score positif, polluant la queue de résultat.
D'où deux planchers indépendants, appliqués dans un WHERE final :
WHERE r.vector_score >= %(semantic_floor)s
OR r.text_score >= %(tsvector_floor)ssemantic_floor(DEFAULT_SEMANTIC_FLOOR, défaut0.55) : similarité cosinus minimale.tsvector_floor(DEFAULT_TSVECTOR_FLOOR, défaut0.05) :ts_rank_cdminimal.- OR, pas AND : un voyage avec un excellent match texte mais une sémantique faible (ou inversement) reste affiché.
Override possible par requête :
?semanticFloor=0.7(plus exigeant → moins de bruit sémantique).?tsvectorFloor=0(laisse passer tous les matchs texte, même très faibles).
Voir architecture/search-tuning.md pour des recettes concrètes.
Forme SQL complète (avec query)
WITH vector_search AS (
SELECT id,
1 - (embedding <=> %(embedding)s::vector) AS vector_score,
ROW_NUMBER() OVER (
ORDER BY embedding <=> %(embedding)s::vector
) AS vector_rank
FROM travels_view
{where_clause} -- filtres communs
),
text_search AS (
SELECT id,
ts_rank_cd(search_vector,
websearch_to_tsquery('french', %(search_query)s)
) AS text_score,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(...) DESC
) AS text_rank
FROM travels_view
{where_clause} AND search_vector @@ websearch_to_tsquery('french', …)
),
rrf_scores AS (
SELECT COALESCE(v.id, t.id) AS id,
COALESCE(v.vector_score, 0) AS vector_score,
COALESCE(t.text_score, 0) AS text_score,
{w} * (1.0 / (60 + COALESCE(v.vector_rank, 1000)))
+ (1 - {w}) * (1.0 / (60 + COALESCE(t.text_rank, 1000)))
AS rrf_score
FROM vector_search v
FULL OUTER JOIN text_search t ON v.id = t.id
)
SELECT t.raw_data,
r.rrf_score AS _relevance_score,
r.vector_score AS _semantic_score,
r.text_score AS _tsvector_score,
t.next_departure,
COUNT(*) OVER() AS _total_count
FROM rrf_scores r
JOIN travels_view t ON t.id = r.id
WHERE r.vector_score >= %(semantic_floor)s
OR r.text_score >= %(tsvector_floor)s
ORDER BY r.rrf_score DESC
LIMIT %(limit)s OFFSET %(offset)sÀ noter :
FULL OUTER JOINentrevector_searchettext_search: un voyage présent uniquement dans un des deux moteurs reste candidat.COALESCE(rank, 1000): si un voyage est absent d'un classement, on lui attribue un rang « tout en bas » pour que sa contribution à RRF soit ~0 sans être nulle.- Filtres communs (
where_clause) appliqués dans chaque CTE, pas à la fin : c'est plus efficace et évite quevector_rank/text_ranksoient calculés sur des documents qu'on jettera ensuite. - Le
text_searchCTE ajouteAND search_vector @@ websearch_to_tsquery(…)pour ne classer que les documents qui ont au moins un match texte (sinonts_rank_cd = 0pour tout le monde, classement aléatoire).
Branche « filtre seul » (sans search)
Si search est vide ou absent, pas d'embedding, pas de RRF. On utilise une requête simple :
SELECT raw_data, 1.0 AS _relevance_score, next_departure,
COUNT(*) OVER() AS _total_count
FROM travels_view
{where_clause}
{order_clause} -- ORDER BY rating/departure/duration si demandé
LIMIT %(limit)s OFFSET %(offset)sTous les résultats ont _relevance_score = 1.0 (aucun classement de pertinence à exposer). Le tri est piloté par ?orderBy= (rating / departure / duration) — voir domain/filter-semantics.md.
Branche « filtre seul » vs « search » — comportement utilisateur
| Cas | Tri appliqué | _relevance_score |
|---|---|---|
?search= non vide | RRF descendant (orderBy ignoré) | RRF score |
?search= vide + orderBy= | Selon orderBy (et orderDirection) | 1.0 |
?search= vide sans tri | Pas de tri explicite (ordre indéfini) | 1.0 |
Conséquence côté front : afficher l'UI de tri seulement quand il n'y a pas de query active, ou prévenir l'utilisateur que le tri n'est appliqué qu'à la liste « non filtrée par recherche ».
Pour aller plus loin
- Recettes de tuning concrètes :
architecture/search-tuning.md. - Détail du schéma DB et des colonnes générées :
architecture/schema.md. - Détail du job de reindex et de comment les embeddings sont calculés et stockés :
architecture/reindex-lifecycle.md. - Détail des filtres (date, destination, gammes, etc.) appliqués via
where_clause:domain/filter-semantics.md.

