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/travelset/api/travels/seaside), consulter le knowledge basespektrum-buchard-horizonsur 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 PASbetter-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 dansspektrum-buchard-horizon(modulestravel-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
| Couche | Choix |
|---|---|
| Langage | Python 3.13 |
| Framework HTTP | FastAPI (avec fastapi[standard]) |
| Base de données | PostgreSQL 17 + extension pgvector |
| Migrations | yoyo-migrations (SQL pur) |
| Driver Postgres | psycopg 3 + psycopg_pool + pgvector.psycopg |
| Embeddings | Infomaniak AI (bge_multilingual_gemma2) |
| Métriques | prometheus-client + prometheus-fastapi-instrumentator |
| Conteneurisation | Docker (image python:3.13-slim + uv) |
| CI/CD | GitLab CI + @spektrum/release-util |
| Registry | registry.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_densitypour filtrer les champs de la réponse seloninfoDensity).src/models.py: modèles Pydantic exposés par l'API (ResultPage, aliasInfoDensity).src/schedule_runner.py: démarre un thread de fond qui rappellereindex()toutes lesREINDEX_FREQUENCYsecondes.
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_sVoir architecture/reindex-lifecycle.md pour les détails (ordre des sources, traitement des erreurs, idempotence).
Pourquoi « better » search ?
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— Pourquoiraw_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.pyinstanciedb = BuchardDatabase()au moment de l'import. Ce singleton est utilisé parserver.pyet parschedule_runner.py(viafrom .server import reindex). Les tests doivent toujours utiliserauto_reindex=Falsepour éviter de déclencher un reindex au moment de l'import du module. - Le reindex est threadé, pas async : un
Lockglobal protège contre les exécutions concurrentes. Si vous appelez/reindexpendant qu'un reindex tourne déjà, l'appel retourne immédiatement et le reindex en cours continue. travels_viewfiltre 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 danstravelsmais dont toutes lesdeparture_datessont passées n'apparaîtra pas dans les résultats. Voirarchitecture/schema.md.- L'amont est dual : depuis la 3.2.0, on ingère
SLOW_SEARCH_URLETSLOW_SEARCH_URL/seaside. Les enregistrements balnéaires sont taggésisSeaside=truedansraw_dataau moment de l'ingestion, et la colonne généréeis_seasidereflète ce flag. - Le sanity check
/exposeVERSION_TAGetBUILD_DATEinjecté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.

