Skip to content

Architecture — Vue d'ensemble

better-search (aussi appelé buchsearch côté Docker registry) est un microservice de recherche sémantique sur le catalogue voyages Buchard. Il sert de remplacement « drop-in » pour l'ancien endpoint de recherche d'Horizon : mêmes paramètres, mêmes filtres, mêmes formats de réponse, mais avec une recherche hybride (texte intégral + vectorielle) au lieu d'un simple LIKE SQL.

Pour comprendre la source de données amont (SLOW_SEARCH_URL, c.-à-d. https://horizon.buchard.ch/api/travels et /api/travels/seaside), consulter le knowledge base spektrum-buchard-horizon sur le même serveur MCP.

Positionnement dans l'écosystème

┌──────────────┐        ┌─────────────────────┐        ┌──────────────────┐
│  site web    │  GET   │   better-search     │  GET   │    Horizon       │
│  / mobile    ├───────►│   (FastAPI)         │◄──────►│   /api/travels   │
│  (front)     │        │   /travels          │        │   /api/travels/  │
└──────────────┘        │   /reindex          │        │     seaside      │
                        └──────────┬──────────┘        └──────────────────┘

                  ┌────────────────┼────────────────┐
                  │                │                │
                  ▼                ▼                ▼
         ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐
         │ PostgreSQL 17  │ │ Infomaniak   │ │ Prometheus       │
         │  + pgvector    │ │  embeddings  │ │ (scraping)       │
         └────────────────┘ └──────────────┘ └──────────────────┘
  • Consommateurs : le site web public (buchard.ch) et l'app mobile. Le back-office Horizon n'utilise PAS better-search — il a sa propre recherche back-office sur SQL Server.
  • Source de données : Horizon (SLOW_SEARCH_URL). C'est la « slow-backend » référencée dans CLAUDE.md. Sa structure de données est documentée dans spektrum-buchard-horizon (modules travel-catalog, seaside).
  • Embeddings : service externe Infomaniak (BGE Multilingual Gemma2, 3584 dimensions). Voir modules/embeddings.md.
  • Persistance : PostgreSQL 17 avec l'extension pgvector. Stockage local au service.

Stack technique

CoucheChoix
LangagePython 3.13
Framework HTTPFastAPI (avec fastapi[standard])
Base de donnéesPostgreSQL 17 + extension pgvector
Migrationsyoyo-migrations (SQL pur)
Driver Postgrespsycopg 3 + psycopg_pool + pgvector.psycopg
EmbeddingsInfomaniak AI (bge_multilingual_gemma2)
Métriquesprometheus-client + prometheus-fastapi-instrumentator
ConteneurisationDocker (image python:3.13-slim + uv)
CI/CDGitLab CI + @spektrum/release-util
Registryregistry.internal.spektrum-suisse.ch/buchsearch

Couches du service

┌────────────────────────────────────────────────────────────┐
│  app.py                                                     │
│  ─ démarre FastAPI, vérifie les vars d'env                  │
│  ─ monte /.well-known en static                             │
│  ─ lance start_schedule() (reindex périodique en thread)    │
└──────────────────────┬─────────────────────────────────────┘

┌──────────────────────▼─────────────────────────────────────┐
│  src/server.py — endpoints HTTP                             │
│  ─ GET /          (sanity check, expose VERSION_TAG)        │
│  ─ GET /travels   (recherche + filtres + tri + pagination)  │
│  ─ GET /reindex   (déclenche un reindex manuel)             │
│  ─ /metrics       (exposé par l'instrumentator Prometheus)  │
│  ─ /docs, /redoc  (uniquement si WITH_DOCS=true)            │
└──────────────────────┬─────────────────────────────────────┘

┌──────────────────────▼─────────────────────────────────────┐
│  src/db.py — façade BuchardDatabase (singleton `db`)        │
│  ─ search(...)          → délègue à search_query + psycopg  │
│  ─ reindex()            → délègue à src/reindex.py          │
│  ─ total_records, all_items                                 │
└─────┬──────────────────────────┬───────────────────────────┘
      │                          │
┌─────▼─────────────────┐  ┌─────▼─────────────────────────┐
│  src/search_query.py  │  │  src/reindex.py               │
│  ─ build_search_query │  │  ─ thread global, lock unique │
│  ─ filtres → WHERE    │  │  ─ paginate /api/travels      │
│  ─ tri, pagination    │  │  ─ paginate /api/travels/sea… │
│  ─ branche hybride    │  │  ─ embed batch                │
│    (vector + tsv RRF) │  │  ─ UPSERT + cleanup stale     │
└───────────────────────┘  └───────────────┬───────────────┘

                              ┌────────────▼───────────────┐
                              │  src/infomaniak.py         │
                              │  ─ get_embeddings(texts)   │
                              │  ─ get_embedding(text)     │
                              └────────────────────────────┘
  • src/_utils.py : fonctions utilitaires partagées (extraction du texte à embedder depuis un travel, nettoyage HTML via BeautifulSoup, apply_info_density pour filtrer les champs de la réponse selon infoDensity).
  • src/models.py : modèles Pydantic exposés par l'API (ResultPage, alias InfoDensity).
  • src/schedule_runner.py : démarre un thread de fond qui rappelle reindex() toutes les REINDEX_FREQUENCY secondes.

Flux d'une requête /travels

1. Client → GET /travels?search=ski%20alpes&destination=CH&size=10
2. server.py:run_search(...) lit les query params
3. db.search() :
     a. Si search != "" → appelle Infomaniak pour obtenir l'embedding 3584D
     b. search_query.build_search_query(...) construit le SQL :
        ─ WHERE clauses depuis les filtres (destination, category, dates, …)
        ─ Branche hybride (CTE vector_search + text_search + rrf_scores)
4. psycopg exécute le SQL, fetchall()
5. Chaque row est aplatie : raw_data (JSONB) + scores
6. apply_info_density filtre les champs selon ?infoDensity=
7. Retour ResultPage { totalRecords, totalFilteredRecords, page, records }

Flux d'un reindex

1. Démarrage du conteneur :
   ─ entrypoint.sh exécute `yoyo apply` puis lance fastapi
   ─ app.py appelle start_schedule()
   ─ BuchardDatabase.__init__ → _maybe_reindex() (si DB vide ou ≥ 15 min)
2. Toutes les REINDEX_FREQUENCY secondes (défaut 900s = 15 min) :
   ─ thread schedule_runner appelle `/reindex`
3. reindex() (src/db.py) → reindex_mod.reindex(pool) (src/reindex.py)
   ─ pose un lock global (skip si reindex déjà en cours)
   ─ lance un thread `_run_reindex`
4. _do_reindex(pool) :
   ─ ingère SLOW_SEARCH_URL (catalogue principal, page=1..n)
   ─ ingère SLOW_SEARCH_URL/seaside (balnéaires, tag isSeaside=True injecté)
   ─ chaque page → batch d'embeddings → UPSERT
   ─ DELETE final pour purger les ids absents de l'amont
   ─ MAJ des metrics db_size, last_reindex_time, reindex_time_s

Voir architecture/reindex-lifecycle.md pour les détails (ordre des sources, traitement des erreurs, idempotence).

L'ancienne recherche d'Horizon utilisait un LIKE %query% SQL sur le nom et la description. Limitations qui ont motivé ce service :

  • Pas de tolérance sémantique : « ski » ne matchait pas « neige » ou « montagne ».
  • Pas de tolérance linguistique : pas de stemming français (« voyage » vs « voyages »).
  • Pas de pondération : nom, sous-titre, description avaient le même poids.
  • Latence acceptable mais bornée côté Horizon (SQL Server + EF Core, output cache 60min).

better-search apporte :

  • Recherche vectorielle sur les embeddings BGE Multilingual Gemma2 (sémantique multilingue).
  • Recherche full-text français via to_tsvector('french', …) (stemming, accents normalisés).
  • Fusion par RRF (Reciprocal Rank Fusion) : combine les deux classements de manière robuste — voir architecture/hybrid-search.md.
  • Filtres typés via colonnes générées indexées (destination, gammes, dates, club, séjour valide, balnéaire).
  • Pondération ajustable au runtime via query params (rrfVectorWeight, semanticFloor, tsvectorFloor).

Décisions de design clés

Voir les ADRs dans architecture/adr/ :

  • 0001-jsonb-source-of-truth.md — Pourquoi raw_data JSONB + colonnes générées plutôt qu'un schéma normalisé.
  • 0002-hybrid-search-rrf.md — Pourquoi RRF plutôt qu'un blending pondéré des scores bruts.
  • 0003-bge-multilingual-gemma2.md — Pourquoi ce modèle d'embedding (multilingue, 3584D) via Infomaniak.
  • 0004-yoyo-migrations.md — Pourquoi yoyo plutôt qu'Alembic ou un autre outil.
  • 0005-threaded-background-reindex.md — Pourquoi un thread interne plutôt qu'un worker ou un scheduler externe.

Points d'attention transverses

  • Singleton db : src/db.py instancie db = BuchardDatabase() au moment de l'import. Ce singleton est utilisé par server.py et par schedule_runner.py (via from .server import reindex). Les tests doivent toujours utiliser auto_reindex=False pour éviter de déclencher un reindex au moment de l'import du module.
  • Le reindex est threadé, pas async : un Lock global protège contre les exécutions concurrentes. Si vous appelez /reindex pendant qu'un reindex tourne déjà, l'appel retourne immédiatement et le reindex en cours continue.
  • travels_view filtre les voyages sans départ futur : la vue, et non la table, est utilisée par tous les SELECT de recherche. Un voyage présent dans travels mais dont toutes les departure_dates sont passées n'apparaîtra pas dans les résultats. Voir architecture/schema.md.
  • L'amont est dual : depuis la 3.2.0, on ingère SLOW_SEARCH_URL ET SLOW_SEARCH_URL/seaside. Les enregistrements balnéaires sont taggés isSeaside=true dans raw_data au moment de l'ingestion, et la colonne générée is_seaside reflète ce flag.
  • Le sanity check / expose VERSION_TAG et BUILD_DATE injectés au build Docker. Si vous voyez l'ancienne version après un déploiement, vérifiez la chaîne --build-arg VERSION_TAG=… dans le pipeline CI.

Contributors

No contributors

Changelog

No recent changes